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/.gitignore b/.gitignore index 045656a5ff..ec10295b10 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 @@ -207,6 +208,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 @@ -243,7 +246,7 @@ _artifacts/ # release-green artifacts # CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.) .artifacts/ -.sandbox/ +.sandbox/ # isolated Devin bridge workspaces, evidence, and test databases # 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..36b164b684 --- /dev/null +++ b/docker/devin-bridge/compose.yml @@ -0,0 +1,164 @@ +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: + - claude-isolated-config:/home/bridge/.claude-devin-isolated + - 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 + working_dir: /workspace + command: ["bash", "/opt/omniroute/docker/devin-bridge/run-claude-e2e.sh"] + 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: + - claude-isolated-config:/home/bridge/.claude-devin-isolated + - ./run-contract.mjs:/opt/omniroute/docker/devin-bridge/run-contract.mjs:ro + + 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_ALLOW_SUFFIXES: .devin.ai,.cognition.ai + GUARD_LOG: /logs/egress.jsonl + volumes: + - ./network-guard:/guard:ro + - ../../.sandbox/evidence:/logs + networks: [bridge-internal, guard-egress] + + omniroute-live: + <<: *runtime + profiles: [live-devin] + hostname: omniroute + depends_on: [network-guard] + environment: + <<: *isolated-environment + CLI_DEVIN_AGENTIC_BIN: /usr/local/bin/devin + HTTPS_PROXY: http://network-guard:8080 + HTTP_PROXY: http://network-guard:8080 + NO_PROXY: localhost,127.0.0.1,omniroute + 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: + - claude-isolated-config:/home/bridge/.claude-devin-isolated + - 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 + working_dir: /workspace + command: ["bash", "/opt/omniroute/docker/devin-bridge/run-claude-live-e2e.sh"] + volumes: + - claude-isolated-config:/home/bridge/.claude-devin-isolated + - devin-auth:/home/bridge/.local/share/devin + - ../../.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 + 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..28919969ed --- /dev/null +++ b/docker/devin-bridge/mock-devin.mjs @@ -0,0 +1,184 @@ +#!/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.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/prompt") { + const prompt = String(message.params?.prompt?.[0]?.text || ""); + 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_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/proxy.mjs b/docker/devin-bridge/network-guard/proxy.mjs new file mode 100644 index 0000000000..88f7409ea5 --- /dev/null +++ b/docker/devin-bridge/network-guard/proxy.mjs @@ -0,0 +1,58 @@ +import http from "node:http"; +import net from "node:net"; +import fs from "node:fs"; + +const [host, portText] = (process.env.GUARD_LISTEN || "0.0.0.0:8080").split(":"); +const port = Number(portText); +const suffixes = (process.env.GUARD_ALLOW_SUFFIXES || "") + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); +const logPath = process.env.GUARD_LOG || "/tmp/egress.jsonl"; + +function allowed(hostname) { + const value = hostname.toLowerCase().replace(/\.$/, ""); + return suffixes.some((suffix) => value === suffix.slice(1) || value.endsWith(suffix)); +} + +function audit(hostname, decision) { + fs.appendFileSync( + logPath, + `${JSON.stringify({ at: new Date().toISOString(), hostname, decision })}\n` + ); +} + +const server = http.createServer((req, res) => { + const target = new URL(req.url); + if (!allowed(target.hostname)) { + audit(target.hostname, "deny"); + res.writeHead(403).end("egress denied\n"); + return; + } + audit(target.hostname, "allow"); + const upstream = http.request(target, { method: req.method, headers: req.headers }, (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 [hostname, portValue] = req.url.split(":"); + if (!allowed(hostname)) { + audit(hostname, "deny"); + client.end("HTTP/1.1 403 Forbidden\r\n\r\n"); + return; + } + audit(hostname, "allow"); + const upstream = net.connect(Number(portValue) || 443, hostname, () => { + client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length) upstream.write(head); + upstream.pipe(client); + client.pipe(upstream); + }); + upstream.on("error", () => client.end("HTTP/1.1 502 Bad Gateway\r\n\r\n")); +}); + +server.listen(port, 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..27a2c853f5 --- /dev/null +++ b/docker/devin-bridge/run-claude-live-e2e.sh @@ -0,0 +1,31 @@ +#!/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 + +run_scenario() { + local evidence_file="$1" + local prompt="$2" + claude -p --output-format stream-json --verbose --max-turns 12 \ + --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 +} + +run_scenario /evidence/live-analysis.jsonl \ + "Read CLAUDE.md, inspect math.js and its test without editing, explain the defect, then end with LIVE_ANALYSIS_COMPLETE." +grep -q LIVE_ANALYSIS_COMPLETE /evidence/live-analysis.jsonl + +run_scenario /evidence/live-fix.jsonl \ + "Fix the defect in math.js, run npm test, and end with LIVE_FIX_COMPLETE only after the test passes." +grep -q 'return a + b;' /workspace/math.js +npm test +grep -q LIVE_FIX_COMPLETE /evidence/live-fix.jsonl + +run_scenario /evidence/live-command.jsonl "/bridge-check" +grep -q BRIDGE_E2E_COMPLETE /evidence/live-command.jsonl + +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..b78cb2d510 --- /dev/null +++ b/docker/devin-bridge/run-contract.mjs @@ -0,0 +1,115 @@ +#!/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 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/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..67c452ee5b --- /dev/null +++ b/scripts/devin-bridge/common @@ -0,0 +1,35 @@ +#!/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_prepare_sandbox() { + mkdir -p "$BRIDGE_SANDBOX/home" "$BRIDGE_SANDBOX/test-data" \ + "$BRIDGE_SANDBOX/e2e-workspace" "$BRIDGE_SANDBOX/live-workspace" \ + "$BRIDGE_SANDBOX/evidence" + chmod 0777 "$BRIDGE_SANDBOX/e2e-workspace" "$BRIDGE_SANDBOX/live-workspace" \ + "$BRIDGE_SANDBOX/evidence" +} +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_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_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" "$@" +} diff --git a/scripts/devin-bridge/launch b/scripts/devin-bridge/launch index 7e8fab8eda..7f6b5c2475 100755 --- a/scripts/devin-bridge/launch +++ b/scripts/devin-bridge/launch @@ -1,13 +1,21 @@ #!/usr/bin/env bash set -euo pipefail - -if [[ "${ENABLE_LIVE_DEVIN_TESTS:-}" != "1" && "${DEVIN_BRIDGE_OFFLINE:-}" != "1" ]]; then - printf 'Set DEVIN_BRIDGE_OFFLINE=1 for mock/offline mode or ENABLE_LIVE_DEVIN_TESTS=1 for live Devin.\n' >&2 - exit 1 -fi - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -"$SCRIPT_DIR/verify-anthropic-isolation" - -printf 'Bridge launch preflight passed. Start Claude Code only inside the isolated container/profile.\n' - +source "$(dirname "$0")/common" +bridge_prepare_sandbox +"$(dirname "$0")/verify-anthropic-isolation" +docker compose -f "$BRIDGE_COMPOSE" --profile offline --profile live-devin down --remove-orphans +docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d network-guard +docker compose -f "$BRIDGE_COMPOSE" --profile live-devin run --rm --no-deps omniroute-live \ + devin auth status +docker compose -f "$BRIDGE_COMPOSE" --profile live-devin run --rm --no-deps omniroute-live \ + 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 +exec docker compose -f "$BRIDGE_COMPOSE" --profile live-devin run --rm --no-deps \ + claude-live claude diff --git a/scripts/devin-bridge/login-devin b/scripts/devin-bridge/login-devin new file mode 100755 index 0000000000..30a9387d30 --- /dev/null +++ b/scripts/devin-bridge/login-devin @@ -0,0 +1,10 @@ +#!/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; } +bridge_prepare_sandbox +docker compose -f "$BRIDGE_COMPOSE" --profile offline --profile live-devin down --remove-orphans +docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d network-guard +docker compose -f "$BRIDGE_COMPOSE" --profile live-devin run --rm --no-deps omniroute-live \ + devin auth login +exec env ENABLE_LIVE_DEVIN_TESTS=1 "$(dirname "$0")/test-live-devin" diff --git a/scripts/devin-bridge/select-live-model.mjs b/scripts/devin-bridge/select-live-model.mjs new file mode 100644 index 0000000000..145b8fd513 --- /dev/null +++ b/scripts/devin-bridge/select-live-model.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import { DEVIN_MODEL_CATALOG } from "../../open-sse/config/providers/registry/devin/catalog.ts"; + +const document = JSON.parse(fs.readFileSync(0, "utf8")); +const candidates = []; + +function collect(value) { + if (Array.isArray(value)) { + value.forEach(collect); + return; + } + if (!value || typeof value !== "object") return; + for (const [key, nested] of Object.entries(value)) { + if ( + typeof nested === "string" && + ["id", "model", "model_id", "slug"].includes(key.toLowerCase()) && + /^[a-z0-9][a-z0-9._/-]*$/i.test(nested) + ) { + candidates.push(nested); + } + collect(nested); + } +} + +collect(document); +const unique = [...new Set(candidates)]; +const catalogIds = new Set(DEVIN_MODEL_CATALOG.map((entry) => entry.id)); +const available = unique.filter((candidate) => catalogIds.has(candidate)); + +for (const [name, configured] of [ + ["DEVIN_BRIDGE_SONNET_MODEL", process.env.DEVIN_BRIDGE_SONNET_MODEL], + ["DEVIN_BRIDGE_OPUS_MODEL", process.env.DEVIN_BRIDGE_OPUS_MODEL], + ["DEVIN_BRIDGE_HAIKU_MODEL", process.env.DEVIN_BRIDGE_HAIKU_MODEL], + ["DEVIN_BRIDGE_SUBAGENT_MODEL", process.env.DEVIN_BRIDGE_SUBAGENT_MODEL], +]) { + if (!configured) continue; + const prefix = "devin-cli-agentic/"; + const modelId = configured.startsWith(prefix) ? configured.slice(prefix.length) : ""; + if (!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") || + 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"); +} +process.stdout.write(selected); diff --git a/scripts/devin-bridge/test-contract b/scripts/devin-bridge/test-contract new file mode 100755 index 0000000000..c1749f66e5 --- /dev/null +++ b/scripts/devin-bridge/test-contract @@ -0,0 +1,17 @@ +#!/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); + if (rows.length !== 5 || rows.some((row) => row.provider !== "devin-cli-agentic")) { + 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..14d2e9d8b8 --- /dev/null +++ b/scripts/devin-bridge/test-e2e-mock @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/common" +bridge_reset_e2e_fixture +"$(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 claude claude +grep -q '"action":"final"' "$BRIDGE_SANDBOX/evidence/mock-acp.jsonl" +grep -q 'BRIDGE_E2E_COMPLETE' "$BRIDGE_SANDBOX/evidence/claude-stream.jsonl" +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..e5734306b5 --- /dev/null +++ b/scripts/devin-bridge/test-live-devin @@ -0,0 +1,32 @@ +#!/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; } +bridge_reset_live_fixture +"$(dirname "$0")/verify-anthropic-isolation" --static +docker compose -f "$BRIDGE_COMPOSE" --profile offline --profile live-devin down --remove-orphans +docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d network-guard +docker compose -f "$BRIDGE_COMPOSE" --profile live-devin run --rm --no-deps omniroute-live \ + devin auth status +docker compose -f "$BRIDGE_COMPOSE" --profile live-devin run --rm --no-deps omniroute-live \ + 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_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 +node -e ' + const fs = require("node:fs"); + const path = process.argv[1]; + if (!fs.existsSync(path)) process.exit(0); + for (const line of fs.readFileSync(path, "utf8").trim().split("\n").filter(Boolean)) { + const entry = JSON.parse(line); + if (entry.decision === "allow" && !/(^|\.)(devin\.ai|cognition\.ai)$/.test(entry.hostname)) { + throw new Error(`unexpected allowed egress: ${entry.hostname}`); + } + } +' "$BRIDGE_SANDBOX/evidence/egress.jsonl" +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 index e938810d7f..5e684724cf 100755 --- a/scripts/devin-bridge/test-unit +++ b/scripts/devin-bridge/test-unit @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail - -node --import tsx/esm --test \ +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 - diff --git a/scripts/devin-bridge/verify-anthropic-isolation b/scripts/devin-bridge/verify-anthropic-isolation index bdaf38d971..97931bcaf9 100755 --- a/scripts/devin-bridge/verify-anthropic-isolation +++ b/scripts/devin-bridge/verify-anthropic-isolation @@ -1,43 +1,71 @@ #!/usr/bin/env bash set -euo pipefail - -fail() { - printf 'FAIL: %s\n' "$1" >&2 - exit 1 -} - -case "${CLAUDE_CONFIG_DIR:-}" in - /home/bridge/.claude-devin-isolated|*/.sandbox/*|*/claude-devin-isolated*) - ;; - "") - fail "CLAUDE_CONFIG_DIR is not set" - ;; - *) - fail "CLAUDE_CONFIG_DIR must point at an isolated bridge directory" - ;; -esac - -for key in \ - 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 -do - if [[ -n "${!key:-}" ]]; then - fail "$key must be unset" - fi +source "$(dirname "$0")/common" +fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; } +bridge_prepare_sandbox +config="$(docker compose -f "$BRIDGE_COMPOSE" --profile offline --profile live-devin config)" +config_json="$(docker compose -f "$BRIDGE_COMPOSE" --profile offline --profile live-devin 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 - -if [[ "${ANTHROPIC_BASE_URL:-}" != "http://omniroute:20128" && "${ANTHROPIC_BASE_URL:-}" != http://127.0.0.1:* && "${ANTHROPIC_BASE_URL:-}" != http://localhost:* ]]; then - fail "ANTHROPIC_BASE_URL must target local OmniRoute" -fi - -if [[ "${ANTHROPIC_AUTH_TOKEN:-}" != sk-local-* ]]; then - fail "ANTHROPIC_AUTH_TOKEN must be a local bridge token" -fi - -printf 'PASS: Anthropic isolation environment looks local and isolated\n' - +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"])) { + 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(["bridge-internal", "guard-egress"])) { + throw new Error(`network guard topology mismatch: ${guardNetworks.join(",")}`); + } + if (config.services["network-guard"].environment.GUARD_ALLOW_SUFFIXES !== ".devin.ai,.cognition.ai") { + throw new Error("network guard allowlist mismatch"); + } + for (const serviceName of ["omniroute", "claude", "contract", "omniroute-live", "claude-live"]) { + 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`); + } + const configMount = (service.volumes || []).find( + (mount) => mount.target === "/home/bridge/.claude-devin-isolated", + ); + if (!configMount || configMount.type !== "volume") { + throw new Error(`${serviceName} does not use the isolated named Claude volume`); + } + 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}`); + } + } + } +' <<<"$config_json" || fail "structured compose isolation checks failed" +if [[ "${1:-}" == --static ]]; then printf 'PASS: static bridge isolation checks passed\n'; exit 0; fi +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 +' +printf 'PASS: runtime bridge isolation checks passed\n' 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..d474a5642e --- /dev/null +++ b/tests/fixtures/devin-bridge/e2e-workspace/.claude/commands/bridge-check.md @@ -0,0 +1,9 @@ +--- +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, run its test, diagnose +the intentionally failed first correction, fix it, rerun the test, and report completion. 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..306607468f --- /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. A failed first attempt must be diagnosed and corrected. 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" + } +}