feat: file operation restful api

This commit is contained in:
Acbox
2026-02-09 21:03:05 +08:00
parent 8ea779779e
commit 4f5a8f5e64
8 changed files with 2135 additions and 143 deletions
+2 -9
View File
@@ -51,19 +51,12 @@ export const createAgent = ({
const fs: HTTPMCPConnection = {
type: 'http',
name: 'fs',
url: `${auth.baseUrl}/bots/${identity.botId}/container/fs`,
url: `${auth.baseUrl}/bots/${identity.botId}/container/fs-mcp`,
headers: {
'Authorization': `Bearer ${auth.bearer}`,
},
}
const mcpFetch: StdioMCPConnection = {
type: 'stdio',
name: 'mcp-fetch',
command: 'npx',
args: ['fetch-mcp'],
env: {},
}
return [fs, mcpFetch]
return [fs]
}
const generateSystemPrompt = () => {
+558 -3
View File
@@ -354,6 +354,116 @@ const docTemplate = `{
}
},
"/bots/{bot_id}/container/fs": {
"get": {
"description": "List entries under a relative path",
"tags": [
"fs"
],
"summary": "List files for a bot",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Relative directory path",
"name": "path",
"in": "query"
},
{
"type": "boolean",
"description": "Recursive listing",
"name": "recursive",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.FSListResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
},
"delete": {
"tags": [
"fs"
],
"summary": "Delete a file or directory",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Relative path",
"name": "path",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Recursive delete for directories",
"name": "recursive",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.FSDeleteResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/container/fs-mcp": {
"post": {
"description": "Forwards MCP JSON-RPC requests to the MCP server inside the container.\nRequired:\n- container task is running\n- container has data mount (default /data) bound to \u003cdata_root\u003e/users/\u003cuser_id\u003e\n- container image contains the \"mcp\" binary\nAuth: Bearer JWT is used to determine user_id (sub or user_id).\nPaths must be relative (no leading slash) and must not contain \"..\".\n\nExample: tools/list\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n\nExample: tools/call (fs.read)\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fs.read\",\"arguments\":{\"path\":\"notes.txt\"}}}",
"tags": [
@@ -413,12 +523,325 @@ const docTemplate = `{
}
}
},
"/bots/{bot_id}/container/fs/dir": {
"post": {
"tags": [
"fs"
],
"summary": "Create a directory",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"description": "Directory payload",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.FSMkdirRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.FSWriteResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/container/fs/file": {
"get": {
"tags": [
"fs"
],
"summary": "Read file content",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Relative file path",
"name": "path",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.FSReadResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
},
"post": {
"tags": [
"fs"
],
"summary": "Create or overwrite a file",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"description": "File write payload",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.FSWriteRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.FSWriteResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"409": {
"description": "Conflict",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/container/fs/stat": {
"get": {
"tags": [
"fs"
],
"summary": "Get file or directory metadata",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Relative path",
"name": "path",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.FSStatResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/container/fs/upload": {
"post": {
"tags": [
"fs"
],
"summary": "Upload a file",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Relative file path or directory",
"name": "path",
"in": "query"
},
{
"type": "file",
"description": "File to upload",
"name": "file",
"in": "formData",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.FSWriteResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/container/fs/usage": {
"get": {
"tags": [
"fs"
],
"summary": "Get usage under a path",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Relative directory path",
"name": "path",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.FSUsageResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/container/skills": {
"get": {
"tags": [
"containerd"
],
"summary": "List skills from container",
"summary": "List skills from data directory",
"parameters": [
{
"type": "string",
@@ -459,7 +882,7 @@ const docTemplate = `{
"tags": [
"containerd"
],
"summary": "Upload skills into container",
"summary": "Upload skills into data directory",
"parameters": [
{
"type": "string",
@@ -509,7 +932,7 @@ const docTemplate = `{
"tags": [
"containerd"
],
"summary": "Delete skills from container",
"summary": "Delete skills from data directory",
"parameters": [
{
"type": "string",
@@ -5236,6 +5659,138 @@ const docTemplate = `{
}
}
},
"handlers.FSDeleteResponse": {
"type": "object",
"properties": {
"ok": {
"type": "boolean"
}
}
},
"handlers.FSListResponse": {
"type": "object",
"properties": {
"entries": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.FSRestEntry"
}
},
"path": {
"type": "string"
}
}
},
"handlers.FSMkdirRequest": {
"type": "object",
"properties": {
"parents": {
"type": "boolean"
},
"path": {
"type": "string"
}
}
},
"handlers.FSReadResponse": {
"type": "object",
"properties": {
"content": {
"type": "string"
},
"mod_time": {
"type": "string"
},
"mode": {
"type": "integer"
},
"path": {
"type": "string"
},
"size": {
"type": "integer"
}
}
},
"handlers.FSRestEntry": {
"type": "object",
"properties": {
"is_dir": {
"type": "boolean"
},
"mod_time": {
"type": "string"
},
"mode": {
"type": "integer"
},
"path": {
"type": "string"
},
"size": {
"type": "integer"
}
}
},
"handlers.FSStatResponse": {
"type": "object",
"properties": {
"is_dir": {
"type": "boolean"
},
"mod_time": {
"type": "string"
},
"mode": {
"type": "integer"
},
"path": {
"type": "string"
},
"size": {
"type": "integer"
}
}
},
"handlers.FSUsageResponse": {
"type": "object",
"properties": {
"dir_count": {
"type": "integer"
},
"file_count": {
"type": "integer"
},
"path": {
"type": "string"
},
"total_bytes": {
"type": "integer"
}
}
},
"handlers.FSWriteRequest": {
"type": "object",
"properties": {
"content": {
"type": "string"
},
"overwrite": {
"type": "boolean"
},
"path": {
"type": "string"
}
}
},
"handlers.FSWriteResponse": {
"type": "object",
"properties": {
"ok": {
"type": "boolean"
}
}
},
"handlers.GetContainerResponse": {
"type": "object",
"properties": {
+558 -3
View File
@@ -345,6 +345,116 @@
}
},
"/bots/{bot_id}/container/fs": {
"get": {
"description": "List entries under a relative path",
"tags": [
"fs"
],
"summary": "List files for a bot",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Relative directory path",
"name": "path",
"in": "query"
},
{
"type": "boolean",
"description": "Recursive listing",
"name": "recursive",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.FSListResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
},
"delete": {
"tags": [
"fs"
],
"summary": "Delete a file or directory",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Relative path",
"name": "path",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Recursive delete for directories",
"name": "recursive",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.FSDeleteResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/container/fs-mcp": {
"post": {
"description": "Forwards MCP JSON-RPC requests to the MCP server inside the container.\nRequired:\n- container task is running\n- container has data mount (default /data) bound to \u003cdata_root\u003e/users/\u003cuser_id\u003e\n- container image contains the \"mcp\" binary\nAuth: Bearer JWT is used to determine user_id (sub or user_id).\nPaths must be relative (no leading slash) and must not contain \"..\".\n\nExample: tools/list\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n\nExample: tools/call (fs.read)\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fs.read\",\"arguments\":{\"path\":\"notes.txt\"}}}",
"tags": [
@@ -404,12 +514,325 @@
}
}
},
"/bots/{bot_id}/container/fs/dir": {
"post": {
"tags": [
"fs"
],
"summary": "Create a directory",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"description": "Directory payload",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.FSMkdirRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.FSWriteResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/container/fs/file": {
"get": {
"tags": [
"fs"
],
"summary": "Read file content",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Relative file path",
"name": "path",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.FSReadResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
},
"post": {
"tags": [
"fs"
],
"summary": "Create or overwrite a file",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"description": "File write payload",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.FSWriteRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.FSWriteResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"409": {
"description": "Conflict",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/container/fs/stat": {
"get": {
"tags": [
"fs"
],
"summary": "Get file or directory metadata",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Relative path",
"name": "path",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.FSStatResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/container/fs/upload": {
"post": {
"tags": [
"fs"
],
"summary": "Upload a file",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Relative file path or directory",
"name": "path",
"in": "query"
},
{
"type": "file",
"description": "File to upload",
"name": "file",
"in": "formData",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.FSWriteResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/container/fs/usage": {
"get": {
"tags": [
"fs"
],
"summary": "Get usage under a path",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Relative directory path",
"name": "path",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.FSUsageResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/container/skills": {
"get": {
"tags": [
"containerd"
],
"summary": "List skills from container",
"summary": "List skills from data directory",
"parameters": [
{
"type": "string",
@@ -450,7 +873,7 @@
"tags": [
"containerd"
],
"summary": "Upload skills into container",
"summary": "Upload skills into data directory",
"parameters": [
{
"type": "string",
@@ -500,7 +923,7 @@
"tags": [
"containerd"
],
"summary": "Delete skills from container",
"summary": "Delete skills from data directory",
"parameters": [
{
"type": "string",
@@ -5227,6 +5650,138 @@
}
}
},
"handlers.FSDeleteResponse": {
"type": "object",
"properties": {
"ok": {
"type": "boolean"
}
}
},
"handlers.FSListResponse": {
"type": "object",
"properties": {
"entries": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.FSRestEntry"
}
},
"path": {
"type": "string"
}
}
},
"handlers.FSMkdirRequest": {
"type": "object",
"properties": {
"parents": {
"type": "boolean"
},
"path": {
"type": "string"
}
}
},
"handlers.FSReadResponse": {
"type": "object",
"properties": {
"content": {
"type": "string"
},
"mod_time": {
"type": "string"
},
"mode": {
"type": "integer"
},
"path": {
"type": "string"
},
"size": {
"type": "integer"
}
}
},
"handlers.FSRestEntry": {
"type": "object",
"properties": {
"is_dir": {
"type": "boolean"
},
"mod_time": {
"type": "string"
},
"mode": {
"type": "integer"
},
"path": {
"type": "string"
},
"size": {
"type": "integer"
}
}
},
"handlers.FSStatResponse": {
"type": "object",
"properties": {
"is_dir": {
"type": "boolean"
},
"mod_time": {
"type": "string"
},
"mode": {
"type": "integer"
},
"path": {
"type": "string"
},
"size": {
"type": "integer"
}
}
},
"handlers.FSUsageResponse": {
"type": "object",
"properties": {
"dir_count": {
"type": "integer"
},
"file_count": {
"type": "integer"
},
"path": {
"type": "string"
},
"total_bytes": {
"type": "integer"
}
}
},
"handlers.FSWriteRequest": {
"type": "object",
"properties": {
"content": {
"type": "string"
},
"overwrite": {
"type": "boolean"
},
"path": {
"type": "string"
}
}
},
"handlers.FSWriteResponse": {
"type": "object",
"properties": {
"ok": {
"type": "boolean"
}
}
},
"handlers.GetContainerResponse": {
"type": "object",
"properties": {
+367 -3
View File
@@ -614,6 +614,91 @@ definitions:
message:
type: string
type: object
handlers.FSDeleteResponse:
properties:
ok:
type: boolean
type: object
handlers.FSListResponse:
properties:
entries:
items:
$ref: '#/definitions/handlers.FSRestEntry'
type: array
path:
type: string
type: object
handlers.FSMkdirRequest:
properties:
parents:
type: boolean
path:
type: string
type: object
handlers.FSReadResponse:
properties:
content:
type: string
mod_time:
type: string
mode:
type: integer
path:
type: string
size:
type: integer
type: object
handlers.FSRestEntry:
properties:
is_dir:
type: boolean
mod_time:
type: string
mode:
type: integer
path:
type: string
size:
type: integer
type: object
handlers.FSStatResponse:
properties:
is_dir:
type: boolean
mod_time:
type: string
mode:
type: integer
path:
type: string
size:
type: integer
type: object
handlers.FSUsageResponse:
properties:
dir_count:
type: integer
file_count:
type: integer
path:
type: string
total_bytes:
type: integer
type: object
handlers.FSWriteRequest:
properties:
content:
type: string
overwrite:
type: boolean
path:
type: string
type: object
handlers.FSWriteResponse:
properties:
ok:
type: boolean
type: object
handlers.GetContainerResponse:
properties:
container_id:
@@ -1617,6 +1702,79 @@ paths:
tags:
- containerd
/bots/{bot_id}/container/fs:
delete:
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
- description: Relative path
in: query
name: path
required: true
type: string
- description: Recursive delete for directories
in: query
name: recursive
type: boolean
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.FSDeleteResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Delete a file or directory
tags:
- fs
get:
description: List entries under a relative path
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
- description: Relative directory path
in: query
name: path
type: string
- description: Recursive listing
in: query
name: recursive
type: boolean
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.FSListResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: List files for a bot
tags:
- fs
/bots/{bot_id}/container/fs-mcp:
post:
description: |-
Forwards MCP JSON-RPC requests to the MCP server inside the container.
@@ -1669,6 +1827,212 @@ paths:
summary: MCP filesystem tools (JSON-RPC)
tags:
- containerd
/bots/{bot_id}/container/fs/dir:
post:
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
- description: Directory payload
in: body
name: payload
required: true
schema:
$ref: '#/definitions/handlers.FSMkdirRequest'
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.FSWriteResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Create a directory
tags:
- fs
/bots/{bot_id}/container/fs/file:
get:
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
- description: Relative file path
in: query
name: path
required: true
type: string
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.FSReadResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Read file content
tags:
- fs
post:
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
- description: File write payload
in: body
name: payload
required: true
schema:
$ref: '#/definitions/handlers.FSWriteRequest'
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.FSWriteResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"409":
description: Conflict
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Create or overwrite a file
tags:
- fs
/bots/{bot_id}/container/fs/stat:
get:
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
- description: Relative path
in: query
name: path
required: true
type: string
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.FSStatResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Get file or directory metadata
tags:
- fs
/bots/{bot_id}/container/fs/upload:
post:
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
- description: Relative file path or directory
in: query
name: path
type: string
- description: File to upload
in: formData
name: file
required: true
type: file
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.FSWriteResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Upload a file
tags:
- fs
/bots/{bot_id}/container/fs/usage:
get:
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
- description: Relative directory path
in: query
name: path
type: string
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.FSUsageResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Get usage under a path
tags:
- fs
/bots/{bot_id}/container/skills:
delete:
parameters:
@@ -1700,7 +2064,7 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Delete skills from container
summary: Delete skills from data directory
tags:
- containerd
get:
@@ -1727,7 +2091,7 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: List skills from container
summary: List skills from data directory
tags:
- containerd
post:
@@ -1760,7 +2124,7 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Upload skills into container
summary: Upload skills into data directory
tags:
- containerd
/bots/{bot_id}/container/snapshots:
+10 -1
View File
@@ -120,9 +120,18 @@ func (h *ContainerdHandler) Register(e *echo.Echo) {
group.GET("/skills", h.ListSkills)
group.POST("/skills", h.UpsertSkills)
group.DELETE("/skills", h.DeleteSkills)
group.POST("/fs", h.HandleMCPFS)
group.POST("/fs-mcp", h.HandleMCPFS)
root := e.Group("/bots/:bot_id")
fs := e.Group("/bots/:bot_id/container/fs")
fs.GET("", h.ListFS)
fs.GET("/file", h.ReadFSFile)
fs.GET("/stat", h.StatFS)
fs.GET("/usage", h.UsageFS)
fs.POST("/file", h.WriteFSFile)
fs.POST("/dir", h.MkdirFS)
fs.POST("/upload", h.UploadFS)
fs.DELETE("", h.DeleteFS)
root.POST("/mcp-stdio", h.CreateMCPStdio)
root.POST("/mcp-stdio/:session_id", h.HandleMCPStdio)
}
+1 -2
View File
@@ -45,7 +45,7 @@ import (
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs [post]
// @Router /bots/{bot_id}/container/fs-mcp [post]
func (h *ContainerdHandler) HandleMCPFS(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
@@ -429,4 +429,3 @@ func (s *mcpSession) writePayloads(payloads []string) error {
}
return nil
}
+585
View File
@@ -0,0 +1,585 @@
package handlers
import (
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/memohai/memoh/internal/config"
)
type FSListResponse struct {
Path string `json:"path"`
Entries []FSRestEntry `json:"entries"`
}
type FSRestEntry struct {
Path string `json:"path"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
Mode uint32 `json:"mode"`
ModTime time.Time `json:"mod_time"`
}
type FSReadResponse struct {
Path string `json:"path"`
Content string `json:"content"`
Size int64 `json:"size"`
Mode uint32 `json:"mode"`
ModTime time.Time `json:"mod_time"`
}
type FSStatResponse struct {
Path string `json:"path"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
Mode uint32 `json:"mode"`
ModTime time.Time `json:"mod_time"`
}
type FSUsageResponse struct {
Path string `json:"path"`
TotalBytes int64 `json:"total_bytes"`
FileCount int64 `json:"file_count"`
DirCount int64 `json:"dir_count"`
}
type FSWriteRequest struct {
Path string `json:"path"`
Content string `json:"content"`
Overwrite *bool `json:"overwrite"`
}
type FSWriteResponse struct {
OK bool `json:"ok"`
}
type FSMkdirRequest struct {
Path string `json:"path"`
Parents *bool `json:"parents"`
}
type FSDeleteResponse struct {
OK bool `json:"ok"`
}
// ListFS godoc
// @Summary List files for a bot
// @Description List entries under a relative path
// @Tags fs
// @Param bot_id path string true "Bot ID"
// @Param path query string false "Relative directory path"
// @Param recursive query bool false "Recursive listing"
// @Success 200 {object} FSListResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs [get]
func (h *ContainerdHandler) ListFS(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
root, err := h.ensureBotDataRoot(botID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
recursive, err := parseBoolQuery(c, "recursive")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
target, rel, err := resolveBotPath(root, c.QueryParam("path"), true)
if err != nil {
return fsHTTPError(err)
}
info, err := os.Stat(target)
if err != nil {
return fsHTTPError(err)
}
if !info.IsDir() {
return echo.NewHTTPError(http.StatusBadRequest, "path is not a directory")
}
entries := []FSRestEntry{}
if recursive {
err = filepath.WalkDir(target, func(p string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if p == target {
return nil
}
entryInfo, err := d.Info()
if err != nil {
return err
}
entry, err := entryForBotPath(root, p, entryInfo)
if err != nil {
return err
}
entries = append(entries, entry)
return nil
})
} else {
dirEntries, err := os.ReadDir(target)
if err != nil {
return fsHTTPError(err)
}
for _, entry := range dirEntries {
entryInfo, err := entry.Info()
if err != nil {
return fsHTTPError(err)
}
fullPath := filepath.Join(target, entry.Name())
fileEntry, err := entryForBotPath(root, fullPath, entryInfo)
if err != nil {
return fsHTTPError(err)
}
entries = append(entries, fileEntry)
}
}
if err != nil {
return fsHTTPError(err)
}
listedPath := strings.TrimSpace(rel)
if listedPath == "" || listedPath == "." {
listedPath = "."
}
return c.JSON(http.StatusOK, FSListResponse{Path: listedPath, Entries: entries})
}
// ReadFSFile godoc
// @Summary Read file content
// @Tags fs
// @Param bot_id path string true "Bot ID"
// @Param path query string true "Relative file path"
// @Success 200 {object} FSReadResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs/file [get]
func (h *ContainerdHandler) ReadFSFile(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
root, err := h.ensureBotDataRoot(botID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
target, rel, err := resolveBotPath(root, c.QueryParam("path"), false)
if err != nil {
return fsHTTPError(err)
}
info, err := os.Stat(target)
if err != nil {
return fsHTTPError(err)
}
if info.IsDir() {
return echo.NewHTTPError(http.StatusBadRequest, "path is a directory")
}
data, err := os.ReadFile(target)
if err != nil {
return fsHTTPError(err)
}
return c.JSON(http.StatusOK, FSReadResponse{
Path: rel,
Content: string(data),
Size: info.Size(),
Mode: uint32(info.Mode().Perm()),
ModTime: info.ModTime(),
})
}
// StatFS godoc
// @Summary Get file or directory metadata
// @Tags fs
// @Param bot_id path string true "Bot ID"
// @Param path query string true "Relative path"
// @Success 200 {object} FSStatResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs/stat [get]
func (h *ContainerdHandler) StatFS(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
root, err := h.ensureBotDataRoot(botID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
target, rel, err := resolveBotPath(root, c.QueryParam("path"), false)
if err != nil {
return fsHTTPError(err)
}
info, err := os.Stat(target)
if err != nil {
return fsHTTPError(err)
}
return c.JSON(http.StatusOK, FSStatResponse{
Path: rel,
IsDir: info.IsDir(),
Size: info.Size(),
Mode: uint32(info.Mode().Perm()),
ModTime: info.ModTime(),
})
}
// UsageFS godoc
// @Summary Get usage under a path
// @Tags fs
// @Param bot_id path string true "Bot ID"
// @Param path query string false "Relative directory path"
// @Success 200 {object} FSUsageResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs/usage [get]
func (h *ContainerdHandler) UsageFS(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
root, err := h.ensureBotDataRoot(botID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
target, rel, err := resolveBotPath(root, c.QueryParam("path"), true)
if err != nil {
return fsHTTPError(err)
}
info, err := os.Stat(target)
if err != nil {
return fsHTTPError(err)
}
var totalBytes int64
var fileCount int64
var dirCount int64
if info.IsDir() {
err = filepath.WalkDir(target, func(p string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if p == target {
return nil
}
entryInfo, err := d.Info()
if err != nil {
return err
}
if entryInfo.IsDir() {
dirCount++
return nil
}
fileCount++
totalBytes += entryInfo.Size()
return nil
})
if err != nil {
return fsHTTPError(err)
}
} else {
fileCount = 1
totalBytes = info.Size()
}
usagePath := strings.TrimSpace(rel)
if usagePath == "" || usagePath == "." {
usagePath = "."
}
return c.JSON(http.StatusOK, FSUsageResponse{
Path: usagePath,
TotalBytes: totalBytes,
FileCount: fileCount,
DirCount: dirCount,
})
}
// WriteFSFile godoc
// @Summary Create or overwrite a file
// @Tags fs
// @Param bot_id path string true "Bot ID"
// @Param payload body FSWriteRequest true "File write payload"
// @Success 200 {object} FSWriteResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs/file [post]
func (h *ContainerdHandler) WriteFSFile(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
var req FSWriteRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if strings.TrimSpace(req.Path) == "" {
return echo.NewHTTPError(http.StatusBadRequest, "path is required")
}
root, err := h.ensureBotDataRoot(botID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
target, _, err := resolveBotPath(root, req.Path, false)
if err != nil {
return fsHTTPError(err)
}
overwrite := true
if req.Overwrite != nil {
overwrite = *req.Overwrite
}
if _, err := os.Stat(target); err == nil && !overwrite {
return echo.NewHTTPError(http.StatusConflict, "file already exists")
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
if err := os.WriteFile(target, []byte(req.Content), 0o644); err != nil {
return fsHTTPError(err)
}
return c.JSON(http.StatusOK, FSWriteResponse{OK: true})
}
// MkdirFS godoc
// @Summary Create a directory
// @Tags fs
// @Param bot_id path string true "Bot ID"
// @Param payload body FSMkdirRequest true "Directory payload"
// @Success 200 {object} FSWriteResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs/dir [post]
func (h *ContainerdHandler) MkdirFS(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
var req FSMkdirRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if strings.TrimSpace(req.Path) == "" {
return echo.NewHTTPError(http.StatusBadRequest, "path is required")
}
root, err := h.ensureBotDataRoot(botID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
target, _, err := resolveBotPath(root, req.Path, false)
if err != nil {
return fsHTTPError(err)
}
parents := true
if req.Parents != nil {
parents = *req.Parents
}
if parents {
if err := os.MkdirAll(target, 0o755); err != nil {
return fsHTTPError(err)
}
} else if err := os.Mkdir(target, 0o755); err != nil {
return fsHTTPError(err)
}
return c.JSON(http.StatusOK, FSWriteResponse{OK: true})
}
// UploadFS godoc
// @Summary Upload a file
// @Tags fs
// @Param bot_id path string true "Bot ID"
// @Param path query string false "Relative file path or directory"
// @Param file formData file true "File to upload"
// @Success 200 {object} FSWriteResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs/upload [post]
func (h *ContainerdHandler) UploadFS(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
file, err := c.FormFile("file")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "file is required")
}
rawPath := strings.TrimSpace(c.FormValue("path"))
if rawPath == "" {
rawPath = strings.TrimSpace(c.QueryParam("path"))
}
if rawPath == "" {
rawPath = file.Filename
}
root, err := h.ensureBotDataRoot(botID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
targetPath := rawPath
if strings.HasSuffix(rawPath, "/") || strings.HasSuffix(rawPath, string(os.PathSeparator)) {
targetPath = filepath.ToSlash(filepath.Join(rawPath, file.Filename))
}
target, _, err := resolveBotPath(root, targetPath, false)
if err != nil {
return fsHTTPError(err)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
src, err := file.Open()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
defer src.Close()
dst, err := os.Create(target)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, FSWriteResponse{OK: true})
}
// DeleteFS godoc
// @Summary Delete a file or directory
// @Tags fs
// @Param bot_id path string true "Bot ID"
// @Param path query string true "Relative path"
// @Param recursive query bool false "Recursive delete for directories"
// @Success 200 {object} FSDeleteResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs [delete]
func (h *ContainerdHandler) DeleteFS(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
recursive, err := parseBoolQuery(c, "recursive")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
root, err := h.ensureBotDataRoot(botID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
target, rel, err := resolveBotPath(root, c.QueryParam("path"), false)
if err != nil {
return fsHTTPError(err)
}
if rel == "." || rel == "" {
return echo.NewHTTPError(http.StatusBadRequest, "refuse to delete root")
}
info, err := os.Stat(target)
if err != nil {
return fsHTTPError(err)
}
if info.IsDir() && recursive {
if err := os.RemoveAll(target); err != nil {
return fsHTTPError(err)
}
} else if err := os.Remove(target); err != nil {
return fsHTTPError(err)
}
return c.JSON(http.StatusOK, FSDeleteResponse{OK: true})
}
func (h *ContainerdHandler) ensureBotDataRoot(botID string) (string, error) {
dataRoot := strings.TrimSpace(h.cfg.DataRoot)
if dataRoot == "" {
dataRoot = config.DefaultDataRoot
}
dataRoot, err := filepath.Abs(dataRoot)
if err != nil {
return "", err
}
root := filepath.Join(dataRoot, "bots", botID)
if err := os.MkdirAll(root, 0o755); err != nil {
return "", err
}
return root, nil
}
func resolveBotPath(root, requestPath string, allowRoot bool) (string, string, error) {
raw := strings.TrimSpace(requestPath)
if raw == "" {
if allowRoot {
return root, ".", nil
}
return "", "", os.ErrInvalid
}
clean := filepath.Clean(filepath.FromSlash(raw))
if clean == "." || clean == "" {
if allowRoot {
return root, ".", nil
}
return "", "", os.ErrInvalid
}
if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
return "", "", os.ErrInvalid
}
target := filepath.Join(root, clean)
rel, err := filepath.Rel(root, target)
if err != nil || strings.HasPrefix(rel, "..") {
return "", "", os.ErrInvalid
}
return target, filepath.ToSlash(rel), nil
}
func entryForBotPath(root, target string, info os.FileInfo) (FSRestEntry, error) {
rel, err := filepath.Rel(root, target)
if err != nil {
return FSRestEntry{}, err
}
if strings.HasPrefix(rel, "..") {
return FSRestEntry{}, os.ErrInvalid
}
if rel == "." {
rel = ""
}
return FSRestEntry{
Path: filepath.ToSlash(rel),
IsDir: info.IsDir(),
Size: info.Size(),
Mode: uint32(info.Mode().Perm()),
ModTime: info.ModTime(),
}, nil
}
func parseBoolQuery(c echo.Context, key string) (bool, error) {
raw := strings.TrimSpace(c.QueryParam(key))
if raw == "" {
return false, nil
}
return strconv.ParseBool(raw)
}
func fsHTTPError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, os.ErrInvalid) {
return echo.NewHTTPError(http.StatusBadRequest, "invalid path")
}
if os.IsNotExist(err) {
return echo.NewHTTPError(http.StatusNotFound, "path not found")
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
+62 -130
View File
@@ -2,19 +2,14 @@ package handlers
import (
"context"
"errors"
"net/http"
"os"
"path"
"strconv"
"path/filepath"
"strings"
"time"
"github.com/labstack/echo/v4"
"gopkg.in/yaml.v3"
"github.com/memohai/memoh/internal/config"
mcptools "github.com/memohai/memoh/internal/mcp"
)
type SkillItem struct {
@@ -41,7 +36,7 @@ type skillsOpResponse struct {
}
// ListSkills godoc
// @Summary List skills from container
// @Summary List skills from data directory
// @Tags containerd
// @Param bot_id path string true "Bot ID"
// @Success 200 {object} SkillsResponse
@@ -54,26 +49,11 @@ func (h *ContainerdHandler) ListSkills(c echo.Context) error {
if err != nil {
return err
}
ctx := c.Request().Context()
containerID, err := h.botContainerID(ctx, botID)
if err != nil {
return err
}
if err := h.ensureTaskRunning(ctx, containerID); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
if err := h.ensureSkillsDirHost(botID); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
listPayload, err := h.callMCPTool(ctx, containerID, "list", map[string]any{
"path": ".skills",
"recursive": false,
})
skillsDir, err := h.ensureSkillsDirHost(botID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
entries, err := extractListEntries(listPayload)
entries, err := listSkillEntries(skillsDir)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
@@ -84,7 +64,7 @@ func (h *ContainerdHandler) ListSkills(c echo.Context) error {
if skillPath == "" {
continue
}
raw, err := h.readSkillFile(ctx, containerID, skillPath)
raw, err := h.readSkillFile(skillsDir, skillPath)
if err != nil {
continue
}
@@ -101,7 +81,7 @@ func (h *ContainerdHandler) ListSkills(c echo.Context) error {
}
// UpsertSkills godoc
// @Summary Upload skills into container
// @Summary Upload skills into data directory
// @Tags containerd
// @Param bot_id path string true "Bot ID"
// @Param payload body SkillsUpsertRequest true "Skills payload"
@@ -123,12 +103,8 @@ func (h *ContainerdHandler) UpsertSkills(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, "skills is required")
}
ctx := c.Request().Context()
containerID, err := h.botContainerID(ctx, botID)
skillsDir, err := h.ensureSkillsDirHost(botID)
if err != nil {
return err
}
if err := h.ensureTaskRunning(ctx, containerID); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
for _, skill := range req.Skills {
@@ -140,11 +116,12 @@ func (h *ContainerdHandler) UpsertSkills(c echo.Context) error {
if content == "" {
content = buildSkillContent(name, strings.TrimSpace(skill.Description))
}
filePath := path.Join(".skills", name, "SKILL.md")
if _, err := h.callMCPTool(ctx, containerID, "write", map[string]any{
"path": filePath,
"content": content,
}); err != nil {
dirPath := filepath.Join(skillsDir, name)
if err := os.MkdirAll(dirPath, 0o755); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
filePath := filepath.Join(dirPath, "SKILL.md")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
}
@@ -153,7 +130,7 @@ func (h *ContainerdHandler) UpsertSkills(c echo.Context) error {
}
// DeleteSkills godoc
// @Summary Delete skills from container
// @Summary Delete skills from data directory
// @Tags containerd
// @Param bot_id path string true "Bot ID"
// @Param payload body SkillsDeleteRequest true "Delete skills payload"
@@ -175,12 +152,8 @@ func (h *ContainerdHandler) DeleteSkills(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, "names is required")
}
ctx := c.Request().Context()
containerID, err := h.botContainerID(ctx, botID)
skillsDir, err := h.ensureSkillsDirHost(botID)
if err != nil {
return err
}
if err := h.ensureTaskRunning(ctx, containerID); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
@@ -189,10 +162,8 @@ func (h *ContainerdHandler) DeleteSkills(c echo.Context) error {
if !isValidSkillName(skillName) {
return echo.NewHTTPError(http.StatusBadRequest, "invalid skill name")
}
deletePath := path.Join(".skills", skillName)
if _, err := h.callMCPTool(ctx, containerID, "delete", map[string]any{
"path": deletePath,
}); err != nil {
deletePath := filepath.Join(skillsDir, skillName)
if err := os.RemoveAll(deletePath); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
}
@@ -203,25 +174,12 @@ func (h *ContainerdHandler) DeleteSkills(c echo.Context) error {
// LoadSkills loads all skills from the container for the given bot.
// This implements chat.SkillLoader.
func (h *ContainerdHandler) LoadSkills(ctx context.Context, botID string) ([]SkillItem, error) {
containerID, err := h.botContainerID(ctx, botID)
skillsDir, err := h.ensureSkillsDirHost(botID)
if err != nil {
return nil, err
}
if err := h.ensureTaskRunning(ctx, containerID); err != nil {
return nil, err
}
if err := h.ensureSkillsDirHost(botID); err != nil {
return nil, err
}
listPayload, err := h.callMCPTool(ctx, containerID, "list", map[string]any{
"path": ".skills",
"recursive": false,
})
if err != nil {
return nil, err
}
entries, err := extractListEntries(listPayload)
entries, err := listSkillEntries(skillsDir)
if err != nil {
return nil, err
}
@@ -232,7 +190,7 @@ func (h *ContainerdHandler) LoadSkills(ctx context.Context, botID string) ([]Ski
if skillPath == "" {
continue
}
raw, err := h.readSkillFile(ctx, containerID, skillPath)
raw, err := h.readSkillFile(skillsDir, skillPath)
if err != nil {
continue
}
@@ -247,69 +205,55 @@ func (h *ContainerdHandler) LoadSkills(ctx context.Context, botID string) ([]Ski
return skills, nil
}
func (h *ContainerdHandler) ensureSkillsDirHost(botID string) error {
dataRoot := strings.TrimSpace(h.cfg.DataRoot)
if dataRoot == "" {
dataRoot = config.DefaultDataRoot
func (h *ContainerdHandler) ensureSkillsDirHost(botID string) (string, error) {
root, err := h.ensureBotDataRoot(botID)
if err != nil {
return "", err
}
skillsDir := path.Join(dataRoot, "bots", botID, ".skills")
return os.MkdirAll(skillsDir, 0o755)
skillsDir := filepath.Join(root, ".skills")
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
return "", err
}
return skillsDir, nil
}
func (h *ContainerdHandler) readSkillFile(ctx context.Context, containerID, filePath string) (string, error) {
payload, err := h.callMCPTool(ctx, containerID, "read", map[string]any{
"path": filePath,
func (h *ContainerdHandler) readSkillFile(skillsDir, filePath string) (string, error) {
safeRel := strings.TrimPrefix(strings.TrimPrefix(filePath, ".skills/"), "./.skills/")
if safeRel == "" {
return "", os.ErrInvalid
}
target := filepath.Join(skillsDir, filepath.FromSlash(safeRel))
data, err := os.ReadFile(target)
if err != nil {
return "", err
}
return string(data), nil
}
func listSkillEntries(skillsDir string) ([]skillEntry, error) {
dirEntries, err := os.ReadDir(skillsDir)
if err != nil {
return nil, err
}
entries := make([]skillEntry, 0, len(dirEntries))
for _, entry := range dirEntries {
name := entry.Name()
if name == "" {
continue
}
if entry.IsDir() {
entries = append(entries, skillEntry{
Path: path.Join(".skills", name),
IsDir: true,
})
if err != nil {
return "", err
}
content, err := extractContentString(payload)
if err != nil {
return "", err
}
return content, nil
}
func (h *ContainerdHandler) callMCPTool(ctx context.Context, containerID, toolName string, args map[string]any) (map[string]any, error) {
id := "skills-" + strconv.FormatInt(time.Now().UnixNano(), 10)
req, err := mcptools.NewToolCallRequest(id, toolName, args)
if err != nil {
return nil, err
}
payload, err := h.callMCPServer(ctx, containerID, req)
if err != nil {
return nil, err
}
if err := mcptools.PayloadError(payload); err != nil {
return nil, err
}
if err := mcptools.ResultError(payload); err != nil {
return nil, err
}
return payload, nil
}
func extractListEntries(payload map[string]any) ([]skillEntry, error) {
result, err := mcptools.StructuredContent(payload)
if err != nil {
return nil, err
}
rawEntries, ok := result["entries"].([]any)
if !ok {
return nil, errors.New("invalid list response")
}
entries := make([]skillEntry, 0, len(rawEntries))
for _, raw := range rawEntries {
entryMap, ok := raw.(map[string]any)
if !ok {
continue
}
entryPath, _ := entryMap["path"].(string)
if entryPath == "" {
continue
if name == "SKILL.md" {
entries = append(entries, skillEntry{
Path: path.Join(".skills", name),
IsDir: false,
})
}
isDir, _ := entryMap["is_dir"].(bool)
entries = append(entries, skillEntry{Path: entryPath, IsDir: isDir})
}
return entries, nil
}
@@ -319,18 +263,6 @@ type skillEntry struct {
IsDir bool
}
func extractContentString(payload map[string]any) (string, error) {
result, err := mcptools.StructuredContent(payload)
if err != nil {
return "", err
}
content, _ := result["content"].(string)
if content == "" {
return "", errors.New("empty content")
}
return content, nil
}
func skillNameFromPath(rel string) string {
if rel == "" || rel == "SKILL.md" {
return "default"