From 279f44b9a05f5f862e1eaa149e7b35f05063d131 Mon Sep 17 00:00:00 2001 From: pabtztd <267608262+pabtztd@users.noreply.github.com> Date: Fri, 6 Nov 2026 07:58:28 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E7=9A=84=E8=BF=9B=E7=A8=8B=E5=8F=82=E6=95=B0=E4=BB=A5=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=8D=87=E7=BA=A7=E5=90=8E=E7=9A=84=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E4=B8=8E=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server.test.ts | 101 ++++++++++++++++++++++++++++++++++++++++++++- src/server.ts | 37 ++++++++++++----- 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/src/server.test.ts b/src/server.test.ts index 7dfb196b1..21ed588ea 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,4 +1,4 @@ -import { isObjectValue, isFunctionValue, isJsonString, isJsonObject, optionalFields } from "./json-value.js"; +import { isObjectValue, isFunctionValue, isJsonString, isJsonNumber, isJsonObject, optionalFields } from "./json-value.js"; import { unsafeNarrow } from "./unsafe-narrow.js"; import type { JsonObject, WireInput } from "./json-value.js"; import assert from "node:assert/strict"; @@ -890,6 +890,105 @@ test("HTTP cached workspaceId arguments preserve canonical schemas and validatio assert.equal(read?.inputSchema.properties?.workspaceId, undefined); }); +test("HTTP cached process arguments preserve working directory, yield and output limits", async (t) => { + const { root, localBaseUrl, accessToken } = await httpServerFixture(t, "devspace-cached-process-test-"); + await mkdir(join(root, "nested")); + + const opened = await postModernMcp(localBaseUrl, accessToken, "tools/call", { + name: "open_workspace", arguments: { path: root }, + }); + + const workspaceId = (await opened.json()).result.structuredContent.workspace_id; + + const command = await postModernMcp(localBaseUrl, accessToken, "tools/call", { + name: "exec_command", + arguments: { + workspaceId, + cmd: "node -e \"console.log(require('node:path').basename(process.cwd()));console.log('x'.repeat(2000))\"", + workingDirectory: "nested", yieldTimeMs: 2_000, maxOutputTokens: 10, + }, + }); + + const completed = (await command.json()).result.structuredContent; + assert.match(completed.result, /^nested\r?\n/); + assert.equal(completed.output_truncated, true); + assert.equal(completed.exit_code, 0); + + const started = await postModernMcp(localBaseUrl, accessToken, "tools/call", { + name: "exec_command", + arguments: { + workspaceId, + cmd: "node -e \"setTimeout(()=>console.log('y'.repeat(2000)),250)\"", + yieldTimeMs: 0, + }, + }); + + const session = (await started.json()).result.structuredContent; + assert.equal(session.running, true); + assert.equal(isJsonNumber(session.session_id), true); + + const polled = await postModernMcp(localBaseUrl, accessToken, "tools/call", { + name: "write_stdin", + arguments: { + workspaceId, sessionId: session.session_id, + yieldTimeMs: 2_000, maxOutputTokens: 10, + }, + }); + + const result = (await polled.json()).result.structuredContent; + assert.equal(result.running, false); + assert.equal(result.exit_code, 0); + assert.equal(result.output_truncated, true); + assert.match(result.result, /yyyy/); +}); + +test("HTTP cached process aliases reject conflicts and invalid types before execution", async (t) => { + const { root, localBaseUrl, accessToken } = await httpServerFixture(t, "devspace-process-alias-validation-"); + + const opened = await postModernMcp(localBaseUrl, accessToken, "tools/call", { + name: "open_workspace", arguments: { path: root }, + }); + + const workspaceId = (await opened.json()).result.structuredContent.workspace_id; + const cmd = "node -e \"require('node:fs').writeFileSync('unexpected','')\""; + + for (const [name, aliases] of [ + ["exec_command", { workingDirectory: ".", working_directory: "other" }], + ["exec_command", { yieldTimeMs: 0, yield_time_ms: 1 }], + ["exec_command", { maxOutputTokens: 10, max_output_tokens: 20 }], + ["write_stdin", { sessionId: 1, session_id: 2 }], + ["write_stdin", { yieldTimeMs: 0, yield_time_ms: 1 }], + ["write_stdin", { maxOutputTokens: 10, max_output_tokens: 20 }], + ] as const) { + const response = await postModernMcp(localBaseUrl, accessToken, "tools/call", { + name, arguments: { workspaceId, cmd, ...aliases }, + }); + + assert.equal(response.status, 400, JSON.stringify(aliases)); + assert.equal((await response.json()).error.code, -32602); + } + + const invalid = await postModernMcp(localBaseUrl, accessToken, "tools/call", { + name: "exec_command", arguments: { workspaceId, cmd, yieldTimeMs: "0" }, + }); + + const body = await invalid.json(); + assert.ok(body.error || body.result?.isError, JSON.stringify(body)); + await assert.rejects(access(join(root, "unexpected")), { code: "ENOENT" }); + + const equal = await postModernMcp(localBaseUrl, accessToken, "tools/call", { + name: "exec_command", + arguments: { + workspaceId, workspace_id: workspaceId, cmd: "node -e \"console.log('equal')\"", + workingDirectory: ".", working_directory: ".", + yieldTimeMs: 2_000, yield_time_ms: 2_000, + maxOutputTokens: 10, max_output_tokens: 10, + }, + }); + + assert.match((await equal.json()).result.structuredContent.result, /equal/); +}); + test("HTTP conflicting workspace aliases preserve request IDs and notification semantics", async (t) => { const { localBaseUrl, accessToken } = await httpServerFixture(t, "devspace-cached-id-test-"); diff --git a/src/server.ts b/src/server.ts index 2002efedc..1087f3c28 100644 --- a/src/server.ts +++ b/src/server.ts @@ -239,9 +239,9 @@ function sendJsonRpcError( }); } -// ChatGPT may retain the pre-snake_case workspace argument in cached tool calls. -// Normalize only that known alias at the HTTP edge; the public schema remains canonical. -function normalizeCachedWorkspaceArgument(body: WireInput): boolean { +// ChatGPT may retain camelCase arguments from older cached tool schemas. +// Normalize known aliases at the HTTP edge; public schemas stay canonical. +function normalizeCachedToolArguments(body: WireInput): boolean { if (!body || !isObjectValue(body) || Array.isArray(body)) return true; // SAFETY: The surrounding checks or construction already match this type. const request = body as JsonObject; @@ -251,20 +251,35 @@ function normalizeCachedWorkspaceArgument(body: WireInput): boolean { if (!params || !isObjectValue(params) || Array.isArray(params)) return true; // SAFETY: The surrounding checks or construction already match this type. - const args = (params as JsonObject).arguments; + const call = params as JsonObject; + const args = call.arguments; if (!args || !isObjectValue(args) || Array.isArray(args)) return true; // SAFETY: The surrounding checks or construction already match this type. const values = args as JsonObject; - if (!Object.hasOwn(values, "workspaceId")) return true; + const aliases = [["workspaceId", "workspace_id"]]; - if (Object.hasOwn(values, "workspace_id") && values.workspace_id !== values.workspaceId) { - return false; + if (call.name === "exec_command" || call.name === "write_stdin") { + aliases.push(["yieldTimeMs", "yield_time_ms"], ["maxOutputTokens", "max_output_tokens"]); + aliases.push(call.name === "exec_command" + ? ["workingDirectory", "working_directory"] + : ["sessionId", "session_id"]); } - if (!Object.hasOwn(values, "workspace_id")) values.workspace_id = values.workspaceId; - delete values.workspaceId; + for (const [cached, canonical] of aliases) { + if (Object.hasOwn(values, cached) && Object.hasOwn(values, canonical) + && values[cached] !== values[canonical]) { + return false; + } + } + + for (const [cached, canonical] of aliases) { + if (!Object.hasOwn(values, cached)) continue; + + if (!Object.hasOwn(values, canonical)) values[canonical] = values[cached]; + delete values[cached]; + } return true; } @@ -1314,7 +1329,7 @@ export function createServer( ...rpcRequestLogFields(req.body), }); - if (!normalizeCachedWorkspaceArgument(req.body)) { + if (!normalizeCachedToolArguments(req.body)) { // SAFETY: The surrounding checks or construction already match this type. const body = req.body as JsonObject; @@ -1325,7 +1340,7 @@ export function createServer( } const id = isJsonString(body.id) || isJsonNumber(body.id) ? body.id : null; - sendJsonRpcError(res, 400, -32602, "Conflicting workspaceId and workspace_id arguments", id); + sendJsonRpcError(res, 400, -32602, "Conflicting cached and canonical tool arguments", id); return; }