From 2a94cbfe1492b8bb8d2b00935505e730f38077ee Mon Sep 17 00:00:00 2001 From: Lucas Israel Date: Thu, 6 Aug 2026 11:08:37 -0300 Subject: [PATCH] feat(executors): add isolated Claude Code bridge over Devin ACP (#8914) Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip) --- .dockerignore | 1 + .env.devin-bridge.example | 6 + .env.example | 12 + .gitignore | 5 + docker/devin-bridge/Dockerfile | 57 ++ docker/devin-bridge/compose.yml | 218 +++++++ docker/devin-bridge/mock-devin.mjs | 229 +++++++ docker/devin-bridge/network-guard/policy.mjs | 130 ++++ docker/devin-bridge/network-guard/proxy.mjs | 136 +++++ docker/devin-bridge/run-claude-e2e.sh | 27 + docker/devin-bridge/run-claude-live-e2e.sh | 52 ++ docker/devin-bridge/run-contract.mjs | 135 ++++ docs/DEVIN_CLAUDE_BRIDGE.md | 181 ++++++ docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md | 115 ++++ docs/reference/ENVIRONMENT.md | 8 + .../plans/2026-07-27-devin-claude-bridge.md | 252 ++++++++ .../2026-07-27-devin-claude-bridge-design.md | 134 ++++ open-sse/config/providers/index.ts | 2 + .../registry/devin-cli-agentic/index.ts | 21 + .../devin-agentic/anthropicResponse.ts | 104 ++++ .../executors/devin-agentic/serializer.ts | 217 +++++++ .../executors/devin-agentic/toolParser.ts | 117 ++++ open-sse/executors/devin-agentic/types.ts | 56 ++ open-sse/executors/devin-cli-agentic.ts | 571 +++++++++++++++++ open-sse/executors/index.ts | 3 + scripts/devin-bridge/build | 5 + scripts/devin-bridge/clean | 12 + scripts/devin-bridge/common | 131 ++++ scripts/devin-bridge/launch | 28 + scripts/devin-bridge/login-devin | 13 + scripts/devin-bridge/runtime-policy.mjs | 111 ++++ scripts/devin-bridge/select-live-model.mjs | 101 +++ scripts/devin-bridge/test-contract | 24 + scripts/devin-bridge/test-e2e-mock | 16 + scripts/devin-bridge/test-live-devin | 37 ++ scripts/devin-bridge/test-unit | 9 + .../devin-bridge/validate-claude-evidence.mjs | 125 ++++ .../devin-bridge/verify-anthropic-isolation | 259 ++++++++ src/shared/constants/providers/noauth.ts | 18 + .../.claude/commands/bridge-check.md | 10 + .../e2e-workspace/.claude/hooks/log-tool.mjs | 6 + .../e2e-workspace/.claude/settings.json | 16 + .../.claude/skills/bridge-proof/SKILL.md | 9 + .../devin-bridge/e2e-workspace/CLAUDE.md | 7 + .../devin-bridge/e2e-workspace/math.js | 3 + .../devin-bridge/e2e-workspace/math.test.js | 8 + .../devin-bridge/e2e-workspace/package.json | 8 + tests/unit/devin-bridge-live-runtime.test.ts | 389 ++++++++++++ tests/unit/devin-bridge-network-guard.test.ts | 217 +++++++ .../executor-devin-cli-agentic-acp.test.ts | 577 ++++++++++++++++++ .../executor-devin-cli-agentic-core.test.ts | 178 ++++++ 51 files changed, 5106 insertions(+) create mode 100644 .env.devin-bridge.example create mode 100644 docker/devin-bridge/Dockerfile create mode 100644 docker/devin-bridge/compose.yml create mode 100755 docker/devin-bridge/mock-devin.mjs create mode 100644 docker/devin-bridge/network-guard/policy.mjs create mode 100644 docker/devin-bridge/network-guard/proxy.mjs create mode 100755 docker/devin-bridge/run-claude-e2e.sh create mode 100644 docker/devin-bridge/run-claude-live-e2e.sh create mode 100644 docker/devin-bridge/run-contract.mjs create mode 100644 docs/DEVIN_CLAUDE_BRIDGE.md create mode 100644 docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md create mode 100644 docs/superpowers/plans/2026-07-27-devin-claude-bridge.md create mode 100644 docs/superpowers/specs/2026-07-27-devin-claude-bridge-design.md create mode 100644 open-sse/config/providers/registry/devin-cli-agentic/index.ts create mode 100644 open-sse/executors/devin-agentic/anthropicResponse.ts create mode 100644 open-sse/executors/devin-agentic/serializer.ts create mode 100644 open-sse/executors/devin-agentic/toolParser.ts create mode 100644 open-sse/executors/devin-agentic/types.ts create mode 100644 open-sse/executors/devin-cli-agentic.ts create mode 100755 scripts/devin-bridge/build create mode 100755 scripts/devin-bridge/clean create mode 100755 scripts/devin-bridge/common create mode 100755 scripts/devin-bridge/launch create mode 100755 scripts/devin-bridge/login-devin create mode 100644 scripts/devin-bridge/runtime-policy.mjs create mode 100644 scripts/devin-bridge/select-live-model.mjs create mode 100755 scripts/devin-bridge/test-contract create mode 100755 scripts/devin-bridge/test-e2e-mock create mode 100755 scripts/devin-bridge/test-live-devin create mode 100755 scripts/devin-bridge/test-unit create mode 100644 scripts/devin-bridge/validate-claude-evidence.mjs create mode 100755 scripts/devin-bridge/verify-anthropic-isolation create mode 100644 tests/fixtures/devin-bridge/e2e-workspace/.claude/commands/bridge-check.md create mode 100644 tests/fixtures/devin-bridge/e2e-workspace/.claude/hooks/log-tool.mjs create mode 100644 tests/fixtures/devin-bridge/e2e-workspace/.claude/settings.json create mode 100644 tests/fixtures/devin-bridge/e2e-workspace/.claude/skills/bridge-proof/SKILL.md create mode 100644 tests/fixtures/devin-bridge/e2e-workspace/CLAUDE.md create mode 100644 tests/fixtures/devin-bridge/e2e-workspace/math.js create mode 100644 tests/fixtures/devin-bridge/e2e-workspace/math.test.js create mode 100644 tests/fixtures/devin-bridge/e2e-workspace/package.json create mode 100644 tests/unit/devin-bridge-live-runtime.test.ts create mode 100644 tests/unit/devin-bridge-network-guard.test.ts create mode 100644 tests/unit/executor-devin-cli-agentic-acp.test.ts create mode 100644 tests/unit/executor-devin-cli-agentic-core.test.ts diff --git a/.dockerignore b/.dockerignore index 4dea7c7d1f..be16eca2a2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -24,6 +24,7 @@ coverage # Runtime data and logs data logs +.sandbox # Local env files (inject at runtime via --env-file or -e) .env diff --git a/.env.devin-bridge.example b/.env.devin-bridge.example new file mode 100644 index 0000000000..fb02ecd66b --- /dev/null +++ b/.env.devin-bridge.example @@ -0,0 +1,6 @@ +ENABLE_LIVE_DEVIN_TESTS=0 +DEVIN_BRIDGE_MODEL=devin-cli-agentic/swe-1-7 +DEVIN_BRIDGE_SONNET_MODEL=devin-cli-agentic/swe-1-7 +DEVIN_BRIDGE_OPUS_MODEL=devin-cli-agentic/swe-1-7 +DEVIN_BRIDGE_HAIKU_MODEL=devin-cli-agentic/swe-1-7 +DEVIN_BRIDGE_SUBAGENT_MODEL=devin-cli-agentic/swe-1-7 diff --git a/.env.example b/.env.example index bc15daead4..6f9be90f29 100644 --- a/.env.example +++ b/.env.example @@ -1919,6 +1919,18 @@ APP_LOG_TO_FILE=true # ── Devin CLI binary path ── # Used by: open-sse/executors/devin-cli.ts. Default: looked up via PATH. # CLI_DEVIN_BIN=devin +# Agentic bridge-only binary override. The bridge still executes ACP stdio only. +# CLI_DEVIN_AGENTIC_BIN=devin +# Required isolated HOME for the agentic Devin child process. +# DEVIN_AGENTIC_HOME=/home/bridge +# Bounded ACP turn timeout in milliseconds. Default: 120000. +# DEVIN_AGENTIC_ACP_TIMEOUT_MS=120000 +# Agentic bridge model aliases. Values must keep the devin-cli-agentic/ prefix. +# DEVIN_BRIDGE_MODEL=devin-cli-agentic/swe-1-7 +# DEVIN_BRIDGE_SONNET_MODEL=devin-cli-agentic/swe-1-7 +# DEVIN_BRIDGE_OPUS_MODEL=devin-cli-agentic/swe-1-7 +# DEVIN_BRIDGE_HAIKU_MODEL=devin-cli-agentic/swe-1-7 +# DEVIN_BRIDGE_SUBAGENT_MODEL=devin-cli-agentic/swe-1-7 # ── Command Code (custom CLI) callback ── # Local port used for OAuth-style callbacks from the Command Code CLI helper. diff --git a/.gitignore b/.gitignore index 4636007b51..b06010eb17 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* !.env.example +!.env.devin-bridge.example !.env.homolog.example # Provider API keys (never commit) *.api-key @@ -209,6 +210,8 @@ scripts/i18n/_pending-keys.json .agents/ .antigravitycli/ .claude/ +!tests/fixtures/devin-bridge/e2e-workspace/.claude/ +!tests/fixtures/devin-bridge/e2e-workspace/.claude/** # PR Reviews and local feedback files pr_reviews*.json @@ -248,6 +251,8 @@ _artifacts/ # CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.) .artifacts/ +# Isolated Devin bridge workspaces, evidence, and test databases +.sandbox/ # Homologation E2E suite (npm run homolog) — real-environment credentials + report output .env.homolog diff --git a/docker/devin-bridge/Dockerfile b/docker/devin-bridge/Dockerfile new file mode 100644 index 0000000000..3fd2a30dba --- /dev/null +++ b/docker/devin-bridge/Dockerfile @@ -0,0 +1,57 @@ +FROM node:26.0.0-bookworm-slim + +ARG CLAUDE_CODE_VERSION=2.1.220 +ARG DEVIN_CLI_VERSION=3000.2.17 +ARG TARGETARCH + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl git bash python3 make g++ tini \ + && rm -rf /var/lib/apt/lists/* \ + && npm install --global "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" + +RUN set -eu; \ + case "${TARGETARCH}" in \ + amd64) devin_arch=x86_64-unknown-linux; devin_sha=f0e1e9363afc6ee68c4ef87bab4aeb7ff5cc08a5fa838350ef3ceefdbb2a2be2 ;; \ + arm64) devin_arch=aarch64-unknown-linux; devin_sha=116dc71ef085a922bc3ff0ea0377d4b26c529a431d58246e36572913e2d25624 ;; \ + *) echo "Unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://static.devin.ai/cli/${DEVIN_CLI_VERSION}/devin-${DEVIN_CLI_VERSION}-${devin_arch}.tar.gz" -o /tmp/devin.tar.gz; \ + echo "${devin_sha} /tmp/devin.tar.gz" | sha256sum -c -; \ + tar -xzf /tmp/devin.tar.gz -C /tmp; \ + install -m 0755 "$(find /tmp -type f -name devin | head -1)" /usr/local/bin/devin; \ + rm -rf /tmp/devin.tar.gz /tmp/devin-* + +RUN groupadd --gid 10001 bridge \ + && useradd --uid 10001 --gid bridge --create-home --home-dir /home/bridge --shell /bin/bash bridge \ + && mkdir -p /opt/omniroute /workspace \ + && chown -R bridge:bridge /opt/omniroute /workspace + +WORKDIR /opt/omniroute +USER bridge +COPY --chown=bridge:bridge package.json package-lock.json .npmrc ./ +RUN npm ci --ignore-scripts --no-audit --fund=false +COPY --chown=bridge:bridge . . +RUN npm rebuild better-sqlite3 || true + +ENV HOME=/home/bridge \ + CLAUDE_CONFIG_DIR=/home/bridge/.claude-devin-isolated \ + DEVIN_AGENTIC_HOME=/home/bridge \ + DATA_DIR=/home/bridge/.omniroute-isolated \ + SQLITE_FILE=/home/bridge/.omniroute-isolated/storage.sqlite \ + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ + DISABLE_TELEMETRY=1 \ + DISABLE_ERROR_REPORTING=1 \ + DISABLE_AUTOUPDATER=1 \ + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 \ + NEXT_TELEMETRY_DISABLED=1 + +RUN mkdir -p /home/bridge/.claude-devin-isolated /home/bridge/.local/share/devin \ + /home/bridge/.omniroute-isolated + +RUN DATA_DIR=/tmp/omniroute-build-data \ + SQLITE_FILE=/tmp/omniroute-build-data/storage.sqlite \ + npm run build \ + && rm -rf /tmp/omniroute-build-data + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["bash"] diff --git a/docker/devin-bridge/compose.yml b/docker/devin-bridge/compose.yml new file mode 100644 index 0000000000..c850414dbc --- /dev/null +++ b/docker/devin-bridge/compose.yml @@ -0,0 +1,218 @@ +name: omniroute-devin-bridge + +x-isolated-environment: &isolated-environment + HOME: /home/bridge + CLAUDE_CONFIG_DIR: /home/bridge/.claude-devin-isolated + DEVIN_AGENTIC_HOME: /home/bridge + DATA_DIR: /home/bridge/.omniroute-isolated + SQLITE_FILE: /home/bridge/.omniroute-isolated/storage.sqlite + ANTHROPIC_BASE_URL: http://omniroute:20128 + ANTHROPIC_AUTH_TOKEN: sk-local-devin-gateway + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" + DISABLE_TELEMETRY: "1" + DISABLE_ERROR_REPORTING: "1" + DISABLE_AUTOUPDATER: "1" + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1" + DEVIN_BRIDGE_MODEL: ${DEVIN_BRIDGE_MODEL:-devin-cli-agentic/swe-1-7} + ANTHROPIC_MODEL: ${DEVIN_BRIDGE_MODEL:-devin-cli-agentic/swe-1-7} + ANTHROPIC_DEFAULT_SONNET_MODEL: ${DEVIN_BRIDGE_SONNET_MODEL:-devin-cli-agentic/swe-1-7} + ANTHROPIC_DEFAULT_OPUS_MODEL: ${DEVIN_BRIDGE_OPUS_MODEL:-devin-cli-agentic/swe-1-7} + ANTHROPIC_DEFAULT_HAIKU_MODEL: ${DEVIN_BRIDGE_HAIKU_MODEL:-devin-cli-agentic/swe-1-7} + CLAUDE_CODE_SUBAGENT_MODEL: ${DEVIN_BRIDGE_SUBAGENT_MODEL:-devin-cli-agentic/swe-1-7} + REQUIRE_API_KEY: "true" + OMNIROUTE_API_KEY: sk-local-devin-gateway + +x-runtime: &runtime + image: omniroute-devin-bridge:local + build: + context: ../.. + dockerfile: docker/devin-bridge/Dockerfile + args: + CLAUDE_CODE_VERSION: 2.1.220 + DEVIN_CLI_VERSION: 3000.2.17 + user: "10001:10001" + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,nodev,size=256m + - /opt/omniroute/.source:rw,nosuid,nodev,size=16m,uid=10001,gid=10001 + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + environment: *isolated-environment + networks: [bridge-internal] + +services: + omniroute: + <<: *runtime + profiles: [offline] + hostname: omniroute + environment: + <<: *isolated-environment + CLI_DEVIN_AGENTIC_BIN: /opt/omniroute/docker/devin-bridge/mock-devin.mjs + DEVIN_BRIDGE_MOCK_LOG: /evidence/mock-acp.jsonl + command: ["npm", "run", "start"] + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:20128/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 2s + timeout: 2s + retries: 60 + volumes: + - omniroute-offline-data:/home/bridge/.omniroute-isolated + - ../../.sandbox/evidence:/evidence + - ./mock-devin.mjs:/opt/omniroute/docker/devin-bridge/mock-devin.mjs:ro + + claude: + <<: *runtime + profiles: [offline] + depends_on: + omniroute: + condition: service_healthy + claude-egress-guard: + condition: service_healthy + working_dir: /workspace + command: ["bash", "/opt/omniroute/docker/devin-bridge/run-claude-e2e.sh"] + environment: + <<: *isolated-environment + NODE_USE_ENV_PROXY: "1" + HTTP_PROXY: http://claude-egress-guard:8080 + HTTPS_PROXY: http://claude-egress-guard:8080 + NO_PROXY: omniroute + volumes: + - claude-isolated-config:/home/bridge/.claude-devin-isolated + - ../../.sandbox/e2e-workspace:/workspace + - ../../.sandbox/evidence:/evidence + - ./run-claude-e2e.sh:/opt/omniroute/docker/devin-bridge/run-claude-e2e.sh:ro + + contract: + <<: *runtime + profiles: [offline] + depends_on: + omniroute: + condition: service_healthy + command: ["node", "/opt/omniroute/docker/devin-bridge/run-contract.mjs"] + volumes: + - ./run-contract.mjs:/opt/omniroute/docker/devin-bridge/run-contract.mjs:ro + + claude-egress-guard: + image: node:26.0.0-bookworm-slim + profiles: [offline, live-devin] + user: "10001:10001" + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + command: ["node", "/guard/proxy.mjs"] + environment: + GUARD_LISTEN: 0.0.0.0:8080 + GUARD_POLICY: deny-all + GUARD_LOG: /guard-audit/egress.jsonl + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "require('net').connect(8080,'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))", + ] + interval: 1s + timeout: 1s + retries: 15 + volumes: + - ./network-guard:/guard:ro + - ../../.sandbox/guard-audit/claude:/guard-audit + networks: [bridge-internal] + + network-guard: + image: node:26.0.0-bookworm-slim + profiles: [live-devin] + user: "10001:10001" + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + command: ["node", "/guard/proxy.mjs"] + environment: + GUARD_LISTEN: 0.0.0.0:8080 + GUARD_POLICY: devin + GUARD_LOG: /guard-audit/egress.jsonl + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "require('net').connect(8080,'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))", + ] + interval: 1s + timeout: 1s + retries: 15 + volumes: + - ./network-guard:/guard:ro + - ../../.sandbox/guard-audit/devin:/guard-audit + networks: [devin-guard-internal, guard-egress] + + omniroute-live: + <<: *runtime + profiles: [live-devin] + hostname: omniroute + depends_on: + network-guard: + condition: service_healthy + environment: + <<: *isolated-environment + CLI_DEVIN_AGENTIC_BIN: /usr/local/bin/devin + DEVIN_BRIDGE_PROXY_URL: http://network-guard:8080 + networks: [bridge-internal, devin-guard-internal] + command: ["npm", "run", "start"] + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:20128/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 2s + timeout: 2s + retries: 60 + volumes: + - devin-auth:/home/bridge/.local/share/devin + - omniroute-live-data:/home/bridge/.omniroute-isolated + + claude-live: + <<: *runtime + profiles: [live-devin] + depends_on: + omniroute-live: + condition: service_healthy + claude-egress-guard: + condition: service_healthy + working_dir: /workspace + command: ["bash", "/opt/omniroute/docker/devin-bridge/run-claude-live-e2e.sh"] + environment: + <<: *isolated-environment + NODE_USE_ENV_PROXY: "1" + HTTP_PROXY: http://claude-egress-guard:8080 + HTTPS_PROXY: http://claude-egress-guard:8080 + NO_PROXY: omniroute + volumes: + - claude-isolated-config:/home/bridge/.claude-devin-isolated + - ../../.sandbox/live-workspace:/workspace + - ../../.sandbox/evidence:/evidence + - ./run-claude-live-e2e.sh:/opt/omniroute/docker/devin-bridge/run-claude-live-e2e.sh:ro + +networks: + bridge-internal: + internal: true + devin-guard-internal: + internal: true + guard-egress: {} + +volumes: + claude-isolated-config: {} + devin-auth: {} + omniroute-offline-data: {} + omniroute-live-data: {} diff --git a/docker/devin-bridge/mock-devin.mjs b/docker/devin-bridge/mock-devin.mjs new file mode 100755 index 0000000000..5d4b1bcd59 --- /dev/null +++ b/docker/devin-bridge/mock-devin.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import readline from "node:readline"; + +if ( + process.argv[2] !== "acp" || + process.argv[3] !== "--agent-type" || + process.argv[4] !== "summarizer" || + process.argv.length !== 5 +) { + process.exit(64); +} + +const logFile = process.env.DEVIN_BRIDGE_MOCK_LOG || "/evidence/mock-acp.jsonl"; +const rl = readline.createInterface({ input: process.stdin }); +const send = (value) => process.stdout.write(`${JSON.stringify(value)}\n`); +const log = (value) => fs.appendFileSync(logFile, `${JSON.stringify(value)}\n`); + +const actions = [ + { + name: "Skill", + arguments: { skill: "bridge-proof" }, + }, + { + name: "Bash", + arguments: { + command: "find . -maxdepth 2 -type f -print", + description: "Locate the fixture files", + }, + }, + { + name: "Read", + arguments: { file_path: "/workspace/math.js" }, + }, + { + name: "Edit", + arguments: { + file_path: "/workspace/math.js", + old_string: "return a - b;", + new_string: "return a * b;", + }, + }, + { + name: "Bash", + arguments: { command: "npm test", description: "Run the fixture tests" }, + }, + { + name: "Edit", + arguments: { + file_path: "/workspace/math.js", + old_string: "return a * b;", + new_string: "return a + b;", + }, + }, + { + name: "Bash", + arguments: { command: "npm test", description: "Confirm the corrected fixture" }, + }, +]; + +rl.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + if (message.params?.protocolVersion !== 1) { + send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "ACP v1 required" } }); + return; + } + send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: 1 } }); + } else if (message.method === "session/new") { + if (message.params?.cwd !== "/home/bridge" || !Array.isArray(message.params?.mcpServers)) { + send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "unsafe session" } }); + return; + } + send({ + jsonrpc: "2.0", + id: message.id, + result: { sessionId: "offline" }, + }); + } else if (message.method === "session/set_config_option") { + send({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32602, message: "summarizer mode must not be mutated" }, + }); + } else if (message.method === "session/prompt") { + const prompt = String(message.params?.prompt?.[0]?.text || ""); + if (!prompt.includes("[Devin Summarizer Bridge]") || !prompt.includes("[Execution Trace]")) { + send({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32602, message: "summarizer bridge framing required" }, + }); + return; + } + if (prompt.includes("CONTRACT_AFTER_TOOL")) { + log({ provider: "devin-cli-agentic", scenario: "after-tool" }); + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "offline", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "contract continued" }, + }, + }, + }); + send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } }); + return; + } + if (prompt.includes("CONTRACT_EXIT")) { + log({ provider: "devin-cli-agentic", scenario: "exit" }); + process.exit(7); + } + if (prompt.includes("CONTRACT_ERROR")) { + log({ provider: "devin-cli-agentic", scenario: "error" }); + send({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32000, message: "deterministic upstream failure" }, + }); + return; + } + if (prompt.includes("CONTRACT_TEXT")) { + log({ provider: "devin-cli-agentic", scenario: "text" }); + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "offline", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "contract text" }, + }, + }, + }); + 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 + ? '{"name":"Read","arguments":{"file_path":"/workspace/math.js"}}' + : "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({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "offline", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: '{"name":"Read","arguments":{"file_path":"/workspace/math.js"}}', + }, + }, + }, + }); + send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } }); + return; + } + const resultCount = (prompt.match(/\[Tool Result\]/g) || []).length; + if (!prompt.includes("CLAUDE_MD_BRIDGE_ACTIVE") || !prompt.includes("COMMAND_BRIDGE_ACTIVE")) { + send({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32602, message: "Claude project context missing" }, + }); + return; + } + + const action = actions[resultCount]; + const text = action + ? `${JSON.stringify(action)}` + : "BRIDGE_E2E_COMPLETE CLAUDE_MD_BRIDGE_ACTIVE SKILL_BRIDGE_ACTIVE COMMAND_BRIDGE_ACTIVE"; + if (!action && !prompt.includes("SKILL_BRIDGE_ACTIVE")) { + send({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32602, message: "Skill result missing" }, + }); + return; + } + log({ + provider: "devin-cli-agentic", + model: message.params?.model || "swe-1-7", + resultCount, + action: action?.name || "final", + }); + const midpoint = Math.max(1, Math.floor(text.length / 2)); + for (const chunk of [text.slice(0, midpoint), text.slice(midpoint)]) { + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "offline", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: chunk }, + }, + }, + }); + } + send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } }); + } +}); diff --git a/docker/devin-bridge/network-guard/policy.mjs b/docker/devin-bridge/network-guard/policy.mjs new file mode 100644 index 0000000000..e5884b4c01 --- /dev/null +++ b/docker/devin-bridge/network-guard/policy.mjs @@ -0,0 +1,130 @@ +export const DEVIN_ALLOWED_SUFFIXES = Object.freeze([".devin.ai", ".cognition.ai"]); +export const DEVIN_ALLOWED_EXACT_HOSTS = Object.freeze([ + "server.codeium.com", + "unleash.codeium.com", +]); + +function normalizeHostname(hostname) { + return String(hostname || "") + .trim() + .toLowerCase() + .replace(/\.$/, ""); +} + +export function isAllowedGuardHostname(hostname, policy = "deny-all") { + if (policy !== "devin") return false; + const value = normalizeHostname(hostname); + if (!value) return false; + if (DEVIN_ALLOWED_EXACT_HOSTS.includes(value)) return true; + return DEVIN_ALLOWED_SUFFIXES.some( + (suffix) => value === suffix.slice(1) || value.endsWith(suffix) + ); +} + +const HOP_BY_HOP_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +export function sanitizeForwardHeaders(headers, target) { + const connectionTokens = String(headers.connection || "") + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + const blocked = new Set([...HOP_BY_HOP_HEADERS, ...connectionTokens]); + const sanitized = {}; + for (const [name, value] of Object.entries(headers)) { + if (value === undefined || blocked.has(name.toLowerCase()) || name.toLowerCase() === "host") { + continue; + } + sanitized[name] = value; + } + sanitized.host = target.host; + return sanitized; +} + +export function parseConnectAuthority(authority) { + const value = String(authority || ""); + const match = value.match(/^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/); + if (!match) return null; + const hostname = normalizeHostname(match[1] || match[2]); + const port = Number(match[3]); + if (!hostname || port !== 443) return null; + return { hostname, port }; +} + +function readUint24(buffer, offset) { + return (buffer[offset] << 16) | (buffer[offset + 1] << 8) | buffer[offset + 2]; +} + +export function parseTlsClientHelloSni(buffer) { + if (!Buffer.isBuffer(buffer)) return { status: "invalid", reason: "not_buffer" }; + let offset = 0; + const handshakeParts = []; + while (offset < buffer.length) { + if (buffer.length - offset < 5) return { status: "need-more" }; + if (buffer[offset] !== 22) return { status: "invalid", reason: "not_handshake_record" }; + const recordLength = buffer.readUInt16BE(offset + 3); + if (recordLength <= 0 || recordLength > 18432) { + return { status: "invalid", reason: "invalid_record_length" }; + } + if (buffer.length - offset - 5 < recordLength) return { status: "need-more" }; + handshakeParts.push(buffer.subarray(offset + 5, offset + 5 + recordLength)); + offset += 5 + recordLength; + } + const handshake = Buffer.concat(handshakeParts); + if (handshake.length < 4) return { status: "need-more" }; + if (handshake[0] !== 1) return { status: "invalid", reason: "not_client_hello" }; + const helloLength = readUint24(handshake, 1); + if (helloLength > 65531) return { status: "invalid", reason: "client_hello_too_large" }; + if (handshake.length - 4 < helloLength) return { status: "need-more" }; + const hello = handshake.subarray(4, 4 + helloLength); + let cursor = 34; + if (hello.length < cursor + 1) return { status: "invalid", reason: "truncated_hello" }; + const sessionLength = hello[cursor++]; + cursor += sessionLength; + if (hello.length < cursor + 2) return { status: "invalid", reason: "truncated_ciphers" }; + const cipherLength = hello.readUInt16BE(cursor); + cursor += 2 + cipherLength; + if (hello.length < cursor + 1) return { status: "invalid", reason: "truncated_compression" }; + const compressionLength = hello[cursor++]; + cursor += compressionLength; + if (hello.length < cursor + 2) return { status: "invalid", reason: "missing_extensions" }; + const extensionsLength = hello.readUInt16BE(cursor); + cursor += 2; + const extensionsEnd = cursor + extensionsLength; + if (extensionsEnd > hello.length) return { status: "invalid", reason: "truncated_extensions" }; + while (cursor < extensionsEnd) { + if (extensionsEnd - cursor < 4) return { status: "invalid", reason: "truncated_extension" }; + const type = hello.readUInt16BE(cursor); + const length = hello.readUInt16BE(cursor + 2); + cursor += 4; + if (cursor + length > extensionsEnd) { + return { status: "invalid", reason: "invalid_extension_length" }; + } + if (type === 0) { + const data = hello.subarray(cursor, cursor + length); + if (data.length < 5 || data.readUInt16BE(0) !== data.length - 2 || data[2] !== 0) { + return { status: "invalid", reason: "invalid_server_name" }; + } + const nameLength = data.readUInt16BE(3); + if (nameLength !== data.length - 5) { + return { status: "invalid", reason: "invalid_server_name_length" }; + } + const serverName = normalizeHostname(data.subarray(5).toString("ascii")); + if (!/^[a-z0-9.-]+$/.test(serverName)) { + return { status: "invalid", reason: "invalid_server_name_value" }; + } + return { status: "ok", serverName }; + } + cursor += length; + } + return { status: "invalid", reason: "missing_sni" }; +} diff --git a/docker/devin-bridge/network-guard/proxy.mjs b/docker/devin-bridge/network-guard/proxy.mjs new file mode 100644 index 0000000000..9efe5b9b34 --- /dev/null +++ b/docker/devin-bridge/network-guard/proxy.mjs @@ -0,0 +1,136 @@ +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import { pathToFileURL } from "node:url"; + +import { + isAllowedGuardHostname, + parseConnectAuthority, + parseTlsClientHelloSni, + sanitizeForwardHeaders, +} from "./policy.mjs"; + +const MAX_CLIENT_HELLO_BYTES = 64 * 1024; +const CLIENT_HELLO_TIMEOUT_MS = 3000; + +export function createGuardProxy({ + policy = "deny-all", + logPath = "/tmp/egress.jsonl", + allowHostname = (hostname) => isAllowedGuardHostname(hostname, policy), + connectSocket = (port, hostname, onConnect) => net.connect(port, hostname, onConnect), +} = {}) { + if (!new Set(["deny-all", "devin"]).has(policy)) { + throw new Error(`Unknown network guard policy: ${policy}`); + } + + function audit(hostname, decision, reason) { + fs.appendFileSync( + logPath, + `${JSON.stringify({ at: new Date().toISOString(), hostname, decision, reason })}\n` + ); + } + + const server = http.createServer((req, res) => { + let target; + try { + target = new URL(req.url); + } catch { + res.writeHead(400).end("invalid proxy target\n"); + return; + } + if (target.protocol !== "http:" || target.username || target.password) { + audit(target.hostname, "deny", "invalid_http_target"); + res.writeHead(403).end("egress denied\n"); + return; + } + if (!allowHostname(target.hostname)) { + audit(target.hostname, "deny", "host_policy"); + res.writeHead(403).end("egress denied\n"); + return; + } + audit(target.hostname, "allow", "host_policy"); + const upstream = http.request( + target, + { + method: req.method, + headers: sanitizeForwardHeaders(req.headers, target), + }, + (reply) => { + res.writeHead(reply.statusCode || 502, reply.headers); + reply.pipe(res); + } + ); + req.pipe(upstream); + upstream.on("error", () => res.writeHead(502).end("upstream error\n")); + }); + + server.on("connect", (req, client, head) => { + const authority = parseConnectAuthority(req.url); + if (!authority) { + audit(req.url, "deny", "invalid_connect_authority"); + client.end("HTTP/1.1 403 Forbidden\r\n\r\n"); + return; + } + const { hostname, port } = authority; + if (!allowHostname(hostname)) { + audit(hostname, "deny", "host_policy"); + client.end("HTTP/1.1 403 Forbidden\r\n\r\n"); + return; + } + + let buffer = Buffer.from(head); + let settled = false; + const timer = setTimeout(() => fail("client_hello_timeout"), CLIENT_HELLO_TIMEOUT_MS); + timer.unref?.(); + + const cleanup = () => { + clearTimeout(timer); + client.removeListener("data", onData); + }; + const fail = (reason) => { + if (settled) return; + settled = true; + cleanup(); + audit(hostname, "deny", reason); + client.destroy(); + }; + const inspect = () => { + if (buffer.length > MAX_CLIENT_HELLO_BYTES) return fail("client_hello_too_large"); + const parsed = parseTlsClientHelloSni(buffer); + if (parsed.status === "need-more") return; + if (parsed.status !== "ok") return fail(parsed.reason || "invalid_client_hello"); + if (parsed.serverName !== hostname) return fail("sni_mismatch"); + settled = true; + cleanup(); + client.pause(); + const upstream = connectSocket(port, hostname, () => { + audit(hostname, "allow", "sni_match"); + if (buffer.length) upstream.write(buffer); + upstream.pipe(client); + client.pipe(upstream); + client.resume(); + }); + upstream.on("error", () => client.destroy()); + }; + const onData = (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + inspect(); + }; + + client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + client.on("data", onData); + if (buffer.length) inspect(); + client.resume(); + }); + + return server; +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + const [host, portText] = (process.env.GUARD_LISTEN || "0.0.0.0:8080").split(":"); + const server = createGuardProxy({ + policy: process.env.GUARD_POLICY || "deny-all", + logPath: process.env.GUARD_LOG || "/tmp/egress.jsonl", + }); + server.listen(Number(portText), host); +} diff --git a/docker/devin-bridge/run-claude-e2e.sh b/docker/devin-bridge/run-claude-e2e.sh new file mode 100755 index 0000000000..726aff33a2 --- /dev/null +++ b/docker/devin-bridge/run-claude-e2e.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +unset ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_BEDROCK_BASE_URL ANTHROPIC_VERTEX_BASE_URL +unset CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX CLAUDE_CODE_USE_FOUNDRY + +set -o pipefail +check() { + "$@" + printf 'E2E check passed: %s\n' "$*" +} + +claude -p --output-format stream-json --verbose --max-turns 12 \ + --permission-mode bypassPermissions \ + "/bridge-check" | tee /evidence/claude-stream.jsonl + +if grep -Eqi 'log[ -]?in|authenticate.*anthropic|claude\.ai' /evidence/claude-stream.jsonl; then + echo "Claude Code requested forbidden authentication" >&2 + exit 1 +fi +check grep -q 'return a + b;' /workspace/math.js +npm test +check grep -q 'Skill' /workspace/.e2e-hook.log +check grep -q 'Read' /workspace/.e2e-hook.log +check grep -q 'Edit' /workspace/.e2e-hook.log +check grep -q 'Bash' /workspace/.e2e-hook.log +check grep -q 'BRIDGE_E2E_COMPLETE' /evidence/claude-stream.jsonl diff --git a/docker/devin-bridge/run-claude-live-e2e.sh b/docker/devin-bridge/run-claude-live-e2e.sh new file mode 100644 index 0000000000..1faf59a33e --- /dev/null +++ b/docker/devin-bridge/run-claude-live-e2e.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +unset ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_BEDROCK_BASE_URL ANTHROPIC_VERTEX_BASE_URL +unset CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX CLAUDE_CODE_USE_FOUNDRY + +bridge_system_prompt="You are a coding agent inside Claude Code. Use only the client-owned tools supplied in the request. Never execute or request a Devin-owned tool. When work requires a tool, select the appropriate client tool and wait for its result before continuing." +scenario_cooldown_seconds="${DEVIN_BRIDGE_LIVE_SCENARIO_COOLDOWN_SECONDS:-15}" + +run_scenario() { + local evidence_file="$1" + local prompt="$2" + claude -p --output-format stream-json --verbose --max-turns 12 \ + --tools Read,Edit,Bash \ + --system-prompt "$bridge_system_prompt" \ + --permission-mode bypassPermissions "$prompt" | tee "$evidence_file" + if grep -Eqi 'log[ -]?in|authenticate.*anthropic|claude\.ai' "$evidence_file"; then + echo "Claude Code requested forbidden authentication" >&2 + exit 1 + fi +} + +validate_scenario() { + local evidence_file="$1" + local marker="$2" + local required_tools="$3" + local require_npm_test="$4" + local required_slash_command="${5:-}" + local required_skill="${6:-}" + local accept_explicit_completion="${7:-false}" + node /opt/omniroute/scripts/devin-bridge/validate-claude-evidence.mjs \ + "$evidence_file" "$marker" "$required_tools" "$require_npm_test" \ + "$required_slash_command" "$required_skill" "$accept_explicit_completion" +} + +run_scenario /evidence/live-analysis.jsonl \ + "Read /workspace/CLAUDE.md, /workspace/math.js, and /workspace/math.test.js directly without searching or editing. Explain the defect, then end with LIVE_ANALYSIS_COMPLETE." +validate_scenario /evidence/live-analysis.jsonl LIVE_ANALYSIS_COMPLETE Read false +sleep "$scenario_cooldown_seconds" + +run_scenario /evidence/live-fix.jsonl \ + "Use Edit now to replace 'return a - b;' with 'return a + b;' in /workspace/math.js. Then use Bash to run npm test. Do not summarize before npm test succeeds. End with LIVE_FIX_COMPLETE only after the test passes." +grep -q 'return a + b;' /workspace/math.js +npm test +validate_scenario /evidence/live-fix.jsonl LIVE_FIX_COMPLETE Edit,Bash true +sleep "$scenario_cooldown_seconds" + +run_scenario /evidence/live-command.jsonl "/bridge-check" +validate_scenario /evidence/live-command.jsonl BRIDGE_E2E_COMPLETE Bash true \ + bridge-check bridge-proof true + +printf 'PASS: three live Devin-backed Claude Code scenarios completed\n' diff --git a/docker/devin-bridge/run-contract.mjs b/docker/devin-bridge/run-contract.mjs new file mode 100644 index 0000000000..a1de76f8d1 --- /dev/null +++ b/docker/devin-bridge/run-contract.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; + +const endpoint = "http://omniroute:20128/v1/messages"; +const headers = { + "anthropic-version": "2023-06-01", + "content-type": "application/json", + "x-api-key": "sk-local-devin-gateway", +}; +const model = process.env.DEVIN_BRIDGE_MODEL || "devin-cli-agentic/swe-1-7"; + +async function request(prompt, extra = {}) { + return fetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify({ + model, + max_tokens: 256, + messages: [{ role: "user", content: prompt }], + ...extra, + }), + }); +} + +const textReply = await request("CONTRACT_TEXT"); +assert.equal(textReply.status, 200); +assert.match(textReply.headers.get("content-type") || "", /application\/json/); +const textBody = await textReply.json(); +assert.equal(textBody.type, "message"); +assert.equal(textBody.role, "assistant"); +assert.equal(textBody.stop_reason, "end_turn"); +assert.deepEqual(textBody.content, [{ type: "text", text: "contract text" }]); + +const toolReply = await request("CONTRACT_TOOL", { + stream: true, + tools: [ + { + name: "Read", + description: "Read a file", + input_schema: { + type: "object", + properties: { file_path: { type: "string" } }, + required: ["file_path"], + additionalProperties: false, + }, + }, + ], +}); +assert.equal(toolReply.status, 200); +assert.match(toolReply.headers.get("content-type") || "", /text\/event-stream/); +const toolStream = await toolReply.text(); +const eventNames = toolStream + .split("\n") + .filter((line) => line.startsWith("event: ")) + .map((line) => line.slice(7)); +assert.deepEqual(eventNames, [ + "message_start", + "content_block_start", + "content_block_delta", + "content_block_stop", + "message_delta", + "message_stop", +]); +const toolEvents = toolStream + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => JSON.parse(line.slice(6))); +const toolUse = toolEvents.find((event) => event.type === "content_block_start")?.content_block; +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, + body: JSON.stringify({ + model, + max_tokens: 256, + tools: [ + { + name: "Read", + description: "Read a file", + input_schema: { type: "object", properties: {}, additionalProperties: true }, + }, + ], + messages: [ + { role: "user", content: "CONTRACT_TOOL" }, + { role: "assistant", content: [toolUse] }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolUse.id, + content: "CONTRACT_AFTER_TOOL", + }, + ], + }, + ], + }), +}); +assert.equal(continuationReply.status, 200); +const continuationBody = await continuationReply.json(); +assert.equal(continuationBody.stop_reason, "end_turn"); +assert.deepEqual(continuationBody.content, [{ type: "text", text: "contract continued" }]); + +for (const marker of ["CONTRACT_ERROR", "CONTRACT_EXIT"]) { + const failedReply = await request(marker); + assert.equal(failedReply.status, 502); + const failedBody = await failedReply.json(); + assert.equal(failedBody.error?.type, "server_error"); + assert.doesNotMatch(JSON.stringify(failedBody), /stack|anthropic|openai/i); +} + +console.log("PASS: Anthropic Messages wire contracts and fail-closed errors passed"); diff --git a/docs/DEVIN_CLAUDE_BRIDGE.md b/docs/DEVIN_CLAUDE_BRIDGE.md new file mode 100644 index 0000000000..4a6d56d370 --- /dev/null +++ b/docs/DEVIN_CLAUDE_BRIDGE.md @@ -0,0 +1,181 @@ +# Devin Claude Bridge + +`devin-cli-agentic` lets the real Claude Code runtime use OmniRoute's local Anthropic +Messages endpoint while the official Devin CLI supplies model responses over ACP stdio. It +does not modify the existing Anthropic, Claude OAuth, Claude Web, or `devin-cli` providers. + +> **Current status: offline and live validated.** The pinned Claude Code `2.1.220` completed +> three isolated scenarios through Devin CLI `3000.2.17` and model +> `swe-1-7-lightning`. The final live run proved client-owned `Read`, `Edit`, and `Bash` +> turns, successful `npm test` results, project command and skill discovery, Devin-only +> routing, and zero Claude egress. + +## Architecture + +```text +Claude Code 2.1.220 (isolated non-root Linux container) + -> http://omniroute:20128/v1/messages + -> devin-cli-agentic (Claude-format, no-auth provider) + -> devin acp --agent-type summarizer (official ACP stdio, no Devin tools) + -> Devin account in the dedicated devin-auth volume +``` + +The official CLI's default ACP agent can execute its own tools, so this bridge does not use +it. It starts the fixed `summarizer` ACP agent, whose official CLI mode has no tools, and +frames the serialized Anthropic request as an execution trace. When another Claude-owned +action is needed, the response must contain exactly one client tool envelope. Any ACP +`tool_call` or `tool_call_update` is rejected before a response can be reported as +successful. + +The serializer in `open-sse/executors/devin-agentic/serializer.ts` preserves `system`, +`text`, `tool_use`, `tool_result`, `thinking`, `redacted_thinking`, `tool_choice`, and the +tools supplied by Claude Code. Images and unknown blocks fail explicitly. Large tool results +use a visible truncation marker. + +The parser accepts one standalone `{...}` envelope per model turn. It checks +the name against the request's tool list, validates arguments against that tool's JSON +Schema, rejects mixed narrative/actions, and permits one bounded repair. Claude Code then +executes the resulting Anthropic `tool_use` locally and sends the `tool_result` back through +OmniRoute. + +## Isolation and threat model + +The host's Claude installation, account, and configuration are out of scope and treated as +forbidden. The Compose services: + +- run as UID/GID `10001:10001`, with a read-only root filesystem, dropped capabilities, and + `no-new-privileges`; +- use a private `/home/bridge`, a dedicated Claude config volume, isolated OmniRoute data, + and a separate `devin-auth` volume; +- mount only disposable `.sandbox` workspaces/evidence; +- do not mount the host home, Keychain, SSH, cloud credentials, or Docker socket; +- construct explicit environments and remove Anthropic API/OAuth/routing variables; +- direct Claude Code inference only to `http://omniroute:20128` with a local-only key. + +The offline profile uses an internal network. In the live profile, OmniRoute reaches the +official Devin endpoints only through `network-guard`; unrelated destinations are denied. +Claude Code has a separate deny-all egress guard and can reach only the local OmniRoute +service through `NO_PROXY`. Guard audit files are mounted only by their guard process. The +scripts verify file ownership, mode, link count, and every decision before exporting +token-free evidence. + +Run the isolation proof independently: + +```bash +./scripts/devin-bridge/verify-anthropic-isolation +``` + +It validates topology, named mounts, non-root/read-only settings, explicit local routing, +absence of sensitive environment variables, absence of the Docker socket, blocked access to +`api.anthropic.com` and `claude.ai`, Devin-only provider selection, and explicit failure when +the ACP backend is unavailable. + +## First-time setup and normal use + +Build the pinned image: + +```bash +./scripts/devin-bridge/build +``` + +Authenticate only the isolated Devin volume: + +```bash +ENABLE_LIVE_DEVIN_TESTS=1 ./scripts/devin-bridge/login-devin +``` + +The login command uses the official manual-token flow intended for remote/container +environments. The value is entered directly into the CLI prompt; it is not passed as a +process argument, written to Git, or copied from the host. + +Launch the isolated Claude Code runtime: + +```bash +./scripts/devin-bridge/launch +``` + +`launch` rechecks isolation, Devin authentication, and model discovery before starting the +containerized Claude Code. It never runs the host's Claude executable. Model aliases can be +set in `.env.devin-bridge`; every configured value must keep the +`devin-cli-agentic/` prefix. + +## Validation commands + +The reproducible offline path requires no Devin account and has no runtime Internet: + +```bash +./scripts/devin-bridge/test-unit +./scripts/devin-bridge/test-contract +./scripts/devin-bridge/test-e2e-mock +./scripts/devin-bridge/verify-anthropic-isolation +``` + +The authenticated opt-in live path is: + +```bash +ENABLE_LIVE_DEVIN_TESTS=1 ./scripts/devin-bridge/test-live-devin +``` + +The live runner waits between scenarios to avoid opening ACP sessions in a burst and +validates structured Claude stream events instead of trusting textual claims. Its three +scenarios prove: + +1. direct project reads and defect analysis; +2. a real `Edit`, a client-owned `Bash` `npm test`, and a terminal result; +3. `/bridge-check` plus `bridge-proof` discovery, project reads, another successful + client-owned `npm test`, and completion without pending work. + +The final gate also checks the Devin network audit and requires the Claude egress audit to +remain empty. + +## Updating pinned tools + +The image pins Node, Claude Code, and Devin CLI in +`docker/devin-bridge/Dockerfile`. To update: + +1. change the explicit versions; +2. replace both architecture-specific Devin archive checksums with values for the official + artifact; +3. rebuild and run every offline validation command; +4. confirm the versions inside the image; +5. rerun the authenticated three-scenario live suite. + +Do not install either CLI globally on the host or replace checksum verification with an +unverified download. + +## Diagnosis and cleanup + +- `docker compose -f docker/devin-bridge/compose.yml --profile offline logs omniroute` + shows local routing and sanitized executor errors. +- `.sandbox/evidence/mock-acp.jsonl` records deterministic mock ACP actions. +- `.sandbox/evidence/claude-stream.jsonl` records the real Claude Code offline run. +- `.sandbox/evidence/live-*.jsonl` records the three validated live streams. +- `.sandbox/evidence/egress.jsonl` and `.sandbox/evidence/claude-egress.jsonl` are validated, + token-free copies of the guard audits. + +Stop owned containers and networks while preserving login/config volumes: + +```bash +./scripts/devin-bridge/clean +``` + +Remove the complete bridge-owned environment, including named volumes: + +```bash +./scripts/devin-bridge/clean --all +``` + +## Limits + +- The bridge relies on the fixed no-tools `summarizer` role because Devin CLI `3000.2.17` + does not expose a neutral no-tools ACP agent. The adapter compensates for summary-shaped + intermediate responses, but one bounded repair can still fail explicitly. +- Live ACP calls can return transient `502`/`504` responses. The harness spaces scenarios; + persistent failure remains fail-closed and never selects another provider. +- ACP context is reconstructed from each Anthropic request; there is no process/session + affinity. +- One tool call is supported per model response; parallel calls are rejected. +- Images are explicitly unsupported. Vision, thinking output, effort controls, and a 1M + context window are not advertised. +- SSE uses valid Anthropic lifecycle events but is emitted after the bounded ACP turn is + collected; ACP chunks are not forwarded incrementally. diff --git a/docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md b/docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md new file mode 100644 index 0000000000..9bc402220b --- /dev/null +++ b/docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md @@ -0,0 +1,115 @@ +# Devin Claude Bridge Progress + +Updated: 2026-07-28 + +## Baseline + +- Fork version: `3.8.49`. +- Starting branch: `release/v3.8.49`. +- Starting commit: `ed7db3ee5f89a144b2d931d8605534522f83de30`. +- Fixed runtime artifacts: Node `26.0.0`, Claude Code `2.1.220`, Devin CLI `3000.2.17`. +- Existing `devin-cli` remains unchanged; the new path is the separate + `devin-cli-agentic` provider. + +## Implemented architecture + +- Claude Code runs only inside the non-root bridge container with its own empty config + volume and local OmniRoute base URL. +- `devin-cli-agentic` preserves Anthropic messages, tool schemas, `tool_use`, and + `tool_result`, then calls the official Devin CLI over ACP stdio. +- The executor starts `devin acp --agent-type summarizer`. This is the only fixed official + ACP role in the pinned CLI that has no Devin-owned tools. +- The request is framed as an execution trace. Devin can return one strict client tool + envelope; Claude Code executes that tool locally. +- Internal ACP `tool_call` events, unsupported blocks, invalid schemas, narrative actions, + timeouts, cancellation, and process failure all fail closed. +- Provider and network policy prevent combo/auto/Anthropic fallback. + +## Offline proof + +- Focused serializer, parser, executor, ACP lifecycle, wire-format, environment, and audit + tests pass (39/39). +- The contract suite covers Anthropic JSON/SSE, `tool_use`, `tool_result` continuation, + fragmented ACP frames, stderr, early exit, timeout, cancellation, and fail-closed provider + loss. +- The production bridge image builds with the pinned CLIs. +- Real Claude Code offline E2E loads `CLAUDE.md`, the project skill and slash command, fires + hooks, executes local tools over multiple turns, observes a failed test, repairs the file, + reruns the test, and completes. +- The isolation verifier proves non-root/read-only execution, isolated mounts and config, + blocked Anthropic/Claude access, no host credential mounts, local-only inference, and no + fallback. + +Evidence is generated under `.sandbox/evidence` and ignored by Git. + +## Regression status + +- `typecheck:core`, focused ESLint, Prettier, shell/Node syntax, and the complete documentation + accuracy suite pass. +- The broad `npm run check` is not reported as passed: after its lint phase, the repository + test runner remained alive while an existing `ioredis` client repeatedly retried an + unavailable local Redis endpoint after `quota-redis-store.test.ts`. The bridge-focused + suites, production image build, offline E2E, isolation proof, and live gate do not use that + Redis service and all pass. + +## Live Devin proof + +Passed with the official in-container login and discovered model +`swe-1-7-lightning`. The terminal live run completed all three scenarios: + +1. Claude Code loaded the fixture instructions, issued client-owned `Read` calls, and + returned a correct defect analysis. +2. Claude Code issued a real `Edit` changing subtraction to addition, then a client-owned + `Bash` call running `npm test`; the test reported one pass and zero failures. +3. Claude Code initialization listed `bridge-check` and `bridge-proof`, read the corrected + source and test, executed another client-owned `npm test`, and completed successfully. + +The live evidence validator parses stream JSON and requires successful tool results. It does +not accept a textual claim that a tool ran. It also rejects terminal summaries that report a +blocker, incomplete work, or required next steps. + +The final live gate reported: + +```text +PASS: validated Claude evidence for LIVE_ANALYSIS_COMPLETE +PASS: validated Claude evidence for LIVE_FIX_COMPLETE +PASS: validated Claude evidence for BRIDGE_E2E_COMPLETE +PASS: three live Devin-backed Claude Code scenarios completed +PASS: live model swe-1-7-lightning was discovered and validated by three scenarios +``` + +The same gate validated the network audit: only the Devin guard path was used, no internal +Devin tool event was accepted, and the Claude egress audit remained empty. + +## Investigation conclusion + +The initial default-agent hypothesis failed because ACP permission modes do not turn the +default Devin agent into a raw inference backend. Even `ask` mode can emit Devin-owned +`tool_call` events. A discovered `allowed-tools: []` agent configuration was not consumed by +`devin acp` in CLI `3000.2.17`. + +The working adaptation uses the official `summarizer` agent because it is structurally +no-tools. Its fixed summarization behavior can produce intermediate prose, so the bridge +frames requests as execution traces, detects future-action narration, performs at most one +strict repair, and otherwise fails. Live validation also exposed transient ACP timeouts; +the harness now spaces independent scenarios rather than weakening routing or retrying into +another provider. + +## Safety record + +No host Claude executable, configuration, login, OAuth token, Keychain, or Anthropic API was +used. The dedicated Docker volumes remain role-separated. No credential value is written to +the repository or evidence output. + +During the early baseline, a focused test without isolated `DATA_DIR` initialized the +repository's normal OmniRoute database at `/Users/lucasisrael/.omniroute/storage.sqlite`. +It was not rolled back or touched again. Every bridge command now pins database and temporary +paths under the worktree's `.sandbox` directory. + +## Remaining limits + +- The no-tools backend has a summarizer system role rather than a neutral generation role. +- One client tool call per response is supported; parallel tool calls are rejected. +- ACP processes are per-turn and stateless. +- Live Devin availability can still produce explicit `502`/`504` failures. +- Images and unadvertised vision/effort/large-context capabilities remain unsupported. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 221db58669..3105ceeb2f 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -381,6 +381,14 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex, | `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Custom path to Qoder CLI binary. | | `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Custom path to the Qwen Code CLI binary. | | `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Custom path to the Devin CLI binary (v3.8.0). Used by the Windsurf/Devin executor. | +| `CLI_DEVIN_AGENTIC_BIN` | `devin` | `open-sse/executors/devin-cli-agentic.ts` | Agentic bridge-only Devin CLI override. The executor accepts only the local ACP stdio upstream. | +| `DEVIN_AGENTIC_HOME` | _(required)_ | `open-sse/executors/devin-cli-agentic.ts` | Absolute isolated home for the agentic Devin subprocess; accepted bridge paths are `/home/bridge` and task-local `.sandbox` paths. | +| `DEVIN_AGENTIC_ACP_TIMEOUT_MS` | `120000` | `open-sse/executors/devin-cli-agentic.ts` | Maximum duration of one Devin ACP turn before the bridge terminates the child and returns an explicit timeout. | +| `DEVIN_BRIDGE_MODEL` | `devin-cli-agentic/swe-1-7` | `docker/devin-bridge/compose.yml` | Main Claude Code model alias for the isolated bridge. The live harness replaces the example with a model returned by the current Devin account. | +| `DEVIN_BRIDGE_SONNET_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Sonnet default. | +| `DEVIN_BRIDGE_OPUS_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Opus default. | +| `DEVIN_BRIDGE_HAIKU_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Haiku default. | +| `DEVIN_BRIDGE_SUBAGENT_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used for Claude Code subagents. | | `AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Absolute-path override for the Augment (Auggie) CLI binary used by the local `auggie` provider. Falls back to `CLI_AUGGIE_BIN`, then a PATH lookup. | | `CLI_AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Alias override for the Augment (Auggie) CLI binary path (checked after `AUGGIE_BIN`). | | `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent home directory where OmniRoute reads/writes the Hermes CLI config. Matches the env var the Hermes PowerShell installer sets on Windows (`%LOCALAPPDATA%\hermes`). | diff --git a/docs/superpowers/plans/2026-07-27-devin-claude-bridge.md b/docs/superpowers/plans/2026-07-27-devin-claude-bridge.md new file mode 100644 index 0000000000..3afd97bbe1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-devin-claude-bridge.md @@ -0,0 +1,252 @@ +# Devin Claude Bridge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a fail-closed `devin-cli-agentic` provider that serves local Anthropic Messages requests through Devin CLI ACP stdio while preserving Claude Code tool-use semantics. + +**Architecture:** Add a separate Claude-format provider and executor instead of changing the existing OpenAI-format `devin-cli` summarizer. Keep parsing, prompt serialization, Anthropic response rendering, and ACP process handling in focused files under `open-sse/executors/devin-agentic/`, then wire them into the existing provider and executor registries. + +**Tech Stack:** TypeScript ES modules, Node child process stdio, Anthropic Messages JSON/SSE, JSON-RPC 2.0 ACP, Node test runner. + +--- + +### Task 1: Agentic Bridge Core + +**Files:** +- Create: `open-sse/executors/devin-agentic/types.ts` +- Create: `open-sse/executors/devin-agentic/serializer.ts` +- Create: `open-sse/executors/devin-agentic/toolParser.ts` +- Create: `open-sse/executors/devin-agentic/anthropicResponse.ts` +- Test: `tests/unit/executor-devin-cli-agentic-core.test.ts` + +- [ ] **Implement and prove serialization, parsing, validation, and Anthropic rendering** + +Interfaces: + +```ts +export function serializeAnthropicForDevin(body: unknown): DevinPrompt; +export function parseDevinToolRequest(text: string, tools: AnthropicTool[]): ParsedToolRequest | null; +export function buildClaudeTextResponse(args: ClaudeResponseArgs): Record; +export function buildClaudeToolUseResponse(args: ClaudeToolUseArgs): Record; +export function buildClaudeSseFrames(message: Record): string; +``` + +Invariants: + +- Preserve `text`, `tool_use`, `tool_result`, `thinking`, and `redacted_thinking`. +- Reject `image` with a clear error. +- Reject unknown content block types. +- Allow only one tool request per model turn. +- Validate tool arguments against object JSON Schema with `required`, `type`, `properties`, `additionalProperties`, `enum`, `items`, and scalar types. +- Generate deterministic ids from tool name and canonicalized arguments. + +Run: `node --import tsx/esm --test tests/unit/executor-devin-cli-agentic-core.test.ts` +Expected: core tests pass after dependencies are installed. + +### Task 2: ACP Executor And Provider Wiring + +**Files:** +- Create: `open-sse/executors/devin-cli-agentic.ts` +- Modify: `open-sse/executors/index.ts` +- Create: `open-sse/config/providers/registry/devin-cli-agentic/index.ts` +- Modify: `open-sse/config/providers/index.ts` +- Test: `tests/unit/executor-devin-cli-agentic-acp.test.ts` + +- [ ] **Implement and prove fail-closed ACP execution** + +Behavior: + +- `buildUrl()` returns `devin://acp/stdio`. +- `buildHeaders()` returns `{}`. +- `execute()` spawns only `devin acp` by default or the explicit `CLI_DEVIN_AGENTIC_BIN`/`CLI_DEVIN_BIN` override. +- The child environment removes Anthropic and Claude routing credentials before spawn. +- The executor sends `initialize`, `session/new`, and `session/prompt`. +- The executor collects `agent_message_chunk` text and `session/prompt` final result. +- Non-streaming Claude clients receive native Anthropic JSON. +- Streaming Claude clients receive native Anthropic SSE lifecycle frames. +- Spawn failure, ACP error, timeout, and early exit produce non-2xx responses with sanitized messages. + +Run: `node --import tsx/esm --test tests/unit/executor-devin-cli-agentic-acp.test.ts` +Expected: ACP mock tests pass after dependencies are installed. + +### Task 3: Isolation Scripts And Documentation + +**Files:** +- Create: `scripts/devin-bridge/verify-anthropic-isolation` +- Create: `scripts/devin-bridge/test-unit` +- Create: `scripts/devin-bridge/launch` +- Create: `docs/DEVIN_CLAUDE_BRIDGE.md` +- Modify: `.gitignore` + +- [ ] **Implement offline guardrails and operator docs** + +Behavior: + +- `verify-anthropic-isolation` fails if `CLAUDE_CONFIG_DIR` is missing, points outside an isolated path, or if Anthropic routing env vars are present. +- `test-unit` runs the focused unit tests. +- `launch` refuses to start unless `ENABLE_LIVE_DEVIN_TESTS=1` for live Devin or `DEVIN_BRIDGE_OFFLINE=1` for offline mock mode. +- Documentation distinguishes tested offline behavior from live Devin opt-in behavior. + +Run: `./scripts/devin-bridge/verify-anthropic-isolation` with explicit isolated env. +Expected: exits 0 with isolated env and non-zero without it. + +### Task 4: Verification + +**Files:** +- No additional source files. + +- [ ] **Run proportional checks and capture real output** + +Commands: + +```bash +./scripts/devin-bridge/test-unit +npm test +``` + +Expected in this workspace before installing dependencies: both commands fail with `ERR_MODULE_NOT_FOUND` for `tsx`. Expected after `npm install`: focused tests pass; `npm test` outcome must be reported from real output. + +### Task 5: Close Core Security And Protocol Gaps + +**Files:** +- Modify: `open-sse/executors/devin-cli-agentic.ts` +- Modify: `open-sse/executors/devin-agentic/*.ts` +- Modify: `tests/unit/executor-devin-cli-agentic-*.test.ts` + +- [ ] **Prove environment allowlisting, response-id correlation, strict standalone tool envelopes, unique ids, bounded repair, size limits, cancellation cleanup, sanitized errors, and explicit `devin://acp/stdio` validation** + +Run with `HOME`, `DATA_DIR`, and `SQLITE_FILE` under `.sandbox`; expected: all focused tests pass and an outside-path test fails closed. + +### Task 6: Build Reproducible Containers And Network Guard + +**Files:** +- Create: `docker/devin-bridge/Dockerfile` +- Create: `docker/devin-bridge/compose.yml` +- Create: `docker/devin-bridge/network-guard/*` +- Create: `docker/devin-bridge/mock-devin/*` +- Create: `.env.devin-bridge.example` + +- [ ] **Pin Claude Code 2.1.220 and Devin CLI 3000.2.17, create non-root offline/live profiles, separate auth/config volumes, explicit env allowlist, no host credential mounts, and denied-domain telemetry** + +Run: `docker compose -f docker/devin-bridge/compose.yml --profile offline config`; expected: no forbidden mounts/env inheritance and only internal runtime networks. + +### Task 7: Deliver Isolation And Operator Scripts + +**Files:** +- Create/modify: `scripts/devin-bridge/{build,test-unit,test-contract,test-e2e-mock,verify-anthropic-isolation,login-devin,test-live-devin,launch,clean}` + +- [ ] **Make every command idempotent, sandbox-scoped, fail-closed, and secret-safe** + +Run: `./scripts/devin-bridge/verify-anthropic-isolation`; expected: positive offline proof passes and each deliberately removed guard returns non-zero. + +### Task 8: Real Claude Code Offline E2E + +**Files:** +- Create: `tests/fixtures/devin-bridge/e2e-workspace/*` +- Create: `tests/e2e/devin-claude-bridge.e2e.*` + +- [ ] **Run pinned Claude Code in the offline container through local `/v1/messages` and mock ACP, proving CLAUDE.md, skill, command, hook, Read/Edit/Bash, tests, multi-turn continuation, and no Anthropic traffic** + +Run: `./scripts/devin-bridge/test-e2e-mock`; expected: workspace diff and tests prove Claude Code executed tools while mock Devin only requested them. + +### Task 9: Regression, Documentation, Live Gate, And Delivery + +**Files:** +- Modify: `docs/DEVIN_CLAUDE_BRIDGE.md` +- Create: `docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md` + +- [ ] **Run focused suites, typecheck, lint, build, docs checks, offline E2E, and isolation proof with fresh output; then run live only after official in-container Devin login** + +If login is unavailable, record live as not tested and expose exactly `./scripts/devin-bridge/login-devin` followed by `./scripts/devin-bridge/test-live-devin`. Commit each reversible unit; do not merge or publish until all offline critical checks are green. + +### Task 10: Close The Authenticated Live Runtime + +**Files:** +- Modify: `open-sse/executors/devin-cli-agentic.ts` +- Modify: `docker/devin-bridge/compose.yml` +- Create: `docker/devin-bridge/network-guard/policy.mjs` +- Modify: `docker/devin-bridge/network-guard/proxy.mjs` +- Modify: `scripts/devin-bridge/select-live-model.mjs` +- Modify: `scripts/devin-bridge/common` +- Modify: `scripts/devin-bridge/login-devin` +- Modify: `scripts/devin-bridge/test-live-devin` +- Modify: `scripts/devin-bridge/verify-anthropic-isolation` +- Modify: `tests/unit/executor-devin-cli-agentic-acp.test.ts` +- Create: `tests/unit/devin-bridge-live-runtime.test.ts` + +- [ ] **Implement and prove the authenticated network, auth, and catalog boundaries with block-level TDD** + +Invariants: + +- The ACP child receives proxy variables only when `DEVIN_BRIDGE_PROXY_URL` is exactly + `http://network-guard:8080`; arbitrary inherited proxy and credential variables stay absent. +- The guard permits suffixes `.devin.ai` and `.cognition.ai`, exact hosts + `server.codeium.com` and `unleash.codeium.com`, and nothing else. +- Claude services cannot mount `devin-auth`; non-Claude services cannot mount the Claude config. +- A zero exit from `devin auth status` is insufficient when output contains a server-fetch failure. +- `family_uid: swe-1.7-lightning` resolves to catalog id `swe-1-7-lightning`; unknown normalized + values fail instead of becoming model ids. +- Login uses the official manual-token flow so no container loopback callback is required. + +Run: + +```bash +./scripts/devin-bridge/test-unit +node --import tsx/esm --test tests/unit/devin-bridge-live-runtime.test.ts +./scripts/devin-bridge/verify-anthropic-isolation --static +``` + +Expected: focused tests and static isolation pass; deliberate untrusted proxy, host, mount, auth +status, and model fixtures fail closed. + +- [ ] **Commit the reversible live-runtime repair** + +```bash +git add open-sse/executors/devin-cli-agentic.ts docker/devin-bridge \ + scripts/devin-bridge tests/unit/devin-bridge-live-runtime.test.ts \ + tests/unit/executor-devin-cli-agentic-acp.test.ts +git commit -m "fix: close Devin bridge live runtime gaps" +``` + +### Task 11: Prove Offline And Live Completion + +**Files:** +- Modify: `docker/devin-bridge/run-claude-live-e2e.sh` +- Modify: `docs/DEVIN_CLAUDE_BRIDGE.md` +- Modify: `docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md` + +- [ ] **Run the complete deterministic bridge proof before any paid request** + +```bash +./scripts/devin-bridge/test-unit +./scripts/devin-bridge/test-contract +./scripts/devin-bridge/test-e2e-mock +./scripts/devin-bridge/verify-anthropic-isolation +npm run typecheck:core +npm run lint +npm run build +npm run check:docs-all +``` + +Expected: all bridge-specific checks, typecheck, lint, build, and documentation checks pass with +isolated data paths. Any unrelated full-suite infrastructure hang is recorded separately and is +not converted into a pass. + +- [ ] **Run exactly the three authorized live scenarios and the no-fallback failure probe** + +```bash +ENABLE_LIVE_DEVIN_TESTS=1 ./scripts/devin-bridge/test-live-devin +``` + +Expected: dynamic discovery selects a returned Devin catalog model; Claude Code reads without +editing, then edits and runs the fixture test, then executes the fixture command. Evidence shows +native tool use by Claude Code, only `devin-cli-agentic` routing, no allowed non-Devin egress, +and an Anthropic-shaped error after the Devin backend is deliberately made unavailable. + +- [ ] **Update verified documentation and commit the evidence-backed delivery state** + +```bash +git add docker/devin-bridge/run-claude-live-e2e.sh docs/DEVIN_CLAUDE_BRIDGE.md \ + docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md +git commit -m "docs: record verified Devin bridge live delivery" +``` diff --git a/docs/superpowers/specs/2026-07-27-devin-claude-bridge-design.md b/docs/superpowers/specs/2026-07-27-devin-claude-bridge-design.md new file mode 100644 index 0000000000..2d19519b9f --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-devin-claude-bridge-design.md @@ -0,0 +1,134 @@ +# Devin Claude Bridge Design + +## Baseline + +- Branch: `release/v3.8.49` +- HEAD: `ed7db3ee5f89a144b2d931d8605534522f83de30` +- Package version: `3.8.49` +- Node: `v26.0.0` +- npm: `11.12.1` +- Pre-existing worktree state: `.tug/` untracked +- Dependency state: `node_modules` is absent; the first focused test run failed before loading tests because `tsx` was not installed. +- Tugline state: `tug` exists, but `tug search` failed with MCP connection closed and `tug doctor` hung; it was interrupted. +- Upstream check: `git ls-remote` failed because GitHub DNS was unavailable. Web search of the public repository showed the existing `devin-cli` summarizer provider, but no evidence of `devin-cli-agentic`. + +## Source Anchors + +- `/v1/messages`: `src/app/api/v1/messages/route.ts` +- Existing Devin provider: `open-sse/config/providers/registry/devin-cli/index.ts` +- Existing Devin executor: `open-sse/executors/devin-cli.ts` +- Executor registry: `open-sse/executors/index.ts` +- Provider registry: `open-sse/config/providers/index.ts` +- Format detection: `open-sse/services/provider.ts` +- Claude non-streaming response conversion: `open-sse/handlers/responseTranslator.ts` +- Existing Devin ACP unit test: `tests/unit/executor-devin-cli-acp-protocol-8406.test.ts` + +## Findings + +The existing `devin-cli` provider is intentionally OpenAI-format and summarizer-oriented. Its executor spawns `devin acp --agent-type summarizer`, flattens the message history into a single text prompt, and emits OpenAI SSE text chunks. It does not preserve Anthropic `tool_use` and `tool_result` blocks. + +The safest implementation is a new provider id, `devin-cli-agentic`, with a separate executor. This leaves `devin-cli`, Anthropic OAuth, Claude OAuth, Claude Web, and all host Claude configuration code untouched. The new provider is fail-closed: it only resolves to `devin://acp/stdio`, uses the official Devin CLI ACP stdio path, and has no fallback provider. + +## Architecture + +Claude Code sends Anthropic Messages requests to local OmniRoute. OmniRoute resolves model ids prefixed with `devin-cli-agentic/` to a new Claude-format provider. The new executor translates the complete Anthropic request into an explicit text prompt for Devin ACP, including system text, structured message history, tool schemas, and prior tool results. + +Devin remains a model backend. The executor starts the official fixed no-tools summarizer +role with `devin acp --agent-type summarizer` and frames the serialized request as an +execution trace. Devin must request client-owned tool execution by emitting a strict +XML-wrapped JSON block: + +```xml + +{"name":"Read","arguments":{"file_path":"src/index.ts"}} + +``` + +The bridge parses exactly one tool request per model turn, validates that the tool name was supplied in the incoming request, validates arguments against a minimal JSON Schema validator, generates a stable `tool_devin_...` id, and returns a native Anthropic `tool_use` block. If no valid tool request is present, the bridge returns text with `stop_reason: "end_turn"`. + +## Error And Safety Rules + +- Unsupported Anthropic content blocks fail explicitly; images are rejected. +- Unknown tools fail explicitly. +- Invalid tool arguments fail explicitly. +- Invalid tool XML/JSON fails explicitly. +- Narrative claims that a tool was executed are returned as text, not actions. +- ACP spawn, timeout, early exit, and stderr-only failures return explicit Devin errors. +- The executor never reads `~/.claude`, `~/.claude.json`, macOS Keychain paths, or host Claude config. +- Live Devin is outside normal tests and remains opt-in via `ENABLE_LIVE_DEVIN_TESTS=1`. + +## Test Strategy + +Focused unit tests cover serialization, tool parsing, validation, Anthropic JSON, Anthropic SSE, malformed tool output, unknown tools, invalid arguments, image rejection, timeout, and spawn failure. Environment scripts provide an offline isolation verifier without reading host Claude credentials. + +## Mandatory Runtime Isolation + +The bridge runs only through `docker/devin-bridge/compose.yml`. The runtime image is non-root, uses a private `/home/bridge`, and mounts only disposable workspaces, evidence, and bridge harness files. Application source is copied into the image. It never mounts the host home, Docker socket, SSH, cloud credentials, or global Claude configuration. The container receives an explicit environment allowlist; the executor also constructs an allowlisted child environment instead of copying `process.env`. + +Build-time network access installs Claude Code `2.1.220` and Devin CLI `3000.2.17` with pinned integrity/checksum. Runtime profiles are separate: `offline` uses only an internal Compose network; `live-devin` exposes egress only through a proxy guard whose allowlist contains Devin/Cognition suffixes and whose default is denial. Devin authentication lives only in the named `devin-auth` volume. Claude configuration lives in a different named volume and is initialized empty. + +## Fail-Closed Routing + +`devin-cli-agentic` accepts only the synthetic `devin://acp/stdio` target and an explicit Devin binary path inside the container. It cannot use provider combos, auto routing, account fallback, fallback URLs, or an HTTP upstream. Model aliases resolve only to models returned by the Devin catalog or explicitly configured Devin model ids. An ACP failure, timeout, cancellation, invalid frame, unavailable model, or stopped sidecar becomes an Anthropic-shaped error response; no secondary provider is attempted. + +## Agentic Contract + +The serializer preserves request order, `system`, `tool_choice`, exact tool schemas, `text`, `tool_use`, `tool_result`, `thinking`, and `redacted_thinking`. It rejects unsupported blocks and caps large tool results with an explicit truncation marker and original size. The parser accepts exactly one standalone `` envelope, validates with Zod/JSON Schema infrastructure already present in OmniRoute, rejects unknown tools and mixed narrative/action output, and performs at most one bounded repair prompt. Tool ids combine a per-request nonce with canonical arguments so repeated identical calls remain unique while their association is stable within the turn. + +## Required Proof + +The offline profile must prove the ACP lifecycle, fragmented frames, stderr, early exit, hang/cancel, Anthropic JSON/SSE order, no fallback, and a real pinned Claude Code run that reads, edits, runs tests, observes `CLAUDE.md`, loads a skill and command, fires a hook, and completes at least one `tool_use -> tool_result -> continuation` loop. The isolation verifier checks env, mounts, UID, config paths, DNS/connection logs, local inference destination, selected provider, and fail-closed behavior. Live Devin is proved only by official in-container login and three isolated agentic scenarios. + +## Safety Incident During Baseline + +The first focused test was run without `DATA_DIR` isolation and initialized `/Users/lucasisrael/.omniroute/storage.sqlite`; logs reported schema-column additions. No Anthropic data was accessed. The external database will not be touched again or destructively rolled back. Every bridge command and test now must set `HOME`, `DATA_DIR`, `SQLITE_FILE`, and temporary directories inside `.sandbox`, and an automated guard must reject paths outside the task workspace. + +## Live Completion Repair + +The first authenticated live attempt disproved four assumptions in the initial container +design. The official CLI reports a valid login even when its server-status request fails; +that request uses the exact hosts `server.codeium.com` and `unleash.codeium.com`, which the +guard denied. The OmniRoute executor also built a fresh allowlisted child environment that +omitted the proxy, so `devin acp` could not leave the internal network. Model discovery emits +family identifiers such as `swe-1.7`, while the OmniRoute catalog uses canonical ids such as +`swe-1-7`. Finally, browser login redirects to a loopback listener inside the one-off +container, which is not reachable from the host browser. + +The repair keeps the fully containerized architecture and does not weaken the deny-by-default +network. The guard gains an exact-host allowlist for the two Codeium control-plane hosts while +retaining suffix-based access only for Devin and Cognition; telemetry destinations such as +Sentry remain denied. Compose supplies `DEVIN_BRIDGE_PROXY_URL` with the single accepted value +`http://network-guard:8080`, and the executor derives `HTTP_PROXY` and `HTTPS_PROXY` from that +explicit bridge setting instead of inheriting arbitrary host proxy variables. Claude services +mount only the Claude config volume, and only the OmniRoute live service mounts the Devin auth +volume. + +Fresh login uses the official `devin auth login --force-manual-token-flow`, which is intended +for remote environments where localhost redirects cannot work. The credential is pasted only +into the interactive CLI terminal and never appears in arguments, logs, evidence, or Git. +Authentication validation requires both the logged-in marker and the absence of a server-fetch +failure. Model discovery accepts the real `family_uid`/`model_uid` fields, maps punctuation to a +catalog id only after an exact normalized match, and prefers the already-proved lightning model +when available. + +Tests first prove the trusted proxy boundary, exact host policy, volume separation, strict auth +status gate, and catalog normalization. The live gate then runs three real Claude Code scenarios +through the authenticated in-container Devin CLI and requires local Read/Edit/Bash activity, +passing fixture tests, Devin-only routing, no allowed non-Devin egress, and an explicit error +when the Devin backend is stopped. + +## Final Live Result + +The default-agent design was rejected after live evidence showed that `ask` mode can still +emit Devin-owned ACP tool calls. The pinned CLI does not apply its top-level agent +configuration to `devin acp`, so an `allowed-tools: []` configuration could not create a +neutral backend. The fixed summarizer role is the only official ACP role in this version that +is structurally no-tools. + +The execution-trace adaptation passed the authenticated live gate with +`swe-1-7-lightning`. Three Claude Code processes completed analysis, edit/test, and local +command/skill scenarios. Structured evidence proved that Claude Code issued `Read`, `Edit`, +and `Bash` tool calls; two client-owned `npm test` calls succeeded. The guard audit proved +Devin-only outbound access and zero Claude egress. Intermediate summary-shaped responses and +transient ACP timeouts remain explicit failure modes; the adapter performs one bounded repair +and the harness spaces scenarios to avoid bursty session creation. diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 68fc1524a4..6e6677dd6a 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -170,6 +170,7 @@ import { kilo_gatewayProvider } from "./registry/kilo-gateway/index.ts"; import { bailian_coding_planProvider } from "./registry/bailian-coding-plan/index.ts"; import { gigachatProvider } from "./registry/gigachat/index.ts"; import { devin_cliProvider } from "./registry/devin-cli/index.ts"; +import { devin_cli_agenticProvider } from "./registry/devin-cli-agentic/index.ts"; import { auggieProvider } from "./registry/auggie/index.ts"; import { chutesProvider } from "./registry/chutes/index.ts"; import { chenzkProvider } from "./registry/chenzk/index.ts"; @@ -391,6 +392,7 @@ export const REGISTRY: Record = { "bailian-coding-plan": bailian_coding_planProvider, gigachat: gigachatProvider, "devin-cli": devin_cliProvider, + "devin-cli-agentic": devin_cli_agenticProvider, auggie: auggieProvider, chutes: chutesProvider, chenzk: chenzkProvider, diff --git a/open-sse/config/providers/registry/devin-cli-agentic/index.ts b/open-sse/config/providers/registry/devin-cli-agentic/index.ts new file mode 100644 index 0000000000..3001936e25 --- /dev/null +++ b/open-sse/config/providers/registry/devin-cli-agentic/index.ts @@ -0,0 +1,21 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { DEVIN_MODEL_CATALOG } from "../devin/catalog.ts"; + +export const devin_cli_agenticProvider: RegistryEntry = { + id: "devin-cli-agentic", + alias: "dva", + format: "claude", + executor: "devin-cli-agentic", + baseUrl: "devin://acp/stdio", + // Authentication is owned exclusively by the official Devin CLI inside its + // isolated volume. OmniRoute must not import or persist a host credential. + authType: "none", + authHeader: "none", + defaultContextLength: 200000, + models: DEVIN_MODEL_CATALOG.map((model) => ({ + ...model, + toolCalling: true, + supportsReasoning: false, + supportsVision: false, + })), +}; diff --git a/open-sse/executors/devin-agentic/anthropicResponse.ts b/open-sse/executors/devin-agentic/anthropicResponse.ts new file mode 100644 index 0000000000..d637ab1612 --- /dev/null +++ b/open-sse/executors/devin-agentic/anthropicResponse.ts @@ -0,0 +1,104 @@ +import { + estimateTokens, + type ClaudeResponseArgs, + type ClaudeToolUseArgs, + type JsonRecord, +} from "./types.ts"; + +function usage(inputTokens: number, outputTokens: number) { + return { + input_tokens: inputTokens, + output_tokens: outputTokens, + }; +} + +export function buildClaudeTextResponse(args: ClaudeResponseArgs): JsonRecord { + return { + id: args.id, + type: "message", + role: "assistant", + model: args.model, + content: [{ type: "text", text: args.text }], + stop_reason: "end_turn", + stop_sequence: null, + usage: usage(args.inputTokens, args.outputTokens || estimateTokens(args.text)), + }; +} + +export function buildClaudeToolUseResponse(args: ClaudeToolUseArgs): JsonRecord { + return { + id: args.id, + type: "message", + role: "assistant", + model: args.model, + content: [ + { + type: "tool_use", + id: args.tool.id, + name: args.tool.name, + input: args.tool.input, + }, + ], + stop_reason: "tool_use", + stop_sequence: null, + usage: usage(args.inputTokens, args.outputTokens), + }; +} + +function frame(event: string, data: JsonRecord): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +export function buildClaudeSseFrames(message: JsonRecord): string { + const content = Array.isArray(message.content) ? message.content : []; + const startMessage = { ...message, content: [], stop_reason: null, stop_sequence: null }; + let out = frame("message_start", { type: "message_start", message: startMessage }); + + content.forEach((block, index) => { + const blockRecord = block as JsonRecord; + if (blockRecord.type === "text") { + out += frame("content_block_start", { + type: "content_block_start", + index, + content_block: { type: "text", text: "" }, + }); + out += frame("content_block_delta", { + type: "content_block_delta", + index, + delta: { type: "text_delta", text: String(blockRecord.text || "") }, + }); + out += frame("content_block_stop", { type: "content_block_stop", index }); + return; + } + + if (blockRecord.type === "tool_use") { + out += frame("content_block_start", { + type: "content_block_start", + index, + content_block: { + type: "tool_use", + id: blockRecord.id, + name: blockRecord.name, + input: {}, + }, + }); + out += frame("content_block_delta", { + type: "content_block_delta", + index, + delta: { + type: "input_json_delta", + partial_json: JSON.stringify(blockRecord.input || {}), + }, + }); + out += frame("content_block_stop", { type: "content_block_stop", index }); + } + }); + + out += frame("message_delta", { + type: "message_delta", + delta: { stop_reason: message.stop_reason, stop_sequence: null }, + usage: { output_tokens: (message.usage as JsonRecord | undefined)?.output_tokens || 0 }, + }); + out += frame("message_stop", { type: "message_stop" }); + return out; +} diff --git a/open-sse/executors/devin-agentic/serializer.ts b/open-sse/executors/devin-agentic/serializer.ts new file mode 100644 index 0000000000..0f37bd1d1b --- /dev/null +++ b/open-sse/executors/devin-agentic/serializer.ts @@ -0,0 +1,217 @@ +import { + asRecord, + DevinAgenticBridgeError, + estimateTokens, + type AnthropicTool, + type DevinPrompt, +} from "./types.ts"; +import { createHash } from "node:crypto"; + +export const MAX_TOOL_RESULT_CHARS = 65536; + +function stringifyContentValue(value: unknown): string { + if (typeof value === "string") return value; + if (value == null) return ""; + return JSON.stringify(value); +} + +function boundedToolResult(value: unknown): string { + const text = stringifyContentValue(value); + if (text.length <= MAX_TOOL_RESULT_CHARS) return text; + const removed = text.length - MAX_TOOL_RESULT_CHARS; + return `${text.slice(0, MAX_TOOL_RESULT_CHARS)}\n[TRUNCATED ${removed} CHARACTERS BY OMNIROUTE]`; +} + +function serializeSystem(system: unknown): string[] { + if (typeof system === "string" && system.trim()) return [`[System]\n${system}`]; + if (!Array.isArray(system)) return []; + + const parts: string[] = []; + for (const block of system) { + const record = asRecord(block); + if (record.type === "text") { + parts.push(String(record.text || "")); + } else if (Object.keys(record).length > 0) { + throw new DevinAgenticBridgeError( + `Unsupported Anthropic system block type: ${String(record.type || "unknown")}`, + "unsupported_system_block" + ); + } + } + return parts.length > 0 ? [`[System]\n${parts.join("\n")}`] : []; +} + +function serializeBlock( + block: unknown, + knownToolUses: Set, + tools: AnthropicTool[] +): string { + const record = asRecord(block); + const type = String(record.type || ""); + + if (type === "text") return String(record.text || ""); + if (type === "thinking") return `[Thinking]\n${String(record.thinking || "")}`; + if (type === "redacted_thinking") return "[Redacted Thinking]"; + if (type === "tool_use") { + const id = String(record.id || "").trim(); + const name = String(record.name || "").trim(); + if (!id || knownToolUses.has(id)) { + throw new DevinAgenticBridgeError( + id ? `Duplicate Anthropic tool_use id: ${id}` : "Anthropic tool_use is missing id", + id ? "duplicate_tool_use_id" : "missing_tool_use_id" + ); + } + const declared = tools.find((tool) => tool.name === name); + if (!declared) { + throw new DevinAgenticBridgeError( + `Historical tool_use references undeclared tool: ${name || "unknown"}`, + "undeclared_historical_tool" + ); + } + knownToolUses.add(id); + return [ + "[Assistant Tool Use]", + `id: ${id}`, + `name: ${name}`, + "arguments:", + JSON.stringify(record.input || {}, null, 2), + ].join("\n"); + } + if (type === "tool_result") { + const toolUseId = String(record.tool_use_id || "").trim(); + if (!toolUseId || !knownToolUses.has(toolUseId)) { + throw new DevinAgenticBridgeError( + `Anthropic tool_result references unknown tool_use id: ${toolUseId || "missing"}`, + "orphan_tool_result" + ); + } + return [ + "[Tool Result]", + `tool_use_id: ${toolUseId}`, + `is_error: ${record.is_error === true ? "true" : "false"}`, + "content:", + boundedToolResult(record.content), + ].join("\n"); + } + if (type === "image") { + throw new DevinAgenticBridgeError( + "Anthropic image blocks are not supported by devin-cli-agentic", + "unsupported_image_block" + ); + } + + throw new DevinAgenticBridgeError( + `Unsupported Anthropic content block type: ${type || "unknown"}`, + "unsupported_content_block" + ); +} + +function serializeMessage( + message: unknown, + knownToolUses: Set, + tools: AnthropicTool[] +): string { + const record = asRecord(message); + const role = String(record.role || "user"); + if (role !== "user" && role !== "assistant") { + throw new DevinAgenticBridgeError( + `Unsupported Anthropic message role: ${role}`, + "unsupported_role" + ); + } + const label = role === "assistant" ? "Assistant" : role === "system" ? "System" : "User"; + const content = record.content; + + if (typeof content === "string") return `[${label}]\n${content}`; + if (!Array.isArray(content)) return `[${label}]\n${stringifyContentValue(content)}`; + + return `[${label}]\n${content + .map((block) => serializeBlock(block, knownToolUses, tools)) + .join("\n\n")}`; +} + +function normalizeTools(tools: unknown): AnthropicTool[] { + if (tools == null) return []; + if (!Array.isArray(tools)) { + throw new DevinAgenticBridgeError("Anthropic tools must be an array", "invalid_tools"); + } + + return tools.map((tool) => { + const record = asRecord(tool); + const name = typeof record.name === "string" ? record.name.trim() : ""; + if (!name) { + throw new DevinAgenticBridgeError("Anthropic tool is missing name", "invalid_tool_name"); + } + return { + name, + description: typeof record.description === "string" ? record.description : undefined, + input_schema: asRecord(record.input_schema), + }; + }); +} + +function serializeToolCatalog(tools: AnthropicTool[]): string[] { + if (tools.length === 0) return []; + return [ + [ + "[Available Tools]", + "When a tool is required, respond with exactly one XML-wrapped JSON object:", + '{"name":"ToolName","arguments":{}}', + "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) => + [ + `[Tool] ${tool.name}`, + tool.description ? `description: ${tool.description}` : "description:", + "input_schema:", + JSON.stringify(tool.input_schema || { type: "object", properties: {} }, null, 2), + ].join("\n") + ), + ]; +} + +function serializeToolChoice(value: unknown, tools: AnthropicTool[]): string[] { + if (value == null) return []; + const choice = asRecord(value); + const type = String(choice.type || ""); + if (type === "auto") return ["[Tool Choice]\nauto"]; + if (type === "any") return ["[Tool Choice]\nA tool call is required."]; + if (type === "none") return ["[Tool Choice]\nDo not call a tool."]; + if (type === "tool") { + const name = String(choice.name || "").trim(); + if (!tools.some((tool) => tool.name === name)) { + throw new DevinAgenticBridgeError( + `tool_choice references unknown tool: ${name}`, + "invalid_tool_choice" + ); + } + return [`[Tool Choice]\nCall exactly this tool: ${name}`]; + } + throw new DevinAgenticBridgeError( + `Unsupported Anthropic tool_choice type: ${type || "missing"}`, + "invalid_tool_choice" + ); +} + +export function serializeAnthropicForDevin(body: unknown): DevinPrompt { + const record = asRecord(body); + const messages = Array.isArray(record.messages) ? record.messages : []; + const tools = normalizeTools(record.tools); + const knownToolUses = new Set(); + const sections: string[] = [ + ...serializeSystem(record.system), + ...serializeToolCatalog(tools), + ...serializeToolChoice(record.tool_choice, tools), + ...messages.map((message) => serializeMessage(message, knownToolUses, tools)), + ].filter((section) => section.trim().length > 0); + + if (sections.length === 0) { + throw new DevinAgenticBridgeError("Anthropic request contains no messages", "empty_messages"); + } + + const text = sections.join("\n\n---\n\n"); + const idSeed = createHash("sha256").update(text).digest("hex").slice(0, 24); + return { text, tools, inputTokensEstimate: estimateTokens(text), idSeed }; +} diff --git a/open-sse/executors/devin-agentic/toolParser.ts b/open-sse/executors/devin-agentic/toolParser.ts new file mode 100644 index 0000000000..fcebe8a1a8 --- /dev/null +++ b/open-sse/executors/devin-agentic/toolParser.ts @@ -0,0 +1,117 @@ +import { createHash } from "node:crypto"; +import { asRecord, DevinAgenticBridgeError, type AnthropicTool, type JsonRecord } from "./types.ts"; + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map((item) => stableJson(item)).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as JsonRecord) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, val]) => `${JSON.stringify(key)}:${stableJson(val)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function typeOf(value: unknown): string { + if (Array.isArray(value)) return "array"; + if (value === null) return "null"; + return typeof value; +} + +function validateSchema(value: unknown, schema: JsonRecord, path: string): string[] { + const errors: string[] = []; + const expectedType = schema.type; + if (typeof expectedType === "string") { + const actual = typeOf(value); + if (expectedType === "integer") { + if (!Number.isInteger(value)) errors.push(`${path} must be integer`); + } else if (actual !== expectedType) { + errors.push(`${path} must be ${expectedType}, got ${actual}`); + } + } + + 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(", ")}` + ); + } + + if (schema.type === "object" || (value && typeof value === "object" && !Array.isArray(value))) { + const record = asRecord(value); + const required = Array.isArray(schema.required) ? schema.required.map(String) : []; + for (const key of required) { + if (!(key in record)) errors.push(`${path}.${key} is required`); + } + + 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 (schema.additionalProperties === false) { + for (const key of Object.keys(record)) { + if (!(key in properties)) errors.push(`${path}.${key} is not allowed`); + } + } + } + + if (Array.isArray(value) && schema.items) { + const itemSchema = asRecord(schema.items); + value.forEach((item, index) => + errors.push(...validateSchema(item, itemSchema, `${path}[${index}]`)) + ); + } + + return errors; +} + +export function parseDevinToolRequest(text: string, tools: AnthropicTool[], idSeed = "") { + const matches = [...text.matchAll(/\s*([\s\S]*?)\s*<\/tool>/g)]; + if (matches.length === 0) return null; + if (matches.length > 1) { + throw new DevinAgenticBridgeError( + "Devin response contained more than one tool request; parallel tool use is not supported", + "multiple_tool_requests" + ); + } + + 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] || "{}")); + } catch { + throw new DevinAgenticBridgeError("Devin tool request was not valid JSON", "invalid_tool_json"); + } + + const name = typeof payload.name === "string" ? payload.name.trim() : ""; + if (!name) + throw new DevinAgenticBridgeError("Devin tool request is missing name", "missing_tool_name"); + + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) { + throw new DevinAgenticBridgeError(`Devin requested unknown tool: ${name}`, "unknown_tool"); + } + + const input = asRecord(payload.arguments); + const schema = tool.input_schema || { type: "object", properties: {} }; + const errors = validateSchema(input, schema, "arguments"); + if (errors.length > 0) { + throw new DevinAgenticBridgeError( + `Devin tool arguments failed schema validation: ${errors.join("; ")}`, + "invalid_tool_arguments" + ); + } + + const digest = createHash("sha256") + .update(`${idSeed}:${name}:${stableJson(input)}`) + .digest("hex") + .slice(0, 16); + return { id: `tool_devin_${digest}`, name, input }; +} diff --git a/open-sse/executors/devin-agentic/types.ts b/open-sse/executors/devin-agentic/types.ts new file mode 100644 index 0000000000..8cde997407 --- /dev/null +++ b/open-sse/executors/devin-agentic/types.ts @@ -0,0 +1,56 @@ +export type JsonRecord = Record; + +export type AnthropicTool = { + name: string; + description?: string; + input_schema?: JsonRecord; +}; + +export type DevinPrompt = { + text: string; + tools: AnthropicTool[]; + inputTokensEstimate: number; + idSeed: string; +}; + +export type ParsedToolRequest = { + id: string; + name: string; + input: JsonRecord; +}; + +export type ClaudeResponseArgs = { + id: string; + model: string; + text: string; + inputTokens: number; + outputTokens: number; +}; + +export type ClaudeToolUseArgs = { + id: string; + model: string; + tool: ParsedToolRequest; + inputTokens: number; + outputTokens: number; +}; + +export class DevinAgenticBridgeError extends Error { + status: number; + code: string; + + constructor(message: string, code = "devin_agentic_error", status = 400) { + super(message); + this.name = "DevinAgenticBridgeError"; + this.code = code; + this.status = status; + } +} + +export function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +export function estimateTokens(text: string): number { + return Math.max(1, Math.ceil(text.length / 4)); +} diff --git a/open-sse/executors/devin-cli-agentic.ts b/open-sse/executors/devin-cli-agentic.ts new file mode 100644 index 0000000000..9180a02c61 --- /dev/null +++ b/open-sse/executors/devin-cli-agentic.ts @@ -0,0 +1,571 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import { randomUUID } from "node:crypto"; +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { DEVIN_MODEL_CATALOG } from "../config/providers/registry/devin/catalog.ts"; +import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts"; +import { + buildClaudeSseFrames, + buildClaudeTextResponse, + buildClaudeToolUseResponse, +} from "./devin-agentic/anthropicResponse.ts"; +import { serializeAnthropicForDevin } from "./devin-agentic/serializer.ts"; +import { parseDevinToolRequest } from "./devin-agentic/toolParser.ts"; +import { asRecord, DevinAgenticBridgeError, estimateTokens } from "./devin-agentic/types.ts"; + +type AcpMessage = { + jsonrpc: "2.0"; + id?: number | null; + method?: string; + params?: unknown; + result?: unknown; + error?: { code: number; message: string }; +}; + +const ACP_PROTOCOL_VERSION = 1; +const MAX_ACP_OUTPUT_CHARS = 1024 * 1024; +const TRUSTED_DEVIN_BRIDGE_PROXY_URL = "http://network-guard:8080"; +const REPAIRABLE_TOOL_ERRORS = new Set([ + "invalid_tool_json", + "missing_tool_name", + "unknown_tool", + "invalid_tool_arguments", + "multiple_tool_requests", + "mixed_tool_narrative", + "unexecuted_tool_intent", +]); + +function describesUnexecutedToolIntent(text: string): boolean { + const action = "(?:read|inspect|examine|edit|fix|run|check|test|start)"; + const futureAction = new RegExp( + `\\b(?:(?:next(?: immediate)?|immediate next)\\s+(?:task|step)|planned actions?)\\b[\\s\\S]{0,320}\\b${action}\\b`, + "i" + ); + return ( + futureAction.test(text) || + new RegExp(`\\b(?:i(?:'ll| will)|let me)\\b[^\\n.!?]{0,160}\\b${action}\\b`, "i").test(text) || + new RegExp(`\\bnext steps?\\s*:\\s*${action}\\b`, "i").test(text) || + new RegExp(`\\bnext immediate (?:task|step)\\s*:\\s*${action}\\b`, "i").test(text) || + new RegExp(`\\bplanned actions?\\s*:\\s*${action}\\b`, "i").test(text) || + new RegExp(`\\b(?:still|now)\\s+(?:need|needs|required)\\s+to\\s+${action}\\b`, "i").test( + text + ) || + /\btests?\s+(?:have|has|were|was)?\s*not\s+(?:yet\s+)?(?:been\s+)?run\b/i.test(text) + ); +} + +function framePromptForNoToolsSummarizer(promptText: string): string { + return [ + "[Devin Summarizer Bridge]", + "Treat the content below as an execution trace whose next assistant output must be determined.", + "If another client-owned action is required, return exactly one JSON envelope using the catalog in the trace and no prose.", + "The client will execute that tool; never execute or claim to execute a tool inside Devin.", + "The client workspace is /workspace; /home/bridge is only the isolated Devin process home.", + "If the task is complete, return only a concise final answer.", + "Do not wrap the response in Markdown fences or a element.", + "", + "[Execution Trace]", + promptText, + ].join("\n"); +} + +const CLAUDE_ENV_BLOCKLIST = [ + "ANTHROPIC_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", + "ANTHROPIC_BEDROCK_BASE_URL", + "ANTHROPIC_VERTEX_BASE_URL", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CODE_USE_FOUNDRY", +]; + +function resolveDevinBin(): string { + const envBin = process.env.CLI_DEVIN_AGENTIC_BIN?.trim() || process.env.CLI_DEVIN_BIN?.trim(); + if (envBin) return envBin; + + if (process.platform === "win32") { + const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"); + const winPath = path.join(localAppData, "devin", "cli", "bin", "devin.exe"); + if (fs.existsSync(winPath)) return winPath; + return "devin.exe"; + } + + for (const candidate of [ + path.join(os.homedir(), ".local", "share", "devin", "bin", "devin"), + path.join(os.homedir(), ".devin", "bin", "devin"), + ]) { + if (fs.existsSync(candidate)) return candidate; + } + return "devin"; +} + +function rpc(method: string, params: unknown, id: number): string { + return JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"; +} + +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; + if (source.DEVIN_BRIDGE_MOCK_LOG === "/evidence/mock-acp.jsonl") { + env.DEVIN_BRIDGE_MOCK_LOG = source.DEVIN_BRIDGE_MOCK_LOG; + } + if (source.DEVIN_BRIDGE_PROXY_URL === TRUSTED_DEVIN_BRIDGE_PROXY_URL) { + env.HTTP_PROXY = TRUSTED_DEVIN_BRIDGE_PROXY_URL; + env.HTTPS_PROXY = TRUSTED_DEVIN_BRIDGE_PROXY_URL; + } + + for (const key of CLAUDE_ENV_BLOCKLIST) delete env[key]; + return env; +} + +function errorBody(error: unknown) { + const bridge = error instanceof DevinAgenticBridgeError ? error : null; + const status = bridge?.status || 500; + const message = bridge?.message || (error instanceof Error ? error.message : String(error)); + return buildErrorBody(status, sanitizeErrorMessage(message), undefined, { + type: "devin_agentic_error", + code: bridge?.code || "devin_agentic_error", + }); +} + +export async function runAcpTurn(args: { + devinBin: string; + env: NodeJS.ProcessEnv; + model: string; + promptText: string; + signal?: AbortSignal | null; + log?: ExecuteInput["log"]; +}) { + const timeoutMs = Number(process.env.DEVIN_AGENTIC_ACP_TIMEOUT_MS || 120000); + const child = spawn(args.devinBin, ["acp", "--agent-type", "summarizer"], { + env: args.env, + cwd: args.env.HOME, + stdio: ["pipe", "pipe", "pipe"], + shell: false, + }); + + let nextId = 1; + let buffer = ""; + let text = ""; + let phase: "initialize" | "session" | "prompt" = "initialize"; + let sessionId = ""; + let initializeRequestId = 0; + let sessionRequestId = 0; + let promptRequestId = 0; + let settled = false; + + return await new Promise((resolve, reject) => { + const abortHandler = () => { + finish(new DevinAgenticBridgeError("Devin ACP request was cancelled", "acp_cancelled", 499)); + }; + + const finish = (err: Error | null, value = "") => { + if (settled) return; + settled = true; + clearTimeout(timer); + args.signal?.removeEventListener("abort", abortHandler); + try { + child.stdin.end(); + } catch {} + if (!child.killed) child.kill("SIGTERM"); + if (err) reject(err); + else resolve(value); + }; + + const timer = setTimeout(() => { + finish( + new DevinAgenticBridgeError(`Devin ACP timed out after ${timeoutMs}ms`, "acp_timeout", 504) + ); + }, timeoutMs); + timer.unref?.(); + + const send = (method: string, params: unknown) => { + const id = nextId++; + child.stdin.write(rpc(method, params, id)); + return id; + }; + + if (args.signal?.aborted) return abortHandler(); + args.signal?.addEventListener("abort", abortHandler, { once: true }); + + child.on("error", (err) => { + const message = + err.message.includes("ENOENT") || err.message.includes("not found") + ? `Devin CLI not found: ${args.devinBin}. Install the official Devin CLI or set CLI_DEVIN_AGENTIC_BIN.` + : `Devin CLI spawn error: ${err.message}`; + finish(new DevinAgenticBridgeError(message, "spawn_failed", 502)); + }); + + child.stderr.on("data", (chunk: Buffer) => { + args.log?.debug?.("DEVIN_AGENTIC", `stderr: ${chunk.toString("utf8").slice(0, 200)}`); + }); + + child.stdout.on("data", (chunk: Buffer) => { + buffer += chunk.toString("utf8"); + if (buffer.length + text.length > MAX_ACP_OUTPUT_CHARS) { + finish( + new DevinAgenticBridgeError( + "Devin ACP output exceeded the bridge limit", + "acp_output_too_large", + 502 + ) + ); + return; + } + let nl: number; + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line) continue; + + let msg: AcpMessage; + try { + msg = JSON.parse(line); + } catch { + finish( + new DevinAgenticBridgeError( + "Devin ACP emitted invalid JSON on stdout", + "invalid_acp_frame", + 502 + ) + ); + return; + } + + if (msg.error) { + finish( + new DevinAgenticBridgeError( + `Devin ACP error ${msg.error.code}: ${msg.error.message}`, + "acp_error", + 502 + ) + ); + return; + } + + if (phase === "initialize" && msg.id === initializeRequestId && msg.result !== undefined) { + const protocolVersion = Number(asRecord(msg.result).protocolVersion); + if (protocolVersion !== ACP_PROTOCOL_VERSION) { + finish( + new DevinAgenticBridgeError( + `Devin ACP negotiated unsupported protocol version: ${String(protocolVersion)}`, + "unsupported_acp_version", + 502 + ) + ); + return; + } + phase = "session"; + sessionRequestId = send("session/new", { + cwd: args.env.HOME, + mcpServers: [], + model: args.model || undefined, + }); + continue; + } + + if (phase === "session" && msg.id === sessionRequestId && msg.result !== undefined) { + const sessionResult = asRecord(msg.result); + sessionId = String(sessionResult.sessionId || ""); + if (!sessionId) { + finish( + new DevinAgenticBridgeError( + "Devin ACP session/new returned no sessionId", + "missing_session_id", + 502 + ) + ); + return; + } + + phase = "prompt"; + promptRequestId = send("session/prompt", { + sessionId, + prompt: [{ type: "text", text: framePromptForNoToolsSummarizer(args.promptText) }], + }); + continue; + } + + if (msg.method === "session/update" || msg.method === "$/update") { + const params = asRecord(msg.params); + const updateSessionId = String(params.sessionId || ""); + if (updateSessionId && sessionId && updateSessionId !== sessionId) { + finish( + new DevinAgenticBridgeError( + "Devin ACP update referenced a different session", + "acp_session_mismatch", + 502 + ) + ); + return; + } + 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 ( + kind === "message_delta" || + kind === "text_delta" || + kind === "content_delta" + ) { + text += String(params.content || params.delta || params.text || ""); + } + continue; + } + + if (phase === "prompt" && msg.id === promptRequestId && msg.result !== undefined) { + const stopReason = String(asRecord(msg.result).stopReason || ""); + if (stopReason === "cancelled") { + finish( + new DevinAgenticBridgeError("Devin ACP cancelled the turn", "acp_cancelled", 502) + ); + return; + } + const resultText = + extractText(asRecord(msg.result).content) || extractText(asRecord(msg.result).message); + const finalText = text || resultText; + if (!finalText) { + finish( + new DevinAgenticBridgeError( + `Devin ACP completed without model output (stopReason=${stopReason || "missing"})`, + "empty_acp_output", + 502 + ) + ); + return; + } + finish(null, finalText); + continue; + } + + if (msg.id !== undefined && msg.id !== null && !msg.method) { + finish( + new DevinAgenticBridgeError( + `Devin ACP returned an unexpected response id: ${String(msg.id)}`, + "unexpected_acp_response", + 502 + ) + ); + return; + } + } + }); + + 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 + ) + ); + }); + + initializeRequestId = send("initialize", { + protocolVersion: ACP_PROTOCOL_VERSION, + clientInfo: { name: "omniroute-devin-cli-agentic", version: "1.0" }, + clientCapabilities: {}, + }); + }); +} + +function assertKnownDevinModel(model: string): void { + if (!DEVIN_MODEL_CATALOG.some((entry) => entry.id === model)) { + throw new DevinAgenticBridgeError( + `Model is not present in the current Devin catalog: ${model}`, + "unknown_devin_model", + 400 + ); + } +} + +async function generateAgenticOutput( + args: Omit[0], "promptText">, + promptText: string +) { + const first = await runAcpTurn({ ...args, promptText }); + return first; +} + +function extractText(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) return value.map((item) => extractText(item)).join(""); + const record = asRecord(value); + if (typeof record.text === "string") return record.text; + if (typeof record.content === "string") return record.content; + return ""; +} + +export class DevinCliAgenticExecutor extends BaseExecutor { + constructor() { + super("devin-cli-agentic", { id: "devin-cli-agentic", baseUrl: "devin://acp/stdio" }); + } + + buildUrl(): string { + const url = "devin://acp/stdio"; + assertLocalAcpUrl(url); + return url; + } + + buildHeaders(): Record { + return {}; + } + + transformRequest(): unknown { + return null; + } + + async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) { + try { + assertKnownDevinModel(model); + const prompt = serializeAnthropicForDevin(body); + const devinBin = resolveDevinBin(); + log?.info?.("DEVIN_AGENTIC", `devin acp → model=${model}, bin=${devinBin}`); + + const turnArgs = { + devinBin, + env: buildDevinChildEnv(credentials), + model, + signal, + log, + }; + + 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 ( + !(error instanceof DevinAgenticBridgeError) || + !REPAIRABLE_TOOL_ERRORS.has(error.code) + ) { + throw error; + } + const requiresToolOnRepair = error.code === "unexecuted_tool_intent"; + const repairPrompt = [ + prompt.text, + "", + "---", + "", + "[Single Repair Attempt]", + `The previous output was rejected: ${sanitizeErrorMessage(error.message)}`, + requiresToolOnRepair + ? "Plain text is not accepted for this repair. Return exactly one standalone JSON envelope now." + : "Return either plain final text or exactly one standalone 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("-", "")}`; + const outputTokens = estimateTokens(text); + const message = tool + ? buildClaudeToolUseResponse({ + id, + model, + tool, + inputTokens: prompt.inputTokensEstimate, + outputTokens, + }) + : buildClaudeTextResponse({ + id, + model, + text, + inputTokens: prompt.inputTokensEstimate, + outputTokens, + }); + + const responseBody = stream ? buildClaudeSseFrames(message) : JSON.stringify(message); + return { + response: new Response(responseBody, { + status: 200, + headers: { + "Content-Type": stream ? "text/event-stream" : "application/json", + "Cache-Control": "no-cache", + }, + }), + url: "devin://acp/stdio", + headers: {}, + transformedBody: { model, promptLength: prompt.text.length }, + }; + } catch (error) { + const bridge = error instanceof DevinAgenticBridgeError ? error : null; + return { + response: new Response(JSON.stringify(errorBody(error)), { + status: bridge?.status || 500, + headers: { "Content-Type": "application/json" }, + }), + url: "devin://acp/stdio", + headers: {}, + transformedBody: { model }, + }; + } + } +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index a47623b6e5..89748306a7 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -31,6 +31,7 @@ import { NlpCloudExecutor } from "./nlpcloud.ts"; import { WindsurfExecutor } from "./windsurf.ts"; import { ZedHostedExecutor } from "./zed-hosted.ts"; import { DevinCliExecutor } from "./devin-cli.ts"; +import { DevinCliAgenticExecutor } from "./devin-cli-agentic.ts"; import { AuggieExecutor } from "./auggie.ts"; import { DeepSeekWebExecutor } from "./deepseek-web.ts"; import { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-refresh.ts"; @@ -128,6 +129,7 @@ const executors = { ws: new WindsurfExecutor(), // Alias "zed-hosted": new ZedHostedExecutor(), "devin-cli": new DevinCliExecutor(), + "devin-cli-agentic": new DevinCliAgenticExecutor(), devin: new DevinCliExecutor(), // Alias "deepseek-web": new DeepSeekWebWithAutoRefreshExecutor(), "ds-web": new DeepSeekWebWithAutoRefreshExecutor(), // Alias @@ -264,6 +266,7 @@ export { NlpCloudExecutor } from "./nlpcloud.ts"; export { WindsurfExecutor } from "./windsurf.ts"; export { ZedHostedExecutor } from "./zed-hosted.ts"; export { DevinCliExecutor } from "./devin-cli.ts"; +export { DevinCliAgenticExecutor } from "./devin-cli-agentic.ts"; export { AuggieExecutor } from "./auggie.ts"; export { CopilotWebExecutor } from "./copilot-web.ts"; export { CopilotM365WebExecutor } from "./copilot-m365-web.ts"; diff --git a/scripts/devin-bridge/build b/scripts/devin-bridge/build new file mode 100755 index 0000000000..abb309d453 --- /dev/null +++ b/scripts/devin-bridge/build @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/common" +bridge_prepare_sandbox +docker compose -f "$BRIDGE_COMPOSE" --profile offline build diff --git a/scripts/devin-bridge/clean b/scripts/devin-bridge/clean new file mode 100755 index 0000000000..1fc195aadb --- /dev/null +++ b/scripts/devin-bridge/clean @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/common" +if [[ "${1:-}" == "--all" ]]; then + docker compose -f "$BRIDGE_COMPOSE" --profile offline --profile live-devin down \ + --remove-orphans --volumes + printf 'Containers, networks, and bridge-owned named volumes were removed.\n' +else + docker compose -f "$BRIDGE_COMPOSE" --profile offline --profile live-devin down \ + --remove-orphans + printf 'Containers and networks stopped. Named auth/config volumes were preserved; use --all to remove them.\n' +fi diff --git a/scripts/devin-bridge/common b/scripts/devin-bridge/common new file mode 100755 index 0000000000..b39519dad5 --- /dev/null +++ b/scripts/devin-bridge/common @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +set -euo pipefail +BRIDGE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BRIDGE_COMPOSE="$BRIDGE_ROOT/docker/devin-bridge/compose.yml" +BRIDGE_SANDBOX="$BRIDGE_ROOT/.sandbox" +BRIDGE_GUARD_AUDIT_ROOT="$BRIDGE_SANDBOX/guard-audit" +BRIDGE_CLAUDE_AUDIT="$BRIDGE_GUARD_AUDIT_ROOT/claude/egress.jsonl" +BRIDGE_DEVIN_AUDIT="$BRIDGE_GUARD_AUDIT_ROOT/devin/egress.jsonl" +BRIDGE_RUNTIME_POLICY="$BRIDGE_ROOT/scripts/devin-bridge/runtime-policy.mjs" +bridge_prepare_sandbox() { + mkdir -p "$BRIDGE_SANDBOX/home" "$BRIDGE_SANDBOX/test-data" \ + "$BRIDGE_SANDBOX/e2e-workspace" "$BRIDGE_SANDBOX/live-workspace" \ + "$BRIDGE_SANDBOX/evidence" "$BRIDGE_GUARD_AUDIT_ROOT/claude" \ + "$BRIDGE_GUARD_AUDIT_ROOT/devin" + chmod 0777 "$BRIDGE_SANDBOX/e2e-workspace" "$BRIDGE_SANDBOX/live-workspace" \ + "$BRIDGE_SANDBOX/evidence" + chmod 01777 "$BRIDGE_GUARD_AUDIT_ROOT/claude" "$BRIDGE_GUARD_AUDIT_ROOT/devin" +} +bridge_reset_guard_audit() { + local audit_path="$1" + local audit_dir + local temp_path + bridge_prepare_sandbox + audit_dir="$(dirname "$audit_path")" + temp_path="$(mktemp "$audit_dir/.egress.jsonl.XXXXXX")" + chmod 0666 "$temp_path" + mv -f "$temp_path" "$audit_path" +} +bridge_reset_claude_egress_audit() { + bridge_reset_guard_audit "$BRIDGE_CLAUDE_AUDIT" +} +bridge_reset_devin_egress_audit() { + bridge_reset_guard_audit "$BRIDGE_DEVIN_AUDIT" +} +bridge_reset_e2e_fixture() { + bridge_prepare_sandbox + cp -R "$BRIDGE_ROOT/tests/fixtures/devin-bridge/e2e-workspace/." \ + "$BRIDGE_SANDBOX/e2e-workspace/" + rm -f "$BRIDGE_SANDBOX/e2e-workspace/.e2e-hook.log" \ + "$BRIDGE_SANDBOX/evidence/claude-stream.jsonl" \ + "$BRIDGE_SANDBOX/evidence/mock-acp.jsonl" + bridge_reset_claude_egress_audit +} +bridge_reset_live_fixture() { + bridge_prepare_sandbox + cp -R "$BRIDGE_ROOT/tests/fixtures/devin-bridge/e2e-workspace/." \ + "$BRIDGE_SANDBOX/live-workspace/" + rm -f "$BRIDGE_SANDBOX/live-workspace/.e2e-hook.log" \ + "$BRIDGE_SANDBOX/evidence/live-analysis.jsonl" \ + "$BRIDGE_SANDBOX/evidence/live-fix.jsonl" \ + "$BRIDGE_SANDBOX/evidence/live-command.jsonl" \ + "$BRIDGE_SANDBOX/evidence/live-models.json" \ + "$BRIDGE_SANDBOX/evidence/egress.jsonl" + bridge_reset_claude_egress_audit + bridge_reset_devin_egress_audit +} +bridge_test_env() { + bridge_prepare_sandbox + env HOME="$BRIDGE_SANDBOX/home" DATA_DIR="$BRIDGE_SANDBOX/test-data" SQLITE_FILE="$BRIDGE_SANDBOX/test-data/storage.sqlite" DEVIN_AGENTIC_HOME="$BRIDGE_SANDBOX/home" "$@" +} + +bridge_run_devin() { + docker compose -f "$BRIDGE_COMPOSE" --profile live-devin run --rm --no-deps \ + omniroute-live sh -ceu ' + trusted_proxy=http://network-guard:8080 + test "${DEVIN_BRIDGE_PROXY_URL:-}" = "$trusted_proxy" + export HTTP_PROXY="$trusted_proxy" HTTPS_PROXY="$trusted_proxy" + unset ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy + exec devin "$@" + ' bridge-devin "$@" +} + +bridge_assert_devin_auth_status() { + local exit_status="$1" + local output="$2" + printf '%s' "$output" | node --input-type=module -e ' + import { pathToFileURL } from "node:url"; + import fs from "node:fs"; + const policy = await import(pathToFileURL(process.argv[1])); + const result = policy.validateDevinAuthStatus(process.argv[2], fs.readFileSync(0, "utf8")); + if (!result.ok) throw new Error(result.error); + ' "$BRIDGE_RUNTIME_POLICY" "$exit_status" +} + +bridge_check_devin_auth() { + local output + local exit_status + set +e + output="$(bridge_run_devin auth status 2>&1)" + exit_status=$? + set -e + bridge_assert_devin_auth_status "$exit_status" "$output" + printf 'PASS: Devin authentication confirmed\n' +} + +bridge_assert_zero_claude_egress() { + local audit_path="$1" + bridge_validate_guard_audit claude-zero "$audit_path" +} + +bridge_assert_claude_guard_denials() { + local audit_path="$1" + bridge_validate_guard_audit claude-denials "$audit_path" +} + +bridge_assert_devin_guard_audit() { + local audit_path="$1" + bridge_validate_guard_audit devin-allowed "$audit_path" +} + +bridge_validate_guard_audit() { + local kind="$1" + local audit_path="$2" + node --input-type=module -e ' + import { pathToFileURL } from "node:url"; + const policy = await import(pathToFileURL(process.argv[1])); + policy.validateAuditFile(process.argv[2], process.argv[3], process.argv[4]); + ' "$BRIDGE_RUNTIME_POLICY" "$kind" "$audit_path" "$(id -u)" +} + +bridge_export_guard_audit() { + local audit_path="$1" + local evidence_name="$2" + cp "$audit_path" "$BRIDGE_SANDBOX/evidence/$evidence_name" + chmod 0644 "$BRIDGE_SANDBOX/evidence/$evidence_name" +} + +bridge_cleanup_compose() { + docker compose -f "$BRIDGE_COMPOSE" --profile offline --profile live-devin \ + down --remove-orphans >/dev/null 2>&1 || true +} diff --git a/scripts/devin-bridge/launch b/scripts/devin-bridge/launch new file mode 100755 index 0000000000..de0515def8 --- /dev/null +++ b/scripts/devin-bridge/launch @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/common" +trap bridge_cleanup_compose EXIT +bridge_cleanup_compose +bridge_prepare_sandbox +"$(dirname "$0")/verify-anthropic-isolation" +bridge_reset_claude_egress_audit +bridge_reset_devin_egress_audit +docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d --wait network-guard claude-egress-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")" +export DEVIN_BRIDGE_MODEL="devin-cli-agentic/$devin_model" +export DEVIN_BRIDGE_SONNET_MODEL="${DEVIN_BRIDGE_SONNET_MODEL:-$DEVIN_BRIDGE_MODEL}" +export DEVIN_BRIDGE_OPUS_MODEL="${DEVIN_BRIDGE_OPUS_MODEL:-$DEVIN_BRIDGE_MODEL}" +export DEVIN_BRIDGE_HAIKU_MODEL="${DEVIN_BRIDGE_HAIKU_MODEL:-$DEVIN_BRIDGE_MODEL}" +export DEVIN_BRIDGE_SUBAGENT_MODEL="${DEVIN_BRIDGE_SUBAGENT_MODEL:-$DEVIN_BRIDGE_MODEL}" +docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d --wait omniroute-live +docker compose -f "$BRIDGE_COMPOSE" --profile live-devin run --rm --no-deps \ + claude-live claude +bridge_cleanup_compose +bridge_assert_devin_guard_audit "$BRIDGE_DEVIN_AUDIT" +bridge_assert_zero_claude_egress "$BRIDGE_CLAUDE_AUDIT" +bridge_export_guard_audit "$BRIDGE_DEVIN_AUDIT" egress.jsonl +bridge_export_guard_audit "$BRIDGE_CLAUDE_AUDIT" claude-egress.jsonl +trap - EXIT diff --git a/scripts/devin-bridge/login-devin b/scripts/devin-bridge/login-devin new file mode 100755 index 0000000000..0489080784 --- /dev/null +++ b/scripts/devin-bridge/login-devin @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/common" +[[ "${ENABLE_LIVE_DEVIN_TESTS:-}" == 1 ]] || { echo 'Set ENABLE_LIVE_DEVIN_TESTS=1' >&2; exit 1; } +trap bridge_cleanup_compose EXIT +bridge_cleanup_compose +bridge_prepare_sandbox +bridge_reset_devin_egress_audit +docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d --wait network-guard +bridge_run_devin auth login --force-manual-token-flow +bridge_cleanup_compose +trap - EXIT +exec env ENABLE_LIVE_DEVIN_TESTS=1 "$(dirname "$0")/test-live-devin" diff --git a/scripts/devin-bridge/runtime-policy.mjs b/scripts/devin-bridge/runtime-policy.mjs new file mode 100644 index 0000000000..73a287d32b --- /dev/null +++ b/scripts/devin-bridge/runtime-policy.mjs @@ -0,0 +1,111 @@ +import fs from "node:fs"; + +const ALLOWED_DEVIN_SUFFIXES = [".devin.ai", ".cognition.ai"]; +const ALLOWED_DEVIN_EXACT = ["server.codeium.com", "unleash.codeium.com"]; + +function normalizedHostname(value) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/\.$/, ""); +} + +export function isAllowedDevinAuditHostname(hostname) { + const value = normalizedHostname(hostname); + return ( + ALLOWED_DEVIN_EXACT.includes(value) || + ALLOWED_DEVIN_SUFFIXES.some((suffix) => value === suffix.slice(1) || value.endsWith(suffix)) + ); +} + +export function validateDevinAuthStatus(exitStatus, output) { + if (Number(exitStatus) !== 0) return { ok: false, error: "auth status command failed" }; + const lines = String(output) + .split(/\r?\n/) + .map((line) => line.trim()); + 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))) { + return { ok: false, error: "auth status could not confirm server access" }; + } + return { ok: true }; +} + +export function parseAuditEntries(text) { + const lines = String(text) + .split(/\r?\n/) + .filter((line) => line.trim().length > 0); + return lines.map((line) => JSON.parse(line)); +} + +export function validateZeroClaudeEgress(text) { + if (String(text).length !== 0) { + return { ok: false, error: "Claude attempted external egress during the real run" }; + } + return { ok: true }; +} + +export function validateClaudeGuardDenials(text) { + const entries = parseAuditEntries(text); + if (!entries.length) return { ok: false, error: "Claude egress audit has no records" }; + if (entries.some((entry) => entry.decision !== "deny")) { + return { ok: false, error: "Claude egress audit contains a non-deny decision" }; + } + for (const hostname of ["api.anthropic.com", "claude.ai"]) { + if (!entries.some((entry) => entry.hostname === hostname && entry.decision === "deny")) { + return { ok: false, error: `Claude egress audit is missing deny for ${hostname}` }; + } + } + return { ok: true }; +} + +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 sawAllowedDevinRequest + ? { ok: true } + : { ok: false, error: "Devin egress audit has no approved request" }; +} + +export function validateAuditFileStat(stat, expectedUid) { + if (!stat || !stat.isFile() || stat.isSymbolicLink()) return "audit path is not a regular file"; + if (stat.nlink !== 1) return "audit file link count is not one"; + if (stat.uid !== Number(expectedUid)) return "audit file owner mismatch"; + if ((stat.mode & 0o777) !== 0o666) return "audit file mode mismatch"; + return null; +} + +export function readValidatedAuditFile(path, expectedUid) { + const stat = fs.lstatSync(path); + const statError = validateAuditFileStat(stat, expectedUid); + if (statError) throw new Error(statError); + return fs.readFileSync(path, "utf8"); +} + +export function validateAuditFile(kind, path, expectedUid) { + const text = readValidatedAuditFile(path, expectedUid); + const result = + kind === "claude-zero" + ? validateZeroClaudeEgress(text) + : kind === "claude-denials" + ? validateClaudeGuardDenials(text) + : kind === "devin-allowed" + ? validateDevinGuardAudit(text) + : { ok: false, error: `unknown audit validation kind: ${kind}` }; + if (!result.ok) throw new Error(result.error); + return text; +} diff --git a/scripts/devin-bridge/select-live-model.mjs b/scripts/devin-bridge/select-live-model.mjs new file mode 100644 index 0000000000..ebe38819d0 --- /dev/null +++ b/scripts/devin-bridge/select-live-model.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import { pathToFileURL } from "node:url"; +import { DEVIN_MODEL_CATALOG } from "../../open-sse/config/providers/registry/devin/catalog.ts"; + +const candidateFields = new Set([ + "model_id", + "modelId", + "model_uid", + "modelUid", + "family_uid", + "familyUid", +]); + +function normalizeModelId(value) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function collect(value, candidates) { + if (Array.isArray(value)) { + value.forEach((item) => collect(item, candidates)); + return; + } + if (!value || typeof value !== "object") return; + for (const [key, nested] of Object.entries(value)) { + if ( + typeof nested === "string" && + candidateFields.has(key) && + /^[a-z0-9][a-z0-9._/-]*$/i.test(nested) + ) { + candidates.push(nested); + } + collect(nested, candidates); + } +} + +export function selectLiveModel( + document, + environment = process.env, + catalog = DEVIN_MODEL_CATALOG +) { + const candidates = []; + collect(document, candidates); + const unique = [...new Set(candidates)]; + const normalizedCatalog = new Map(); + for (const entry of catalog) { + const normalized = normalizeModelId(entry.id); + const existing = normalizedCatalog.get(normalized) || []; + existing.push(entry.id); + normalizedCatalog.set(normalized, existing); + } + for (const [normalized, ids] of normalizedCatalog) { + if (ids.length > 1) { + throw new Error( + `Ambiguous OmniRoute catalog normalization for ${normalized}: ${ids.join(", ")}` + ); + } + } + const catalogIds = new Set(catalog.map((entry) => entry.id)); + const available = [ + ...new Set( + unique + .map((candidate) => normalizeModelId(candidate)) + .filter((candidate) => normalizedCatalog.has(candidate)) + ), + ]; + + for (const [name, configured] of [ + ["DEVIN_BRIDGE_SONNET_MODEL", environment.DEVIN_BRIDGE_SONNET_MODEL], + ["DEVIN_BRIDGE_OPUS_MODEL", environment.DEVIN_BRIDGE_OPUS_MODEL], + ["DEVIN_BRIDGE_HAIKU_MODEL", environment.DEVIN_BRIDGE_HAIKU_MODEL], + ["DEVIN_BRIDGE_SUBAGENT_MODEL", environment.DEVIN_BRIDGE_SUBAGENT_MODEL], + ]) { + if (!configured) continue; + const prefix = "devin-cli-agentic/"; + const modelId = configured.startsWith(prefix) ? configured.slice(prefix.length) : ""; + if (!modelId || !catalogIds.has(modelId) || !available.includes(modelId)) { + throw new Error(`${name} is not a model returned by Devin and present in OmniRoute`); + } + } + + const selected = + available.find((candidate) => candidate === "swe-1-7-lightning") || + available.find((candidate) => candidate === "swe-1-7") || + available.find((candidate) => /swe|claude|gpt|gemini/i.test(candidate)) || + available[0]; + + if (!selected) { + throw new Error("Devin returned no model identifier present in OmniRoute's Devin catalog"); + } + return selected; +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + const document = JSON.parse(fs.readFileSync(0, "utf8")); + process.stdout.write(selectLiveModel(document)); +} diff --git a/scripts/devin-bridge/test-contract b/scripts/devin-bridge/test-contract new file mode 100755 index 0000000000..91e49448d5 --- /dev/null +++ b/scripts/devin-bridge/test-contract @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/common" +bridge_prepare_sandbox +rm -f "$BRIDGE_SANDBOX/evidence/mock-acp.jsonl" +"$(dirname "$0")/verify-anthropic-isolation" --static +docker compose -f "$BRIDGE_COMPOSE" --profile offline down --remove-orphans +docker compose -f "$BRIDGE_COMPOSE" --profile offline up --abort-on-container-exit \ + --exit-code-from contract contract +node -e ' + const fs = require("node:fs"); + const rows = fs.readFileSync(process.argv[1], "utf8").trim().split("\n").map(JSON.parse); + 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" +printf 'PASS: bridge wire contract suite completed without provider fallback\n' diff --git a/scripts/devin-bridge/test-e2e-mock b/scripts/devin-bridge/test-e2e-mock new file mode 100755 index 0000000000..43db057fca --- /dev/null +++ b/scripts/devin-bridge/test-e2e-mock @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/common" +trap bridge_cleanup_compose EXIT +bridge_cleanup_compose +bridge_reset_e2e_fixture +"$(dirname "$0")/verify-anthropic-isolation" --static +docker compose -f "$BRIDGE_COMPOSE" --profile offline up --abort-on-container-exit \ + --exit-code-from claude claude +grep -q '"action":"final"' "$BRIDGE_SANDBOX/evidence/mock-acp.jsonl" +grep -q 'BRIDGE_E2E_COMPLETE' "$BRIDGE_SANDBOX/evidence/claude-stream.jsonl" +bridge_cleanup_compose +bridge_assert_zero_claude_egress "$BRIDGE_CLAUDE_AUDIT" +bridge_export_guard_audit "$BRIDGE_CLAUDE_AUDIT" claude-egress.jsonl +trap - EXIT +printf 'PASS: real Claude Code completed the offline agentic fixture\n' diff --git a/scripts/devin-bridge/test-live-devin b/scripts/devin-bridge/test-live-devin new file mode 100755 index 0000000000..e0458eb1a0 --- /dev/null +++ b/scripts/devin-bridge/test-live-devin @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/common" +[[ "${ENABLE_LIVE_DEVIN_TESTS:-}" == 1 ]] || { echo 'Set ENABLE_LIVE_DEVIN_TESTS=1' >&2; exit 1; } +trap bridge_cleanup_compose EXIT +bridge_cleanup_compose +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 +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" +export DEVIN_BRIDGE_HAIKU_MODEL="$DEVIN_BRIDGE_MODEL" +export DEVIN_BRIDGE_SUBAGENT_MODEL="$DEVIN_BRIDGE_MODEL" +docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up --abort-on-container-exit --exit-code-from claude-live claude-live +bridge_cleanup_compose +bridge_assert_devin_guard_audit "$BRIDGE_DEVIN_AUDIT" +bridge_assert_zero_claude_egress "$BRIDGE_CLAUDE_AUDIT" +bridge_export_guard_audit "$BRIDGE_DEVIN_AUDIT" egress.jsonl +bridge_export_guard_audit "$BRIDGE_CLAUDE_AUDIT" claude-egress.jsonl +trap - EXIT +printf 'PASS: live model %s was discovered and validated by three scenarios\n' "$devin_model" diff --git a/scripts/devin-bridge/test-unit b/scripts/devin-bridge/test-unit new file mode 100755 index 0000000000..c6f3a4518c --- /dev/null +++ b/scripts/devin-bridge/test-unit @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/common" +cd "$BRIDGE_ROOT" +bridge_test_env node --import tsx/esm --test \ + tests/unit/executor-devin-cli-agentic-core.test.ts \ + tests/unit/executor-devin-cli-agentic-acp.test.ts \ + tests/unit/devin-bridge-network-guard.test.ts \ + tests/unit/devin-bridge-live-runtime.test.ts diff --git a/scripts/devin-bridge/validate-claude-evidence.mjs b/scripts/devin-bridge/validate-claude-evidence.mjs new file mode 100644 index 0000000000..6a7c5fcf15 --- /dev/null +++ b/scripts/devin-bridge/validate-claude-evidence.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import { pathToFileURL } from "node:url"; + +function contentBlocks(message) { + return Array.isArray(message?.message?.content) ? message.message.content : []; +} + +export function validateClaudeEvidenceText(text, options) { + const marker = String(options?.marker || "").trim(); + const requiredTools = Array.isArray(options?.requiredTools) ? options.requiredTools : []; + if (!marker) throw new Error("A final marker is required"); + + const toolUses = new Map(); + const successfulResults = new Set(); + const slashCommands = new Set(); + const skills = new Set(); + let finalResult = null; + + for (const [index, rawLine] of String(text).split(/\r?\n/).entries()) { + const line = rawLine.trim(); + if (!line) continue; + let event; + try { + event = JSON.parse(line); + } catch { + throw new Error(`Invalid Claude evidence JSON at line ${index + 1}`); + } + + if (event?.type === "system" && event?.subtype === "init") { + for (const command of Array.isArray(event.slash_commands) ? event.slash_commands : []) { + slashCommands.add(String(command)); + } + for (const skill of Array.isArray(event.skills) ? event.skills : []) { + skills.add(String(skill)); + } + } + + for (const block of contentBlocks(event)) { + if (block?.type === "tool_use" && typeof block.id === "string") { + toolUses.set(block.id, { name: String(block.name || ""), input: block.input || {} }); + } + if ( + block?.type === "tool_result" && + typeof block.tool_use_id === "string" && + block.is_error !== true + ) { + successfulResults.add(block.tool_use_id); + } + } + + if (event?.type === "result") finalResult = event; + } + + if (!finalResult || finalResult.subtype !== "success" || finalResult.is_error === true) { + throw new Error("Claude evidence has no successful terminal result"); + } + const resultText = String(finalResult.result || ""); + const incompleteResult = [ + /(?:^|\n)\s*(?:\*\*)?blocker(?:\*\*)?\s*:/im, + /\btask (?:is|remains) (?:not complete|incomplete)\b/i, + /(?:^|\n)\s*(?:[-*]\s*)?(?:\*\*)?next steps? needed(?:\*\*)?\s*:/im, + ].some((pattern) => pattern.test(resultText)); + if (incompleteResult) { + throw new Error("Claude terminal result explicitly reports incomplete work"); + } + if (options?.requiredSlashCommand && !slashCommands.has(options.requiredSlashCommand)) { + throw new Error(`Claude did not load required slash command: ${options.requiredSlashCommand}`); + } + if (options?.requiredSkill && !skills.has(options.requiredSkill)) { + throw new Error(`Claude did not load required skill: ${options.requiredSkill}`); + } + const markerIsStandalone = resultText.split(/\r?\n/).some((line) => line.trim() === marker); + + for (const requiredTool of requiredTools) { + if (![...toolUses.values()].some((tool) => tool.name === requiredTool)) { + throw new Error(`Claude did not request required client-owned tool: ${requiredTool}`); + } + } + + const npmTestSucceeded = [...toolUses.entries()].some( + ([id, tool]) => + tool.name === "Bash" && + /\bnpm\s+test\b/.test(String(tool.input?.command || "")) && + successfulResults.has(id) + ); + if (options?.requireSuccessfulNpmTest) { + if (!npmTestSucceeded) throw new Error("Claude evidence has no successful npm test tool turn"); + } + const markerIsCorroborated = + options?.requireSuccessfulNpmTest && npmTestSucceeded && resultText.includes(marker); + const explicitCompletionIsCorroborated = + options?.acceptExplicitCompletion === true && + npmTestSucceeded && + /\btask is complete\b/i.test(resultText); + if (!markerIsStandalone && !markerIsCorroborated && !explicitCompletionIsCorroborated) { + throw new Error(`Claude result has no standalone marker: ${marker}`); + } +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + const [ + evidencePath, + marker, + requiredTools = "", + requireNpmTest = "false", + requiredSlashCommand = "", + requiredSkill = "", + acceptExplicitCompletion = "false", + ] = process.argv.slice(2); + if (!evidencePath) + throw new Error("Usage: validate-claude-evidence.mjs FILE MARKER [TOOLS] [NPM_TEST]"); + validateClaudeEvidenceText(fs.readFileSync(evidencePath, "utf8"), { + marker, + requiredTools: requiredTools + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + requireSuccessfulNpmTest: requireNpmTest === "true", + requiredSlashCommand: requiredSlashCommand || undefined, + requiredSkill: requiredSkill || undefined, + acceptExplicitCompletion: acceptExplicitCompletion === "true", + }); + process.stdout.write(`PASS: validated Claude evidence for ${marker}\n`); +} diff --git a/scripts/devin-bridge/verify-anthropic-isolation b/scripts/devin-bridge/verify-anthropic-isolation new file mode 100755 index 0000000000..f7169661ea --- /dev/null +++ b/scripts/devin-bridge/verify-anthropic-isolation @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/common" +fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; } +bridge_prepare_sandbox +compose_config=(docker compose -f "$BRIDGE_COMPOSE" --env-file /dev/null --profile offline --profile live-devin config) +config="$("${compose_config[@]}")" +config_json="$("${compose_config[@]}" --format json)" +for forbidden in "$HOME/.claude" "$HOME/.claude.json" "$HOME/.ssh" "/var/run/docker.sock"; do + [[ "$config" != *"$forbidden"* ]] || fail "forbidden host mount appears in compose: $forbidden" +done +grep -q 'user: 10001:10001' <<<"$config" || fail "runtime is not non-root" +grep -q 'read_only: true' <<<"$config" || fail "runtime root filesystem is not read-only" +grep -q 'internal: true' <<<"$config" || fail "internal network is missing" +grep -q 'CLAUDE_CONFIG_DIR: /home/bridge/.claude-devin-isolated' <<<"$config" || fail "isolated Claude config is missing" +node -e ' + const fs = require("node:fs"); + const config = JSON.parse(fs.readFileSync(0, "utf8")); + const liveNetworks = Object.keys(config.services["omniroute-live"].networks || {}).sort(); + if (JSON.stringify(liveNetworks) !== JSON.stringify(["bridge-internal", "devin-guard-internal"])) { + throw new Error(`live runtime network escape: ${liveNetworks.join(",")}`); + } + const guardNetworks = Object.keys(config.services["network-guard"].networks || {}).sort(); + if (JSON.stringify(guardNetworks) !== JSON.stringify(["devin-guard-internal", "guard-egress"])) { + throw new Error(`network guard topology mismatch: ${guardNetworks.join(",")}`); + } + const claudeGuard = config.services["claude-egress-guard"]; + if (JSON.stringify(Object.keys(claudeGuard.networks || {})) !== JSON.stringify(["bridge-internal"])) { + throw new Error("Claude egress guard must remain on the internal network only"); + } + if (config.services["network-guard"].environment.GUARD_POLICY !== "devin") { + throw new Error("Devin network guard policy mismatch"); + } + if (claudeGuard.environment.GUARD_POLICY !== "deny-all") { + throw new Error("Claude egress guard is not deny-all"); + } + for (const guardName of ["network-guard", "claude-egress-guard"]) { + const guard = config.services[guardName]; + const env = config.services[guardName].environment; + if (env.GUARD_ALLOW_SUFFIXES || env.GUARD_ALLOW_HOSTS) { + throw new Error(`${guardName} exposes mutable host allowlists`); + } + if (!guard.healthcheck?.test) throw new Error(`${guardName} has no healthcheck`); + const auditMount = (guard.volumes || []).find((mount) => mount.target === "/guard-audit"); + if (!auditMount || auditMount.type !== "bind" || !auditMount.source.includes("/.sandbox/guard-audit/")) { + throw new Error(`${guardName} does not use its guard-only audit bind`); + } + } + const runtimeNames = ["omniroute", "claude", "contract", "omniroute-live", "claude-live"]; + for (const serviceName of [...runtimeNames, "network-guard", "claude-egress-guard"]) { + const service = config.services[serviceName]; + if (String(service.user) !== "10001:10001" || !service.read_only) { + throw new Error(`${serviceName} is not non-root and read-only`); + } + } + for (const serviceName of runtimeNames) { + const service = config.services[serviceName]; + if ((service.volumes || []).some((mount) => mount.target === "/guard-audit")) { + throw new Error(`${serviceName} can mutate guard audit evidence`); + } + const namedVolumes = (service.volumes || []).filter((mount) => mount.type === "volume"); + const hasClaudeConfig = namedVolumes.some( + (mount) => mount.target === "/home/bridge/.claude-devin-isolated", + ); + const hasDevinAuth = namedVolumes.some( + (mount) => mount.target === "/home/bridge/.local/share/devin", + ); + const expectsClaudeConfig = serviceName === "claude" || serviceName === "claude-live"; + const expectsDevinAuth = serviceName === "omniroute-live"; + if (hasClaudeConfig !== expectsClaudeConfig) { + throw new Error(`${serviceName} Claude config volume ownership mismatch`); + } + if (hasDevinAuth !== expectsDevinAuth) { + throw new Error(`${serviceName} Devin auth volume ownership mismatch`); + } + for (const key of [ + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "CLAUDE_CODE_SUBAGENT_MODEL", + ]) { + if (!String(service.environment[key] || "").startsWith("devin-cli-agentic/")) { + throw new Error(`${serviceName} has a non-Devin model alias in ${key}`); + } + } + } + if (config.services["omniroute-live"].depends_on["network-guard"].condition !== "service_healthy") { + throw new Error("omniroute-live does not wait for a healthy Devin guard"); + } + for (const serviceName of ["claude", "claude-live"]) { + if (config.services[serviceName].depends_on["claude-egress-guard"].condition !== "service_healthy") { + throw new Error(`${serviceName} does not wait for a healthy Claude guard`); + } + } + const liveEnv = config.services["omniroute-live"].environment; + if (liveEnv.DEVIN_BRIDGE_PROXY_URL !== "http://network-guard:8080") { + throw new Error("trusted Devin bridge proxy is missing"); + } + for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]) { + if (liveEnv[key]) throw new Error(`omniroute-live must not inherit ${key}`); + } + for (const serviceName of runtimeNames.filter((name) => name !== "omniroute-live")) { + if (config.services[serviceName].environment.DEVIN_BRIDGE_PROXY_URL) { + throw new Error(`${serviceName} received the Devin bridge proxy setting`); + } + } + for (const serviceName of ["claude", "claude-live"]) { + const env = config.services[serviceName].environment; + if ( + env.NODE_USE_ENV_PROXY !== "1" || + env.HTTP_PROXY !== "http://claude-egress-guard:8080" || + env.HTTPS_PROXY !== "http://claude-egress-guard:8080" || + env.NO_PROXY !== "omniroute" + ) { + throw new Error(`${serviceName} does not use the deny-all Claude guard`); + } + if (env.HTTP_PROXY === "http://network-guard:8080") { + throw new Error(`${serviceName} received the Devin-capable guard`); + } + } +' <<<"$config_json" || fail "structured compose isolation checks failed" +node --input-type=module -e ' + import { pathToFileURL } from "node:url"; + const policy = await import(pathToFileURL(process.argv[1])); + const allowed = [ + "devin.ai", + "api.devin.ai", + "cognition.ai", + "api.cognition.ai", + "server.codeium.com", + "unleash.codeium.com", + ]; + const denied = [ + "evildevin.ai", + "codeium.com", + "api.codeium.com", + "o123.ingest.sentry.io", + "api.anthropic.com", + "claude.ai", + ]; + for (const hostname of allowed) { + if (!policy.isAllowedGuardHostname(hostname, "devin")) throw new Error(`denied ${hostname}`); + } + for (const hostname of denied) { + if (policy.isAllowedGuardHostname(hostname, "devin")) throw new Error(`allowed ${hostname}`); + } + if (policy.isAllowedGuardHostname("api.devin.ai", "deny-all")) { + throw new Error("deny-all guard allowed Devin traffic"); + } +' "$BRIDGE_ROOT/docker/devin-bridge/network-guard/policy.mjs" || fail "network guard policy checks failed" +bridge_test_env node --import tsx/esm --input-type=module -e ' + import { pathToFileURL } from "node:url"; + const { buildDevinChildEnv } = await import(pathToFileURL(process.argv[1])); + const home = process.env.DEVIN_AGENTIC_HOME; + const trusted = buildDevinChildEnv({}, { + DEVIN_AGENTIC_HOME: home, + DEVIN_BRIDGE_PROXY_URL: "http://network-guard:8080", + HTTP_PROXY: "http://user:password@host-proxy.example:3128", + HTTPS_PROXY: "http://user:password@host-proxy.example:3128", + ALL_PROXY: "socks5://host-proxy.example:1080", + }); + if ( + trusted.HTTP_PROXY !== "http://network-guard:8080" || + trusted.HTTPS_PROXY !== "http://network-guard:8080" || + trusted.ALL_PROXY + ) { + throw new Error("trusted child proxy derivation failed"); + } + const untrusted = buildDevinChildEnv({}, { + DEVIN_AGENTIC_HOME: home, + DEVIN_BRIDGE_PROXY_URL: "http://user:password@network-guard:8080", + HTTP_PROXY: "http://host-proxy.example:3128", + }); + if (untrusted.HTTP_PROXY || untrusted.HTTPS_PROXY) { + throw new Error("untrusted child proxy was inherited"); + } +' "$BRIDGE_ROOT/open-sse/executors/devin-cli-agentic.ts" || \ + fail "Devin child proxy boundary checks failed" +bridge_assert_devin_auth_status 0 $'Logged in (via Devin)\n' || fail "clean auth fixture was rejected" +if bridge_assert_devin_auth_status 0 $'Logged in (via Devin)\nFailed to fetch from server\n' 2>/dev/null; then + fail "server-fetch auth failure was accepted" +fi +if bridge_assert_devin_auth_status 0 $'Logged out\n' 2>/dev/null; then + fail "logged-out auth fixture was accepted" +fi +if bridge_assert_devin_auth_status 0 $'Not Logged in (via Devin)\n' 2>/dev/null; then + fail "misleading auth fixture was accepted" +fi +selected_model="$(printf '%s' '{"models":[{"family_uid":"swe-1.7"},{"modelUid":"swe-1.7-lightning"}]}' | \ + node --import tsx/esm "$BRIDGE_ROOT/scripts/devin-bridge/select-live-model.mjs")" +[[ "$selected_model" == swe-1-7-lightning ]] || fail "live model normalization or preference failed" +if printf '%s' '{"models":[{"family_uid":"unknown.9"}]}' | \ + node --import tsx/esm "$BRIDGE_ROOT/scripts/devin-bridge/select-live-model.mjs" >/dev/null 2>&1; then + fail "unknown normalized live model was accepted" +fi +grep -q 'bridge_run_devin auth login --force-manual-token-flow' \ + "$BRIDGE_ROOT/scripts/devin-bridge/login-devin" || fail "manual token login flow is missing" +if grep -Eqi 'read[[:space:]].*token|printf[[:space:]].*token|echo[[:space:]].*token' \ + "$BRIDGE_ROOT/scripts/devin-bridge/login-devin"; then + fail "login script could expose a token" +fi +grep -q 'bridge_check_devin_auth' "$BRIDGE_ROOT/scripts/devin-bridge/test-live-devin" || \ + fail "live test bypasses strict auth status" +grep -q 'bridge_check_devin_auth' "$BRIDGE_ROOT/scripts/devin-bridge/launch" || \ + fail "normal launch bypasses strict auth status" +grep -q 'up -d --wait network-guard claude-egress-guard' "$BRIDGE_ROOT/scripts/devin-bridge/launch" || \ + fail "normal launch does not start the audited Claude egress guard" +grep -qx '\.sandbox' "$BRIDGE_ROOT/.dockerignore" || fail ".sandbox is not excluded from builds" +if [[ "${1:-}" == --static ]]; then printf 'PASS: static bridge isolation checks passed\n'; exit 0; fi +trap bridge_cleanup_compose EXIT +bridge_cleanup_compose +bridge_reset_claude_egress_audit +docker compose -f "$BRIDGE_COMPOSE" --profile offline up -d --wait claude-egress-guard +docker compose -f "$BRIDGE_COMPOSE" --profile offline run --rm --no-deps claude bash -ceu ' + test "$(id -u)" = 10001 + test "$HOME" = /home/bridge + test "$CLAUDE_CONFIG_DIR" = /home/bridge/.claude-devin-isolated + test "$ANTHROPIC_BASE_URL" = http://omniroute:20128 + test "$ANTHROPIC_AUTH_TOKEN" = sk-local-devin-gateway + test -z "${ANTHROPIC_API_KEY:-}${CLAUDE_CODE_OAUTH_TOKEN:-}${AWS_ACCESS_KEY_ID:-}${AWS_SECRET_ACCESS_KEY:-}${GOOGLE_APPLICATION_CREDENTIALS:-}${AZURE_OPENAI_API_KEY:-}" + test ! -e /var/run/docker.sock + if touch /bridge-must-remain-read-only 2>/dev/null; then + echo "container root filesystem is writable" >&2; exit 1 + fi + for host in api.anthropic.com claude.ai; do + if node -e "require(\"net\").connect(443,process.argv[1]).on(\"connect\",()=>process.exit(0)).on(\"error\",()=>process.exit(1)).setTimeout(1500,()=>process.exit(1))" "$host"; then + echo "unexpected network access to $host" >&2; exit 1 + fi + done +' +docker compose -f "$BRIDGE_COMPOSE" --profile offline run --rm --no-deps claude \ + node --input-type=module -e ' + async function expectProxyDenial(request) { + try { + const response = await request; + if (response.status !== 403) { + throw new Error(`unexpected proxy response: ${response.status}`); + } + } catch (error) { + if (error instanceof Error && error.message.startsWith("unexpected proxy response:")) { + throw error; + } + } + } + await expectProxyDenial(fetch("https://api.anthropic.com", { + signal: AbortSignal.timeout(3000), + })); + await expectProxyDenial(fetch("https://claude.ai", { + signal: AbortSignal.timeout(3000), + })); +' +bridge_cleanup_compose +bridge_assert_claude_guard_denials "$BRIDGE_CLAUDE_AUDIT" || \ + fail "Claude proxy denial audit proof failed" +bridge_export_guard_audit "$BRIDGE_CLAUDE_AUDIT" claude-egress-verifier.jsonl +trap - EXIT +bridge_reset_claude_egress_audit +printf 'PASS: runtime bridge isolation checks passed\n' diff --git a/src/shared/constants/providers/noauth.ts b/src/shared/constants/providers/noauth.ts index e7c0dda99e..7b7246beba 100644 --- a/src/shared/constants/providers/noauth.ts +++ b/src/shared/constants/providers/noauth.ts @@ -3,6 +3,24 @@ * Pure data literal; re-exported by the providers.ts barrel. No behavior change. */ export const NOAUTH_PROVIDERS = { + "devin-cli-agentic": { + id: "devin-cli-agentic", + alias: "dva", + name: "Devin CLI Agentic Bridge", + icon: "terminal", + color: "#635BFF", + textIcon: "DV", + website: "https://docs.devin.ai/work-with-devin/devin-cli", + noAuth: true, + hasFree: false, + serviceKinds: ["llm"], + isLocalCli: true, + toolCalling: "emulated", + authHint: "Authentication is owned by the official Devin CLI in its isolated bridge volume.", + notice: { + text: "This provider accepts only the official Devin CLI over local ACP stdio and never falls back to another provider.", + }, + }, opencode: { id: "opencode", alias: "oc", diff --git a/tests/fixtures/devin-bridge/e2e-workspace/.claude/commands/bridge-check.md b/tests/fixtures/devin-bridge/e2e-workspace/.claude/commands/bridge-check.md new file mode 100644 index 0000000000..3cc47d2db4 --- /dev/null +++ b/tests/fixtures/devin-bridge/e2e-workspace/.claude/commands/bridge-check.md @@ -0,0 +1,10 @@ +--- +description: Exercise the isolated Claude-to-Devin agentic bridge +allowed-tools: Skill, Read, Edit, Bash +--- + +`COMMAND_BRIDGE_ACTIVE` + +Use the bridge-proof skill. Locate and read the implementation. Correct it if needed, run its test, +and diagnose and fix any real failure. If it is already correct, do not introduce a regression. +End with `BRIDGE_E2E_COMPLETE` only after `npm test` passes. diff --git a/tests/fixtures/devin-bridge/e2e-workspace/.claude/hooks/log-tool.mjs b/tests/fixtures/devin-bridge/e2e-workspace/.claude/hooks/log-tool.mjs new file mode 100644 index 0000000000..5bf44abd6e --- /dev/null +++ b/tests/fixtures/devin-bridge/e2e-workspace/.claude/hooks/log-tool.mjs @@ -0,0 +1,6 @@ +import fs from "node:fs"; + +let input = ""; +for await (const chunk of process.stdin) input += chunk; +const event = JSON.parse(input || "{}"); +fs.appendFileSync("/workspace/.e2e-hook.log", `${String(event.tool_name || "unknown")}\n`); diff --git a/tests/fixtures/devin-bridge/e2e-workspace/.claude/settings.json b/tests/fixtures/devin-bridge/e2e-workspace/.claude/settings.json new file mode 100644 index 0000000000..a27b989a5e --- /dev/null +++ b/tests/fixtures/devin-bridge/e2e-workspace/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Skill|Read|Edit|Bash", + "hooks": [ + { + "type": "command", + "command": "node .claude/hooks/log-tool.mjs", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/tests/fixtures/devin-bridge/e2e-workspace/.claude/skills/bridge-proof/SKILL.md b/tests/fixtures/devin-bridge/e2e-workspace/.claude/skills/bridge-proof/SKILL.md new file mode 100644 index 0000000000..292a032b92 --- /dev/null +++ b/tests/fixtures/devin-bridge/e2e-workspace/.claude/skills/bridge-proof/SKILL.md @@ -0,0 +1,9 @@ +--- +name: bridge-proof +description: This skill should be used when the user invokes the bridge-check command or asks to verify the isolated Devin bridge. +version: 1.0.0 +--- + +`SKILL_BRIDGE_ACTIVE` + +Use project-local tools to inspect, edit, and test the fixture. Do not merely narrate actions. diff --git a/tests/fixtures/devin-bridge/e2e-workspace/CLAUDE.md b/tests/fixtures/devin-bridge/e2e-workspace/CLAUDE.md new file mode 100644 index 0000000000..b9f4fab561 --- /dev/null +++ b/tests/fixtures/devin-bridge/e2e-workspace/CLAUDE.md @@ -0,0 +1,7 @@ +# Offline bridge fixture + +`CLAUDE_MD_BRIDGE_ACTIVE` + +Use the `bridge-proof` project skill for the `/bridge-check` task. Inspect the project, repair +`math.js`, and run the tests. If a test fails, diagnose and correct it. Never introduce a regression +to manufacture a failure. Finish only after the tests pass. diff --git a/tests/fixtures/devin-bridge/e2e-workspace/math.js b/tests/fixtures/devin-bridge/e2e-workspace/math.js new file mode 100644 index 0000000000..29b385181d --- /dev/null +++ b/tests/fixtures/devin-bridge/e2e-workspace/math.js @@ -0,0 +1,3 @@ +export function add(a, b) { + return a - b; +} diff --git a/tests/fixtures/devin-bridge/e2e-workspace/math.test.js b/tests/fixtures/devin-bridge/e2e-workspace/math.test.js new file mode 100644 index 0000000000..bc07a818bc --- /dev/null +++ b/tests/fixtures/devin-bridge/e2e-workspace/math.test.js @@ -0,0 +1,8 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { add } from "./math.js"; + +test("add returns the sum", () => { + assert.equal(add(2, 3), 5); +}); diff --git a/tests/fixtures/devin-bridge/e2e-workspace/package.json b/tests/fixtures/devin-bridge/e2e-workspace/package.json new file mode 100644 index 0000000000..9c983c74eb --- /dev/null +++ b/tests/fixtures/devin-bridge/e2e-workspace/package.json @@ -0,0 +1,8 @@ +{ + "name": "devin-bridge-e2e-fixture", + "private": true, + "type": "module", + "scripts": { + "test": "node --test math.test.js" + } +} diff --git a/tests/unit/devin-bridge-live-runtime.test.ts b/tests/unit/devin-bridge-live-runtime.test.ts new file mode 100644 index 0000000000..9aeede06b9 --- /dev/null +++ b/tests/unit/devin-bridge-live-runtime.test.ts @@ -0,0 +1,389 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +import { CORE_SCHEMA, load, mergeTag } from "js-yaml"; + +import { isAllowedGuardHostname } from "../../docker/devin-bridge/network-guard/policy.mjs"; +import { + validateAuditFileStat, + validateClaudeGuardDenials, + validateDevinAuthStatus, + validateDevinGuardAudit, + validateZeroClaudeEgress, +} from "../../scripts/devin-bridge/runtime-policy.mjs"; +import { selectLiveModel } from "../../scripts/devin-bridge/select-live-model.mjs"; +import { validateClaudeEvidenceText } from "../../scripts/devin-bridge/validate-claude-evidence.mjs"; + +const root = process.cwd(); +const composePath = path.join(root, "docker", "devin-bridge", "compose.yml"); +const commonPath = path.join(root, "scripts", "devin-bridge", "common"); +const mockE2ePath = path.join(root, "scripts", "devin-bridge", "test-e2e-mock"); +const launchPath = path.join(root, "scripts", "devin-bridge", "launch"); +const loginPath = path.join(root, "scripts", "devin-bridge", "login-devin"); +const liveE2ePath = path.join(root, "scripts", "devin-bridge", "test-live-devin"); +const liveRunnerPath = path.join(root, "docker", "devin-bridge", "run-claude-live-e2e.sh"); +const bridgeCommandPath = path.join( + root, + "tests", + "fixtures", + "devin-bridge", + "e2e-workspace", + ".claude", + "commands", + "bridge-check.md" +); +const verifierPath = path.join(root, "scripts", "devin-bridge", "verify-anthropic-isolation"); + +interface ComposeService { + depends_on?: Record; + environment?: Record; + healthcheck?: { test?: unknown }; + volumes?: Array; +} + +interface ComposeConfig { + services: Record; +} + +function composeConfig(): ComposeConfig { + return load(fs.readFileSync(composePath, "utf8"), { + filename: composePath, + schema: CORE_SCHEMA.withTags(mergeTag), + }) as ComposeConfig; +} + +function volumeSources(service: ComposeService): string[] { + return (service.volumes || []).map((mount: string | { source?: string }) => + typeof mount === "string" ? mount.split(":", 1)[0] : String(mount.source || "") + ); +} + +test("network policy permits only the intended Devin destinations", () => { + for (const hostname of [ + "api.devin.ai", + "devin.ai", + "nested.api.cognition.ai", + "cognition.ai", + "server.codeium.com", + "unleash.codeium.com", + ]) { + assert.equal(isAllowedGuardHostname(hostname, "devin"), true, hostname); + } + for (const hostname of [ + "evildevin.ai", + "codeium.com", + "api.codeium.com", + "server.codeium.com.evil.example", + "o123.ingest.sentry.io", + "api.anthropic.com", + "claude.ai", + ]) { + assert.equal(isAllowedGuardHostname(hostname, "devin"), false, hostname); + } + assert.equal(isAllowedGuardHostname("api.devin.ai", "deny-all"), false); +}); + +test("compose isolates guard audit mounts and waits for healthy guards", () => { + const services = composeConfig().services; + for (const [name, service] of Object.entries(services)) { + const sources = volumeSources(service); + const hasGuardAudit = sources.some((source) => source.includes(".sandbox/guard-audit")); + assert.equal(hasGuardAudit, name === "network-guard" || name === "claude-egress-guard", name); + } + assert.match(volumeSources(services["network-guard"]).join(" "), /\.sandbox\/guard-audit\/devin/); + assert.match( + volumeSources(services["claude-egress-guard"]).join(" "), + /\.sandbox\/guard-audit\/claude/ + ); + for (const guard of ["network-guard", "claude-egress-guard"]) { + assert.ok(services[guard].healthcheck?.test, `${guard} healthcheck`); + } + assert.equal( + services["omniroute-live"].depends_on?.["network-guard"].condition, + "service_healthy" + ); + assert.equal(services.claude.depends_on?.["claude-egress-guard"].condition, "service_healthy"); + assert.equal( + services["claude-live"].depends_on?.["claude-egress-guard"].condition, + "service_healthy" + ); +}); + +test("compose keeps role-separated credentials and proxy settings", () => { + const services = composeConfig().services; + for (const [name, service] of Object.entries(services)) { + const sources = volumeSources(service); + assert.equal( + sources.includes("claude-isolated-config"), + name === "claude" || name === "claude-live", + `${name} Claude config ownership` + ); + assert.equal(sources.includes("devin-auth"), name === "omniroute-live", `${name} Devin auth`); + } + assert.equal( + services["omniroute-live"].environment?.DEVIN_BRIDGE_PROXY_URL, + "http://network-guard:8080" + ); + for (const name of ["claude", "claude-live"]) { + assert.equal(services[name].environment?.HTTP_PROXY, "http://claude-egress-guard:8080"); + assert.equal(services[name].environment?.NO_PROXY, "omniroute"); + } +}); + +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", + "Logged out\n", + "Logged in (via Devin)\nFailed to fetch from server\n", + ]) { + assert.equal(validateDevinAuthStatus(0, output).ok, false, output); + } + assert.equal(validateDevinAuthStatus(1, "Logged in (via Devin)\n").ok, false); +}); + +test("model discovery accepts only explicit uid/id fields and detects catalog ambiguity", () => { + const selected = selectLiveModel({ + models: [ + { family_uid: "swe-1.7" }, + { familyUid: "swe-1.7-lightning" }, + { model_uid: "swe-1.6-fast" }, + { modelUid: "swe-1.6" }, + ], + }); + assert.equal(selected, "swe-1-7-lightning"); + assert.throws(() => selectLiveModel({ metadata: { id: "swe-1.7" } }), /no model identifier/i); + assert.throws( + () => selectLiveModel({ models: [{ model_uid: "a.b" }] }, {}, [{ id: "a.b" }, { id: "a-b" }]), + /Ambiguous OmniRoute catalog normalization/ + ); +}); + +test("Claude evidence requires a standalone marker and a successful client-owned npm test", () => { + const toolId = "tool-npm-test"; + const events = [ + { + type: "assistant", + message: { + content: [{ type: "tool_use", id: toolId, name: "Bash", input: { command: "npm test" } }], + }, + }, + { + type: "user", + message: { + content: [ + { type: "tool_result", tool_use_id: toolId, is_error: false, content: "1 passed" }, + ], + }, + }, + { type: "result", subtype: "success", result: "Fixed and tested.\nLIVE_FIX_COMPLETE" }, + ]; + const text = events.map((event) => JSON.stringify(event)).join("\n"); + + assert.doesNotThrow(() => + validateClaudeEvidenceText(text, { + marker: "LIVE_FIX_COMPLETE", + requiredTools: ["Bash"], + requireSuccessfulNpmTest: true, + }) + ); + assert.doesNotThrow(() => + validateClaudeEvidenceText( + `${events + .slice(0, 2) + .map((event) => JSON.stringify(event)) + .join("\n")}\n${JSON.stringify({ + type: "result", + subtype: "success", + result: "Task completed; the requested marker is LIVE_FIX_COMPLETE.", + })}`, + { + marker: "LIVE_FIX_COMPLETE", + requiredTools: ["Bash"], + requireSuccessfulNpmTest: true, + } + ) + ); + assert.throws( + () => + validateClaudeEvidenceText( + `${events + .slice(0, 2) + .map((event) => JSON.stringify(event)) + .join("\n")}\n${JSON.stringify({ + type: "result", + subtype: "success", + result: "Task completion condition: end with LIVE_FIX_COMPLETE after tests.", + })}`, + { marker: "LIVE_FIX_COMPLETE" } + ), + /standalone marker/ + ); + assert.throws( + () => + validateClaudeEvidenceText( + [events[0], events[2]].map((event) => JSON.stringify(event)).join("\n"), + { + marker: "LIVE_FIX_COMPLETE", + requiredTools: ["Bash"], + requireSuccessfulNpmTest: true, + } + ), + /successful npm test/ + ); + assert.throws( + () => + validateClaudeEvidenceText( + `${events + .slice(0, 2) + .map((event) => JSON.stringify(event)) + .join("\n")}\n${JSON.stringify({ + type: "result", + subtype: "success", + result: + "Task ran npm test.\n\nNext steps needed:\n- finish the work\n\n**Blocker**: work is incomplete. BRIDGE_E2E_COMPLETE", + })}`, + { + marker: "BRIDGE_E2E_COMPLETE", + requiredTools: ["Bash"], + requireSuccessfulNpmTest: true, + } + ), + /explicitly reports incomplete work/ + ); + + const commandEvidence = [ + { + type: "system", + subtype: "init", + slash_commands: ["bridge-check"], + skills: ["bridge-proof"], + }, + ...events.slice(0, 2), + { type: "result", subtype: "success", result: "The task is complete." }, + ] + .map((event) => JSON.stringify(event)) + .join("\n"); + assert.doesNotThrow(() => + validateClaudeEvidenceText(commandEvidence, { + marker: "BRIDGE_E2E_COMPLETE", + requiredTools: ["Bash"], + requireSuccessfulNpmTest: true, + requiredSlashCommand: "bridge-check", + requiredSkill: "bridge-proof", + acceptExplicitCompletion: true, + }) + ); + assert.throws( + () => + validateClaudeEvidenceText(commandEvidence, { + marker: "BRIDGE_E2E_COMPLETE", + requiredTools: ["Bash"], + requireSuccessfulNpmTest: true, + requiredSlashCommand: "missing-command", + requiredSkill: "bridge-proof", + acceptExplicitCompletion: true, + }), + /required slash command/ + ); +}); + +test("live command declares the terminal marker required by its evidence validator", () => { + const command = fs.readFileSync(bridgeCommandPath, "utf8"); + const runner = fs.readFileSync(liveRunnerPath, "utf8"); + assert.match(command, /BRIDGE_E2E_COMPLETE/); + assert.match(command, /only after `npm test` passes/); + assert.match(command, /do not introduce a regression/); + assert.match(runner, /validate_scenario[^\n]*BRIDGE_E2E_COMPLETE Bash true/); +}); + +test("audit policies reject forged metadata, missing proof, and unexpected Devin hosts", () => { + const safeStat = { + isFile: () => true, + isSymbolicLink: () => false, + nlink: 1, + uid: 501, + mode: 0o100666, + }; + assert.equal(validateAuditFileStat(safeStat as unknown as fs.Stats, 501), null); + assert.match( + validateAuditFileStat({ ...safeStat, nlink: 2 } as unknown as fs.Stats, 501) || "", + /link count/ + ); + assert.match( + validateAuditFileStat( + { ...safeStat, isSymbolicLink: () => true } as unknown as fs.Stats, + 501 + ) || "", + /regular file/ + ); + assert.equal(validateZeroClaudeEgress("").ok, true); + assert.equal(validateZeroClaudeEgress("{}\n").ok, false); + assert.equal( + validateClaudeGuardDenials( + '{"hostname":"api.anthropic.com","decision":"deny"}\n' + + '{"hostname":"claude.ai","decision":"deny"}\n' + ).ok, + true + ); + assert.equal(validateDevinGuardAudit("").ok, false); + assert.equal( + validateDevinGuardAudit('{"hostname":"api.devin.ai","decision":"allow"}\n').ok, + true + ); + assert.equal( + 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 + ); +}); + +test("scripts use atomic audit resets, readiness waits, cleanup traps, and strict gates", () => { + const common = fs.readFileSync(commonPath, "utf8"); + const login = fs.readFileSync(loginPath, "utf8"); + const launch = fs.readFileSync(launchPath, "utf8"); + const live = fs.readFileSync(liveE2ePath, "utf8"); + const liveRunner = fs.readFileSync(liveRunnerPath, "utf8"); + const mock = fs.readFileSync(mockE2ePath, "utf8"); + const verifier = fs.readFileSync(verifierPath, "utf8"); + assert.match(common, /chmod 01777/); + assert.match(common, /mktemp/); + assert.match(common, /chmod 0666/); + assert.match(common, /mv -f/); + for (const script of [login, launch, live, verifier]) { + assert.match(script, /trap bridge_cleanup_compose EXIT/); + } + for (const script of [login, launch, live, verifier]) assert.match(script, /up -d --wait/); + assert.match(login, /auth login --force-manual-token-flow/); + assert.match(live, /bridge_assert_devin_guard_audit/); + assert.match(live, /bridge_assert_zero_claude_egress/); + assert.match(liveRunner, /validate-claude-evidence\.mjs/); + assert.match(liveRunner, /Use Edit now to replace/); + assert.match(liveRunner, /Do not summarize before npm test succeeds/); + assert.match(liveRunner, /DEVIN_BRIDGE_LIVE_SCENARIO_COOLDOWN_SECONDS:-15/); + assert.equal([...liveRunner.matchAll(/sleep "\$scenario_cooldown_seconds"/g)].length, 2); + assert.match(mock, /bridge_assert_zero_claude_egress/); + assert.ok([...verifier.matchAll(/bridge_reset_claude_egress_audit/g)].length >= 2); +}); + +test("Docker build context excludes sandbox runtime state", () => { + const dockerignore = fs.readFileSync(path.join(root, ".dockerignore"), "utf8"); + assert.match(dockerignore, /^\.sandbox$/m); +}); diff --git a/tests/unit/devin-bridge-network-guard.test.ts b/tests/unit/devin-bridge-network-guard.test.ts new file mode 100644 index 0000000000..2114311637 --- /dev/null +++ b/tests/unit/devin-bridge-network-guard.test.ts @@ -0,0 +1,217 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createGuardProxy } from "../../docker/devin-bridge/network-guard/proxy.mjs"; +import { + parseConnectAuthority, + parseTlsClientHelloSni, +} from "../../docker/devin-bridge/network-guard/policy.mjs"; + +function listen(server: net.Server | http.Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") return reject(new Error("missing port")); + resolve(address.port); + }); + }); +} + +function close(server: net.Server | http.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +function uint24(value: number) { + return Buffer.from([(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]); +} + +function clientHello(serverName?: string) { + const extensions: Buffer[] = []; + if (serverName) { + const name = Buffer.from(serverName, "ascii"); + const entry = Buffer.concat([ + Buffer.from([0]), + Buffer.from([name.length >> 8, name.length]), + name, + ]); + const list = Buffer.concat([Buffer.from([entry.length >> 8, entry.length]), entry]); + extensions.push(Buffer.concat([Buffer.from([0, 0, 0, list.length]), list])); + } + const extensionBytes = Buffer.concat(extensions); + const body = Buffer.concat([ + Buffer.from([3, 3]), + Buffer.alloc(32, 1), + Buffer.from([0]), + Buffer.from([0, 2, 0x13, 0x01]), + Buffer.from([1, 0]), + Buffer.from([extensionBytes.length >> 8, extensionBytes.length]), + extensionBytes, + ]); + const handshake = Buffer.concat([Buffer.from([1]), uint24(body.length), body]); + return Buffer.concat([ + Buffer.from([22, 3, 1, handshake.length >> 8, handshake.length]), + handshake, + ]); +} + +test("CONNECT authority is fixed to port 443 and ClientHello SNI is bounded", () => { + assert.deepEqual(parseConnectAuthority("api.devin.ai:443"), { + hostname: "api.devin.ai", + port: 443, + }); + assert.equal(parseConnectAuthority("api.devin.ai:80"), null); + assert.equal(parseConnectAuthority("api.devin.ai"), null); + + const hello = clientHello("api.devin.ai"); + assert.equal(parseTlsClientHelloSni(hello.subarray(0, 8)).status, "need-more"); + assert.deepEqual(parseTlsClientHelloSni(hello), { + status: "ok", + serverName: "api.devin.ai", + }); + assert.deepEqual(parseTlsClientHelloSni(clientHello()), { + status: "invalid", + reason: "missing_sni", + }); + assert.equal(parseTlsClientHelloSni(Buffer.from("not tls")).status, "invalid"); +}); + +test("HTTP proxy overwrites Host and strips proxy and hop-by-hop credentials", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "devin-guard-http-")); + const logPath = path.join(tmp, "audit.jsonl"); + fs.writeFileSync(logPath, ""); + let receivedHeaders: http.IncomingHttpHeaders = {}; + const upstream = http.createServer((req, res) => { + receivedHeaders = req.headers; + res.end("forwarded"); + }); + const upstreamPort = await listen(upstream); + const proxy = createGuardProxy({ logPath, allowHostname: () => true }); + const proxyPort = await listen(proxy); + try { + const body = await new Promise((resolve, reject) => { + const req = http.request( + { + host: "127.0.0.1", + port: proxyPort, + path: `http://127.0.0.1:${upstreamPort}/proof`, + headers: { + host: "attacker.example", + "proxy-authorization": "Basic forged", + "proxy-connection": "keep-alive", + connection: "keep-alive, x-remove", + "x-remove": "forged", + }, + }, + (res) => { + let text = ""; + res.on("data", (chunk) => (text += chunk)); + res.on("end", () => resolve(text)); + } + ); + req.on("error", reject); + req.end(); + }); + assert.equal(body, "forwarded"); + assert.equal(receivedHeaders.host, `127.0.0.1:${upstreamPort}`); + assert.equal(receivedHeaders["proxy-authorization"], undefined); + assert.equal(receivedHeaders["proxy-connection"], undefined); + assert.equal(receivedHeaders["x-remove"], undefined); + } finally { + await close(proxy); + await close(upstream); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("CONNECT rejects mismatched SNI before opening an upstream socket", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "devin-guard-sni-")); + const logPath = path.join(tmp, "audit.jsonl"); + fs.writeFileSync(logPath, ""); + let upstreamConnections = 0; + const proxy = createGuardProxy({ + logPath, + allowHostname: () => true, + connectSocket: () => { + upstreamConnections += 1; + throw new Error("must not connect"); + }, + }); + const proxyPort = await listen(proxy); + try { + await new Promise((resolve, reject) => { + const socket = net.connect(proxyPort, "127.0.0.1", () => { + socket.write("CONNECT api.devin.ai:443 HTTP/1.1\r\nHost: api.devin.ai:443\r\n\r\n"); + }); + let sentHello = false; + socket.on("data", (chunk) => { + if (!sentHello && chunk.toString("latin1").includes("200 Connection Established")) { + sentHello = true; + socket.write(clientHello("claude.ai")); + } + }); + socket.on("close", () => resolve()); + socket.on("error", reject); + setTimeout(() => reject(new Error("CONNECT mismatch test timed out")), 2000).unref(); + }); + assert.equal(upstreamConnections, 0); + assert.match(fs.readFileSync(logPath, "utf8"), /"reason":"sni_mismatch"/); + } finally { + await close(proxy); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("CONNECT forwards only after matching SNI is validated", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "devin-guard-sni-ok-")); + const logPath = path.join(tmp, "audit.jsonl"); + fs.writeFileSync(logPath, ""); + let forwardedBytes = 0; + const upstream = net.createServer((socket) => { + socket.once("data", (chunk) => { + forwardedBytes += chunk.length; + socket.write("UPSTREAM_OK"); + }); + }); + const upstreamPort = await listen(upstream); + const proxy = createGuardProxy({ + logPath, + allowHostname: () => true, + connectSocket: (_port, _hostname, onConnect) => + net.connect(upstreamPort, "127.0.0.1", onConnect), + }); + const proxyPort = await listen(proxy); + try { + await new Promise((resolve, reject) => { + const socket = net.connect(proxyPort, "127.0.0.1", () => { + socket.write("CONNECT api.devin.ai:443 HTTP/1.1\r\nHost: api.devin.ai:443\r\n\r\n"); + }); + let sentHello = false; + socket.on("data", (chunk) => { + const text = chunk.toString("latin1"); + if (!sentHello && text.includes("200 Connection Established")) { + sentHello = true; + socket.write(clientHello("api.devin.ai")); + return; + } + if (text.includes("UPSTREAM_OK")) { + socket.destroy(); + resolve(); + } + }); + socket.on("error", reject); + setTimeout(() => reject(new Error("CONNECT forwarding test timed out")), 2000).unref(); + }); + assert.ok(forwardedBytes > 0); + assert.match(fs.readFileSync(logPath, "utf8"), /"reason":"sni_match"/); + } finally { + await close(proxy); + await close(upstream); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/executor-devin-cli-agentic-acp.test.ts b/tests/unit/executor-devin-cli-agentic-acp.test.ts new file mode 100644 index 0000000000..5538d3a86e --- /dev/null +++ b/tests/unit/executor-devin-cli-agentic-acp.test.ts @@ -0,0 +1,577 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { writeFileSync } from "node:fs"; + +const isolatedRoot = path.join(process.cwd(), ".sandbox", "direct-acp-test"); +process.env.HOME = path.join(isolatedRoot, "home"); +process.env.DATA_DIR = path.join(isolatedRoot, "data"); +process.env.SQLITE_FILE = path.join(isolatedRoot, "data", "storage.sqlite"); +process.env.DEVIN_AGENTIC_HOME = process.env.HOME; +fs.mkdirSync(process.env.HOME, { recursive: true }); +fs.mkdirSync(process.env.DATA_DIR, { recursive: true }); + +const { assertLocalAcpUrl, buildDevinChildEnv, DevinCliAgenticExecutor } = + await import("../../open-sse/executors/devin-cli-agentic.ts"); +const { devin_cli_agenticProvider } = + await import("../../open-sse/config/providers/registry/devin-cli-agentic/index.ts"); +const { getProviderCredentials } = await import("../../src/sse/services/auth.ts"); + +async function readResponseText(response: Response) { + return await response.text(); +} + +function sandboxTmp(prefix: string) { + const root = path.join(process.cwd(), ".sandbox", "unit-processes"); + fs.mkdirSync(root, { recursive: true }); + return fs.mkdtempSync(path.join(root, prefix)); +} + +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, + DEVIN_BRIDGE_MOCK_LOG: "/evidence/mock-acp.jsonl", + } + ); + + 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.equal(env.DEVIN_BRIDGE_MOCK_LOG, "/evidence/mock-acp.jsonl"); + assert.equal( + buildDevinChildEnv( + {}, + { + PATH: "/usr/bin", + DEVIN_AGENTIC_HOME: isolatedHome, + DEVIN_BRIDGE_MOCK_LOG: "/tmp/unsafe.jsonl", + } + ).DEVIN_BRIDGE_MOCK_LOG, + undefined + ); + assert.throws( + () => buildDevinChildEnv({}, { PATH: "/usr/bin", DEVIN_AGENTIC_HOME: "/tmp/outside" }), + /inside the bridge sandbox/ + ); +}); + +test("Devin child environment derives only the trusted bridge proxy", () => { + const isolatedHome = path.join(process.cwd(), ".sandbox", "unit-home"); + const trustedProxy = "http://network-guard:8080"; + const trusted = buildDevinChildEnv( + {}, + { + DEVIN_AGENTIC_HOME: isolatedHome, + DEVIN_BRIDGE_PROXY_URL: trustedProxy, + HTTP_PROXY: "http://user:password@host-proxy.example:3128", + HTTPS_PROXY: "http://user:password@host-proxy.example:3128", + ALL_PROXY: "socks5://host-proxy.example:1080", + NO_PROXY: "metadata.internal", + } + ); + + assert.equal(trusted.HTTP_PROXY, trustedProxy); + assert.equal(trusted.HTTPS_PROXY, trustedProxy); + assert.equal(trusted.ALL_PROXY, undefined); + assert.equal(trusted.NO_PROXY, undefined); + assert.equal(trusted.DEVIN_BRIDGE_PROXY_URL, undefined); + + const untrusted = buildDevinChildEnv( + {}, + { + DEVIN_AGENTIC_HOME: isolatedHome, + DEVIN_BRIDGE_PROXY_URL: "http://user:password@network-guard:8080", + HTTP_PROXY: "http://host-proxy.example:3128", + HTTPS_PROXY: "http://host-proxy.example:3128", + } + ); + assert.equal(untrusted.HTTP_PROXY, undefined); + assert.equal(untrusted.HTTPS_PROXY, undefined); +}); + +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/); +}); + +test("Devin agentic provider delegates auth only to the isolated CLI", () => { + assert.equal(devin_cli_agenticProvider.authType, "none"); + assert.equal(devin_cli_agenticProvider.baseUrl, "devin://acp/stdio"); + assert.equal(devin_cli_agenticProvider.baseUrls, undefined); +}); + +test("Devin agentic provider resolves synthetic no-auth credentials without a DB row", async () => { + const credentials = await getProviderCredentials("devin-cli-agentic"); + assert.equal(credentials?.connectionId, "noauth"); + assert.equal(credentials?.apiKey, null); +}); + +function writeMockDevin(tmpDir: string, responseText: string) { + const framesFile = path.join(tmpDir, "frames.json"); + const argsFile = path.join(tmpDir, "args.json"); + const scriptFile = path.join(tmpDir, "mock-devin.cjs"); + const script = `#!/usr/bin/env node +const fs = require("fs"); +const readline = require("readline"); +const frames = []; +fs.writeFileSync(${JSON.stringify(argsFile)}, JSON.stringify(process.argv.slice(2))); +const rl = readline.createInterface({ input: process.stdin }); +rl.on("line", (line) => { + if (!line.trim()) return; + const msg = JSON.parse(line); + frames.push(msg); + fs.writeFileSync(${JSON.stringify(framesFile)}, JSON.stringify(frames, null, 2)); + if (msg.method === "initialize") { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } }) + "\\n"); + } else if (msg.method === "session/new") { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { + sessionId: "sess_agentic", + modes: { currentModeId: "accept-edits", availableModes: [{ id: "accept-edits" }, { id: "ask" }] } + } }) + "\\n"); + } else if (msg.method === "session/set_config_option") { + process.stdout.write(JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { configOptions: [{ id: "mode", currentValue: "ask" }] } + }) + "\\n"); + } else if (msg.method === "session/set_mode") { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: {} }) + "\\n"); + } else if (msg.method === "session/prompt") { + process.stdout.write(JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: ${JSON.stringify(responseText)} } } } + }) + "\\n"); + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { stopReason: "end_turn" } }) + "\\n"); + } +}); +`; + writeFileSync(scriptFile, script, { mode: 0o755 }); + return { scriptFile, framesFile, argsFile }; +} + +function writeScenarioMock(tmpDir: string, body: string) { + const scriptFile = path.join(tmpDir, "mock-devin.cjs"); + writeFileSync( + scriptFile, + `#!/usr/bin/env node +const readline = require("readline"); +const rl = readline.createInterface({ input: process.stdin }); +const send = (value) => process.stdout.write(JSON.stringify(value) + "\\n"); +const safeModes = { currentModeId: "accept-edits", availableModes: [{ id: "accept-edits" }, { id: "ask" }] }; +const sendSession = (msg, sessionId) => send({ jsonrpc: "2.0", id: msg.id, result: { sessionId, modes: safeModes } }); +const acceptAskMode = (msg) => { + if (msg.method === "session/set_mode") { + send({ jsonrpc: "2.0", id: msg.id, result: {} }); + return true; + } + if (msg.method !== "session/set_config_option") return false; + if (msg.params?.configId !== "mode" || msg.params?.type !== "id" || msg.params?.value !== "ask") { + send({ jsonrpc: "2.0", id: msg.id, error: { code: -32602, message: "unsafe mode" } }); + } else { + send({ jsonrpc: "2.0", id: msg.id, result: { configOptions: [{ id: "mode", currentValue: "ask" }] } }); + } + return true; +}; +${body} +`, + { mode: 0o755 } + ); + return scriptFile; +} + +async function executeTextRequest(scriptFile: string, signal?: AbortSignal) { + const oldBin = process.env.CLI_DEVIN_AGENTIC_BIN; + process.env.CLI_DEVIN_AGENTIC_BIN = scriptFile; + try { + return await new DevinCliAgenticExecutor().execute({ + model: "swe-1-7", + stream: false, + credentials: {}, + signal, + body: { messages: [{ role: "user", content: "Say hello" }] }, + }); + } finally { + if (oldBin === undefined) delete process.env.CLI_DEVIN_AGENTIC_BIN; + else process.env.CLI_DEVIN_AGENTIC_BIN = oldBin; + } +} + +test("DevinCliAgenticExecutor returns Anthropic tool_use JSON and sends ACP frames", async () => { + const tmpDir = sandboxTmp("devin-agentic-"); + const oldBin = process.env.CLI_DEVIN_AGENTIC_BIN; + const { scriptFile, framesFile, argsFile } = writeMockDevin( + tmpDir, + '{"name":"Read","arguments":{"file_path":"src/index.ts"}}' + ); + process.env.CLI_DEVIN_AGENTIC_BIN = scriptFile; + + try { + const executor = new DevinCliAgenticExecutor(); + const result = await executor.execute({ + model: "swe-1-7", + stream: false, + credentials: { apiKey: "devin-test" }, + body: { + messages: [{ role: "user", content: [{ type: "text", text: "Read src/index.ts" }] }], + tools: [ + { + name: "Read", + input_schema: { + type: "object", + required: ["file_path"], + properties: { file_path: { type: "string" } }, + additionalProperties: false, + }, + }, + ], + }, + }); + + assert.equal(result.response.status, 200, await result.response.clone().text()); + const json = JSON.parse(await readResponseText(result.response)); + assert.equal(json.stop_reason, "tool_use"); + assert.equal(json.content[0].type, "tool_use"); + assert.equal(json.content[0].name, "Read"); + assert.deepEqual(json.content[0].input, { file_path: "src/index.ts" }); + + const frames = JSON.parse(fs.readFileSync(framesFile, "utf8")); + assert.ok(frames.some((frame: { method?: string }) => frame.method === "initialize")); + assert.ok(frames.some((frame: { method?: string }) => frame.method === "session/new")); + assert.ok( + !frames.some((frame: { method?: string }) => frame.method === "session/set_config_option") + ); + assert.ok(frames.some((frame: { method?: string }) => frame.method === "session/prompt")); + const initialize = frames.find((frame: { method?: string }) => frame.method === "initialize"); + assert.equal(initialize.params.protocolVersion, 1); + assert.deepEqual(initialize.params.clientCapabilities, {}); + const prompt = frames.find((frame: { method?: string }) => frame.method === "session/prompt"); + const promptText = prompt.params.prompt[0].text; + assert.match(promptText, /Devin Summarizer Bridge/); + assert.match(promptText, /execution trace/i); + assert.match(promptText, /client workspace is \/workspace/i); + assert.match(promptText, /Read src\/index\.ts/); + assert.deepEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), [ + "acp", + "--agent-type", + "summarizer", + ]); + } 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 }); + } +}); + +test("no-tools summarizer does not depend on mutable ACP permission modes", async () => { + const tmpDir = sandboxTmp("devin-agentic-no-ask-mode-"); + 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: "no-ask", + modes: { currentModeId: "accept-edits", availableModes: [{ id: "accept-edits", name: "Code" }] } + } + }); + if (msg.method === "session/prompt") { + send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "no-ask", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "unsafe" } } } }); + send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: "end_turn" } }); + } +});` + ); + try { + const result = await executeTextRequest(scriptFile); + assert.equal(result.response.status, 200); + const body = JSON.parse(await result.response.text()); + assert.equal(body.content[0].text, "unsafe"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test("ACP client fails closed when session/new omits the session id", async () => { + const tmpDir = sandboxTmp("devin-agentic-unconfirmed-ask-mode-"); + 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: {} }); +});` + ); + 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, "missing_session_id"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test("DevinCliAgenticExecutor returns Anthropic SSE for streaming Claude clients", async () => { + const tmpDir = sandboxTmp("devin-agentic-sse-"); + const oldBin = process.env.CLI_DEVIN_AGENTIC_BIN; + const { scriptFile } = writeMockDevin(tmpDir, "Done"); + process.env.CLI_DEVIN_AGENTIC_BIN = scriptFile; + + try { + const executor = new DevinCliAgenticExecutor(); + const result = await executor.execute({ + model: "swe-1-7", + stream: true, + credentials: {}, + body: { messages: [{ role: "user", content: [{ type: "text", text: "Say done" }] }] }, + }); + + assert.equal(result.response.status, 200, await result.response.clone().text()); + const sse = await readResponseText(result.response); + assert.match(sse, /event: message_start/); + assert.match(sse, /event: content_block_delta/); + assert.match(sse, /Done/); + assert.match(sse, /event: message_stop/); + } 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 }); + } +}); + +test("ACP client handles fragmented frames, multiple chunks, and stderr", async () => { + const tmpDir = sandboxTmp("devin-agentic-fragmented-"); + const scriptFile = writeScenarioMock( + tmpDir, + `rl.on("line", (line) => { + const msg = JSON.parse(line); + if (acceptAskMode(msg)) return; + if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } }); + if (msg.method === "session/new") sendSession(msg, "fragmented"); + if (msg.method === "session/prompt") { + process.stderr.write("bounded diagnostic\\n"); + const first = JSON.stringify({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fragmented", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Hel" } } } }); + process.stdout.write(first.slice(0, 13)); + setTimeout(() => { + process.stdout.write(first.slice(13) + "\\n"); + send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fragmented", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "lo" } } } }); + send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: "end_turn" } }); + }, 10); + } +});` + ); + try { + const result = await executeTextRequest(scriptFile); + assert.equal(result.response.status, 200, await result.response.clone().text()); + const body = JSON.parse(await result.response.text()); + assert.equal(body.content[0].text, "Hello"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +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 (acceptAskMode(msg)) return; + if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } }); + if (msg.method === "session/new") sendSession(msg, "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 = [ + { + name: "invalid-frame", + code: "invalid_acp_frame", + body: `rl.on("line", () => process.stdout.write("not-json\\n"));`, + }, + { + name: "rpc-error", + code: "acp_error", + body: `rl.on("line", (line) => { const msg = JSON.parse(line); send({ jsonrpc: "2.0", id: msg.id, error: { code: -32602, message: "bad request" } }); });`, + }, + { + name: "early-exit", + code: "acp_early_exit", + body: `rl.on("line", () => process.exit(7));`, + }, + ]; + + for (const scenario of cases) { + const tmpDir = sandboxTmp(`devin-agentic-${scenario.name}-`); + try { + const result = await executeTextRequest(writeScenarioMock(tmpDir, scenario.body)); + assert.equal(result.response.status, 502, scenario.name); + const body = JSON.parse(await result.response.text()); + assert.equal(body.error.code, scenario.code, scenario.name); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + } +}); + +test("ACP client times out, cancels, and terminates a stuck process", async () => { + const tmpDir = sandboxTmp("devin-agentic-stuck-"); + const scriptFile = writeScenarioMock(tmpDir, `rl.on("line", () => {});`); + const oldTimeout = process.env.DEVIN_AGENTIC_ACP_TIMEOUT_MS; + try { + process.env.DEVIN_AGENTIC_ACP_TIMEOUT_MS = "80"; + const timeoutResult = await executeTextRequest(scriptFile); + assert.equal(timeoutResult.response.status, 504); + assert.equal(JSON.parse(await timeoutResult.response.text()).error.code, "acp_timeout"); + + process.env.DEVIN_AGENTIC_ACP_TIMEOUT_MS = "1000"; + const controller = new AbortController(); + setTimeout(() => controller.abort(), 30); + const cancelled = await executeTextRequest(scriptFile, controller.signal); + assert.equal(cancelled.response.status, 499); + assert.equal(JSON.parse(await cancelled.response.text()).error.code, "acp_cancelled"); + } finally { + if (oldTimeout === undefined) delete process.env.DEVIN_AGENTIC_ACP_TIMEOUT_MS; + else process.env.DEVIN_AGENTIC_ACP_TIMEOUT_MS = oldTimeout; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +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( + 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 (acceptAskMode(msg)) return; + if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } }); + if (msg.method === "session/new") sendSession(msg, "repair"); + if (msg.method === "session/prompt") { + const text = count === 1 + ? 'Current State: inspected math.js. The immediate next step was to read math.test.js, then fix the bug and run npm test.' + : '{"name":"Read","arguments":{"file_path":"a.ts"}}'; + 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" } }); + } +});` + ); + 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, 200, await result.response.clone().text()); + assert.equal(JSON.parse(await result.response.text()).stop_reason, "tool_use"); + 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 }); + } +}); + +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 (acceptAskMode(msg)) return; + if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } }); + if (msg.method === "session/new") sendSession(msg, "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 }); + } +}); diff --git a/tests/unit/executor-devin-cli-agentic-core.test.ts b/tests/unit/executor-devin-cli-agentic-core.test.ts new file mode 100644 index 0000000000..defad057ba --- /dev/null +++ b/tests/unit/executor-devin-cli-agentic-core.test.ts @@ -0,0 +1,178 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildClaudeSseFrames } from "../../open-sse/executors/devin-agentic/anthropicResponse.ts"; +import { + MAX_TOOL_RESULT_CHARS, + serializeAnthropicForDevin, +} from "../../open-sse/executors/devin-agentic/serializer.ts"; +import { parseDevinToolRequest } from "../../open-sse/executors/devin-agentic/toolParser.ts"; + +const readTool = { + name: "Read", + description: "Read a file", + input_schema: { + type: "object", + required: ["file_path"], + additionalProperties: false, + properties: { + file_path: { type: "string" }, + }, + }, +}; + +test("devin agentic serializer preserves Anthropic tool history and schemas", () => { + const prompt = serializeAnthropicForDevin({ + system: [{ type: "text", text: "Follow CLAUDE.md" }], + tools: [readTool], + messages: [ + { 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" } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "export {}" }], + }, + ], + }); + + 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"); + assert.match(prompt.idSeed, /^[a-f0-9]{24}$/); +}); + +test("devin agentic serializer preserves tool choice and validates tool-result association", () => { + const prompt = serializeAnthropicForDevin({ + tools: [readTool], + tool_choice: { type: "tool", name: "Read" }, + messages: [{ role: "user", content: "Read it" }], + }); + assert.match(prompt.text, /Call exactly this tool: Read/); + assert.throws( + () => + serializeAnthropicForDevin({ + tools: [readTool], + messages: [ + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_missing", content: "nope" }], + }, + ], + }), + /unknown tool_use id/ + ); +}); + +test("devin agentic serializer marks bounded tool-result truncation explicitly", () => { + const prompt = serializeAnthropicForDevin({ + tools: [readTool], + messages: [ + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_big", name: "Read", input: { file_path: "a" } }], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_big", + content: "x".repeat(MAX_TOOL_RESULT_CHARS + 9), + }, + ], + }, + ], + }); + assert.match(prompt.text, /\[TRUNCATED 9 CHARACTERS BY OMNIROUTE\]/); +}); + +test("devin agentic serializer rejects images explicitly", () => { + assert.throws( + () => + serializeAnthropicForDevin({ + messages: [{ role: "user", content: [{ type: "image", source: { type: "base64" } }] }], + }), + /image blocks are not supported/ + ); +}); + +test("devin agentic parser validates known tool and arguments", () => { + const parsed = parseDevinToolRequest( + '{"name":"Read","arguments":{"file_path":"src/index.ts"}}', + [readTool], + "request-a" + ); + + assert.equal(parsed?.name, "Read"); + assert.deepEqual(parsed?.input, { file_path: "src/index.ts" }); + assert.match(parsed?.id || "", /^tool_devin_/); +}); + +test("devin agentic tool ids are stable per request and distinct across turns", () => { + const text = '{"name":"Read","arguments":{"file_path":"src/index.ts"}}'; + const first = parseDevinToolRequest(text, [readTool], "request-a"); + const retry = parseDevinToolRequest(text, [readTool], "request-a"); + const laterTurn = parseDevinToolRequest(text, [readTool], "request-b"); + assert.equal(first?.id, retry?.id); + assert.notEqual(first?.id, laterTurn?.id); +}); + +test("devin agentic parser rejects unknown tools and invalid arguments", () => { + assert.throws( + () => parseDevinToolRequest('{"name":"Write","arguments":{}}', [readTool]), + /unknown tool/ + ); + assert.throws( + () => parseDevinToolRequest('{"name":"Read","arguments":{}}', [readTool]), + /file_path is required/ + ); + assert.throws( + () => + parseDevinToolRequest( + '{"name":"Read","arguments":{"file_path":"a","extra":true}}', + [readTool] + ), + /extra is not allowed/ + ); +}); + +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" } }], + stop_reason: "tool_use", + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 2 }, + }); + + assert.match(sse, /event: message_start/); + assert.match(sse, /event: content_block_start/); + assert.match(sse, /input_json_delta/); + assert.match(sse, /event: message_delta/); + assert.match(sse, /"stop_reason":"tool_use"/); + assert.match(sse, /event: message_stop/); +});