From e1448c488a16e5105cababf3b7b239621a57f76f Mon Sep 17 00:00:00 2001 From: Blasius Patrick Date: Thu, 25 Jun 2026 11:45:19 +0700 Subject: [PATCH 1/2] fix: move request body models to module level to fix 422 on POST With , inline BaseModel classes defined inside create_app() cause FastAPI/Pydantic to treat body params as query params, returning a silent 422 validation error on every exec/read/write POST. Moving _ExecRequest, _ReadRequest, and _WriteRequest to module level resolves the annotation resolution issue. Signed-off-by: Blasius Patrick --- wsserver/server.py | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/wsserver/server.py b/wsserver/server.py index 45c2713..3749396 100644 --- a/wsserver/server.py +++ b/wsserver/server.py @@ -232,6 +232,31 @@ async def _safe_close(websocket: WebSocket, code: int) -> None: pass +# --------------------------------------------------------------------------- +# Request body models for internal HTTP endpoints. +# Defined at module level to avoid the `from __future__ import annotations` +# + inline-BaseModel interaction that causes FastAPI/Pydantic to treat body +# params as query params → silent 422 (see skill §2.7.0). +# --------------------------------------------------------------------------- + + +class _ExecRequest(BaseModel): + command: str + cwd: str | None = None + env: dict[str, str] | None = None + timeout_ms: int | None = None + + +class _ReadRequest(BaseModel): + path: str + + +class _WriteRequest(BaseModel): + path: str + content: str + mode: str = "overwrite" + + # --------------------------------------------------------------------------- # FastAPI app factory # --------------------------------------------------------------------------- @@ -509,12 +534,6 @@ async def server_status() -> dict[str, Any]: } # -- Internal exec endpoint -------------------------------------------- - class _ExecRequest(BaseModel): - command: str - cwd: str | None = None - env: dict[str, str] | None = None - timeout_ms: int | None = None - @app.post("/nodes/{node_name}/exec") async def nodes_exec(node_name: str, body: _ExecRequest) -> dict[str, Any]: conn = await registry.get(node_name) @@ -570,9 +589,6 @@ async def nodes_exec(node_name: str, body: _ExecRequest) -> dict[str, Any]: return {"status": "error", "code": 500, "reason": str(e)} # -- Internal read endpoint ------------------------------------------- - class _ReadRequest(BaseModel): - path: str - @app.post("/nodes/{node_name}/read") async def nodes_read(node_name: str, body: _ReadRequest) -> dict[str, Any]: conn = await registry.get(node_name) @@ -620,11 +636,6 @@ async def nodes_read(node_name: str, body: _ReadRequest) -> dict[str, Any]: return {"status": "error", "code": 500, "reason": str(e)} # -- Internal write endpoint ------------------------------------------- - class _WriteRequest(BaseModel): - path: str - content: str - mode: str = "overwrite" - @app.post("/nodes/{node_name}/write") async def nodes_write(node_name: str, body: _WriteRequest) -> dict[str, Any]: conn = await registry.get(node_name) From 37d9bd104e7b0eee0eb03e25f406ace6da63e02e Mon Sep 17 00:00:00 2001 From: Blasius Patrick Date: Thu, 25 Jun 2026 12:06:51 +0700 Subject: [PATCH 2/2] fix: use content_b64 field to match Go protocol wire format Go client's ReadResultPayload and WritePayload use content_b64 (base64-encoded) for file content, but the Python plugin was using raw content with the wrong field name: - tools.py _node_read_impl: read 'content' instead of 'content_b64' from Go's read_result response. Added base64 decode. - server.py nodes_write: sent 'content' instead of 'content_b64' to the Go client. Now base64-encodes before sending. Fixes node_read returning empty content and node_write returning bytes_written=0 on macOS (and any platform). Signed-off-by: Blasius Patrick --- tools.py | 11 ++++++++++- wsserver/server.py | 5 ++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/tools.py b/tools.py index 40403fd..7baade5 100644 --- a/tools.py +++ b/tools.py @@ -229,8 +229,17 @@ def _node_read_impl( if result.get("status") == "ok": read_result = result.get("read_result", {}) + import base64 as _base64 + + content_b64 = read_result.get("content_b64", "") + content = "" + if content_b64: + try: + content = _base64.b64decode(content_b64).decode("utf-8") + except Exception: + content = "" return json.dumps({ - "content": read_result.get("content", ""), + "content": content, "size_bytes": read_result.get("size_bytes", 0), "truncated": read_result.get("truncated", False), "encoding": "utf-8", diff --git a/wsserver/server.py b/wsserver/server.py index 3749396..e31cefb 100644 --- a/wsserver/server.py +++ b/wsserver/server.py @@ -658,12 +658,15 @@ async def nodes_write(node_name: str, body: _WriteRequest) -> dict[str, Any]: } try: + import base64 as _base64 + + content_b64 = _base64.b64encode(body.content.encode("utf-8")).decode("ascii") await conn.websocket.send_json( { "type": "write", "id": request_id, "path": body.path, - "content": body.content, + "content_b64": content_b64, "mode": body.mode, } )