diff --git a/docker/devin-bridge/mock-devin.mjs b/docker/devin-bridge/mock-devin.mjs
index 7c7b15efd3..5d4b1bcd59 100755
--- a/docker/devin-bridge/mock-devin.mjs
+++ b/docker/devin-bridge/mock-devin.mjs
@@ -2,7 +2,12 @@
import fs from "node:fs";
import readline from "node:readline";
-if (process.argv[2] !== "acp" || process.argv.length !== 3) {
+if (
+ process.argv[2] !== "acp" ||
+ process.argv[3] !== "--agent-type" ||
+ process.argv[4] !== "summarizer" ||
+ process.argv.length !== 5
+) {
process.exit(64);
}
@@ -66,9 +71,27 @@ rl.on("line", (line) => {
send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "unsafe session" } });
return;
}
- send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "offline" } });
+ send({
+ jsonrpc: "2.0",
+ id: message.id,
+ result: { sessionId: "offline" },
+ });
+ } else if (message.method === "session/set_config_option") {
+ send({
+ jsonrpc: "2.0",
+ id: message.id,
+ error: { code: -32602, message: "summarizer mode must not be mutated" },
+ });
} else if (message.method === "session/prompt") {
const prompt = String(message.params?.prompt?.[0]?.text || "");
+ if (!prompt.includes("[Devin Summarizer Bridge]") || !prompt.includes("[Execution Trace]")) {
+ send({
+ jsonrpc: "2.0",
+ id: message.id,
+ error: { code: -32602, message: "summarizer bridge framing required" },
+ });
+ return;
+ }
if (prompt.includes("CONTRACT_AFTER_TOOL")) {
log({ provider: "devin-cli-agentic", scenario: "after-tool" });
send({
diff --git a/docker/devin-bridge/run-claude-live-e2e.sh b/docker/devin-bridge/run-claude-live-e2e.sh
index 27a2c853f5..1faf59a33e 100644
--- a/docker/devin-bridge/run-claude-live-e2e.sh
+++ b/docker/devin-bridge/run-claude-live-e2e.sh
@@ -4,10 +4,15 @@ set -euo pipefail
unset ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_BEDROCK_BASE_URL ANTHROPIC_VERTEX_BASE_URL
unset CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX CLAUDE_CODE_USE_FOUNDRY
+bridge_system_prompt="You are a coding agent inside Claude Code. Use only the client-owned tools supplied in the request. Never execute or request a Devin-owned tool. When work requires a tool, select the appropriate client tool and wait for its result before continuing."
+scenario_cooldown_seconds="${DEVIN_BRIDGE_LIVE_SCENARIO_COOLDOWN_SECONDS:-15}"
+
run_scenario() {
local evidence_file="$1"
local prompt="$2"
claude -p --output-format stream-json --verbose --max-turns 12 \
+ --tools Read,Edit,Bash \
+ --system-prompt "$bridge_system_prompt" \
--permission-mode bypassPermissions "$prompt" | tee "$evidence_file"
if grep -Eqi 'log[ -]?in|authenticate.*anthropic|claude\.ai' "$evidence_file"; then
echo "Claude Code requested forbidden authentication" >&2
@@ -15,17 +20,33 @@ run_scenario() {
fi
}
+validate_scenario() {
+ local evidence_file="$1"
+ local marker="$2"
+ local required_tools="$3"
+ local require_npm_test="$4"
+ local required_slash_command="${5:-}"
+ local required_skill="${6:-}"
+ local accept_explicit_completion="${7:-false}"
+ node /opt/omniroute/scripts/devin-bridge/validate-claude-evidence.mjs \
+ "$evidence_file" "$marker" "$required_tools" "$require_npm_test" \
+ "$required_slash_command" "$required_skill" "$accept_explicit_completion"
+}
+
run_scenario /evidence/live-analysis.jsonl \
- "Read 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
+ "Read /workspace/CLAUDE.md, /workspace/math.js, and /workspace/math.test.js directly without searching or editing. Explain the defect, then end with LIVE_ANALYSIS_COMPLETE."
+validate_scenario /evidence/live-analysis.jsonl LIVE_ANALYSIS_COMPLETE Read false
+sleep "$scenario_cooldown_seconds"
run_scenario /evidence/live-fix.jsonl \
- "Fix the defect in math.js, run npm test, and end with LIVE_FIX_COMPLETE only after the test passes."
+ "Use Edit now to replace 'return a - b;' with 'return a + b;' in /workspace/math.js. Then use Bash to run npm test. Do not summarize before npm test succeeds. End with LIVE_FIX_COMPLETE only after the test passes."
grep -q 'return a + b;' /workspace/math.js
npm test
-grep -q LIVE_FIX_COMPLETE /evidence/live-fix.jsonl
+validate_scenario /evidence/live-fix.jsonl LIVE_FIX_COMPLETE Edit,Bash true
+sleep "$scenario_cooldown_seconds"
run_scenario /evidence/live-command.jsonl "/bridge-check"
-grep -q BRIDGE_E2E_COMPLETE /evidence/live-command.jsonl
+validate_scenario /evidence/live-command.jsonl BRIDGE_E2E_COMPLETE Bash true \
+ bridge-check bridge-proof true
printf 'PASS: three live Devin-backed Claude Code scenarios completed\n'
diff --git a/docs/DEVIN_CLAUDE_BRIDGE.md b/docs/DEVIN_CLAUDE_BRIDGE.md
index 2e8783a50b..4a6d56d370 100644
--- a/docs/DEVIN_CLAUDE_BRIDGE.md
+++ b/docs/DEVIN_CLAUDE_BRIDGE.md
@@ -1,162 +1,159 @@
# Devin Claude Bridge
`devin-cli-agentic` lets the real Claude Code runtime use OmniRoute's local Anthropic
-Messages endpoint while the official Devin CLI supplies model responses over ACP stdio.
-It does not modify the existing Anthropic, Claude OAuth, Claude Web, or `devin-cli`
-providers.
+Messages endpoint while the official Devin CLI supplies model responses over ACP stdio. It
+does not modify the existing Anthropic, Claude OAuth, Claude Web, or `devin-cli` providers.
-> **Current status: offline-complete, live-blocked.** Devin CLI `3000.2.17` does not
-> expose a neutral no-tools inference mode over ACP. Its `summarizer` agent has a fixed
-> summarization role and does not reliably follow the tool-envelope protocol; its default
-> agent attempts to execute tools inside Devin. The adapter rejects those internal tool
-> events with `502`, so it is fail-closed but the three required live scenarios do not pass.
-> Do not use `launch` as a working live bridge until the official CLI provides a neutral
-> generation mode or an equivalent supported API.
+> **Current status: offline and live validated.** The pinned Claude Code `2.1.220` completed
+> three isolated scenarios through Devin CLI `3000.2.17` and model
+> `swe-1-7-lightning`. The final live run proved client-owned `Read`, `Edit`, and `Bash`
+> turns, successful `npm test` results, project command and skill discovery, Devin-only
+> routing, and zero Claude egress.
## Architecture
```text
-Claude Code 2.1.220 (isolated Linux container)
+Claude Code 2.1.220 (isolated non-root Linux container)
-> http://omniroute:20128/v1/messages
-> devin-cli-agentic (Claude-format, no-auth provider)
- -> devin acp (ACP v1 over stdio; internal Devin tool-call events are rejected)
- -> Devin account in the dedicated devin-auth volume (live profile only)
+ -> devin acp --agent-type summarizer (official ACP stdio, no Devin tools)
+ -> Devin account in the dedicated devin-auth volume
```
+The official CLI's default ACP agent can execute its own tools, so this bridge does not use
+it. It starts the fixed `summarizer` ACP agent, whose official CLI mode has no tools, and
+frames the serialized Anthropic request as an execution trace. When another Claude-owned
+action is needed, the response must contain exactly one client tool envelope. Any ACP
+`tool_call` or `tool_call_update` is rejected before a response can be reported as
+successful.
+
The serializer in `open-sse/executors/devin-agentic/serializer.ts` preserves `system`,
`text`, `tool_use`, `tool_result`, `thinking`, `redacted_thinking`, `tool_choice`, and the
-tools supplied by Claude Code. Images and unknown content blocks fail explicitly. Large
-tool results use a visible truncation marker.
+tools supplied by Claude Code. Images and unknown blocks fail explicitly. Large tool results
+use a visible truncation marker.
The parser accepts one standalone `{...}` envelope per model turn. It checks
-the requested name against the request's tool list, validates arguments against that
-tool's JSON Schema, rejects mixed narrative/actions, and permits one bounded repair.
-Claude Code executes the resulting Anthropic `tool_use`; Devin never executes those local
-tools through this adapter.
+the name against the request's tool list, validates arguments against that tool's JSON
+Schema, rejects mixed narrative/actions, and permits one bounded repair. Claude Code then
+executes the resulting Anthropic `tool_use` locally and sends the `tool_result` back through
+OmniRoute.
-The executor intentionally starts the default `devin acp` agent with every ACP client
-capability disabled. Any `tool_call` or `tool_call_update` emitted by Devin aborts the turn
-before OmniRoute can report success. This guard is required because allowing the default
-agent to execute tools would make Devin, rather than Claude Code, the agentic runtime.
+## Isolation and threat model
-## Threat model and isolation
+The host's Claude installation, account, and configuration are out of scope and treated as
+forbidden. The Compose services:
-The bridge assumes the host contains an unrelated personal Claude installation and treats
-all host Claude configuration and credentials as forbidden. The Compose services:
+- run as UID/GID `10001:10001`, with a read-only root filesystem, dropped capabilities, and
+ `no-new-privileges`;
+- use a private `/home/bridge`, a dedicated Claude config volume, isolated OmniRoute data,
+ and a separate `devin-auth` volume;
+- mount only disposable `.sandbox` workspaces/evidence;
+- do not mount the host home, Keychain, SSH, cloud credentials, or Docker socket;
+- construct explicit environments and remove Anthropic API/OAuth/routing variables;
+- direct Claude Code inference only to `http://omniroute:20128` with a local-only key.
-- run as UID/GID `10001:10001`, with a read-only root filesystem, all capabilities dropped,
- and `no-new-privileges`;
-- use `/home/bridge`, a dedicated named Claude config volume, separate OmniRoute data
- volumes, and a separate `devin-auth` volume;
-- mount only disposable `.sandbox` workspaces/evidence and the bridge test harness;
-- do not mount the host home, SSH files, cloud credentials, Keychain, or Docker socket;
-- provide an explicit environment and remove Anthropic OAuth/API/routing variables before
- Claude Code and the Devin subprocess run;
-- direct Claude Code inference only to `http://omniroute:20128` using a local OmniRoute key.
+The offline profile uses an internal network. In the live profile, OmniRoute reaches the
+official Devin endpoints only through `network-guard`; unrelated destinations are denied.
+Claude Code has a separate deny-all egress guard and can reach only the local OmniRoute
+service through `NO_PROXY`. Guard audit files are mounted only by their guard process. The
+scripts verify file ownership, mode, link count, and every decision before exporting
+token-free evidence.
-The `offline` network is internal, so no runtime container can reach the Internet. The
-`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 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:
+Run the isolation proof independently:
```bash
./scripts/devin-bridge/verify-anthropic-isolation
```
-It validates Compose topology, named config mounts, non-root/read-only settings, explicit
-local routing, absence of sensitive environment variables, absence of the Docker socket,
-and failed TCP access to `api.anthropic.com` and `claude.ai`. The wire contract separately
-stops the mock ACP process and confirms an explicit error with no fallback.
+It validates topology, named mounts, non-root/read-only settings, explicit local routing,
+absence of sensitive environment variables, absence of the Docker socket, blocked access to
+`api.anthropic.com` and `claude.ai`, Devin-only provider selection, and explicit failure when
+the ACP backend is unavailable.
-## Reproducible offline validation
+## First-time setup and normal use
-The normal automated path does not need a Devin account and has no runtime Internet:
+Build the pinned image:
```bash
./scripts/devin-bridge/build
+```
+
+Authenticate only the isolated Devin volume:
+
+```bash
+ENABLE_LIVE_DEVIN_TESTS=1 ./scripts/devin-bridge/login-devin
+```
+
+The login command uses the official manual-token flow intended for remote/container
+environments. The value is entered directly into the CLI prompt; it is not passed as a
+process argument, written to Git, or copied from the host.
+
+Launch the isolated Claude Code runtime:
+
+```bash
+./scripts/devin-bridge/launch
+```
+
+`launch` rechecks isolation, Devin authentication, and model discovery before starting the
+containerized Claude Code. It never runs the host's Claude executable. Model aliases can be
+set in `.env.devin-bridge`; every configured value must keep the
+`devin-cli-agentic/` prefix.
+
+## Validation commands
+
+The reproducible offline path requires no Devin account and has no runtime Internet:
+
+```bash
./scripts/devin-bridge/test-unit
./scripts/devin-bridge/test-contract
./scripts/devin-bridge/test-e2e-mock
./scripts/devin-bridge/verify-anthropic-isolation
```
-`test-e2e-mock` copies `tests/fixtures/devin-bridge/e2e-workspace` into `.sandbox`, then
-runs the pinned Claude Code binary. The fixture contains `CLAUDE.md`, a project skill, a
-slash command, hooks, source, and tests. The deterministic ACP mock asks Claude Code to
-locate, read, edit, test, observe a failure, repair, retest, and finish. Evidence stays
-unversioned in `.sandbox/evidence`.
-
-## Devin login and live use
-
-The commands below are retained for reproducing the live compatibility check. They are not
-a claim that the bridge is live-ready; the current pinned CLI fails closed for the reason
-documented above.
-
-Authentication uses only the official CLI inside the dedicated volume. It never imports a
-host session:
-
-```bash
-ENABLE_LIVE_DEVIN_TESTS=1 ./scripts/devin-bridge/login-devin
-```
-
-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:
+The authenticated opt-in live path is:
```bash
ENABLE_LIVE_DEVIN_TESTS=1 ./scripts/devin-bridge/test-live-devin
```
-For interactive use, `launch` repeats isolation, auth, and model discovery checks before
-starting the containerized Claude Code runtime:
+The live runner waits between scenarios to avoid opening ACP sessions in a burst and
+validates structured Claude stream events instead of trusting textual claims. Its three
+scenarios prove:
-```bash
-./scripts/devin-bridge/launch
-```
+1. direct project reads and defect analysis;
+2. a real `Edit`, a client-owned `Bash` `npm test`, and a terminal result;
+3. `/bridge-check` plus `bridge-proof` discovery, project reads, another successful
+ client-owned `npm test`, and completion without pending work.
-Optional model aliases live in `.env.devin-bridge.example`. `DEVIN_BRIDGE_MODEL` controls
-the main model; `DEVIN_BRIDGE_SONNET_MODEL`, `DEVIN_BRIDGE_OPUS_MODEL`,
-`DEVIN_BRIDGE_HAIKU_MODEL`, and `DEVIN_BRIDGE_SUBAGENT_MODEL` allow explicit mapping. Every
-value must retain the `devin-cli-agentic/` prefix. The live test uses a model returned by
-the current Devin account instead of trusting the example value.
+The final gate also checks the Devin network audit and requires the Claude egress audit to
+remain empty.
## Updating pinned tools
-The image pins Node, Claude Code, and Devin CLI in `docker/devin-bridge/Dockerfile` and
-`docker/devin-bridge/compose.yml`. To update:
+The image pins Node, Claude Code, and Devin CLI in
+`docker/devin-bridge/Dockerfile`. To update:
-1. change the explicit version arguments;
-2. update both architecture-specific Devin archive checksums from the official artifact;
-3. run the complete offline command set above;
-4. confirm the artifact versions inside the rebuilt image;
-5. run the live suite only after offline proof remains green.
+1. change the explicit versions;
+2. replace both architecture-specific Devin archive checksums with values for the official
+ artifact;
+3. rebuild and run every offline validation command;
+4. confirm the versions inside the image;
+5. rerun the authenticated three-scenario live suite.
-Do not replace the checksum with an unverified download or install either CLI globally on
-the host.
+Do not install either CLI globally on the host or replace checksum verification with an
+unverified download.
## Diagnosis and cleanup
- `docker compose -f docker/devin-bridge/compose.yml --profile offline logs omniroute`
- shows local routing and sanitized executor failures.
-- `.sandbox/evidence/mock-acp.jsonl` records deterministic offline provider actions.
+ shows local routing and sanitized executor errors.
+- `.sandbox/evidence/mock-acp.jsonl` records deterministic mock ACP actions.
- `.sandbox/evidence/claude-stream.jsonl` records the real Claude Code offline run.
-- `.sandbox/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.
+- `.sandbox/evidence/live-*.jsonl` records the three validated live streams.
+- `.sandbox/evidence/egress.jsonl` and `.sandbox/evidence/claude-egress.jsonl` are validated,
+ token-free copies of the guard audits.
-Stop containers and networks while preserving login/config volumes:
+Stop owned containers and networks while preserving login/config volumes:
```bash
./scripts/devin-bridge/clean
@@ -168,24 +165,17 @@ Remove the complete bridge-owned environment, including named volumes:
./scripts/devin-bridge/clean --all
```
-`.sandbox` can then be deleted independently; it contains only disposable fixtures,
-isolated databases, and evidence.
-
## Limits
-- **Blocking limitation:** official Devin CLI `3000.2.17` offers `summarizer` (no tools,
- fixed summarization behavior), `review` (read-only and shell tools), or the default agent.
- There is no documented neutral text-generation agent that both follows the envelope and
- structurally cannot execute tools. The default agent emitted internal tool calls in the
- authorized live test, and OmniRoute rejected them with `502`.
-- ACP context is reconstructed from each Anthropic request; there is no persistent process
- or session affinity.
-- One tool call is supported per model response; parallel tool calls are rejected.
+- The bridge relies on the fixed no-tools `summarizer` role because Devin CLI `3000.2.17`
+ does not expose a neutral no-tools ACP agent. The adapter compensates for summary-shaped
+ intermediate responses, but one bounded repair can still fail explicitly.
+- Live ACP calls can return transient `502`/`504` responses. The harness spaces scenarios;
+ persistent failure remains fail-closed and never selects another provider.
+- ACP context is reconstructed from each Anthropic request; there is no process/session
+ affinity.
+- One tool call is supported per model response; parallel calls are rejected.
- Images are explicitly unsupported. Vision, thinking output, effort controls, and a 1M
- context window are not advertised as bridge capabilities.
-- SSE has valid Anthropic lifecycle events but is rendered after the bounded ACP response is
- collected; ACP chunks are not forwarded incrementally to the client.
-- The strict parser depends on the Devin model following the documented tool envelope. One
- repair is attempted before the request fails.
-- Offline proof is deterministic. Live readiness requires a successful official Devin login
- and all three live scenarios; it must not be inferred from offline results.
+ context window are not advertised.
+- SSE uses valid Anthropic lifecycle events but is emitted after the bounded ACP turn is
+ collected; ACP chunks are not forwarded incrementally.
diff --git a/docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md b/docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md
index c15eb911c0..9bc402220b 100644
--- a/docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md
+++ b/docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md
@@ -1,6 +1,6 @@
# Devin Claude Bridge Progress
-Updated: 2026-07-27
+Updated: 2026-07-28
## Baseline
@@ -11,64 +11,105 @@ Updated: 2026-07-27
- Existing `devin-cli` remains unchanged; the new path is the separate
`devin-cli-agentic` provider.
-## Proved offline
+## Implemented architecture
-- Focused unit and ACP suite: 27 tests passed.
-- Anthropic wire suite: non-streaming JSON, SSE event order, `tool_use`, direct
- `tool_result` continuation, ACP error, and early process exit passed.
-- Final container build completed with the pinned CLIs and the production OmniRoute build.
-- Artifact inspection confirmed Node `26.0.0`, Claude Code `2.1.220`, and Devin CLI
- `3000.2.17` while the container had no network.
-- Runtime isolation verifier passed in static and container checks.
-- Real Claude Code offline E2E passed in 9 turns. It loaded `CLAUDE.md`, discovered the
- project skill and slash command, fired hooks, requested `Skill`, `Bash`, `Read`, and
- `Edit`, observed a failing test, corrected the edit, reran the test successfully, and
- returned the fixture's final success marker.
-- Every offline inference action recorded by the ACP fixture names only
- `devin-cli-agentic`; provider termination returned an explicit error.
+- Claude Code runs only inside the non-root bridge container with its own empty config
+ volume and local OmniRoute base URL.
+- `devin-cli-agentic` preserves Anthropic messages, tool schemas, `tool_use`, and
+ `tool_result`, then calls the official Devin CLI over ACP stdio.
+- The executor starts `devin acp --agent-type summarizer`. This is the only fixed official
+ ACP role in the pinned CLI that has no Devin-owned tools.
+- The request is framed as an execution trace. Devin can return one strict client tool
+ envelope; Claude Code executes that tool locally.
+- Internal ACP `tool_call` events, unsupported blocks, invalid schemas, narrative actions,
+ timeouts, cancellation, and process failure all fail closed.
+- Provider and network policy prevent combo/auto/Anthropic fallback.
-Evidence is generated under `.sandbox/evidence` and is intentionally ignored by Git.
+## Offline proof
+
+- Focused serializer, parser, executor, ACP lifecycle, wire-format, environment, and audit
+ tests pass (39/39).
+- The contract suite covers Anthropic JSON/SSE, `tool_use`, `tool_result` continuation,
+ fragmented ACP frames, stderr, early exit, timeout, cancellation, and fail-closed provider
+ loss.
+- The production bridge image builds with the pinned CLIs.
+- Real Claude Code offline E2E loads `CLAUDE.md`, the project skill and slash command, fires
+ hooks, executes local tools over multiple turns, observes a failed test, repairs the file,
+ reruns the test, and completes.
+- The isolation verifier proves non-root/read-only execution, isolated mounts and config,
+ blocked Anthropic/Claude access, no host credential mounts, local-only inference, and no
+ fallback.
+
+Evidence is generated under `.sandbox/evidence` and ignored by Git.
## Regression status
-- Focused ESLint and `typecheck:core` passed; the final executor changes were rechecked with
- `typecheck:core` and the 27-test bridge suite.
-- The complete `npm run check` reached the unit suite after lint, but the repository test
- runner did not terminate after `quota-redis-store.test.ts`: an `ioredis` client kept
- reconnecting to an unavailable local Redis endpoint. The runner was interrupted after
- repeated `ECONNREFUSED` events, so this command is not reported as passed.
-- Documentation accuracy checks passed before this final status update and are rerun after it.
-- The fresh image rebuild and dedicated Devin volume ownership retry passed.
+- `typecheck:core`, focused ESLint, Prettier, shell/Node syntax, and the complete documentation
+ accuracy suite pass.
+- The broad `npm run check` is not reported as passed: after its lint phase, the repository
+ test runner remained alive while an existing `ioredis` client repeatedly retried an
+ unavailable local Redis endpoint after `quota-redis-store.test.ts`. The bridge-focused
+ suites, production image build, offline E2E, isolation proof, and live gate do not use that
+ Redis service and all pass.
-## Live Devin
+## Live Devin proof
-Not passed. The official Devin login succeeded inside the dedicated volume, and model
-discovery selected `swe-1-7-lightning`. Authorized live runs established the following:
+Passed with the official in-container login and discovered model
+`swe-1-7-lightning`. The terminal live run completed all three scenarios:
-1. `devin acp --agent-type summarizer` is not a neutral inference backend. The CLI's own
- help identifies it as a no-tools summarizer, and its injected role caused future-action
- narration, summaries, and malformed tool envelopes instead of a reliable Claude Code
- tool loop.
-2. The default `devin acp` agent can emit the required XML tool envelope in a minimal probe,
- but the full Claude Code request caused it to emit ACP `tool_call` events and attempt to
- own tool execution.
-3. The adapter now rejects `tool_call` and `tool_call_update` with
- `devin_internal_tool_execution` (`502`). Two live requests were observed failing closed;
- a third in-flight request was aborted when the test was stopped to avoid automatic paid
- retries.
-4. No three-scenario live pass exists. The live test must remain red until the official CLI
- exposes a neutral no-tools generation mode (without the summarizer role) or another
- supported Devin API provides equivalent raw model inference.
+1. Claude Code loaded the fixture instructions, issued client-owned `Read` calls, and
+ returned a correct defect analysis.
+2. Claude Code issued a real `Edit` changing subtraction to addition, then a client-owned
+ `Bash` call running `npm test`; the test reported one pass and zero failures.
+3. Claude Code initialization listed `bridge-check` and `bridge-proof`, read the corrected
+ source and test, executed another client-owned `npm test`, and completed successfully.
-The offline contract still proves `narrative -> single repair -> tool_use`, and a second
-narrative now fails closed instead of being accepted as a successful final response.
+The live evidence validator parses stream JSON and requires successful tool results. It does
+not accept a textual claim that a tool ran. It also rejects terminal summaries that report a
+blocker, incomplete work, or required next steps.
+
+The final live gate reported:
+
+```text
+PASS: validated Claude evidence for LIVE_ANALYSIS_COMPLETE
+PASS: validated Claude evidence for LIVE_FIX_COMPLETE
+PASS: validated Claude evidence for BRIDGE_E2E_COMPLETE
+PASS: three live Devin-backed Claude Code scenarios completed
+PASS: live model swe-1-7-lightning was discovered and validated by three scenarios
+```
+
+The same gate validated the network audit: only the Devin guard path was used, no internal
+Devin tool event was accepted, and the Claude egress audit remained empty.
+
+## Investigation conclusion
+
+The initial default-agent hypothesis failed because ACP permission modes do not turn the
+default Devin agent into a raw inference backend. Even `ask` mode can emit Devin-owned
+`tool_call` events. A discovered `allowed-tools: []` agent configuration was not consumed by
+`devin acp` in CLI `3000.2.17`.
+
+The working adaptation uses the official `summarizer` agent because it is structurally
+no-tools. Its fixed summarization behavior can produce intermediate prose, so the bridge
+frames requests as execution traces, detects future-action narration, performs at most one
+strict repair, and otherwise fails. Live validation also exposed transient ACP timeouts;
+the harness now spaces independent scenarios rather than weakening routing or retrying into
+another provider.
## Safety record
No host Claude executable, configuration, login, OAuth token, Keychain, or Anthropic API was
-used. The first pre-isolation unit attempt initialized the repository's default OmniRoute
-database at `/Users/lucasisrael/.omniroute/storage.sqlite`; it was not rolled back or touched
-again. All subsequent bridge commands set isolated database paths under `.sandbox`.
+used. The dedicated Docker volumes remain role-separated. No credential value is written to
+the repository or evidence output.
-The final live attempts used only the dedicated Docker volumes. Claude Code ran only inside
-the non-root container; no host Claude configuration or credential path was mounted or read.
+During the early baseline, a focused test without isolated `DATA_DIR` initialized the
+repository's normal OmniRoute database at `/Users/lucasisrael/.omniroute/storage.sqlite`.
+It was not rolled back or touched again. Every bridge command now pins database and temporary
+paths under the worktree's `.sandbox` directory.
+
+## Remaining limits
+
+- The no-tools backend has a summarizer system role rather than a neutral generation role.
+- One client tool call per response is supported; parallel tool calls are rejected.
+- ACP processes are per-turn and stateless.
+- Live Devin availability can still produce explicit `502`/`504` failures.
+- Images and unadvertised vision/effort/large-context capabilities remain unsupported.
diff --git a/docs/superpowers/specs/2026-07-27-devin-claude-bridge-design.md b/docs/superpowers/specs/2026-07-27-devin-claude-bridge-design.md
index 9baa70ab88..2d19519b9f 100644
--- a/docs/superpowers/specs/2026-07-27-devin-claude-bridge-design.md
+++ b/docs/superpowers/specs/2026-07-27-devin-claude-bridge-design.md
@@ -33,7 +33,10 @@ The safest implementation is a new provider id, `devin-cli-agentic`, with a sepa
Claude Code sends Anthropic Messages requests to local OmniRoute. OmniRoute resolves model ids prefixed with `devin-cli-agentic/` to a new Claude-format provider. The new executor translates the complete Anthropic request into an explicit text prompt for Devin ACP, including system text, structured message history, tool schemas, and prior tool results.
-Devin remains a model backend. It must request tool execution by emitting a strict XML-wrapped JSON block:
+Devin remains a model backend. The executor starts the official fixed no-tools summarizer
+role with `devin acp --agent-type summarizer` and frames the serialized request as an
+execution trace. Devin must request client-owned tool execution by emitting a strict
+XML-wrapped JSON block:
```xml
@@ -74,7 +77,7 @@ The serializer preserves request order, `system`, `tool_choice`, exact tool sche
## Required Proof
-The offline profile must prove the ACP lifecycle, fragmented frames, stderr, early exit, hang/cancel, Anthropic JSON/SSE order, no fallback, and a real pinned Claude Code run that reads, edits, runs tests, observes `CLAUDE.md`, loads a skill and command, fires a hook, and completes at least one `tool_use -> tool_result -> continuation` loop. The isolation verifier checks env, mounts, UID, config paths, DNS/connection logs, local inference destination, selected provider, and fail-closed behavior. Live Devin remains unproved until official in-container login and three isolated agentic scenarios succeed.
+The offline profile must prove the ACP lifecycle, fragmented frames, stderr, early exit, hang/cancel, Anthropic JSON/SSE order, no fallback, and a real pinned Claude Code run that reads, edits, runs tests, observes `CLAUDE.md`, loads a skill and command, fires a hook, and completes at least one `tool_use -> tool_result -> continuation` loop. The isolation verifier checks env, mounts, UID, config paths, DNS/connection logs, local inference destination, selected provider, and fail-closed behavior. Live Devin is proved only by official in-container login and three isolated agentic scenarios.
## Safety Incident During Baseline
@@ -113,3 +116,19 @@ status gate, and catalog normalization. The live gate then runs three real Claud
through the authenticated in-container Devin CLI and requires local Read/Edit/Bash activity,
passing fixture tests, Devin-only routing, no allowed non-Devin egress, and an explicit error
when the Devin backend is stopped.
+
+## Final Live Result
+
+The default-agent design was rejected after live evidence showed that `ask` mode can still
+emit Devin-owned ACP tool calls. The pinned CLI does not apply its top-level agent
+configuration to `devin acp`, so an `allowed-tools: []` configuration could not create a
+neutral backend. The fixed summarizer role is the only official ACP role in this version that
+is structurally no-tools.
+
+The execution-trace adaptation passed the authenticated live gate with
+`swe-1-7-lightning`. Three Claude Code processes completed analysis, edit/test, and local
+command/skill scenarios. Structured evidence proved that Claude Code issued `Read`, `Edit`,
+and `Bash` tool calls; two client-owned `npm test` calls succeeded. The guard audit proved
+Devin-only outbound access and zero Claude egress. Intermediate summary-shaped responses and
+transient ACP timeouts remain explicit failure modes; the adapter performs one bounded repair
+and the harness spaces scenarios to avoid bursty session creation.
diff --git a/open-sse/executors/devin-cli-agentic.ts b/open-sse/executors/devin-cli-agentic.ts
index 858326034a..9180a02c61 100644
--- a/open-sse/executors/devin-cli-agentic.ts
+++ b/open-sse/executors/devin-cli-agentic.ts
@@ -38,9 +38,37 @@ const REPAIRABLE_TOOL_ERRORS = new Set([
]);
function describesUnexecutedToolIntent(text: string): boolean {
- return /\b(?:i(?:'ll| will)|let me)\b[^\n.!?]{0,160}\b(?:read|inspect|examine|edit|fix|run|check|test|start)\b/i.test(
- text
+ const action = "(?:read|inspect|examine|edit|fix|run|check|test|start)";
+ const futureAction = new RegExp(
+ `\\b(?:(?:next(?: immediate)?|immediate next)\\s+(?:task|step)|planned actions?)\\b[\\s\\S]{0,320}\\b${action}\\b`,
+ "i"
);
+ return (
+ futureAction.test(text) ||
+ new RegExp(`\\b(?:i(?:'ll| will)|let me)\\b[^\\n.!?]{0,160}\\b${action}\\b`, "i").test(text) ||
+ new RegExp(`\\bnext steps?\\s*:\\s*${action}\\b`, "i").test(text) ||
+ new RegExp(`\\bnext immediate (?:task|step)\\s*:\\s*${action}\\b`, "i").test(text) ||
+ new RegExp(`\\bplanned actions?\\s*:\\s*${action}\\b`, "i").test(text) ||
+ new RegExp(`\\b(?:still|now)\\s+(?:need|needs|required)\\s+to\\s+${action}\\b`, "i").test(
+ text
+ ) ||
+ /\btests?\s+(?:have|has|were|was)?\s*not\s+(?:yet\s+)?(?:been\s+)?run\b/i.test(text)
+ );
+}
+
+function framePromptForNoToolsSummarizer(promptText: string): string {
+ return [
+ "[Devin Summarizer Bridge]",
+ "Treat the content below as an execution trace whose next assistant output must be determined.",
+ "If another client-owned action is required, return exactly one JSON envelope using the catalog in the trace and no prose.",
+ "The client will execute that tool; never execute or claim to execute a tool inside Devin.",
+ "The client workspace is /workspace; /home/bridge is only the isolated Devin process home.",
+ "If the task is complete, return only a concise final answer.",
+ "Do not wrap the response in Markdown fences or a element.",
+ "",
+ "[Execution Trace]",
+ promptText,
+ ].join("\n");
}
const CLAUDE_ENV_BLOCKLIST = [
@@ -149,7 +177,7 @@ export async function runAcpTurn(args: {
log?: ExecuteInput["log"];
}) {
const timeoutMs = Number(process.env.DEVIN_AGENTIC_ACP_TIMEOUT_MS || 120000);
- const child = spawn(args.devinBin, ["acp"], {
+ const child = spawn(args.devinBin, ["acp", "--agent-type", "summarizer"], {
env: args.env,
cwd: args.env.HOME,
stdio: ["pipe", "pipe", "pipe"],
@@ -277,7 +305,8 @@ export async function runAcpTurn(args: {
}
if (phase === "session" && msg.id === sessionRequestId && msg.result !== undefined) {
- sessionId = String(asRecord(msg.result).sessionId || "");
+ const sessionResult = asRecord(msg.result);
+ sessionId = String(sessionResult.sessionId || "");
if (!sessionId) {
finish(
new DevinAgenticBridgeError(
@@ -288,10 +317,11 @@ export async function runAcpTurn(args: {
);
return;
}
+
phase = "prompt";
promptRequestId = send("session/prompt", {
sessionId,
- prompt: [{ type: "text", text: args.promptText }],
+ prompt: [{ type: "text", text: framePromptForNoToolsSummarizer(args.promptText) }],
});
continue;
}
diff --git a/scripts/devin-bridge/test-unit b/scripts/devin-bridge/test-unit
index c5ed3457cf..c6f3a4518c 100755
--- a/scripts/devin-bridge/test-unit
+++ b/scripts/devin-bridge/test-unit
@@ -5,4 +5,5 @@ cd "$BRIDGE_ROOT"
bridge_test_env node --import tsx/esm --test \
tests/unit/executor-devin-cli-agentic-core.test.ts \
tests/unit/executor-devin-cli-agentic-acp.test.ts \
- tests/unit/devin-bridge-network-guard.test.ts
+ tests/unit/devin-bridge-network-guard.test.ts \
+ tests/unit/devin-bridge-live-runtime.test.ts
diff --git a/scripts/devin-bridge/validate-claude-evidence.mjs b/scripts/devin-bridge/validate-claude-evidence.mjs
new file mode 100644
index 0000000000..6a7c5fcf15
--- /dev/null
+++ b/scripts/devin-bridge/validate-claude-evidence.mjs
@@ -0,0 +1,125 @@
+#!/usr/bin/env node
+import fs from "node:fs";
+import { pathToFileURL } from "node:url";
+
+function contentBlocks(message) {
+ return Array.isArray(message?.message?.content) ? message.message.content : [];
+}
+
+export function validateClaudeEvidenceText(text, options) {
+ const marker = String(options?.marker || "").trim();
+ const requiredTools = Array.isArray(options?.requiredTools) ? options.requiredTools : [];
+ if (!marker) throw new Error("A final marker is required");
+
+ const toolUses = new Map();
+ const successfulResults = new Set();
+ const slashCommands = new Set();
+ const skills = new Set();
+ let finalResult = null;
+
+ for (const [index, rawLine] of String(text).split(/\r?\n/).entries()) {
+ const line = rawLine.trim();
+ if (!line) continue;
+ let event;
+ try {
+ event = JSON.parse(line);
+ } catch {
+ throw new Error(`Invalid Claude evidence JSON at line ${index + 1}`);
+ }
+
+ if (event?.type === "system" && event?.subtype === "init") {
+ for (const command of Array.isArray(event.slash_commands) ? event.slash_commands : []) {
+ slashCommands.add(String(command));
+ }
+ for (const skill of Array.isArray(event.skills) ? event.skills : []) {
+ skills.add(String(skill));
+ }
+ }
+
+ for (const block of contentBlocks(event)) {
+ if (block?.type === "tool_use" && typeof block.id === "string") {
+ toolUses.set(block.id, { name: String(block.name || ""), input: block.input || {} });
+ }
+ if (
+ block?.type === "tool_result" &&
+ typeof block.tool_use_id === "string" &&
+ block.is_error !== true
+ ) {
+ successfulResults.add(block.tool_use_id);
+ }
+ }
+
+ if (event?.type === "result") finalResult = event;
+ }
+
+ if (!finalResult || finalResult.subtype !== "success" || finalResult.is_error === true) {
+ throw new Error("Claude evidence has no successful terminal result");
+ }
+ const resultText = String(finalResult.result || "");
+ const incompleteResult = [
+ /(?:^|\n)\s*(?:\*\*)?blocker(?:\*\*)?\s*:/im,
+ /\btask (?:is|remains) (?:not complete|incomplete)\b/i,
+ /(?:^|\n)\s*(?:[-*]\s*)?(?:\*\*)?next steps? needed(?:\*\*)?\s*:/im,
+ ].some((pattern) => pattern.test(resultText));
+ if (incompleteResult) {
+ throw new Error("Claude terminal result explicitly reports incomplete work");
+ }
+ if (options?.requiredSlashCommand && !slashCommands.has(options.requiredSlashCommand)) {
+ throw new Error(`Claude did not load required slash command: ${options.requiredSlashCommand}`);
+ }
+ if (options?.requiredSkill && !skills.has(options.requiredSkill)) {
+ throw new Error(`Claude did not load required skill: ${options.requiredSkill}`);
+ }
+ const markerIsStandalone = resultText.split(/\r?\n/).some((line) => line.trim() === marker);
+
+ for (const requiredTool of requiredTools) {
+ if (![...toolUses.values()].some((tool) => tool.name === requiredTool)) {
+ throw new Error(`Claude did not request required client-owned tool: ${requiredTool}`);
+ }
+ }
+
+ const npmTestSucceeded = [...toolUses.entries()].some(
+ ([id, tool]) =>
+ tool.name === "Bash" &&
+ /\bnpm\s+test\b/.test(String(tool.input?.command || "")) &&
+ successfulResults.has(id)
+ );
+ if (options?.requireSuccessfulNpmTest) {
+ if (!npmTestSucceeded) throw new Error("Claude evidence has no successful npm test tool turn");
+ }
+ const markerIsCorroborated =
+ options?.requireSuccessfulNpmTest && npmTestSucceeded && resultText.includes(marker);
+ const explicitCompletionIsCorroborated =
+ options?.acceptExplicitCompletion === true &&
+ npmTestSucceeded &&
+ /\btask is complete\b/i.test(resultText);
+ if (!markerIsStandalone && !markerIsCorroborated && !explicitCompletionIsCorroborated) {
+ throw new Error(`Claude result has no standalone marker: ${marker}`);
+ }
+}
+
+if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
+ const [
+ evidencePath,
+ marker,
+ requiredTools = "",
+ requireNpmTest = "false",
+ requiredSlashCommand = "",
+ requiredSkill = "",
+ acceptExplicitCompletion = "false",
+ ] = process.argv.slice(2);
+ if (!evidencePath)
+ throw new Error("Usage: validate-claude-evidence.mjs FILE MARKER [TOOLS] [NPM_TEST]");
+ validateClaudeEvidenceText(fs.readFileSync(evidencePath, "utf8"), {
+ marker,
+ requiredTools: requiredTools
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean),
+ requireSuccessfulNpmTest: requireNpmTest === "true",
+ requiredSlashCommand: requiredSlashCommand || undefined,
+ requiredSkill: requiredSkill || undefined,
+ acceptExplicitCompletion: acceptExplicitCompletion === "true",
+ });
+ process.stdout.write(`PASS: validated Claude evidence for ${marker}\n`);
+}
diff --git a/tests/fixtures/devin-bridge/e2e-workspace/.claude/commands/bridge-check.md b/tests/fixtures/devin-bridge/e2e-workspace/.claude/commands/bridge-check.md
index d474a5642e..3cc47d2db4 100644
--- a/tests/fixtures/devin-bridge/e2e-workspace/.claude/commands/bridge-check.md
+++ b/tests/fixtures/devin-bridge/e2e-workspace/.claude/commands/bridge-check.md
@@ -5,5 +5,6 @@ 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.
+Use the bridge-proof skill. Locate and read the implementation. Correct it if needed, run its test,
+and diagnose and fix any real failure. If it is already correct, do not introduce a regression.
+End with `BRIDGE_E2E_COMPLETE` only after `npm test` passes.
diff --git a/tests/fixtures/devin-bridge/e2e-workspace/CLAUDE.md b/tests/fixtures/devin-bridge/e2e-workspace/CLAUDE.md
index 306607468f..b9f4fab561 100644
--- a/tests/fixtures/devin-bridge/e2e-workspace/CLAUDE.md
+++ b/tests/fixtures/devin-bridge/e2e-workspace/CLAUDE.md
@@ -3,5 +3,5 @@
`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.
+`math.js`, and run the tests. If a test fails, diagnose and correct it. Never introduce a regression
+to manufacture a failure. Finish only after the tests pass.
diff --git a/tests/unit/devin-bridge-live-runtime.test.ts b/tests/unit/devin-bridge-live-runtime.test.ts
index 91eb6cb1f9..9aeede06b9 100644
--- a/tests/unit/devin-bridge-live-runtime.test.ts
+++ b/tests/unit/devin-bridge-live-runtime.test.ts
@@ -14,6 +14,7 @@ import {
validateZeroClaudeEgress,
} from "../../scripts/devin-bridge/runtime-policy.mjs";
import { selectLiveModel } from "../../scripts/devin-bridge/select-live-model.mjs";
+import { validateClaudeEvidenceText } from "../../scripts/devin-bridge/validate-claude-evidence.mjs";
const root = process.cwd();
const composePath = path.join(root, "docker", "devin-bridge", "compose.yml");
@@ -22,6 +23,17 @@ const mockE2ePath = path.join(root, "scripts", "devin-bridge", "test-e2e-mock");
const launchPath = path.join(root, "scripts", "devin-bridge", "launch");
const loginPath = path.join(root, "scripts", "devin-bridge", "login-devin");
const liveE2ePath = path.join(root, "scripts", "devin-bridge", "test-live-devin");
+const liveRunnerPath = path.join(root, "docker", "devin-bridge", "run-claude-live-e2e.sh");
+const bridgeCommandPath = path.join(
+ root,
+ "tests",
+ "fixtures",
+ "devin-bridge",
+ "e2e-workspace",
+ ".claude",
+ "commands",
+ "bridge-check.md"
+);
const verifierPath = path.join(root, "scripts", "devin-bridge", "verify-anthropic-isolation");
interface ComposeService {
@@ -151,6 +163,144 @@ test("model discovery accepts only explicit uid/id fields and detects catalog am
);
});
+test("Claude evidence requires a standalone marker and a successful client-owned npm test", () => {
+ const toolId = "tool-npm-test";
+ const events = [
+ {
+ type: "assistant",
+ message: {
+ content: [{ type: "tool_use", id: toolId, name: "Bash", input: { command: "npm test" } }],
+ },
+ },
+ {
+ type: "user",
+ message: {
+ content: [
+ { type: "tool_result", tool_use_id: toolId, is_error: false, content: "1 passed" },
+ ],
+ },
+ },
+ { type: "result", subtype: "success", result: "Fixed and tested.\nLIVE_FIX_COMPLETE" },
+ ];
+ const text = events.map((event) => JSON.stringify(event)).join("\n");
+
+ assert.doesNotThrow(() =>
+ validateClaudeEvidenceText(text, {
+ marker: "LIVE_FIX_COMPLETE",
+ requiredTools: ["Bash"],
+ requireSuccessfulNpmTest: true,
+ })
+ );
+ assert.doesNotThrow(() =>
+ validateClaudeEvidenceText(
+ `${events
+ .slice(0, 2)
+ .map((event) => JSON.stringify(event))
+ .join("\n")}\n${JSON.stringify({
+ type: "result",
+ subtype: "success",
+ result: "Task completed; the requested marker is LIVE_FIX_COMPLETE.",
+ })}`,
+ {
+ marker: "LIVE_FIX_COMPLETE",
+ requiredTools: ["Bash"],
+ requireSuccessfulNpmTest: true,
+ }
+ )
+ );
+ assert.throws(
+ () =>
+ validateClaudeEvidenceText(
+ `${events
+ .slice(0, 2)
+ .map((event) => JSON.stringify(event))
+ .join("\n")}\n${JSON.stringify({
+ type: "result",
+ subtype: "success",
+ result: "Task completion condition: end with LIVE_FIX_COMPLETE after tests.",
+ })}`,
+ { marker: "LIVE_FIX_COMPLETE" }
+ ),
+ /standalone marker/
+ );
+ assert.throws(
+ () =>
+ validateClaudeEvidenceText(
+ [events[0], events[2]].map((event) => JSON.stringify(event)).join("\n"),
+ {
+ marker: "LIVE_FIX_COMPLETE",
+ requiredTools: ["Bash"],
+ requireSuccessfulNpmTest: true,
+ }
+ ),
+ /successful npm test/
+ );
+ assert.throws(
+ () =>
+ validateClaudeEvidenceText(
+ `${events
+ .slice(0, 2)
+ .map((event) => JSON.stringify(event))
+ .join("\n")}\n${JSON.stringify({
+ type: "result",
+ subtype: "success",
+ result:
+ "Task ran npm test.\n\nNext steps needed:\n- finish the work\n\n**Blocker**: work is incomplete. BRIDGE_E2E_COMPLETE",
+ })}`,
+ {
+ marker: "BRIDGE_E2E_COMPLETE",
+ requiredTools: ["Bash"],
+ requireSuccessfulNpmTest: true,
+ }
+ ),
+ /explicitly reports incomplete work/
+ );
+
+ const commandEvidence = [
+ {
+ type: "system",
+ subtype: "init",
+ slash_commands: ["bridge-check"],
+ skills: ["bridge-proof"],
+ },
+ ...events.slice(0, 2),
+ { type: "result", subtype: "success", result: "The task is complete." },
+ ]
+ .map((event) => JSON.stringify(event))
+ .join("\n");
+ assert.doesNotThrow(() =>
+ validateClaudeEvidenceText(commandEvidence, {
+ marker: "BRIDGE_E2E_COMPLETE",
+ requiredTools: ["Bash"],
+ requireSuccessfulNpmTest: true,
+ requiredSlashCommand: "bridge-check",
+ requiredSkill: "bridge-proof",
+ acceptExplicitCompletion: true,
+ })
+ );
+ assert.throws(
+ () =>
+ validateClaudeEvidenceText(commandEvidence, {
+ marker: "BRIDGE_E2E_COMPLETE",
+ requiredTools: ["Bash"],
+ requireSuccessfulNpmTest: true,
+ requiredSlashCommand: "missing-command",
+ requiredSkill: "bridge-proof",
+ acceptExplicitCompletion: true,
+ }),
+ /required slash command/
+ );
+});
+
+test("live command declares the terminal marker required by its evidence validator", () => {
+ const command = fs.readFileSync(bridgeCommandPath, "utf8");
+ const runner = fs.readFileSync(liveRunnerPath, "utf8");
+ assert.match(command, /BRIDGE_E2E_COMPLETE/);
+ assert.match(command, /only after `npm test` passes/);
+ assert.match(command, /do not introduce a regression/);
+ assert.match(runner, /validate_scenario[^\n]*BRIDGE_E2E_COMPLETE Bash true/);
+});
+
test("audit policies reject forged metadata, missing proof, and unexpected Devin hosts", () => {
const safeStat = {
isFile: () => true,
@@ -210,6 +360,7 @@ test("scripts use atomic audit resets, readiness waits, cleanup traps, and stric
const login = fs.readFileSync(loginPath, "utf8");
const launch = fs.readFileSync(launchPath, "utf8");
const live = fs.readFileSync(liveE2ePath, "utf8");
+ const liveRunner = fs.readFileSync(liveRunnerPath, "utf8");
const mock = fs.readFileSync(mockE2ePath, "utf8");
const verifier = fs.readFileSync(verifierPath, "utf8");
assert.match(common, /chmod 01777/);
@@ -223,6 +374,11 @@ test("scripts use atomic audit resets, readiness waits, cleanup traps, and stric
assert.match(login, /auth login --force-manual-token-flow/);
assert.match(live, /bridge_assert_devin_guard_audit/);
assert.match(live, /bridge_assert_zero_claude_egress/);
+ assert.match(liveRunner, /validate-claude-evidence\.mjs/);
+ assert.match(liveRunner, /Use Edit now to replace/);
+ assert.match(liveRunner, /Do not summarize before npm test succeeds/);
+ assert.match(liveRunner, /DEVIN_BRIDGE_LIVE_SCENARIO_COOLDOWN_SECONDS:-15/);
+ assert.equal([...liveRunner.matchAll(/sleep "\$scenario_cooldown_seconds"/g)].length, 2);
assert.match(mock, /bridge_assert_zero_claude_egress/);
assert.ok([...verifier.matchAll(/bridge_reset_claude_egress_audit/g)].length >= 2);
});
diff --git a/tests/unit/executor-devin-cli-agentic-acp.test.ts b/tests/unit/executor-devin-cli-agentic-acp.test.ts
index 87049df628..5538d3a86e 100644
--- a/tests/unit/executor-devin-cli-agentic-acp.test.ts
+++ b/tests/unit/executor-devin-cli-agentic-acp.test.ts
@@ -137,7 +137,18 @@ rl.on("line", (line) => {
if (msg.method === "initialize") {
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } }) + "\\n");
} else if (msg.method === "session/new") {
- process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "sess_agentic" } }) + "\\n");
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: {
+ sessionId: "sess_agentic",
+ modes: { currentModeId: "accept-edits", availableModes: [{ id: "accept-edits" }, { id: "ask" }] }
+ } }) + "\\n");
+ } else if (msg.method === "session/set_config_option") {
+ process.stdout.write(JSON.stringify({
+ jsonrpc: "2.0",
+ id: msg.id,
+ result: { configOptions: [{ id: "mode", currentValue: "ask" }] }
+ }) + "\\n");
+ } else if (msg.method === "session/set_mode") {
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: {} }) + "\\n");
} else if (msg.method === "session/prompt") {
process.stdout.write(JSON.stringify({
jsonrpc: "2.0",
@@ -160,6 +171,21 @@ function writeScenarioMock(tmpDir: string, body: string) {
const readline = require("readline");
const rl = readline.createInterface({ input: process.stdin });
const send = (value) => process.stdout.write(JSON.stringify(value) + "\\n");
+const safeModes = { currentModeId: "accept-edits", availableModes: [{ id: "accept-edits" }, { id: "ask" }] };
+const sendSession = (msg, sessionId) => send({ jsonrpc: "2.0", id: msg.id, result: { sessionId, modes: safeModes } });
+const acceptAskMode = (msg) => {
+ if (msg.method === "session/set_mode") {
+ send({ jsonrpc: "2.0", id: msg.id, result: {} });
+ return true;
+ }
+ if (msg.method !== "session/set_config_option") return false;
+ if (msg.params?.configId !== "mode" || msg.params?.type !== "id" || msg.params?.value !== "ask") {
+ send({ jsonrpc: "2.0", id: msg.id, error: { code: -32602, message: "unsafe mode" } });
+ } else {
+ send({ jsonrpc: "2.0", id: msg.id, result: { configOptions: [{ id: "mode", currentValue: "ask" }] } });
+ }
+ return true;
+};
${body}
`,
{ mode: 0o755 }
@@ -225,11 +251,24 @@ test("DevinCliAgenticExecutor returns Anthropic tool_use JSON and sends ACP fram
const frames = JSON.parse(fs.readFileSync(framesFile, "utf8"));
assert.ok(frames.some((frame: { method?: string }) => frame.method === "initialize"));
assert.ok(frames.some((frame: { method?: string }) => frame.method === "session/new"));
+ assert.ok(
+ !frames.some((frame: { method?: string }) => frame.method === "session/set_config_option")
+ );
assert.ok(frames.some((frame: { method?: string }) => frame.method === "session/prompt"));
const initialize = frames.find((frame: { method?: string }) => frame.method === "initialize");
assert.equal(initialize.params.protocolVersion, 1);
assert.deepEqual(initialize.params.clientCapabilities, {});
- assert.deepEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["acp"]);
+ const prompt = frames.find((frame: { method?: string }) => frame.method === "session/prompt");
+ const promptText = prompt.params.prompt[0].text;
+ assert.match(promptText, /Devin Summarizer Bridge/);
+ assert.match(promptText, /execution trace/i);
+ assert.match(promptText, /client workspace is \/workspace/i);
+ assert.match(promptText, /Read src\/index\.ts/);
+ assert.deepEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), [
+ "acp",
+ "--agent-type",
+ "summarizer",
+ ]);
} finally {
if (oldBin === undefined) delete process.env.CLI_DEVIN_AGENTIC_BIN;
else process.env.CLI_DEVIN_AGENTIC_BIN = oldBin;
@@ -237,6 +276,57 @@ test("DevinCliAgenticExecutor returns Anthropic tool_use JSON and sends ACP fram
}
});
+test("no-tools summarizer does not depend on mutable ACP permission modes", async () => {
+ const tmpDir = sandboxTmp("devin-agentic-no-ask-mode-");
+ const scriptFile = writeScenarioMock(
+ tmpDir,
+ `rl.on("line", (line) => {
+ const msg = JSON.parse(line);
+ if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } });
+ if (msg.method === "session/new") send({
+ jsonrpc: "2.0",
+ id: msg.id,
+ result: {
+ sessionId: "no-ask",
+ modes: { currentModeId: "accept-edits", availableModes: [{ id: "accept-edits", name: "Code" }] }
+ }
+ });
+ if (msg.method === "session/prompt") {
+ send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "no-ask", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "unsafe" } } } });
+ send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: "end_turn" } });
+ }
+});`
+ );
+ try {
+ const result = await executeTextRequest(scriptFile);
+ assert.equal(result.response.status, 200);
+ const body = JSON.parse(await result.response.text());
+ assert.equal(body.content[0].text, "unsafe");
+ } finally {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+});
+
+test("ACP client fails closed when session/new omits the session id", async () => {
+ const tmpDir = sandboxTmp("devin-agentic-unconfirmed-ask-mode-");
+ const scriptFile = writeScenarioMock(
+ tmpDir,
+ `rl.on("line", (line) => {
+ const msg = JSON.parse(line);
+ if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } });
+ if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: {} });
+});`
+ );
+ try {
+ const result = await executeTextRequest(scriptFile);
+ assert.equal(result.response.status, 502);
+ const body = JSON.parse(await result.response.text());
+ assert.equal(body.error.code, "missing_session_id");
+ } finally {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+});
+
test("DevinCliAgenticExecutor returns Anthropic SSE for streaming Claude clients", async () => {
const tmpDir = sandboxTmp("devin-agentic-sse-");
const oldBin = process.env.CLI_DEVIN_AGENTIC_BIN;
@@ -271,8 +361,9 @@ test("ACP client handles fragmented frames, multiple chunks, and stderr", async
tmpDir,
`rl.on("line", (line) => {
const msg = JSON.parse(line);
+ if (acceptAskMode(msg)) return;
if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } });
- if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "fragmented" } });
+ if (msg.method === "session/new") sendSession(msg, "fragmented");
if (msg.method === "session/prompt") {
process.stderr.write("bounded diagnostic\\n");
const first = JSON.stringify({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fragmented", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Hel" } } } });
@@ -301,8 +392,9 @@ test("ACP client fails closed when Devin attempts an internal tool call", async
tmpDir,
`rl.on("line", (line) => {
const msg = JSON.parse(line);
+ if (acceptAskMode(msg)) return;
if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } });
- if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "internal-tool" } });
+ if (msg.method === "session/new") sendSession(msg, "internal-tool");
if (msg.method === "session/prompt") {
send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "internal-tool", update: { sessionUpdate: "tool_call", toolCallId: "internal-1", title: "Read a.ts" } } });
send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "internal-tool", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Done" } } } });
@@ -386,11 +478,12 @@ const count = Number(fs.existsSync(stateFile) ? fs.readFileSync(stateFile, "utf8
fs.writeFileSync(stateFile, String(count));
rl.on("line", (line) => {
const msg = JSON.parse(line);
+ if (acceptAskMode(msg)) return;
if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } });
- if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "repair" } });
+ if (msg.method === "session/new") sendSession(msg, "repair");
if (msg.method === "session/prompt") {
const text = count === 1
- ? 'I will read the file and start by examining its contents.'
+ ? 'Current State: inspected math.js. The immediate next step was to read math.test.js, then fix the bug and run npm test.'
: '{"name":"Read","arguments":{"file_path":"a.ts"}}';
send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "repair", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } } } });
send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: "end_turn" } });
@@ -439,8 +532,9 @@ const count = Number(fs.existsSync(stateFile) ? fs.readFileSync(stateFile, "utf8
fs.writeFileSync(stateFile, String(count));
rl.on("line", (line) => {
const msg = JSON.parse(line);
+ if (acceptAskMode(msg)) return;
if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } });
- if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "repair-narrative" } });
+ if (msg.method === "session/new") sendSession(msg, "repair-narrative");
if (msg.method === "session/prompt") {
const text = count === 1
? "I'll start by reading the file."