diff --git a/open-sse/executors/devin-agentic/toolParser.ts b/open-sse/executors/devin-agentic/toolParser.ts index e964d2e684..10e087d905 100644 --- a/open-sse/executors/devin-agentic/toolParser.ts +++ b/open-sse/executors/devin-agentic/toolParser.ts @@ -31,7 +31,9 @@ function validateSchema(value: unknown, schema: JsonRecord, path: string): strin } if (Array.isArray(schema.enum) && !schema.enum.some((item) => item === value)) { - errors.push(`${path} must be one of ${schema.enum.map((item) => JSON.stringify(item)).join(", ")}`); + errors.push( + `${path} must be one of ${schema.enum.map((item) => JSON.stringify(item)).join(", ")}` + ); } if (schema.type === "object" || (value && typeof value === "object" && !Array.isArray(value))) { @@ -43,7 +45,8 @@ function validateSchema(value: unknown, schema: JsonRecord, path: string): strin const properties = asRecord(schema.properties); for (const [key, propSchema] of Object.entries(properties)) { - if (key in record) errors.push(...validateSchema(record[key], asRecord(propSchema), `${path}.${key}`)); + if (key in record) + errors.push(...validateSchema(record[key], asRecord(propSchema), `${path}.${key}`)); } if (schema.additionalProperties === false) { @@ -55,7 +58,9 @@ function validateSchema(value: unknown, schema: JsonRecord, path: string): strin if (Array.isArray(value) && schema.items) { const itemSchema = asRecord(schema.items); - value.forEach((item, index) => errors.push(...validateSchema(item, itemSchema, `${path}[${index}]`))); + value.forEach((item, index) => + errors.push(...validateSchema(item, itemSchema, `${path}[${index}]`)) + ); } return errors; @@ -71,6 +76,13 @@ export function parseDevinToolRequest(text: string, tools: AnthropicTool[]) { ); } + if (text.trim() !== matches[0][0].trim()) { + throw new DevinAgenticBridgeError( + "Devin tool request must be a standalone tool envelope without narrative text", + "mixed_tool_narrative" + ); + } + let payload: JsonRecord; try { payload = asRecord(JSON.parse(matches[0][1] || "{}")); @@ -79,7 +91,8 @@ export function parseDevinToolRequest(text: string, tools: AnthropicTool[]) { } const name = typeof payload.name === "string" ? payload.name.trim() : ""; - if (!name) throw new DevinAgenticBridgeError("Devin tool request is missing name", "missing_tool_name"); + if (!name) + throw new DevinAgenticBridgeError("Devin tool request is missing name", "missing_tool_name"); const tool = tools.find((candidate) => candidate.name === name); if (!tool) { @@ -96,7 +109,9 @@ export function parseDevinToolRequest(text: string, tools: AnthropicTool[]) { ); } - const digest = createHash("sha256").update(`${name}:${stableJson(input)}`).digest("hex").slice(0, 16); + const digest = createHash("sha256") + .update(`${name}:${stableJson(input)}`) + .digest("hex") + .slice(0, 16); return { id: `tool_devin_${digest}`, name, input }; } - diff --git a/open-sse/executors/devin-cli-agentic.ts b/open-sse/executors/devin-cli-agentic.ts index 4f657267ac..ceccf1edbd 100644 --- a/open-sse/executors/devin-cli-agentic.ts +++ b/open-sse/executors/devin-cli-agentic.ts @@ -55,18 +55,49 @@ function rpc(method: string, params: unknown, id: number): string { return JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"; } -function buildChildEnv(credentials: ExecuteInput["credentials"]): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...process.env }; +export function assertLocalAcpUrl(url: string): void { + if (url !== "devin://acp/stdio") { + throw new DevinAgenticBridgeError( + "devin-cli-agentic accepts only the local Devin ACP stdio upstream", + "invalid_acp_upstream", + 500 + ); + } +} + +function isIsolatedHome(value: string): boolean { + return value === "/home/bridge" || value.includes("/.sandbox/"); +} + +export function buildDevinChildEnv( + _credentials: ExecuteInput["credentials"], + source: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { + const home = source.DEVIN_AGENTIC_HOME?.trim() || ""; + if (!home || !path.isAbsolute(home) || !isIsolatedHome(home)) { + throw new DevinAgenticBridgeError( + "DEVIN_AGENTIC_HOME must be an absolute path inside the bridge sandbox", + "unsafe_devin_home", + 500 + ); + } + + const env: NodeJS.ProcessEnv = { + HOME: home, + XDG_CONFIG_HOME: path.join(home, ".config"), + XDG_DATA_HOME: path.join(home, ".local", "share"), + XDG_CACHE_HOME: path.join(home, ".cache"), + PATH: source.PATH || "/usr/local/bin:/usr/bin:/bin", + LANG: source.LANG || "C.UTF-8", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + DISABLE_TELEMETRY: "1", + DISABLE_ERROR_REPORTING: "1", + DISABLE_AUTOUPDATER: "1", + }; + if (source.LC_ALL) env.LC_ALL = source.LC_ALL; + if (source.TERM) env.TERM = source.TERM; + for (const key of CLAUDE_ENV_BLOCKLIST) delete env[key]; - - env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"; - env.DISABLE_TELEMETRY = "1"; - env.DISABLE_ERROR_REPORTING = "1"; - env.DISABLE_AUTOUPDATER = "1"; - - const apiKey = - credentials.apiKey || credentials.accessToken || process.env.WINDSURF_API_KEY || ""; - if (apiKey) env.WINDSURF_API_KEY = apiKey; return env; } @@ -93,7 +124,7 @@ async function runAcpTurn(args: { const child = spawn(args.devinBin, ["acp"], { env: args.env, stdio: ["pipe", "pipe", "pipe"], - shell: process.platform === "win32", + shell: false, }); let nextId = 1; @@ -120,11 +151,7 @@ async function runAcpTurn(args: { const timer = setTimeout(() => { finish( - new DevinAgenticBridgeError( - `Devin ACP timed out after ${timeoutMs}ms`, - "acp_timeout", - 504 - ) + new DevinAgenticBridgeError(`Devin ACP timed out after ${timeoutMs}ms`, "acp_timeout", 504) ); }, timeoutMs); timer.unref?.(); @@ -190,7 +217,13 @@ async function runAcpTurn(args: { if (initialized && !sessionCreated && msg.result !== undefined && !msg.method) { sessionId = String(asRecord(msg.result).sessionId || ""); if (!sessionId) { - finish(new DevinAgenticBridgeError("Devin ACP session/new returned no sessionId", "missing_session_id", 502)); + finish( + new DevinAgenticBridgeError( + "Devin ACP session/new returned no sessionId", + "missing_session_id", + 502 + ) + ); return; } sessionCreated = true; @@ -207,14 +240,19 @@ async function runAcpTurn(args: { const kind = String(update.sessionUpdate || params.type || ""); if (kind === "agent_message_chunk") { text += extractText(update.content); - } else if (kind === "message_delta" || kind === "text_delta" || kind === "content_delta") { + } else if ( + kind === "message_delta" || + kind === "text_delta" || + kind === "content_delta" + ) { text += String(params.content || params.delta || params.text || ""); } continue; } if (sessionCreated && msg.id === promptRequestId && msg.result !== undefined) { - const resultText = extractText(asRecord(msg.result).content) || extractText(asRecord(msg.result).message); + const resultText = + extractText(asRecord(msg.result).content) || extractText(asRecord(msg.result).message); finish(null, text || resultText); } } @@ -223,7 +261,14 @@ async function runAcpTurn(args: { child.on("close", (code) => { if (settled) return; if (code === 0 && text) finish(null, text); - else finish(new DevinAgenticBridgeError(`Devin CLI exited before completing the turn with code ${code}`, "acp_early_exit", 502)); + else + finish( + new DevinAgenticBridgeError( + `Devin CLI exited before completing the turn with code ${code}`, + "acp_early_exit", + 502 + ) + ); }); send("initialize", { @@ -249,7 +294,9 @@ export class DevinCliAgenticExecutor extends BaseExecutor { } buildUrl(): string { - return "devin://acp/stdio"; + const url = "devin://acp/stdio"; + assertLocalAcpUrl(url); + return url; } buildHeaders(): Record { @@ -268,7 +315,7 @@ export class DevinCliAgenticExecutor extends BaseExecutor { const text = await runAcpTurn({ devinBin, - env: buildChildEnv(credentials), + env: buildDevinChildEnv(credentials), model, promptText: prompt.text, signal, @@ -321,4 +368,3 @@ export class DevinCliAgenticExecutor extends BaseExecutor { } } } - diff --git a/tests/unit/executor-devin-cli-agentic-acp.test.ts b/tests/unit/executor-devin-cli-agentic-acp.test.ts index 726ef3cf4d..49f668806c 100644 --- a/tests/unit/executor-devin-cli-agentic-acp.test.ts +++ b/tests/unit/executor-devin-cli-agentic-acp.test.ts @@ -5,12 +5,50 @@ import os from "node:os"; import path from "node:path"; import { writeFileSync } from "node:fs"; -import { DevinCliAgenticExecutor } from "../../open-sse/executors/devin-cli-agentic.ts"; +import { + assertLocalAcpUrl, + buildDevinChildEnv, + DevinCliAgenticExecutor, +} from "../../open-sse/executors/devin-cli-agentic.ts"; + +process.env.DEVIN_AGENTIC_HOME = path.join(process.cwd(), ".sandbox", "unit-home"); async function readResponseText(response: Response) { return await response.text(); } +test("Devin child environment is allowlisted and requires an isolated home", () => { + const isolatedHome = path.join(process.cwd(), ".sandbox", "unit-home"); + const env = buildDevinChildEnv( + { apiKey: "devin-test" }, + { + HOME: "/Users/example", + PATH: "/usr/bin:/bin", + ANTHROPIC_AUTH_TOKEN: "must-not-leak", + AWS_ACCESS_KEY_ID: "must-not-leak", + GITHUB_TOKEN: "must-not-leak", + DEVIN_AGENTIC_HOME: isolatedHome, + } + ); + + assert.equal(env.HOME, isolatedHome); + assert.equal(env.PATH, "/usr/bin:/bin"); + assert.equal(env.WINDSURF_API_KEY, undefined); + assert.equal(env.ANTHROPIC_AUTH_TOKEN, undefined); + assert.equal(env.AWS_ACCESS_KEY_ID, undefined); + assert.equal(env.GITHUB_TOKEN, undefined); + assert.throws( + () => buildDevinChildEnv({}, { PATH: "/usr/bin", DEVIN_AGENTIC_HOME: "/tmp/outside" }), + /inside the bridge sandbox/ + ); +}); + +test("Devin agentic upstream is fixed to local ACP stdio", () => { + assert.doesNotThrow(() => assertLocalAcpUrl("devin://acp/stdio")); + assert.throws(() => assertLocalAcpUrl("https://api.anthropic.com"), /ACP stdio/); + assert.throws(() => assertLocalAcpUrl("http://localhost:9999"), /ACP stdio/); +}); + function writeMockDevin(tmpDir: string, responseText: string) { const framesFile = path.join(tmpDir, "frames.json"); const scriptFile = path.join(tmpDir, "mock-devin"); @@ -118,4 +156,3 @@ test("DevinCliAgenticExecutor returns Anthropic SSE for streaming Claude clients fs.rmSync(tmpDir, { recursive: true, force: true }); } }); - diff --git a/tests/unit/executor-devin-cli-agentic-core.test.ts b/tests/unit/executor-devin-cli-agentic-core.test.ts index 6cfb3721dd..701e18756e 100644 --- a/tests/unit/executor-devin-cli-agentic-core.test.ts +++ b/tests/unit/executor-devin-cli-agentic-core.test.ts @@ -26,9 +26,7 @@ test("devin agentic serializer preserves Anthropic tool history and schemas", () { role: "user", content: [{ type: "text", text: "Inspect the file" }] }, { role: "assistant", - content: [ - { type: "tool_use", id: "toolu_1", name: "Read", input: { file_path: "a.ts" } }, - ], + content: [{ type: "tool_use", id: "toolu_1", name: "Read", input: { file_path: "a.ts" } }], }, { role: "user", @@ -88,15 +86,24 @@ test("devin agentic parser leaves narrative text as text, not a tool", () => { assert.equal(parseDevinToolRequest("I read the file and it passes.", [readTool]), null); }); +test("devin agentic parser rejects mixed narrative and tool action", () => { + assert.throws( + () => + parseDevinToolRequest( + 'I will read it. {"name":"Read","arguments":{"file_path":"a.ts"}}', + [readTool] + ), + /standalone tool envelope/ + ); +}); + test("devin agentic SSE renders Anthropic tool lifecycle", () => { const sse = buildClaudeSseFrames({ id: "msg_1", type: "message", role: "assistant", model: "swe-1-7", - content: [ - { type: "tool_use", id: "tool_devin_1", name: "Read", input: { file_path: "a.ts" } }, - ], + content: [{ type: "tool_use", id: "tool_devin_1", name: "Read", input: { file_path: "a.ts" } }], stop_reason: "tool_use", stop_sequence: null, usage: { input_tokens: 10, output_tokens: 2 }, @@ -109,4 +116,3 @@ test("devin agentic SSE renders Anthropic tool lifecycle", () => { assert.match(sse, /"stop_reason":"tool_use"/); assert.match(sse, /event: message_stop/); }); -