From ccbe6bc288c42322ccba6a0ed1bde73cb02ab850 Mon Sep 17 00:00:00 2001 From: Lucas Israel Date: Mon, 27 Jul 2026 14:02:01 -0300 Subject: [PATCH] fix: harden Devin bridge runtime boundaries --- .dockerignore | 1 + docker/devin-bridge/compose.yml | 38 +- docker/devin-bridge/network-guard/policy.mjs | 108 ++++++ docker/devin-bridge/network-guard/proxy.mjs | 172 ++++++--- docs/DEVIN_CLAUDE_BRIDGE.md | 19 +- scripts/devin-bridge/common | 100 +++--- scripts/devin-bridge/launch | 15 +- scripts/devin-bridge/login-devin | 8 +- scripts/devin-bridge/runtime-policy.mjs | 101 ++++++ scripts/devin-bridge/select-live-model.mjs | 108 +++--- scripts/devin-bridge/test-e2e-mock | 8 +- scripts/devin-bridge/test-live-devin | 25 +- scripts/devin-bridge/test-unit | 3 +- .../devin-bridge/verify-anthropic-isolation | 36 +- tests/unit/devin-bridge-live-runtime.test.ts | 332 ++++++++---------- tests/unit/devin-bridge-network-guard.test.ts | 217 ++++++++++++ .../executor-devin-cli-agentic-acp.test.ts | 21 +- 17 files changed, 945 insertions(+), 367 deletions(-) create mode 100644 scripts/devin-bridge/runtime-policy.mjs create mode 100644 tests/unit/devin-bridge-network-guard.test.ts diff --git a/.dockerignore b/.dockerignore index 67d4905b6a..8cc5c7632b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,6 +18,7 @@ coverage # Runtime data and logs data logs +.sandbox # Local env files (inject at runtime via --env-file or -e) .env diff --git a/docker/devin-bridge/compose.yml b/docker/devin-bridge/compose.yml index 885c78e64b..c850414dbc 100644 --- a/docker/devin-bridge/compose.yml +++ b/docker/devin-bridge/compose.yml @@ -73,7 +73,7 @@ services: omniroute: condition: service_healthy claude-egress-guard: - condition: service_started + condition: service_healthy working_dir: /workspace command: ["bash", "/opt/omniroute/docker/devin-bridge/run-claude-e2e.sh"] environment: @@ -109,10 +109,21 @@ services: environment: GUARD_LISTEN: 0.0.0.0:8080 GUARD_POLICY: deny-all - GUARD_LOG: /logs/claude-egress.jsonl + 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/evidence:/logs + - ../../.sandbox/guard-audit/claude:/guard-audit networks: [bridge-internal] network-guard: @@ -126,17 +137,30 @@ services: environment: GUARD_LISTEN: 0.0.0.0:8080 GUARD_POLICY: devin - GUARD_LOG: /logs/egress.jsonl + 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/evidence:/logs + - ../../.sandbox/guard-audit/devin:/guard-audit networks: [devin-guard-internal, guard-egress] omniroute-live: <<: *runtime profiles: [live-devin] hostname: omniroute - depends_on: [network-guard] + depends_on: + network-guard: + condition: service_healthy environment: <<: *isolated-environment CLI_DEVIN_AGENTIC_BIN: /usr/local/bin/devin @@ -165,7 +189,7 @@ services: omniroute-live: condition: service_healthy claude-egress-guard: - condition: service_started + condition: service_healthy working_dir: /workspace command: ["bash", "/opt/omniroute/docker/devin-bridge/run-claude-live-e2e.sh"] environment: diff --git a/docker/devin-bridge/network-guard/policy.mjs b/docker/devin-bridge/network-guard/policy.mjs index 4522d7085c..e5884b4c01 100644 --- a/docker/devin-bridge/network-guard/policy.mjs +++ b/docker/devin-bridge/network-guard/policy.mjs @@ -20,3 +20,111 @@ export function isAllowedGuardHostname(hostname, policy = "deny-all") { (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 index 8f1d8e8c7f..9efe5b9b34 100644 --- a/docker/devin-bridge/network-guard/proxy.mjs +++ b/docker/devin-bridge/network-guard/proxy.mjs @@ -1,58 +1,136 @@ +import fs from "node:fs"; import http from "node:http"; import net from "node:net"; -import fs from "node:fs"; -import { isAllowedGuardHostname } from "./policy.mjs"; +import { pathToFileURL } from "node:url"; -const [host, portText] = (process.env.GUARD_LISTEN || "0.0.0.0:8080").split(":"); -const port = Number(portText); -const policy = process.env.GUARD_POLICY || "deny-all"; -if (!new Set(["deny-all", "devin"]).has(policy)) { - throw new Error(`Unknown network guard policy: ${policy}`); -} -const logPath = process.env.GUARD_LOG || "/tmp/egress.jsonl"; +import { + isAllowedGuardHostname, + parseConnectAuthority, + parseTlsClientHelloSni, + sanitizeForwardHeaders, +} from "./policy.mjs"; -function allowed(hostname) { - return isAllowedGuardHostname(hostname, policy); -} +const MAX_CLIENT_HELLO_BYTES = 64 * 1024; +const CLIENT_HELLO_TIMEOUT_MS = 3000; -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; +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}`); } - 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); + + 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")); }); - 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, () => { + 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"); - if (head.length) upstream.write(head); - upstream.pipe(client); - client.pipe(upstream); + client.on("data", onData); + if (buffer.length) inspect(); + client.resume(); }); - upstream.on("error", () => client.end("HTTP/1.1 502 Bad Gateway\r\n\r\n")); -}); -server.listen(port, host); + 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/docs/DEVIN_CLAUDE_BRIDGE.md b/docs/DEVIN_CLAUDE_BRIDGE.md index 538bc54cba..e1de2603dd 100644 --- a/docs/DEVIN_CLAUDE_BRIDGE.md +++ b/docs/DEVIN_CLAUDE_BRIDGE.md @@ -45,7 +45,11 @@ The `offline` network is internal, so no runtime container can reach the Interne `live-devin` OmniRoute service is also attached only to that internal network; outbound HTTP(S) goes through `network-guard`, whose only allowed suffixes are `.devin.ai` and `.cognition.ai`. Anthropic, Claude, Statsig, Sentry, and every unrelated destination are -denied by default. The guard records decisions in `.sandbox/evidence/egress.jsonl`. +denied by default. The guards write their canonical audit logs to the guard-only binds +`.sandbox/guard-audit/devin/egress.jsonl` and +`.sandbox/guard-audit/claude/egress.jsonl`. Runtime services do not mount those directories. +After the guarded services stop, the scripts validate file ownership, mode, link count, and +every audit decision before copying the token-free audit record into `.sandbox/evidence`. Run the executable proof at any time: @@ -85,9 +89,10 @@ host session: ENABLE_LIVE_DEVIN_TESTS=1 ./scripts/devin-bridge/login-devin ``` -After login, the live test checks `devin auth status`, obtains the account's machine-readable -model list with `devin models list --format json`, selects a returned model identifier, and -runs three disposable Claude Code scenarios: +After login, the live test accepts `devin auth status` only when its output contains the exact +line `Logged in (via Devin)`. It then obtains the account's machine-readable model list with +`devin models list --format json`, selects an identifier from an explicit model-identifier +field, and runs three disposable Claude Code scenarios: ```bash ENABLE_LIVE_DEVIN_TESTS=1 ./scripts/devin-bridge/test-live-devin @@ -126,7 +131,11 @@ the host. shows local routing and sanitized executor failures. - `.sandbox/evidence/mock-acp.jsonl` records deterministic offline provider actions. - `.sandbox/evidence/claude-stream.jsonl` records the real Claude Code offline run. -- `.sandbox/evidence/egress.jsonl` records live guard decisions without tokens. +- `.sandbox/guard-audit/devin/egress.jsonl` and + `.sandbox/guard-audit/claude/egress.jsonl` are the canonical guard-only audit files. +- `.sandbox/evidence/egress.jsonl`, `.sandbox/evidence/claude-egress.jsonl`, and + `.sandbox/evidence/claude-egress-verifier.jsonl` are validated, post-shutdown copies + without tokens. - An ACP timeout, malformed frame, unavailable binary/model, or process exit is an explicit `502`; it never selects a second provider. diff --git a/scripts/devin-bridge/common b/scripts/devin-bridge/common index 2b6b1f3b92..2d5080716e 100755 --- a/scripts/devin-bridge/common +++ b/scripts/devin-bridge/common @@ -3,17 +3,34 @@ 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_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_prepare_sandbox - : >"$BRIDGE_SANDBOX/evidence/claude-egress.jsonl" - chmod 0666 "$BRIDGE_SANDBOX/evidence/claude-egress.jsonl" + 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 @@ -35,6 +52,7 @@ bridge_reset_live_fixture() { "$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 @@ -55,18 +73,13 @@ bridge_run_devin() { bridge_assert_devin_auth_status() { local exit_status="$1" local output="$2" - [[ "$exit_status" == 0 ]] || { - printf 'FAIL: Devin auth status command failed\n' >&2 - return 1 - } - [[ "$output" == *"Logged in (via Devin)"* ]] || { - printf 'FAIL: Devin auth status did not confirm login\n' >&2 - return 1 - } - if grep -Fqi 'Failed to fetch from server' <<<"$output"; then - printf 'FAIL: Devin auth status could not confirm server access\n' >&2 - return 1 - fi + 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() { @@ -82,34 +95,37 @@ bridge_check_devin_auth() { bridge_assert_zero_claude_egress() { local audit_path="$1" - [[ -f "$audit_path" ]] || { - printf 'FAIL: Claude egress audit file is missing\n' >&2 - return 1 - } - [[ ! -s "$audit_path" ]] || { - printf 'FAIL: Claude attempted external egress during the real run\n' >&2 - return 1 - } + bridge_validate_guard_audit claude-zero "$audit_path" } bridge_assert_claude_guard_denials() { local audit_path="$1" - [[ -s "$audit_path" ]] || { - printf 'FAIL: Claude egress denial audit is missing or empty\n' >&2 - return 1 - } - node -e ' - const fs = require("node:fs"); - const entries = fs.readFileSync(process.argv[1], "utf8") - .trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)); - if (!entries.length) throw new Error("Claude egress audit has no records"); - if (entries.some((entry) => entry.decision !== "deny")) { - throw new 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")) { - throw new Error(`Claude egress audit is missing deny for ${hostname}`); - } - } - ' "$audit_path" + 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 index 0106e0f921..de0515def8 100755 --- a/scripts/devin-bridge/launch +++ b/scripts/devin-bridge/launch @@ -1,10 +1,13 @@ #!/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" -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 claude-egress-guard +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" \ @@ -15,5 +18,11 @@ 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 \ +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 index c7848c04a8..0489080784 100755 --- a/scripts/devin-bridge/login-devin +++ b/scripts/devin-bridge/login-devin @@ -2,8 +2,12 @@ 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 -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 +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..81952de712 --- /dev/null +++ b/scripts/devin-bridge/runtime-policy.mjs @@ -0,0 +1,101 @@ +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.includes("Logged in (via Devin)")) { + 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" }; + for (const entry of entries) { + if (entry.decision !== "allow" || !isAllowedDevinAuditHostname(entry.hostname)) { + return { ok: false, error: `unexpected Devin egress record: ${String(entry.hostname)}` }; + } + } + return { ok: true }; +} + +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 index 69016e4cd3..ebe38819d0 100644 --- a/scripts/devin-bridge/select-live-model.mjs +++ b/scripts/devin-bridge/select-live-model.mjs @@ -1,19 +1,15 @@ #!/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 document = JSON.parse(fs.readFileSync(0, "utf8")); -const candidates = []; const candidateFields = new Set([ - "id", - "model", "model_id", - "modelid", + "modelId", "model_uid", - "modeluid", + "modelUid", "family_uid", - "familyuid", - "slug", + "familyUid", ]); function normalizeModelId(value) { @@ -24,56 +20,82 @@ function normalizeModelId(value) { .replace(/^-+|-+$/g, ""); } -function collect(value) { +function collect(value, candidates) { if (Array.isArray(value)) { - value.forEach(collect); + 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.toLowerCase()) && + candidateFields.has(key) && /^[a-z0-9][a-z0-9._/-]*$/i.test(nested) ) { candidates.push(nested); } - collect(nested); + collect(nested, candidates); } } -collect(document); -const unique = [...new Set(candidates)]; -const catalogIds = new Set(DEVIN_MODEL_CATALOG.map((entry) => entry.id)); -const available = [ - ...new Set( - unique - .map((candidate) => normalizeModelId(candidate)) - .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`); +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; } -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"); +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)); } -process.stdout.write(selected); diff --git a/scripts/devin-bridge/test-e2e-mock b/scripts/devin-bridge/test-e2e-mock index 344cbef1e8..43db057fca 100755 --- a/scripts/devin-bridge/test-e2e-mock +++ b/scripts/devin-bridge/test-e2e-mock @@ -1,12 +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 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" -bridge_assert_zero_claude_egress "$BRIDGE_SANDBOX/evidence/claude-egress.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 index 5948978076..5f0abbeee0 100755 --- a/scripts/devin-bridge/test-live-devin +++ b/scripts/devin-bridge/test-live-devin @@ -2,10 +2,11 @@ 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 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 up -d --wait network-guard bridge_check_devin_auth bridge_run_devin models list --format json >"$BRIDGE_SANDBOX/evidence/live-models.json" devin_model="$(node --import tsx/esm "$BRIDGE_ROOT/scripts/devin-bridge/select-live-model.mjs" \ @@ -16,18 +17,10 @@ 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); - const suffixAllowed = /(^|\.)(devin\.ai|cognition\.ai)$/.test(entry.hostname); - const exactAllowed = ["server.codeium.com", "unleash.codeium.com"].includes(entry.hostname); - if (entry.decision === "allow" && !suffixAllowed && !exactAllowed) { - throw new Error(`unexpected allowed egress: ${entry.hostname}`); - } - } -' "$BRIDGE_SANDBOX/evidence/egress.jsonl" -bridge_assert_zero_claude_egress "$BRIDGE_SANDBOX/evidence/claude-egress.jsonl" +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 index 5e684724cf..c5ed3457cf 100755 --- a/scripts/devin-bridge/test-unit +++ b/scripts/devin-bridge/test-unit @@ -4,4 +4,5 @@ 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/executor-devin-cli-agentic-acp.test.ts \ + tests/unit/devin-bridge-network-guard.test.ts diff --git a/scripts/devin-bridge/verify-anthropic-isolation b/scripts/devin-bridge/verify-anthropic-isolation index 4db231e001..f7169661ea 100755 --- a/scripts/devin-bridge/verify-anthropic-isolation +++ b/scripts/devin-bridge/verify-anthropic-isolation @@ -35,10 +35,16 @@ node -e ' 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"]) { @@ -49,6 +55,9 @@ node -e ' } 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", @@ -76,6 +85,14 @@ node -e ' } } } + 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"); @@ -167,6 +184,9 @@ 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" @@ -184,15 +204,14 @@ grep -q 'bridge_check_devin_auth' "$BRIDGE_ROOT/scripts/devin-bridge/test-live-d 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 network-guard claude-egress-guard' "$BRIDGE_ROOT/scripts/devin-bridge/launch" || \ +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 claude-egress-guard -cleanup_claude_guard() { - docker compose -f "$BRIDGE_COMPOSE" --profile offline stop claude-egress-guard >/dev/null 2>&1 || true -} -trap cleanup_claude_guard EXIT +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 @@ -231,9 +250,10 @@ docker compose -f "$BRIDGE_COMPOSE" --profile offline run --rm --no-deps claude signal: AbortSignal.timeout(3000), })); ' -bridge_assert_claude_guard_denials "$BRIDGE_SANDBOX/evidence/claude-egress.jsonl" || \ +bridge_cleanup_compose +bridge_assert_claude_guard_denials "$BRIDGE_CLAUDE_AUDIT" || \ fail "Claude proxy denial audit proof failed" -cleanup_claude_guard +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/tests/unit/devin-bridge-live-runtime.test.ts b/tests/unit/devin-bridge-live-runtime.test.ts index a23ef72bee..0b6f3a66ae 100644 --- a/tests/unit/devin-bridge-live-runtime.test.ts +++ b/tests/unit/devin-bridge-live-runtime.test.ts @@ -1,87 +1,54 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; 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"; + 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 selectorPath = path.join(root, "scripts", "devin-bridge", "select-live-model.mjs"); const liveE2ePath = path.join(root, "scripts", "devin-bridge", "test-live-devin"); const verifierPath = path.join(root, "scripts", "devin-bridge", "verify-anthropic-isolation"); -function composeConfig() { - const result = spawnSync( - "docker-compose", - [ - "-f", - composePath, - "--env-file", - "/dev/null", - "--profile", - "offline", - "--profile", - "live-devin", - "config", - "--format", - "json", - ], - { cwd: root, encoding: "utf8" } - ); - assert.equal(result.status, 0, result.stderr); - return JSON.parse(result.stdout); +interface ComposeService { + depends_on?: Record; + environment?: Record; + healthcheck?: { test?: unknown }; + volumes?: Array; } -function volumeSources(service: { volumes?: Array }): string[] { - return (service.volumes || []).map((mount) => +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 || "") ); } -function networkNames(service: { networks?: string[] | Record }): string[] { - return Array.isArray(service.networks) ? service.networks : Object.keys(service.networks || {}); -} - -function assertAuthStatus(exitStatus: number, output: string) { - return spawnSync( - "bash", - [ - "-c", - 'source "$1"; bridge_assert_devin_auth_status "$2" "$3"', - "bridge-auth-test", - commonPath, - String(exitStatus), - output, - ], - { cwd: root, encoding: "utf8" } - ); -} - -function selectModel(document: unknown, env: NodeJS.ProcessEnv = {}) { - return spawnSync(process.execPath, ["--import", "tsx/esm", selectorPath], { - cwd: root, - encoding: "utf8", - input: JSON.stringify(document), - env: { PATH: process.env.PATH, ...env }, - }); -} - -function runCommon(functionName: string, filePath: string) { - return spawnSync( - "bash", - ["-c", 'source "$1"; "$2" "$3"', "bridge-audit-test", commonPath, functionName, filePath], - { cwd: root, encoding: "utf8" } - ); -} - -test("network policy permits only Devin/Cognition and exact Codeium control-plane hosts", async () => { - const { isAllowedGuardHostname } = - await import("../../docker/devin-bridge/network-guard/policy.mjs"); - +test("network policy permits only the intended Devin destinations", () => { for (const hostname of [ "api.devin.ai", "devin.ai", @@ -106,62 +73,68 @@ test("network policy permits only Devin/Cognition and exact Codeium control-plan assert.equal(isAllowedGuardHostname("api.devin.ai", "deny-all"), false); }); -test("compose separates Claude config, Devin auth, and the two egress guards", () => { - const config = composeConfig(); - const services = config.services; +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" + ); +}); - for (const [name, service] of Object.entries(services) as Array< - [string, { environment?: Record; networks?: object; volumes?: object[] }] - >) { +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.some( - (source) => - source === "claude-isolated-config" || source.endsWith("_claude-isolated-config") - ), + sources.includes("claude-isolated-config"), name === "claude" || name === "claude-live", - `${name} Claude config volume ownership` - ); - assert.equal( - sources.some((source) => source === "devin-auth" || source.endsWith("_devin-auth")), - name === "omniroute-live", - `${name} Devin auth volume ownership` + `${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, + services["omniroute-live"].environment?.DEVIN_BRIDGE_PROXY_URL, "http://network-guard:8080" ); - assert.equal(services["omniroute-live"].environment.HTTP_PROXY, undefined); - assert.equal(services["omniroute-live"].environment.HTTPS_PROXY, undefined); - for (const name of ["claude", "claude-live"]) { - const env = services[name].environment; - assert.equal(env.NODE_USE_ENV_PROXY, "1"); - assert.equal(env.HTTP_PROXY, "http://claude-egress-guard:8080"); - assert.equal(env.HTTPS_PROXY, "http://claude-egress-guard:8080"); - assert.equal(env.NO_PROXY, "omniroute"); - assert.notEqual(env.HTTP_PROXY, "http://network-guard:8080"); + assert.equal(services[name].environment?.HTTP_PROXY, "http://claude-egress-guard:8080"); + assert.equal(services[name].environment?.NO_PROXY, "omniroute"); } - - assert.equal(services["network-guard"].environment.GUARD_POLICY, "devin"); - assert.equal(services["claude-egress-guard"].environment.GUARD_POLICY, "deny-all"); - assert.deepEqual(networkNames(services["claude-egress-guard"]), ["bridge-internal"]); }); -test("auth status gate requires a clean server-confirmed login", () => { - assert.equal(assertAuthStatus(0, "Logged in (via Devin)\n").status, 0); - assert.notEqual( - assertAuthStatus(0, "Logged in (via Devin)\nFailed to fetch from server\n").status, - 0 - ); - assert.notEqual(assertAuthStatus(0, "Logged out\n").status, 0); - assert.notEqual(assertAuthStatus(1, "Logged in (via Devin)\n").status, 0); +test("auth status requires the exact positive line and rejects misleading text", () => { + 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("live model selection normalizes real family/model uid fields and prefers lightning", () => { - const result = selectModel({ +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" }, @@ -169,82 +142,77 @@ test("live model selection normalizes real family/model uid fields and prefers l { modelUid: "swe-1.6" }, ], }); - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "swe-1-7-lightning"); -}); - -test("live model selection rejects normalized identifiers absent from the catalog", () => { - const result = selectModel({ models: [{ family_uid: "swe-9.9-unknown" }] }); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /no model identifier present/i); -}); - -test("login uses the official manual token flow without accepting tokens as arguments", () => { - const login = fs.readFileSync(loginPath, "utf8"); - const common = fs.readFileSync(commonPath, "utf8"); - assert.match(login, /bridge_run_devin auth login --force-manual-token-flow/); - assert.match(common, /exec devin "\$@"/); - assert.doesNotMatch(login, /read\s+.*token|printf\s+.*token|echo\s+.*token/i); -}); - -test("normal live launch uses the strict auth and proxied Devin helpers", () => { - const launch = fs.readFileSync(launchPath, "utf8"); - assert.match(launch, /up -d network-guard claude-egress-guard/); - assert.match(launch, /bridge_check_devin_auth/); - assert.match(launch, /bridge_run_devin models list --format json/); - assert.doesNotMatch(launch, /\bdevin auth status\b/); -}); - -test("Claude audit helpers fail closed for missing, empty, partial, or any real-run record", () => { - const sandboxRoot = path.join(root, ".sandbox"); - fs.mkdirSync(sandboxRoot, { recursive: true }); - const auditRoot = fs.mkdtempSync(path.join(sandboxRoot, "audit-unit-")); - const auditPath = path.join(auditRoot, "claude-egress.jsonl"); - try { - assert.notEqual(runCommon("bridge_assert_zero_claude_egress", auditPath).status, 0); - fs.writeFileSync(auditPath, ""); - assert.equal(runCommon("bridge_assert_zero_claude_egress", auditPath).status, 0); - fs.writeFileSync(auditPath, '{"hostname":"api.anthropic.com","decision":"deny"}\n'); - assert.notEqual(runCommon("bridge_assert_zero_claude_egress", auditPath).status, 0); - - assert.notEqual(runCommon("bridge_assert_claude_guard_denials", auditPath).status, 0); - fs.appendFileSync(auditPath, '{"hostname":"claude.ai","decision":"deny"}\n'); - assert.equal(runCommon("bridge_assert_claude_guard_denials", auditPath).status, 0); - fs.appendFileSync(auditPath, '{"hostname":"example.com","decision":"allow"}\n'); - assert.notEqual(runCommon("bridge_assert_claude_guard_denials", auditPath).status, 0); - } finally { - fs.rmSync(auditRoot, { recursive: true, force: true }); - } -}); - -test("offline and live resets precreate an empty world-writable Claude audit", () => { - const auditPath = path.join(root, ".sandbox", "evidence", "claude-egress.jsonl"); - for (const resetFunction of ["bridge_reset_e2e_fixture", "bridge_reset_live_fixture"]) { - const reset = spawnSync( - "bash", - ["-c", 'source "$1"; "$2"', "bridge-reset-test", commonPath, resetFunction], - { cwd: root, encoding: "utf8" } - ); - assert.equal(reset.status, 0, reset.stderr); - assert.equal(fs.existsSync(auditPath), true, resetFunction); - assert.equal(fs.statSync(auditPath).size, 0, resetFunction); - assert.equal(fs.statSync(auditPath).mode & 0o777, 0o666, resetFunction); - fs.writeFileSync(auditPath, "record"); - } -}); - -test("verifier proves audited denials while real E2E gates require zero Claude attempts", () => { - const verifier = fs.readFileSync(verifierPath, "utf8"); - const mockE2e = fs.readFileSync(mockE2ePath, "utf8"); - const liveE2e = fs.readFileSync(liveE2ePath, "utf8"); - - assert.match(verifier, /fetch\("https:\/\/api\.anthropic\.com"/); - assert.match(verifier, /fetch\("https:\/\/claude\.ai"/); - assert.match(verifier, /bridge_assert_claude_guard_denials/); - assert.ok( - [...verifier.matchAll(/bridge_reset_claude_egress_audit/g)].length >= 2, - "deliberate proof must reset the audit before and after its own requests" + 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/ ); - assert.match(mockE2e, /bridge_assert_zero_claude_egress/); - assert.match(liveE2e, /bridge_assert_zero_claude_egress/); +}); + +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').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 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(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 index 6b7bbde5da..529c86365e 100644 --- a/tests/unit/executor-devin-cli-agentic-acp.test.ts +++ b/tests/unit/executor-devin-cli-agentic-acp.test.ts @@ -4,16 +4,19 @@ import fs from "node:fs"; import path from "node:path"; import { writeFileSync } from "node:fs"; -import { - assertLocalAcpUrl, - buildDevinChildEnv, - DevinCliAgenticExecutor, -} from "../../open-sse/executors/devin-cli-agentic.ts"; -import { devin_cli_agenticProvider } from "../../open-sse/config/providers/registry/devin-cli-agentic/index.ts"; -import { getProviderCredentials } from "../../src/sse/services/auth.ts"; +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 }); -process.env.DEVIN_AGENTIC_HOME = path.join(process.cwd(), ".sandbox", "unit-home"); -fs.mkdirSync(process.env.DEVIN_AGENTIC_HOME, { 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();