diff --git a/agent/src/agent.ts b/agent/src/agent.ts index 6cc3cf7d..e96e43e8 100644 --- a/agent/src/agent.ts +++ b/agent/src/agent.ts @@ -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 = () => { diff --git a/docs/docs.go b/docs/docs.go index 063aa69f..9187bec8 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": { diff --git a/docs/swagger.json b/docs/swagger.json index 7b795f7c..57d5d4ba 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 5bf23b95..6f690b22 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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: diff --git a/internal/handlers/containerd.go b/internal/handlers/containerd.go index 417b9789..6599c4f2 100644 --- a/internal/handlers/containerd.go +++ b/internal/handlers/containerd.go @@ -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) } diff --git a/internal/handlers/fs.go b/internal/handlers/fs.go index 8dbac89e..d10d5b35 100644 --- a/internal/handlers/fs.go +++ b/internal/handlers/fs.go @@ -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 } - diff --git a/internal/handlers/fs_rest.go b/internal/handlers/fs_rest.go new file mode 100644 index 00000000..96e529d8 --- /dev/null +++ b/internal/handlers/fs_rest.go @@ -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()) +} diff --git a/internal/handlers/skills.go b/internal/handlers/skills.go index 3fd4169f..4a636ff5 100644 --- a/internal/handlers/skills.go +++ b/internal/handlers/skills.go @@ -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 - } - skillsDir := path.Join(dataRoot, "bots", botID, ".skills") - return os.MkdirAll(skillsDir, 0o755) -} - -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) ensureSkillsDirHost(botID string) (string, error) { + root, err := h.ensureBotDataRoot(botID) if err != nil { return "", err } - content, err := extractContentString(payload) + skillsDir := filepath.Join(root, ".skills") + if err := os.MkdirAll(skillsDir, 0o755); err != nil { + return "", err + } + return skillsDir, nil +} + +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 content, nil + return string(data), 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) +func listSkillEntries(skillsDir string) ([]skillEntry, error) { + dirEntries, err := os.ReadDir(skillsDir) 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 { + entries := make([]skillEntry, 0, len(dirEntries)) + for _, entry := range dirEntries { + name := entry.Name() + if name == "" { continue } - entryPath, _ := entryMap["path"].(string) - if entryPath == "" { + if entry.IsDir() { + entries = append(entries, skillEntry{ + Path: path.Join(".skills", name), + IsDir: true, + }) continue } - isDir, _ := entryMap["is_dir"].(bool) - entries = append(entries, skillEntry{Path: entryPath, IsDir: isDir}) + if name == "SKILL.md" { + entries = append(entries, skillEntry{ + Path: path.Join(".skills", name), + IsDir: false, + }) + } } 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"