fix: validate live Claude Devin bridge

This commit is contained in:
Lucas Israel
2026-07-27 22:07:18 -03:00
parent 0dcac8bfc3
commit 347bfe257c
12 changed files with 697 additions and 196 deletions

View File

@@ -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({

View File

@@ -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'

View File

@@ -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 `<tool>{...}</tool>` 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.

View File

@@ -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.

View File

@@ -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
<tool>
@@ -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.

View File

@@ -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 <tool> 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 <summary> 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;
}

View File

@@ -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

View File

@@ -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`);
}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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);
});

View File

@@ -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.'
? '<summary>Current State: inspected math.js. The immediate next step was to read math.test.js, then fix the bug and run npm test.</summary>'
: '<tool>{"name":"Read","arguments":{"file_path":"a.ts"}}</tool>';
send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "repair", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } } } });
send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: "end_turn" } });
@@ -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."