mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 02:02:13 +03:00
fix: fail closed on incompatible Devin ACP behavior
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -246,7 +246,8 @@ _artifacts/ # release-green artifacts
|
||||
|
||||
# CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.)
|
||||
.artifacts/
|
||||
.sandbox/ # isolated Devin bridge workspaces, evidence, and test databases
|
||||
# Isolated Devin bridge workspaces, evidence, and test databases
|
||||
.sandbox/
|
||||
|
||||
# Homologation E2E suite (npm run homolog) — real-environment credentials + report output
|
||||
.env.homolog
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
import fs from "node:fs";
|
||||
import readline from "node:readline";
|
||||
|
||||
if (
|
||||
process.argv[2] !== "acp" ||
|
||||
process.argv[3] !== "--agent-type" ||
|
||||
process.argv[4] !== "summarizer"
|
||||
) {
|
||||
if (process.argv[2] !== "acp" || process.argv.length !== 3) {
|
||||
process.exit(64);
|
||||
}
|
||||
|
||||
@@ -118,6 +114,32 @@ rl.on("line", (line) => {
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } });
|
||||
return;
|
||||
}
|
||||
if (prompt.includes("CONTRACT_NARRATIVE_REPAIR")) {
|
||||
const isRepair = prompt.includes("[Single Repair Attempt]");
|
||||
log({
|
||||
provider: "devin-cli-agentic",
|
||||
scenario: "narrative-repair",
|
||||
stage: isRepair ? "repair" : "initial",
|
||||
});
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "offline",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: isRepair
|
||||
? '<tool>{"name":"Read","arguments":{"file_path":"/workspace/math.js"}}</tool>'
|
||||
: "I'll start by reading the math.js file, then run the tests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } });
|
||||
return;
|
||||
}
|
||||
if (prompt.includes("CONTRACT_TOOL")) {
|
||||
log({ provider: "devin-cli-agentic", scenario: "tool" });
|
||||
send({
|
||||
|
||||
@@ -70,6 +70,26 @@ assert.equal(toolUse?.type, "tool_use");
|
||||
assert.equal(toolUse?.name, "Read");
|
||||
assert.match(toolUse?.id || "", /^tool_devin_/);
|
||||
|
||||
const repairedNarrativeReply = await request("CONTRACT_NARRATIVE_REPAIR", {
|
||||
tools: [
|
||||
{
|
||||
name: "Read",
|
||||
description: "Read a file",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: { file_path: { type: "string" } },
|
||||
required: ["file_path"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(repairedNarrativeReply.status, 200);
|
||||
const repairedNarrativeBody = await repairedNarrativeReply.json();
|
||||
assert.equal(repairedNarrativeBody.stop_reason, "tool_use");
|
||||
assert.equal(repairedNarrativeBody.content?.[0]?.type, "tool_use");
|
||||
assert.equal(repairedNarrativeBody.content?.[0]?.name, "Read");
|
||||
|
||||
const continuationReply = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
|
||||
@@ -158,6 +158,8 @@ function serializeToolCatalog(tools: AnthropicTool[]): string[] {
|
||||
"When a tool is required, respond with exactly one XML-wrapped JSON object:",
|
||||
'<tool>{"name":"ToolName","arguments":{}}</tool>',
|
||||
"Use only the tools listed below. Do not claim that a tool was executed.",
|
||||
"Do not execute tools inside Devin or emit ACP tool-call events; request them only with the XML envelope.",
|
||||
"Never describe a future tool action in plain text; emit the tool envelope instead.",
|
||||
].join("\n"),
|
||||
...tools.map((tool) =>
|
||||
[
|
||||
|
||||
@@ -34,8 +34,15 @@ const REPAIRABLE_TOOL_ERRORS = new Set([
|
||||
"invalid_tool_arguments",
|
||||
"multiple_tool_requests",
|
||||
"mixed_tool_narrative",
|
||||
"unexecuted_tool_intent",
|
||||
]);
|
||||
|
||||
function describesUnexecutedToolIntent(text: string): boolean {
|
||||
return /\b(?:i(?:'ll| will)|let me)\b[^\n.!?]{0,160}\b(?:read|inspect|examine|edit|fix|run|check|test|start)\b/i.test(
|
||||
text
|
||||
);
|
||||
}
|
||||
|
||||
const CLAUDE_ENV_BLOCKLIST = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
@@ -142,7 +149,7 @@ export async function runAcpTurn(args: {
|
||||
log?: ExecuteInput["log"];
|
||||
}) {
|
||||
const timeoutMs = Number(process.env.DEVIN_AGENTIC_ACP_TIMEOUT_MS || 120000);
|
||||
const child = spawn(args.devinBin, ["acp", "--agent-type", "summarizer"], {
|
||||
const child = spawn(args.devinBin, ["acp"], {
|
||||
env: args.env,
|
||||
cwd: args.env.HOME,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
@@ -304,6 +311,16 @@ export async function runAcpTurn(args: {
|
||||
}
|
||||
const update = asRecord(params.update);
|
||||
const kind = String(update.sessionUpdate || params.type || "");
|
||||
if (kind === "tool_call" || kind === "tool_call_update") {
|
||||
finish(
|
||||
new DevinAgenticBridgeError(
|
||||
"Devin attempted to execute a tool internally; Claude Code must own all tool execution",
|
||||
"devin_internal_tool_execution",
|
||||
502
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (kind === "agent_message_chunk") {
|
||||
text += extractText(update.content);
|
||||
} else if (
|
||||
@@ -439,6 +456,12 @@ export class DevinCliAgenticExecutor extends BaseExecutor {
|
||||
let text = await generateAgenticOutput(turnArgs, prompt.text);
|
||||
let tool;
|
||||
try {
|
||||
if (prompt.tools.length > 0 && describesUnexecutedToolIntent(text)) {
|
||||
throw new DevinAgenticBridgeError(
|
||||
"The response described a future action without performing it; call exactly one tool now",
|
||||
"unexecuted_tool_intent"
|
||||
);
|
||||
}
|
||||
tool = parseDevinToolRequest(text, prompt.tools, prompt.idSeed);
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -447,6 +470,7 @@ export class DevinCliAgenticExecutor extends BaseExecutor {
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const requiresToolOnRepair = error.code === "unexecuted_tool_intent";
|
||||
const repairPrompt = [
|
||||
prompt.text,
|
||||
"",
|
||||
@@ -454,11 +478,20 @@ export class DevinCliAgenticExecutor extends BaseExecutor {
|
||||
"",
|
||||
"[Single Repair Attempt]",
|
||||
`The previous output was rejected: ${sanitizeErrorMessage(error.message)}`,
|
||||
"Return either plain final text or exactly one standalone <tool> JSON envelope.",
|
||||
requiresToolOnRepair
|
||||
? "Plain text is not accepted for this repair. Return exactly one standalone <tool> JSON envelope now."
|
||||
: "Return either plain final text or exactly one standalone <tool> JSON envelope.",
|
||||
"Do not narrate a tool action.",
|
||||
].join("\n");
|
||||
text = await generateAgenticOutput(turnArgs, repairPrompt);
|
||||
tool = parseDevinToolRequest(text, prompt.tools, prompt.idSeed);
|
||||
if (requiresToolOnRepair && !tool) {
|
||||
throw new DevinAgenticBridgeError(
|
||||
"Devin repeated a narrated tool action instead of requesting a tool",
|
||||
"unexecuted_tool_intent",
|
||||
502
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const id = `msg_devin_${randomUUID().replaceAll("-", "")}`;
|
||||
|
||||
@@ -89,8 +89,8 @@ bridge_check_devin_auth() {
|
||||
output="$(bridge_run_devin auth status 2>&1)"
|
||||
exit_status=$?
|
||||
set -e
|
||||
printf '%s\n' "$output"
|
||||
bridge_assert_devin_auth_status "$exit_status" "$output"
|
||||
printf 'PASS: Devin authentication confirmed\n'
|
||||
}
|
||||
|
||||
bridge_assert_zero_claude_egress() {
|
||||
|
||||
@@ -23,7 +23,7 @@ export function validateDevinAuthStatus(exitStatus, output) {
|
||||
const lines = String(output)
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim());
|
||||
if (!lines.includes("Logged in (via Devin)")) {
|
||||
if (!lines.some((line) => /^Logged in \(via Devin\)\.?$/.test(line))) {
|
||||
return { ok: false, error: "auth status did not confirm login" };
|
||||
}
|
||||
if (lines.some((line) => /failed to fetch from server/i.test(line))) {
|
||||
@@ -63,12 +63,22 @@ export function validateClaudeGuardDenials(text) {
|
||||
export function validateDevinGuardAudit(text) {
|
||||
const entries = parseAuditEntries(text);
|
||||
if (!entries.length) return { ok: false, error: "Devin egress audit has no records" };
|
||||
let sawAllowedDevinRequest = false;
|
||||
for (const entry of entries) {
|
||||
if (entry.decision === "deny") {
|
||||
if (/anthropic|claude\.ai/i.test(normalizedHostname(entry.hostname))) {
|
||||
return { ok: false, error: `forbidden Devin egress attempt: ${String(entry.hostname)}` };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.decision !== "allow" || !isAllowedDevinAuditHostname(entry.hostname)) {
|
||||
return { ok: false, error: `unexpected Devin egress record: ${String(entry.hostname)}` };
|
||||
}
|
||||
sawAllowedDevinRequest = true;
|
||||
}
|
||||
return { ok: true };
|
||||
return sawAllowedDevinRequest
|
||||
? { ok: true }
|
||||
: { ok: false, error: "Devin egress audit has no approved request" };
|
||||
}
|
||||
|
||||
export function validateAuditFileStat(stat, expectedUid) {
|
||||
|
||||
@@ -10,7 +10,14 @@ docker compose -f "$BRIDGE_COMPOSE" --profile offline up --abort-on-container-ex
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const rows = fs.readFileSync(process.argv[1], "utf8").trim().split("\n").map(JSON.parse);
|
||||
if (rows.length !== 5 || rows.some((row) => row.provider !== "devin-cli-agentic")) {
|
||||
const repairRows = rows.filter((row) => row.scenario === "narrative-repair");
|
||||
if (
|
||||
rows.length !== 7 ||
|
||||
rows.some((row) => row.provider !== "devin-cli-agentic") ||
|
||||
repairRows.length !== 2 ||
|
||||
repairRows[0].stage !== "initial" ||
|
||||
repairRows[1].stage !== "repair"
|
||||
) {
|
||||
throw new Error("wire contract observed a missing or non-Devin provider");
|
||||
}
|
||||
' "$BRIDGE_SANDBOX/evidence/mock-acp.jsonl"
|
||||
|
||||
@@ -8,9 +8,20 @@ bridge_reset_live_fixture
|
||||
"$(dirname "$0")/verify-anthropic-isolation" --static
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d --wait network-guard
|
||||
bridge_check_devin_auth
|
||||
bridge_run_devin models list --format json >"$BRIDGE_SANDBOX/evidence/live-models.json"
|
||||
devin_model="$(node --import tsx/esm "$BRIDGE_ROOT/scripts/devin-bridge/select-live-model.mjs" \
|
||||
<"$BRIDGE_SANDBOX/evidence/live-models.json")"
|
||||
models_file="$BRIDGE_SANDBOX/evidence/live-models.json"
|
||||
if [[ -n "${DEVIN_BRIDGE_DISCOVERED_MODEL:-}" ]]; then
|
||||
devin_model="$DEVIN_BRIDGE_DISCOVERED_MODEL"
|
||||
else
|
||||
for attempt in 1 2 3; do
|
||||
if bridge_run_devin models list --format json >"$models_file"; then
|
||||
break
|
||||
fi
|
||||
[[ "$attempt" == 3 ]] && exit 1
|
||||
sleep 1
|
||||
done
|
||||
devin_model="$(node --import tsx/esm "$BRIDGE_ROOT/scripts/devin-bridge/select-live-model.mjs" \
|
||||
<"$models_file")"
|
||||
fi
|
||||
export DEVIN_BRIDGE_MODEL="devin-cli-agentic/$devin_model"
|
||||
export DEVIN_BRIDGE_SONNET_MODEL="$DEVIN_BRIDGE_MODEL"
|
||||
export DEVIN_BRIDGE_OPUS_MODEL="$DEVIN_BRIDGE_MODEL"
|
||||
|
||||
@@ -122,6 +122,7 @@ test("compose keeps role-separated credentials and proxy settings", () => {
|
||||
|
||||
test("auth status requires the exact positive line and rejects misleading text", () => {
|
||||
assert.equal(validateDevinAuthStatus(0, "Logged in (via Devin)\n").ok, true);
|
||||
assert.equal(validateDevinAuthStatus(0, "Logged in (via Devin).\n").ok, true);
|
||||
for (const output of [
|
||||
"Not Logged in (via Devin)\n",
|
||||
"prefix Logged in (via Devin) suffix\n",
|
||||
@@ -185,7 +186,21 @@ test("audit policies reject forged metadata, missing proof, and unexpected Devin
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
validateDevinGuardAudit('{"hostname":"o1.ingest.sentry.io","decision":"deny"}\n').ok,
|
||||
validateDevinGuardAudit(
|
||||
'{"hostname":"o1.ingest.sentry.io","decision":"deny"}\n' +
|
||||
'{"hostname":"api.devin.ai","decision":"allow"}\n'
|
||||
).ok,
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
validateDevinGuardAudit('{"hostname":"api.devin.ai","decision":"deny"}\n').ok,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
validateDevinGuardAudit(
|
||||
'{"hostname":"api.anthropic.com","decision":"deny"}\n' +
|
||||
'{"hostname":"api.devin.ai","decision":"allow"}\n'
|
||||
).ok,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
@@ -229,11 +229,7 @@ test("DevinCliAgenticExecutor returns Anthropic tool_use JSON and sends ACP fram
|
||||
const initialize = frames.find((frame: { method?: string }) => frame.method === "initialize");
|
||||
assert.equal(initialize.params.protocolVersion, 1);
|
||||
assert.deepEqual(initialize.params.clientCapabilities, {});
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), [
|
||||
"acp",
|
||||
"--agent-type",
|
||||
"summarizer",
|
||||
]);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["acp"]);
|
||||
} finally {
|
||||
if (oldBin === undefined) delete process.env.CLI_DEVIN_AGENTIC_BIN;
|
||||
else process.env.CLI_DEVIN_AGENTIC_BIN = oldBin;
|
||||
@@ -299,6 +295,31 @@ test("ACP client handles fragmented frames, multiple chunks, and stderr", async
|
||||
}
|
||||
});
|
||||
|
||||
test("ACP client fails closed when Devin attempts an internal tool call", async () => {
|
||||
const tmpDir = sandboxTmp("devin-agentic-internal-tool-");
|
||||
const scriptFile = writeScenarioMock(
|
||||
tmpDir,
|
||||
`rl.on("line", (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } });
|
||||
if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "internal-tool" } });
|
||||
if (msg.method === "session/prompt") {
|
||||
send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "internal-tool", update: { sessionUpdate: "tool_call", toolCallId: "internal-1", title: "Read a.ts" } } });
|
||||
send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "internal-tool", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Done" } } } });
|
||||
send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: "end_turn" } });
|
||||
}
|
||||
});`
|
||||
);
|
||||
try {
|
||||
const result = await executeTextRequest(scriptFile);
|
||||
assert.equal(result.response.status, 502);
|
||||
const body = JSON.parse(await result.response.text());
|
||||
assert.equal(body.error.code, "devin_internal_tool_execution");
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("ACP client fails closed on protocol errors and early exit", async () => {
|
||||
const cases = [
|
||||
{
|
||||
@@ -354,7 +375,7 @@ test("ACP client times out, cancels, and terminates a stuck process", async () =
|
||||
}
|
||||
});
|
||||
|
||||
test("tool repair is attempted once and produces a validated tool_use", async () => {
|
||||
test("premature tool narration is repaired once into a validated tool_use", async () => {
|
||||
const tmpDir = sandboxTmp("devin-agentic-repair-");
|
||||
const stateFile = path.join(tmpDir, "spawn-count");
|
||||
const scriptFile = writeScenarioMock(
|
||||
@@ -369,7 +390,7 @@ rl.on("line", (line) => {
|
||||
if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "repair" } });
|
||||
if (msg.method === "session/prompt") {
|
||||
const text = count === 1
|
||||
? 'I will read it. <tool>{"name":"Read","arguments":{"file_path":"a.ts"}}</tool>'
|
||||
? 'I will read the file and start by examining its contents.'
|
||||
: '<tool>{"name":"Read","arguments":{"file_path":"a.ts"}}</tool>';
|
||||
send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "repair", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } } } });
|
||||
send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: "end_turn" } });
|
||||
@@ -406,3 +427,57 @@ rl.on("line", (line) => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("premature tool narration fails closed when the single repair is still narrative", async () => {
|
||||
const tmpDir = sandboxTmp("devin-agentic-repair-narrative-");
|
||||
const stateFile = path.join(tmpDir, "spawn-count");
|
||||
const scriptFile = writeScenarioMock(
|
||||
tmpDir,
|
||||
`const fs = require("fs");
|
||||
const stateFile = ${JSON.stringify(stateFile)};
|
||||
const count = Number(fs.existsSync(stateFile) ? fs.readFileSync(stateFile, "utf8") : "0") + 1;
|
||||
fs.writeFileSync(stateFile, String(count));
|
||||
rl.on("line", (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } });
|
||||
if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "repair-narrative" } });
|
||||
if (msg.method === "session/prompt") {
|
||||
const text = count === 1
|
||||
? "I'll start by reading the file."
|
||||
: "I'll read the file now, then run the tests.";
|
||||
send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "repair-narrative", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } } } });
|
||||
send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: "end_turn" } });
|
||||
}
|
||||
});`
|
||||
);
|
||||
const oldBin = process.env.CLI_DEVIN_AGENTIC_BIN;
|
||||
process.env.CLI_DEVIN_AGENTIC_BIN = scriptFile;
|
||||
try {
|
||||
const result = await new DevinCliAgenticExecutor().execute({
|
||||
model: "swe-1-7",
|
||||
stream: false,
|
||||
credentials: {},
|
||||
body: {
|
||||
tools: [
|
||||
{
|
||||
name: "Read",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
required: ["file_path"],
|
||||
properties: { file_path: { type: "string" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
messages: [{ role: "user", content: "Read a.ts" }],
|
||||
},
|
||||
});
|
||||
assert.equal(result.response.status, 502);
|
||||
const body = JSON.parse(await result.response.text());
|
||||
assert.equal(body.error.code, "unexecuted_tool_intent");
|
||||
assert.equal(fs.readFileSync(stateFile, "utf8"), "2");
|
||||
} finally {
|
||||
if (oldBin === undefined) delete process.env.CLI_DEVIN_AGENTIC_BIN;
|
||||
else process.env.CLI_DEVIN_AGENTIC_BIN = oldBin;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ test("devin agentic serializer preserves Anthropic tool history and schemas", ()
|
||||
|
||||
assert.match(prompt.text, /\[System\]\nFollow CLAUDE\.md/);
|
||||
assert.match(prompt.text, /\[Tool\] Read/);
|
||||
assert.match(prompt.text, /Do not execute tools inside Devin/);
|
||||
assert.match(prompt.text, /\[Assistant Tool Use\]/);
|
||||
assert.match(prompt.text, /\[Tool Result\]/);
|
||||
assert.equal(prompt.tools[0].name, "Read");
|
||||
|
||||
Reference in New Issue
Block a user