mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 05:32:19 +03:00
fix: fail closed around Devin ACP execution
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string> {
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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. <tool>{"name":"Read","arguments":{"file_path":"a.ts"}}</tool>',
|
||||
[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/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user