From 9b33f3e6970347b42f6fa9213660348b648f0432 Mon Sep 17 00:00:00 2001 From: Oleg Kopachovets Date: Mon, 13 Jul 2026 10:40:52 +0100 Subject: [PATCH 1/4] feat(backends): register Devin + opencode as worker backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds devin + opencode to resolve-model.py (backend enum + tier->model maps; opencode uses provider/model strings, multi-provider) and validate-manifest.py VALID_BACKENDS + worktree-required set. New adapter-devin.md (Devin -p headless, --permission-mode dangerous; Devin -p run VERIFIED LIVE with an authenticated account: exit 0, bare-text stdout, a retryable transient-capacity error class, cwd-reset noted) + adapter-opencode.md (opencode run --format json, multi- provider, MANDATORY env-scrub — opencode authed with zero creds via inherited ANTHROPIC_BASE_URL). Both LOWER-TRUST, opt-in, WORKER-ONLY (excluded from the arbiter panel — multi-provider family != fixed backend family). xhigh stays codex-only; no-Haiku respected; no fabricated metrics. Co-Authored-By: Claude Opus 4.8 --- commands/v-init.md | 120 ++++++++++++- commands/v-models.md | 85 ++++++++- scripts/compound-v-resolve-model.py | 81 ++++++++- scripts/compound-v-validate-manifest.py | 167 +++++++++++++++++- skills/backend-launcher/SKILL.md | 10 +- skills/backend-launcher/adapter-devin.md | 158 +++++++++++++++++ skills/backend-launcher/adapter-opencode.md | 180 ++++++++++++++++++++ skills/compound-v/execution-manifest.md | 20 ++- skills/compound-v/routing-policy.md | 19 ++- 9 files changed, 800 insertions(+), 40 deletions(-) create mode 100644 skills/backend-launcher/adapter-devin.md create mode 100644 skills/backend-launcher/adapter-opencode.md diff --git a/commands/v-init.md b/commands/v-init.md index c4f9c6d..58a2280 100644 --- a/commands/v-init.md +++ b/commands/v-init.md @@ -124,6 +124,88 @@ cursor-agent status &1 | head -3 # or: [ -n "$CURSOR_API_KEY" ] > preferred for untrusted / high-stakes work**. See > [`adapter-cursor.md`](../skills/backend-launcher/adapter-cursor.md). +### 1a-quater. Devin CLI (`devin`) — optional, lower-trust, WORKER-ONLY backend + +```bash +command -v devin +``` + +If absent → Devin is **not available** (record it; routing never offers it). + +If present → check **authentication** (auth-free command, verified live): + +```bash +devin auth status &1 | head -3 # or: [ -n "$COGNITION_API_KEY" ] +``` + +- Installed **and** authenticated (`devin auth login`, or `COGNITION_API_KEY` set) → + Devin is **usable**; record it and add it to `backends`. The pinned headless invocation + (devin-cli 3000.1.27, verified live for its help/flag surface — task-execution behavior is + DOC-CLAIMED, unverified without an authenticated run): + `cd "$WT" && devin -p "" --permission-mode dangerous [--model ] --export **Flag it as lower-trust AND worker-only when you record it.** Devin has a real, +> live-confirmed kernel `--sandbox` flag (macOS Seatbelt / Linux bwrap+seccomp) — a +> genuine differentiator from Antigravity/Cursor — but Cognition labels it +> **"[Research Preview]"**, its coverage is scoped to "exec-tool processes" (non-shell +> file-edit tool coverage unverified), and its network-filtering is called "currently +> unstable" in Cognition's own docs. Until those are live-verified, this plugin treats +> Devin as **opt-in, lower-trust — the same tier as Antigravity/Cursor**, NOT Codex: the +> worktree + `git diff` gate is the *real* enforcement (detection, not confirmed +> prevention). **Prefer Codex for untrusted / high-stakes work.** Devin is also +> **model-agnostic** (`--model` accepts a free string spanning Claude/GPT/Gemini/Devin's +> own SWE family) — its resolved model family is data-dependent, so it is **WORKER-ONLY +> for v1, excluded from any cross-model arbiter/review panel** until family-dedup keys on +> the resolved model rather than the backend name. See +> [`adapter-devin.md`](../skills/backend-launcher/adapter-devin.md). + +### 1a-quinquies. opencode CLI (`opencode`) — optional, lower-trust, WORKER-ONLY, multi-provider backend + +```bash +command -v opencode || npx -y opencode-ai@latest --version # confirms installable even if not on PATH +``` + +If absent → opencode is **not available** (record it; routing never offers it). + +If present → check for at least one usable provider credential (auth-free command, +verified live): + +```bash +opencode providers list &1 | grep -qv '0 credentials' \ + && echo "opencode has stored credentials" \ + || echo "opencode has NO stored credentials (may still work via ambient provider env vars)" +``` + +- Installed **and** (stored credentials via `opencode providers login`, **or** a known + provider env var like `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`/`ANTHROPIC_BASE_URL` is + explicitly set for this purpose) → opencode is **usable**; record it and add it to + `backends`. **Load-bearing, live-observed finding:** opencode successfully authenticated + with **zero** stored credentials purely from an inherited `ANTHROPIC_BASE_URL` — record + `auth` as `ambient-env` vs `stored-credentials` so the operator knows which path is live + on this machine, and see the adapter's env-scrub requirement below. +- Installed but no credentials and no relevant env var set → present but unauthenticated; + tell the user to run `opencode providers login`. + +> **Flag it as lower-trust, worker-only, AND multi-provider when you record it.** opencode +> has **no kernel write-confinement** at all (no `--sandbox` equivalent), and per its own +> docs defaults to **allowing all operations without explicit approval** — the opposite +> posture from Cursor/Antigravity, which refuse until explicitly unlocked. The worktree + +> `git diff` gate is the only real enforcement (detection, not prevention). **Prefer Codex +> for untrusted / high-stakes work.** opencode addresses models as `provider/model` +> strings (e.g. `anthropic/claude-opus-4-6`), so — like Devin — its resolved model family +> is data-dependent; it is **WORKER-ONLY for v1, excluded from any cross-model +> arbiter/review panel** until family-dedup keys on the resolved model. See +> [`adapter-opencode.md`](../skills/backend-launcher/adapter-opencode.md) for the +> **mandatory env-scrub** (the worker script must NOT blindly inherit the dispatcher's own +> provider env vars into the `opencode run` child process). + ### 1b. Context7 MCP (match by namespace) Context7 is **plugin-namespaced** — match the namespace, not a bare `context7`: @@ -408,18 +490,27 @@ was, in those two fields). "claude": { "deep": "opus", "standard": "opus", "light": "sonnet" }, "codex": { "deep": "gpt-5.6-sol", "standard": "gpt-5.6-terra", "light": "gpt-5.6-luna" }, "antigravity": { "deep": "Gemini 3.1 Pro (High)", "standard": "Gemini 3.1 Pro (Low)", "light": "Gemini 3.5 Flash (Low)" }, - "cursor": { "deep": "auto", "standard": "auto", "light": "auto" } + "cursor": { "deep": "auto", "standard": "auto", "light": "auto" }, + "devin": { "deep": "claude-opus-4.6", "standard": "claude-sonnet-4", "light": "gpt-5.5" }, + "opencode": { "deep": "anthropic/claude-opus-4-6", "standard": "openai/gpt-5.6-terra", "light": "opencode/mimo-v2.5-free" } }, "cost-aware": { "claude": { "deep": "opus", "standard": "sonnet", "light": "sonnet" }, "codex": { "deep": "gpt-5.6-sol", "standard": "gpt-5.6-terra", "light": "gpt-5.6-luna" }, "antigravity": { "deep": "Gemini 3.1 Pro (High)", "standard": "Gemini 3.1 Pro (Low)", "light": "Gemini 3.5 Flash (Low)" }, - "cursor": { "deep": "auto", "standard": "auto", "light": "auto" } + "cursor": { "deep": "auto", "standard": "auto", "light": "auto" }, + "devin": { "deep": "claude-opus-4.6", "standard": "claude-sonnet-4", "light": "gpt-5.5" }, + "opencode": { "deep": "anthropic/claude-opus-4-6", "standard": "openai/gpt-5.6-terra", "light": "opencode/mimo-v2.5-free" } } } } ``` +- `devin` and `opencode` are **worker-only** backends (v1): excluded from any cross-model + arbiter/review panel until family-dedup keys on the *resolved* model rather than the + backend name (both are multi-provider routers, so `backend: devin`/`backend: opencode` + does not fix a single model family — see `adapter-devin.md` / `adapter-opencode.md`). + (`conservative` and `claude-only` mirror `balanced` — seed those two stance blocks identically to `balanced`. Only `cost-aware.claude.standard` differs: `sonnet`, not `opus`; `cost-aware.claude.deep` stays `opus`.) @@ -506,6 +597,20 @@ The user-level cache of what this machine can do, reused across repos: "codex": { "available": true, "exec_flags_verified": true, "version": "" }, "antigravity": { "available": false, "trust": "lower (no kernel sandbox)", "version": "" }, "cursor": { "available": false, "authenticated": false, "trust": "lower (no kernel sandbox)", "version": "" }, + "devin": { + "available": false, + "authenticated": false, + "trust": "lower (Research-Preview kernel sandbox; exec-tool-only coverage unverified; network filtering unstable per vendor); worker-only", + "version": "", + "sandbox_setup_required": null + }, + "opencode": { + "available": false, + "auth": "none", + "trust": "lower (no kernel sandbox; docs claim default-allow permissions); worker-only, multi-provider", + "version": "", + "providers_configured": [] + }, "context7": { "available": true }, "workflows": { "available": false }, "deep_research": true, @@ -518,6 +623,17 @@ The user-level cache of what this machine can do, reused across repos: - `antigravity.available` reflects the Step 1a-bis `command -v agy` probe; record the `version` from `agy --version`. When present, Step 1a-bis also seeds a real model map via `agy models /dev/null 2>&1 && ech `sonnet-4-thinking`) — a curated roster like codex (no discovery; whatever the plan accepts via `cursor-agent --model` is valid). Only offer named models if the user confirms a paid plan. +### 1e. devin — curated list, no discovery command (mirrors codex's pattern) + +Devin has **no `devin models` / `--list-models` subcommand** — like codex, discovery is +only via the interactive `/model` picker inside a session, so the map is a small +**curated** roster the user can override by hand (devin accepts whatever string you pass +to `--model`). Present this curated starting roster (DOC-CLAIMED aliases — devin-cli +3000.1.27's own `--help` uses these exact strings as its examples, but no authenticated +run has confirmed they resolve): + +- `claude-opus-4.6` — strongest; suggested for `deep` +- `claude-sonnet-4` — balanced; suggested for `standard` +- `gpt-5.5` — fast/cheap; suggested for `light` + +Confirm devin is even usable first: + +```bash +command -v devin && devin auth status &1 | grep -q 'Not logged in' \ + && echo "devin present, unauthenticated" || echo "devin usable (or absent)" +``` + +If devin is unavailable/unauthenticated, say so, keep the existing devin block +unchanged, and skip its reassignment. **Devin is model-agnostic** (`--model` spans +Claude/GPT/Gemini/Devin's own SWE family) — remind the user that whichever family they +pick here determines Devin's error-correlation with the rest of the panel, which is +exactly why it stays **worker-only, never an arbiter seat**, in this plugin. + +### 1f. opencode — real discovery command, but curated + user-confirmed assignment + +opencode **does** have a real, live discovery command that works even with **zero** +stored credentials (it falls back to a credential-free `opencode/*` catalog via +models.dev): + +```bash +command -v opencode >/dev/null && opencode models &1 || echo "opencode unavailable" +``` + +Unlike antigravity's single-family Gemini catalog, opencode spans **multiple unrelated +providers with no shared naming convention** — so, like cursor, Compound V does **not** +auto-rank it. Show the user the live `opencode models` output, then let them assign each +tier. The one genuinely novel option here: `light` MAY legitimately point at one of the +**credential-free** `opencode/*` models (e.g. `opencode/mimo-v2.5-free`) — a real free +tier no other backend offers. Every cell **MUST** be a full `provider/model` string (the +resolver's selftest asserts every `opencode` tier cell contains `/`); a bare model name +will likely fail opencode's own model resolution even though `--model` accepts the +string syntactically. The built-in fallback map (curated, user-overridable): + +- `deep` → `anthropic/claude-opus-4-6` +- `standard` → `openai/gpt-5.6-terra` +- `light` → `opencode/mimo-v2.5-free` + +If opencode is unavailable/unauthenticated, say so and keep the existing opencode block +unchanged. **opencode is model-agnostic per-cell** (each tier may use a different +provider) — remind the user this is exactly why it stays **worker-only, never an +arbiter seat**: an opencode ballot's family is determined entirely by which +`provider/model` was resolved, not by the backend name. + --- ## Step 2 — Show findings and let the user assign tiers @@ -164,6 +222,8 @@ fast/cheap option → `light`). Example shape: | claude | opus, sonnet | opus | opus | sonnet | | codex | gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna | gpt-5.6-sol | gpt-5.6-terra | gpt-5.6-luna | | antigravity | *(from `agy models {tier -> model}} map for one stance. Only the claude - sub-map varies by stance; codex/antigravity/cursor are shared (read-only).""" - return {"claude": claude_map, "codex": _CODEX, "antigravity": _ANTIGRAVITY, "cursor": _CURSOR} + sub-map varies by stance; codex/antigravity/cursor/devin/opencode are shared + (read-only).""" + return { + "claude": claude_map, + "codex": _CODEX, + "antigravity": _ANTIGRAVITY, + "cursor": _CURSOR, + "devin": _DEVIN, + "opencode": _OPENCODE, + } # Built-in default map, now keyed by STANCE. @@ -96,7 +130,7 @@ def _stance_map(claude_map): # working unchanged: balanced is the default stance. DEFAULT_MODELS = DEFAULT_MODELS_BY_STANCE["balanced"] -BACKENDS = ("claude", "codex", "antigravity", "cursor") +BACKENDS = ("claude", "codex", "antigravity", "cursor", "devin", "opencode") TIERS = ("deep", "standard", "light") # `xhigh` is valid iff backend == "codex": it maps to codex's kernel # model_reasoning_effort dimension, which live-accepts xhigh (verified @@ -307,6 +341,39 @@ def raises(fn): and resolve("antigravity", "deep")["effort"] == "high", ) + # Devin (multi-vendor broker, curated DOC-CLAIMED aliases) resolves for every tier. + expect( + "devin/deep -> claude-opus-4.6", + resolve("devin", "deep")["model"] == "claude-opus-4.6", + ) + expect( + "devin/standard -> claude-sonnet-4", + resolve("devin", "standard")["model"] == "claude-sonnet-4", + ) + expect( + "devin/light -> gpt-5.5", + resolve("devin", "light")["model"] == "gpt-5.5", + ) + + # opencode (provider-agnostic router): every cell resolves, AND every cell is a + # genuine "provider/model" string (a bare name would silently pass --model but + # likely fail opencode's own model resolution) -- the key structural invariant + # from the design (resolve-model.py treats every cell as opaque; opencode is the + # one backend where that opaque string legitimately varies its provider prefix + # per tier). + expect( + "opencode/deep -> anthropic/claude-opus-4-6", + resolve("opencode", "deep")["model"] == "anthropic/claude-opus-4-6", + ) + expect( + "opencode/light -> credential-free opencode/* model", + resolve("opencode", "light")["model"] == "opencode/mimo-v2.5-free", + ) + expect( + "every opencode tier cell is a provider/model string", + all("/" in _OPENCODE[t] for t in TIERS), + ) + # No 'haiku' anywhere in any stance map. flat = json.dumps(DEFAULT_MODELS_BY_STANCE).lower() expect("no haiku in any stance map", "haiku" not in flat) @@ -344,6 +411,14 @@ def raises(fn): "cursor+xhigh rejected", raises(lambda: resolve("cursor", "deep", effort="xhigh")), ) + expect( + "devin+xhigh rejected", + raises(lambda: resolve("devin", "deep", effort="xhigh")), + ) + expect( + "opencode+xhigh rejected", + raises(lambda: resolve("opencode", "deep", effort="xhigh")), + ) _xhigh_msg = "" try: resolve("claude", "deep", effort="xhigh") diff --git a/scripts/compound-v-validate-manifest.py b/scripts/compound-v-validate-manifest.py index 7974d46..eed41c9 100755 --- a/scripts/compound-v-validate-manifest.py +++ b/scripts/compound-v-validate-manifest.py @@ -36,8 +36,10 @@ ``acceptance_criteria``, ``routing_stance``, ``max_parallel``. Per-job: ``id``, ``title``, ``type``, ``backend``, ``isolation``, ``run``, ``write_allowed``, ``read_allowed``, ``acceptance``, plus (``model`` OR ``tier``). Enums: ``backend`` -∈ {claude, codex, antigravity, cursor} (``none`` is the routing "return to planning" -sentinel, NOT a dispatched job backend); ``isolation`` ∈ {direct, worktree}; +∈ {claude, codex, antigravity, cursor, devin, opencode} (``none`` is the routing +"return to planning" sentinel, NOT a dispatched job backend; ``devin``/``opencode`` +are lower-trust, opt-in, WORKER-ONLY backends — see adapter-devin.md / +adapter-opencode.md); ``isolation`` ∈ {direct, worktree}; ``run`` ∈ {serial, parallel}; ``routing_stance`` ∈ {balanced, conservative, cost-aware, claude-only}; ``tier`` ∈ {deep, standard, light}; ``effort`` ∈ {low, medium, high, xhigh} @@ -514,7 +516,7 @@ def globs_overlap(a, b): VALID_EFFORTS = ("low", "medium", "high", "xhigh") # Enum vocabularies for required-field validation (per execution-manifest.md). -VALID_BACKENDS = ("claude", "codex", "antigravity", "cursor") +VALID_BACKENDS = ("claude", "codex", "antigravity", "cursor", "devin", "opencode") VALID_ISOLATIONS = ("direct", "worktree") VALID_RUNS = ("serial", "parallel") VALID_STANCES = ("balanced", "conservative", "cost-aware", "claude-only") @@ -1697,14 +1699,18 @@ def validate(manifest, mode=None, repo_root=None, config_path=None, wa = [] job_globs.append((jid, [str(g) for g in wa])) - # Invariant 2: codex => worktree, antigravity => worktree, cursor => worktree. - # All three are EXTERNAL workers. Codex has a kernel sandbox scoped to a directory; - # antigravity and cursor have NO kernel write-confinement at all (antigravity runs - # with --dangerously-skip-permissions; cursor's headless `-f` grants arbitrary - # write+shell), so worktree + git-diff is the ONLY file-scope enforcement they get. + # Invariant 2: codex => worktree, antigravity => worktree, cursor => worktree, + # devin => worktree, opencode => worktree. All five are EXTERNAL workers. Codex + # has a kernel sandbox scoped to a directory; antigravity and cursor have NO + # kernel write-confinement at all (antigravity runs with + # --dangerously-skip-permissions; cursor's headless `-f` grants arbitrary + # write+shell); devin has a live but Research-Preview `--sandbox` whose coverage + # is unverified (treated as no-confinement for enforcement purposes, v1); opencode + # has NO kernel write-confinement and defaults to allowing all operations. For all + # five, worktree + git-diff is the ONLY file-scope enforcement that actually holds. # A non-worktree external worker cannot be deterministically attributed and is rejected. backend_lc = str(job.get("backend", "")).lower() - if backend_lc in ("codex", "antigravity", "cursor"): + if backend_lc in ("codex", "antigravity", "cursor", "devin", "opencode"): if str(job.get("isolation", "")).lower() != "worktree": problems.append( "job '%s' uses backend %s but isolation is '%s' " @@ -2340,6 +2346,128 @@ def main(argv): """ +# A complete, otherwise-valid manifest whose ONE defect is a devin job with +# isolation: direct (devin's --sandbox is Research-Preview and unverified for this +# plugin's purposes, so it is treated as no-confinement like antigravity/cursor and +# MUST be worktree-isolated). +DEVIN_DIRECT_MANIFEST = """ +run_id: 2026-07-13-devin +feature: "devin" +spec_path: docs/superpowers/specs/2026-07-13-devin.md +plan_path: docs/superpowers/plans/2026-07-13-devin.md +audits: + archaeology: docs/superpowers/archaeology/2026-07-13-devin.md + domain: docs/superpowers/expert/2026-07-13-devin.md + library: docs/superpowers/library-audit/2026-07-13-devin.md +routing_stance: balanced +max_parallel: 2 +acceptance_criteria: + - "ships" +jobs: + - id: task-1-devin + title: "devin slice" + type: large_isolated + backend: devin + tier: standard + isolation: direct + run: serial + write_allowed: [src/devin/**] + read_allowed: [src/**] + acceptance: ["builds"] +""" + + +# A complete, VALID manifest with a single devin job, worktree-isolated -- confirms +# "devin" is accepted end-to-end (VALID_BACKENDS + the worktree invariant) once it is +# NOT paired with isolation: direct. +DEVIN_WORKTREE_MANIFEST = """ +run_id: 2026-07-13-devin-ok +feature: "devin-ok" +spec_path: docs/superpowers/specs/2026-07-13-devin-ok.md +plan_path: docs/superpowers/plans/2026-07-13-devin-ok.md +audits: + archaeology: docs/superpowers/archaeology/2026-07-13-devin-ok.md + domain: docs/superpowers/expert/2026-07-13-devin-ok.md + library: docs/superpowers/library-audit/2026-07-13-devin-ok.md +routing_stance: balanced +max_parallel: 2 +acceptance_criteria: + - "ships" +jobs: + - id: task-1-devin-ok + title: "devin slice" + type: large_isolated + backend: devin + tier: standard + isolation: worktree + run: serial + write_allowed: [src/devin/**] + read_allowed: [src/**] + acceptance: ["builds"] +""" + + +# A complete, otherwise-valid manifest whose ONE defect is an opencode job with +# isolation: direct (opencode has NO kernel write-confinement and defaults to +# allowing all operations, so worktree isolation is REQUIRED). +OPENCODE_DIRECT_MANIFEST = """ +run_id: 2026-07-13-opencode +feature: "opencode" +spec_path: docs/superpowers/specs/2026-07-13-opencode.md +plan_path: docs/superpowers/plans/2026-07-13-opencode.md +audits: + archaeology: docs/superpowers/archaeology/2026-07-13-opencode.md + domain: docs/superpowers/expert/2026-07-13-opencode.md + library: docs/superpowers/library-audit/2026-07-13-opencode.md +routing_stance: balanced +max_parallel: 2 +acceptance_criteria: + - "ships" +jobs: + - id: task-1-opencode + title: "opencode slice" + type: large_isolated + backend: opencode + tier: standard + isolation: direct + run: serial + write_allowed: [src/opencode/**] + read_allowed: [src/**] + acceptance: ["builds"] +""" + + +# A complete, VALID manifest with a single opencode job, worktree-isolated -- confirms +# "opencode" is accepted end-to-end (VALID_BACKENDS + the worktree invariant) once it +# is NOT paired with isolation: direct. model is a genuine "provider/model" string, +# matching the resolver's opencode convention. +OPENCODE_WORKTREE_MANIFEST = """ +run_id: 2026-07-13-opencode-ok +feature: "opencode-ok" +spec_path: docs/superpowers/specs/2026-07-13-opencode-ok.md +plan_path: docs/superpowers/plans/2026-07-13-opencode-ok.md +audits: + archaeology: docs/superpowers/archaeology/2026-07-13-opencode-ok.md + domain: docs/superpowers/expert/2026-07-13-opencode-ok.md + library: docs/superpowers/library-audit/2026-07-13-opencode-ok.md +routing_stance: balanced +max_parallel: 2 +acceptance_criteria: + - "ships" +jobs: + - id: task-1-opencode-ok + title: "opencode slice" + type: large_isolated + backend: opencode + model: "anthropic/claude-sonnet-4-6" + isolation: worktree + run: serial + write_allowed: [src/opencode/**] + read_allowed: [src/**] + acceptance: ["builds"] +""" + + # --------------------------------------------------------------------------- # # v2.9 fast-path self-test — builds on-disk fixtures in a temp repo root so the # containment + cross-artifact-binding checks exercise real files. Digests are @@ -3377,6 +3505,27 @@ def expect(name, cond): and "antigravity requires worktree" in p for p in agy_bad), ) + # devin ⇒ worktree: same invariant, new backend (v1: worker-only, lower-trust). + devin_bad = validate_text(DEVIN_DIRECT_MANIFEST) + expect( + "devin+direct caught (devin requires worktree)", + any("backend devin but isolation" in p + and "devin requires worktree" in p for p in devin_bad), + ) + devin_ok = validate_text(DEVIN_WORKTREE_MANIFEST) + expect("devin+worktree manifest is valid", devin_ok == []) + + # opencode ⇒ worktree: same invariant, new backend (v1: worker-only, lower-trust). + opencode_bad = validate_text(OPENCODE_DIRECT_MANIFEST) + expect( + "opencode+direct caught (opencode requires worktree)", + any("backend opencode but isolation" in p + and "opencode requires worktree" in p for p in opencode_bad), + ) + opencode_ok = validate_text(OPENCODE_WORKTREE_MANIFEST) + expect("opencode+worktree manifest is valid (provider/model string accepted)", + opencode_ok == []) + # Reviewer satisfied by tier: deep (no model) — GOOD manifest task-3 uses # tier: deep and must not trip the reviewer invariant. expect( diff --git a/skills/backend-launcher/SKILL.md b/skills/backend-launcher/SKILL.md index d9d49e9..ee6efe2 100644 --- a/skills/backend-launcher/SKILL.md +++ b/skills/backend-launcher/SKILL.md @@ -1,6 +1,6 @@ --- name: backend-launcher -description: Use when Compound V's dispatcher needs to run one file-scoped job on a chosen backend (Claude subagent, headless Codex worker, headless Antigravity worker, or headless Cursor worker) and get back a canonical job_result. The single job_spec → job_result contract every adapter implements; the orchestrator speaks only this contract and never sees backend-specific flags. +description: Use when Compound V's dispatcher needs to run one file-scoped job on a chosen backend (Claude subagent, headless Codex worker, headless Antigravity worker, headless Cursor worker, headless Devin worker, or headless opencode worker) and get back a canonical job_result. The single job_spec → job_result contract every adapter implements; the orchestrator speaks only this contract and never sees backend-specific flags. --- # Backend Launcher @@ -19,7 +19,7 @@ There is no skill-import API: an adapter is a sibling doc (`adapter-codex.md`, ` ```jsonc { - "backend": "codex", // claude | codex | antigravity | cursor + "backend": "codex", // claude | codex | antigravity | cursor | devin | opencode "prompt": "…", // the worker prompt (opens with the planner/executor lock, below) "tier": "standard", // deep | standard | light — the routing INTENT (stable across model churn) "effort": "medium", // low | medium | high | xhigh — orthogonal reasoning-effort hint (optional; xhigh is codex-only) @@ -109,7 +109,7 @@ This is the *instructed* half. The git-diff scope gate above is the *enforced* h --- -## The three adapters (contract level) +## The adapters (contract level) | Adapter | Backend | Mechanism | Isolation | Enforcement | Status | |---|---|---|---|---|---| @@ -117,11 +117,15 @@ This is the *instructed* half. The git-diff scope gate above is the *enforced* h | `adapter-codex.md` | headless Codex | Bash-spawned `codex exec` (own process, own worktree) | `worktree` (mandatory) | git-diff scope gate | ships v1.0 | | `adapter-antigravity.md` | headless Antigravity | Bash-spawned `agy --print` (own process, own worktree) | `worktree` (mandatory) | git-diff scope gate | ships 1.1 — **lower-trust / opt-in (no kernel sandbox)** | | `adapter-cursor.md` | headless Cursor | Bash-spawned `cursor-agent -p -f` (own process, own worktree) | `worktree` (mandatory) | git-diff scope gate | ships 2.1 — **lower-trust / opt-in (no kernel sandbox)** | +| `adapter-devin.md` | headless Devin | Bash-spawned `devin -p` (own process, own worktree) | `worktree` (mandatory) | git-diff scope gate | **lower-trust / opt-in, WORKER-ONLY** (Research-Preview `--sandbox`, unverified coverage; multi-vendor model broker — excluded from any arbiter panel) | +| `adapter-opencode.md` | headless opencode | Bash-spawned `opencode run` (own process, own worktree) | `worktree` (mandatory) | git-diff scope gate | **lower-trust / opt-in, WORKER-ONLY** (no kernel sandbox; multi-provider `provider/model` router — excluded from any arbiter panel until family-dedup keys on the resolved model) | - **claude-subagent** — reuses today's `Task`-based dispatch with a `model` override and `maxTurns: 15`, optionally inside a worktree, and runs the **same** scope gate on return so enforcement is identical to Codex. Direct writes are gated against a baseline commit. - **codex** — a Bash-spawned `codex exec` worker in its own process and its own worktree (never an `agents/` entry, never the experimental `openai-codex` app-server broker, which is single-flight and can't fan out). Pinned flag set below. - **antigravity** — a Bash-spawned `agy --print` worker in its own process and its own worktree, mirroring Codex (worktree + git-diff scope gate, normalize → `job_result`). **Lower-trust / opt-in:** `agy` has **no kernel write-confinement** like Codex's `--sandbox workspace-write`, and headless writes require `--dangerously-skip-permissions` (arbitrary shell + out-of-worktree writes possible). The git-diff gate enforces file-scope *inside* the worktree but cannot *prevent* an out-of-worktree side-effect — so **prefer Codex for untrusted / high-stakes work**, and route to Antigravity only when the prompt/surface is trusted. Available only when `agy` is installed (env-aware routing). Runbook: [`adapter-antigravity.md`](adapter-antigravity.md); worker: [`scripts/compound-v-run-antigravity-worker.sh`](../../scripts/compound-v-run-antigravity-worker.sh). - **cursor** — a Bash-spawned `cursor-agent -p -f` worker in its own process and its own worktree, mirroring Antigravity (worktree + git-diff scope gate, normalize → `job_result`). **Lower-trust / opt-in (same tier as Antigravity):** cursor-agent has **no kernel write-confinement**, and a headless run **requires `-f`** (an untrusted dir is otherwise refused) which also grants arbitrary write+shell. Verified live (success + BLOCKED paths). Output is one JSON object — `.result` → summary, `.session_id` (a real UUID) → resumable via `cursor-agent --resume`. **Prefer Codex for untrusted / high-stakes work**; route to Cursor only when the prompt/surface is trusted (its editing models suit isolated build/UI work). Available only when `cursor-agent` is installed AND authenticated (env-aware routing). Runbook: [`adapter-cursor.md`](adapter-cursor.md); worker: [`scripts/compound-v-run-cursor-worker.sh`](../../scripts/compound-v-run-cursor-worker.sh). +- **devin** — a Bash-spawned `devin -p` worker in its own process and its own worktree, mirroring Antigravity/Cursor (worktree + git-diff scope gate, normalize → `job_result`). **Lower-trust / opt-in, WORKER-ONLY (v1):** Devin has a real, live-confirmed kernel `--sandbox` flag (macOS Seatbelt / Linux bwrap+seccomp) — a genuine differentiator — but it is labelled "[Research Preview]" by Cognition, its coverage is scoped to "exec-tool processes" (non-shell tool coverage unverified), and network filtering is admitted-unstable, so this plugin treats it as no-confinement for v1 and relies on the worktree + git-diff gate exactly like Antigravity/Cursor. Devin is also a **multi-vendor model broker** (`--model` spans Claude/GPT/Gemini/Devin's own SWE family) — its resolved model family is data-dependent, so it is **excluded from any cross-model arbiter/review panel** until family-dedup keys on the resolved model rather than the backend name. Available only when `devin` is installed AND authenticated (`devin auth login` / `COGNITION_API_KEY`). Runbook: [`adapter-devin.md`](adapter-devin.md); worker script: draft only, not yet built (see the adapter for the pinned invocation). +- **opencode** — a Bash-spawned `opencode run` worker in its own process and its own worktree, mirroring Antigravity/Cursor (worktree + git-diff scope gate, normalize → `job_result`). **Lower-trust / opt-in, WORKER-ONLY (v1):** opencode has **no kernel write-confinement at all** and, per its own docs, defaults to allowing all operations without explicit approval — the opposite default posture from Cursor/Antigravity's refuse-until-unlocked stance. opencode is **provider-agnostic** — every resolved model is a `provider/model` string, and the provider may differ per tier — so, like Devin, it is **excluded from any cross-model arbiter/review panel** until family-dedup keys on the resolved model. **Load-bearing safety caveat:** opencode can authenticate purely from inherited provider env vars (live-observed: it completed a real request with zero stored credentials via an ambient `ANTHROPIC_BASE_URL`) — the worker MUST scrub the dispatcher's own provider env vars rather than blindly inherit them (see the adapter). Available only when `opencode` is installed AND a provider is configured (stored credentials or an intentional env var). Runbook: [`adapter-opencode.md`](adapter-opencode.md); worker script: draft only, not yet built (see the adapter for the pinned invocation). --- diff --git a/skills/backend-launcher/adapter-devin.md b/skills/backend-launcher/adapter-devin.md new file mode 100644 index 0000000..e8b1506 --- /dev/null +++ b/skills/backend-launcher/adapter-devin.md @@ -0,0 +1,158 @@ +# Adapter: Devin (headless `devin -p` worker) + +> Read the contract in [`SKILL.md`](SKILL.md) first — this adapter implements that `job_spec → job_result` interface. This file is the backend-specific runbook; the wiring would live in `scripts/compound-v-run-devin-worker.sh` (**not yet built** — see "Worker script" below). + +The Devin backend is a **Bash-spawned `devin -p` worker** — its own process, its own git worktree. It mirrors the Antigravity / Cursor adapters step-for-step ([`adapter-antigravity.md`](adapter-antigravity.md), [`adapter-cursor.md`](adapter-cursor.md)): worktree isolation, a git-derived scope gate, normalize → `job_result`, caller merges. It does **not** mirror the Codex adapter, because Devin's `--sandbox` is Research-Preview and its coverage/network-confinement claims are unverified for this plugin's purposes (see SAFETY). + +Verified live against **devin-cli 3000.1.27 (0d4bf12e)** on stock macOS. The auth-free surface (`--help`, `sandbox setup`, `auth status`, `list --format json`) plus — **as of 2026-07-13, with an AUTHENTICATED account** — a real headless `-p` run: `devin -p "…" --permission-mode dangerous `) — the worker's git-diff scope gate is unaffected because it checks the worktree by explicit path, but a worker must not rely on `$PWD` after the call. **Still DOC-CLAIMED / UNVERIFIED** (not needed for a working worker, upgrade when convenient): `--export` ATIF field names, `--model` alias *resolution* (flag accepted; alias→model mapping unconfirmed), `-r ` resume, and a rich failure-classifier table (the worker defaults unknown non-zero exits to `other`). + +--- + +## ⚠️ SAFETY — lower-trust, opt-in, WORKER-ONLY backend for v1 (read first) + +**Devin HAS a real kernel-sandbox flag** (`--sandbox`, VERIFIED live: *"[Research Preview] Sandbox exec-tool processes (macOS seatbelt / Linux bwrap+seccomp)"*) — unlike Antigravity/Cursor, which have none. But: + +1. It is Cognition's own **"[Research Preview]"** label — immature/changeable by their own admission. +2. Its documented scope is **"exec-tool processes"** — whether Devin's own (non-shell) file-edit tool calls are equally kernel-confined, or only the shelled-out subprocess surface is, is **unverified**. +3. **Network filtering inside `--sandbox` is explicitly called "currently unstable"** by Cognition's own docs — there is no simple boolean like Codex's `-c sandbox_workspace_write.network_access=false`. +4. Read/Write scopes come from Devin's own **granted permission scopes** (`Write(...)`/`Read(...)`), coupled to `--agent-config`, not a single `--cd $WT` directory root — the exact mechanics of "scope the sandbox to exactly `$WT`" are unverified without a live account. + +**Therefore: Devin ships in the same opt-in / lower-trust tier as Antigravity/Cursor for v1** — the worktree + post-hoc `git diff` gate is the **real** enforcement (detection, not confirmed prevention), exactly as for Antigravity/Cursor. **Prefer Codex for untrusted / high-stakes work.** Route to Devin only when you trust the prompt/surface, or specifically want its model-agnostic routing. A future v1.1 could pass `--sandbox` + a scoped `--agent-config` and re-classify Devin's trust tier upward — but that needs a live account to verify scope actually holds, so it is explicitly **out of scope** for this v1 adapter. + +**WORKER-ONLY — never an arbiter/review-panel seat (this is separate from the trust-tier question above).** Devin is a **multi-vendor model broker**: `--model` accepts a free string spanning Claude, GPT, Gemini, and Devin's own SWE family, with **no `devin models` / `--list-models` command** to enumerate it. Because of this, Devin's *resolved model family* is not fixed at the backend level — `model_family(resolved_model)` (the same substring heuristic `compound-v-epic-arbiter.py` already applies to every backend) is the only correct way to classify a Devin ballot's family, and no v2.10 arbiter-panel plumbing currently reads a per-ballot resolved-model family for a non-Codex/non-Claude backend. Wiring Devin into the arbiter panel is a **separate, later change** (family-dedup fix) — this adapter is **worker dispatch only**. + +--- + +## The 6 load-bearing steps (draft — worker script not yet built) + +The worker script would perform steps 1–5; the **caller** (dispatcher) performs step 6 — identical division of labor to every other adapter. + +``` +1. ISOLATE git -C worktree add HEAD # clean diff baseline (NO kernel sandbox relied on — see Safety) +2. RUN cd && devin -p "$(cat "$prompt_file")" \ + --permission-mode dangerous \ + ${model:+--model "$model"} \ + --export "$ART/devin_export.json" --baseline # git-derived ∪ untracked ∪ ignored +4. ENFORCE every changed path ∉ write_allowed ⇒ violation ⇒ blocked (do NOT merge) +5. NORMALIZE → job_result (summary ← devin's printed stdout; + session_id ← best-effort from `devin list --format json` in — UNVERIFIED shape) +6. MERGE caller, on PASS only: git -C add -A + git -C diff --cached --binary HEAD | (cd && git apply --index) → git worktree remove -f +``` + +Step 4 is the keystone — and because Devin's sandbox coverage/confinement is unverified for this plugin's purposes, worktree + `git diff` is the **only enforcement this adapter relies on**, exactly like Antigravity/Cursor. Steps 3–4 must be computed in git, never read from anything the model says it did, and would delegate to the deterministic authority [`scripts/compound-v-scope-check.py`](../../scripts/compound-v-scope-check.py) — the same gate the dispatcher runs after every job. A worker script for this backend must **not** re-implement glob matching in bash. + +**Only `write_allowed` would be enforced; `read_allowed` stays advisory** — same rule as every lower-trust adapter (the gate is a git diff; git tracks writes, not reads). + +--- + +## Worker-prompt planner/executor lock + +Every dispatched `prompt` MUST open with this lock (verbatim-in-spirit), exactly as the contract in `SKILL.md` requires: + +> You are an implementation worker, NOT the planner. Do not change architecture. Do not write outside WRITE_ALLOWED. If the task needs a forbidden file, STOP and report BLOCKED. + +This is the *instructed* half of planner/executor separation; the git-diff scope gate (steps 3–4) is the *enforced* half. Because Devin's sandbox coverage is unverified here, the lock carries the same weight it does for Antigravity/Cursor — instruction only; the git-derived gate is what actually catches an in-worktree scope leak. The prompt would be passed via `--prompt-file ` (VERIFIED live flag exists) so multi-paragraph prompts and the lock survive shell quoting. + +--- + +## Pinned `devin -p` invocation (devin-cli 3000.1.27 — auth-free flags VERIFIED, task-execution behavior DOC-CLAIMED) + +```bash +cd "$WT" && python3 "$SUPERVISOR" --timeout "$timeout_sec" --grace 3 --cwd "$WT" -- \ + devin -p "$(cat "$prompt_file")" \ + --permission-mode dangerous \ + ${model:+--model "$model"} \ + --export "$ART/devin_export.json" ]`** — VERIFIED live: *"Print response and exit. Runs in non-interactive mode."* This is the `codex exec …` / `cursor-agent -p` equivalent. +- **`--permission-mode dangerous` is REQUIRED for unattended writes.** The default is `auto` (VERIFIED live help text: *"'auto' auto-approves read-only tools"* only) — a non-interactive run with the default would stall on the first write/shell approval with no one to answer. This is the flag that removes Devin's own safety rail — the git-diff gate is what actually enforces scope (see SAFETY). +- **`]`** — VERIFIED live flag exists: *"Export conversation to a file. Exports after each turn."* Format is ATIF (Agent Trajectory Interchange Format) — DOC-CLAIMED, unconfirmed field names for a "final message" or "session id" without an authenticated run. +- **`--model`** — VERIFIED live flag exists (`--help` text uses `claude-sonnet-4` / `claude-opus-4.6` / `opus` / `codex` as its own examples), optional; the worker would omit it when the resolved value is empty, letting Devin use its configured default. +- **No `--timeout` flag** (VERIFIED live absence) — run under the shared process-group supervisor exactly like every other backend (see Timeout, below). +- **`--prompt-file `** — VERIFIED live top-level flag exists, mirroring this plugin's `--prompt-file` convention. + +| Flag | Role | +|---|---| +| `cd "$WT"` (not a flag) | working root = the worktree (devin has no `--cd`) | +| `-p "$prompt"` | non-interactive print mode; value is the worker prompt (lock first) | +| `--permission-mode dangerous` | **required** for unattended writes — also removes the write/shell rail (Safety) | +| `--model "$model"` | optional execution-layer model — resolved from `(backend=devin, tier, effort)`; omitted when empty | +| `--export "$ART/devin_export.json"` | ATIF trace file (outside the worktree — scratch, never in the diff) | + +### `--read-only` / `--network` are advisory for Devin + +Devin's `--sandbox` (Research Preview) is not relied on for enforcement in v1 (see SAFETY), so the worker would accept `--read-only` / `--network` for **CLI parity** only, exactly like Antigravity/Cursor. A read-only / review job is enforced post-hoc: pass an **empty** `--write-allowed`, and the git-diff gate treats any changed path as a violation ⇒ BLOCKED. + +### `--output-schema` is accepted but ignored + +Devin has no output-schema flag (VERIFIED live absence). Accept-and-ignore for CLI parity, like Antigravity/Cursor. + +### Model + effort: resolved before dispatch, not hardcoded + +The dispatcher resolves the concrete model **before** dispatch via [`scripts/compound-v-resolve-model.py`](../../scripts/compound-v-resolve-model.py) with `--backend devin --tier [--config .claude/compound-v.json]`. The built-in curated map (DOC-CLAIMED aliases — Devin's own `--help` text uses these exact strings as its examples, but no authenticated run has confirmed they resolve): `deep → claude-opus-4.6`, `standard → claude-sonnet-4`, `light → gpt-5.5`. Devin has **no list command**, so — like Codex — its map is curated + user-overridable (refresh via `/v:models`). An explicit manifest `model` override skips resolution and wins. + +**`xhigh` is codex-only.** Devin takes no separate reasoning-effort flag, so `effort` is advisory for this backend (like Claude/Antigravity/Cursor). `effort: xhigh` paired with `backend: devin` is rejected by both [`compound-v-resolve-model.py`](../../scripts/compound-v-resolve-model.py) (`ValueError` naming the rule) and [`compound-v-validate-manifest.py`](../../scripts/compound-v-validate-manifest.py) — the same backend-agnostic guard every non-codex backend gets. + +### Timeout — the shared process-group supervisor + +No built-in `--timeout` flag exists (VERIFIED live absence). A worker script would run Devin under [`scripts/compound-v-run-with-timeout.py`](../../scripts/compound-v-run-with-timeout.py) exactly like Codex/Antigravity/Cursor — `killpg`s the whole `devin` process tree on expiry and returns 124 → `status: "timeout"`. No external `timeout`/`gtimeout` binary needed. + +--- + +## Worktree lifecycle / Merge-back (draft, mirrors Antigravity/Cursor exactly) + +Identical in shape to [`adapter-antigravity.md`](adapter-antigravity.md) / [`adapter-cursor.md`](adapter-cursor.md): a fresh `git worktree add "$WT" HEAD` gives a clean diff baseline; worktrees would live **outside the repo** under `"${TMPDIR:-/tmp}"/compound-v//`; scratch (`$WT.art`: captured stdout/stderr, the `--export` ATIF file, the expanded allow-globs file) lives **outside** the worktree so it stays pristine; idempotent on resume (a stale worktree is `worktree remove -f`'d, falling back to `rm -rf` only after asserting the path sits under `$TMPDIR/compound-v/`); never removed on success by the worker itself — that's the caller's job after a successful `git apply`. The baseline SHA is captured **before** `worktree add` and passed as `--baseline ` so an in-worktree commit-to-hide-changes attempt is still diffed. + +Merge-back is **caller-only, PASS-only** — the script observes and reports, never merges: + +```bash +# PASS +git -C "$WT" add -A +git -C "$WT" diff --cached --binary HEAD | (cd "$REPO" && git apply --index) +git -C "$REPO" worktree remove -f "$WT" +# BLOCKED / timeout / error: do not merge; leave the worktree for inspection; eligible for /v:resume +``` + +--- + +## Resume + +Devin exposes a **real resumable session** — VERIFIED live `--help`: `-c, --continue` (most recent conversation) and `-r, --resume []` (a specific id, or an *interactive* picker if omitted — unusable headlessly, so a worker must always pass an explicit id). This is closer to **Cursor's** resumability than Codex's UUID-from-JSON-stream pattern, but **the capture mechanism is unverified**: the best VERIFIED-live candidate is `devin list --format json` (confirmed to work **without login**, returns `[]` cleanly in an empty dir) run in the worktree directory after the job — session listing appears to be scoped per-directory (DOC-CLAIMED), but the exact field name and whether it is populated immediately after `-p` exits is **unverified without an authenticated run**. Compound V's default **git-wins / fresh re-dispatch** tie-break still applies regardless of whether resume capture works. + +--- + +## Backend-failure classification (not yet built — needs real error samples) + +A `_DEVIN_RULES` table in [`scripts/compound-v-classify-failure.py`](../../scripts/compound-v-classify-failure.py) (mirroring the existing per-backend substring-match tables) is **not yet built**, because no real Devin failure text (expired `COGNITION_API_KEY`, rate-limit, ACU exhaustion, etc.) is available without an authenticated failing run — this plugin does not fabricate error-text signatures. Until built, a Devin worker script should fail closed to `failure_class: "other"` on any non-zero exit, matching the same fail-closed rule every adapter already applies when the classifier can't determine a specific class. **Flagged as follow-on work**, not part of this v1 change. + +--- + +## Worker script — draft only, not yet built + +Following the exact shape of [`scripts/compound-v-run-codex-worker.sh`](../../scripts/compound-v-run-codex-worker.sh) / `-antigravity-worker.sh` / `-cursor-worker.sh`, a `scripts/compound-v-run-devin-worker.sh` would be a straightforward port of the Cursor worker's structure (worktree lifecycle, `write_allowed` expansion into an allow-file, `compound-v-scope-check.py` invocation, `emit_job_result` via `jq`) with three backend-specific differences: (1) no `-f`/`--dangerously-skip-permissions`-equivalent flag name (`--permission-mode dangerous` instead), (2) `summary` comes straight from captured stdout (no `.result` JSON field to parse — the Antigravity pattern, not the Cursor one), (3) `session_id` extraction is a **best-effort, unverified** `devin list --format json` parse rather than a confirmed JSON field. Given (3) is unverified without a live account, **this v1 change documents the invocation here rather than shipping the worker script** — building it now would encode an unverified session-id extraction path as if it were proven. Building the script is real, scoped, buildable follow-on work once a Cognition account is available to verify the task-execution facts marked DOC-CLAIMED above. + +## Invoking the (future) script + +```bash +scripts/compound-v-run-devin-worker.sh \ + --run-id 2026-07-13-some-feature \ + --job-id task-1-build \ + --repo /abs/path/to/repo \ + --prompt-file /abs/path/to/jobs/task-1-build.prompt.md \ + --model "claude-sonnet-4" \ + --write-allowed "src/features/build/**" \ + --timeout-sec 900 \ + --network false # accepted for CLI parity, NOT enforced (see Safety) +# optional: --read-only true (advisory — enforced post-hoc via empty --write-allowed) +# --model is OPTIONAL: omit it (or pass "") to let devin use its configured default. +``` + +All file paths MUST be **absolute**. `--write-allowed` is a **colon-separated** glob list; an **empty** `--write-allowed` is a read-only/review job (any change ⇒ BLOCKED). `--timeout-sec` must be a **positive integer**. Devin requires an **authenticated** session (`devin auth login` / `COGNITION_API_KEY`) — `/v:init` records availability + auth (see `commands/v-init.md` §1a-quater). diff --git a/skills/backend-launcher/adapter-opencode.md b/skills/backend-launcher/adapter-opencode.md new file mode 100644 index 0000000..93d9906 --- /dev/null +++ b/skills/backend-launcher/adapter-opencode.md @@ -0,0 +1,180 @@ +# Adapter: opencode (headless `opencode run` worker) + +> Read the contract in [`SKILL.md`](SKILL.md) first — this adapter implements that `job_spec → job_result` interface. This file is the backend-specific runbook; the wiring would live in `scripts/compound-v-run-opencode-worker.sh` (**not yet built** — see "Worker script" below). + +The opencode backend is a **Bash-spawned `opencode run` worker** — its own process, its own git worktree. It mirrors the Antigravity / Cursor adapters step-for-step ([`adapter-antigravity.md`](adapter-antigravity.md), [`adapter-cursor.md`](adapter-cursor.md)): worktree isolation, a git-derived scope gate, normalize → `job_result`, caller merges. UNLIKE every other backend, opencode is **provider-agnostic / multi-provider** — its resolved `model` is always a `provider/model` string (e.g. `anthropic/claude-opus-4-6`), never a bare model name. + +Verified live against **opencode-ai 1.17.18** (npm, installed via `npm install -g opencode-ai`) on stock macOS. **This package ships new dev/beta builds multiple times per day** (`npm view opencode-ai --json` showed dist-tags timestamped within the hour of the original research probe) — re-probe the flag set at `/v:init` time; do not assume it is stable across even a few weeks. + +--- + +## ⚠️ SAFETY — lower-trust, opt-in, WORKER-ONLY backend (read first) + +**opencode has NO kernel write-confinement** — VERIFIED live by omission: the full `--help` and `run --help` output contains no `--sandbox`, `--read-only`, or equivalent OS-level isolation flag. Third-party plugins (`opencode-daytona`, `opencode-devcontainers`) can add real sandboxing, but none are core/bundled and none are used by this adapter. + +**opencode's default permission posture is the opposite of Cursor/Antigravity's refuse-until-trusted stance.** Per opencode's own docs (DOC-CLAIMED, high confidence, not independently live-verified against a real write attempt in this research pass): *"By default, OpenCode allows all operations without explicit approval."* Contrast: Cursor refuses to run in an untrusted directory without `-f`; Antigravity refuses to write without `--dangerously-skip-permissions`; **opencode allegedly allows everything by default**, and you opt IN to asking via `opencode.json`'s `permission.*` config. Treat this specific claim as **DOC-CLAIMED, not independently confirmed live** — verify with a real write-attempting probe (no `--auto`, no permission config) before relying on it as the safety story. + +**Net trust tier: opencode belongs in the same lower-trust, opt-in tier as Antigravity/Cursor** — the worktree + `git diff` scope gate detects an in-worktree scope leak but **cannot prevent** an out-of-worktree write or arbitrary shell side-effect, because there is no kernel boundary underneath it. If the "allows everything by default" doc claim holds, opencode may be the **most permissive of the three lower-trust backends by default**. **Prefer Codex for untrusted/high-stakes work; route to opencode only when the prompt/surface is trusted.** + +**MANDATORY mitigation — always ship a restrictive `opencode.json` into the worktree.** Because opencode's baseline posture is unverified-but-plausibly-wide-open, this adapter does NOT rely on it: the worker must write a minimal `opencode.json` pinning `permission: {"*": "ask"}` (or stricter) into `$WT` before invoking `opencode run`, then pass `--auto` to auto-approve exactly the non-denied subset — so the effective behavior matches the codex/cursor/antigravity precedent (writes happen, but only inside a worktree the scope gate can diff) rather than trusting opencode's undocumented-in-practice wide-open default. + +**CRITICAL — ambient-credential leak (load-bearing, live-observed).** opencode successfully authenticated and completed a real request with **ZERO** stored credentials (`opencode providers list` → `0 credentials`, file at `~/.local/share/opencode/auth.json`), purely by picking up an inherited `ANTHROPIC_BASE_URL` environment variable from the parent shell. **This is a real security-relevant finding, not a hypothetical:** + +> **The worker script MUST NOT blindly inherit the dispatcher's own provider environment variables into the `opencode run` child process.** If it does, a job could silently authenticate as the ORCHESTRATOR's own Claude/OpenAI/Anthropic credentials rather than a credential intentionally scoped to that job — an unintended privilege leak from the calling process into an isolated, lower-trust worker. The worker MUST explicitly pass through only a documented allow-list of provider env vars (or none at all, forcing `opencode providers login` / a real `auth.json`), never a raw environment inherit (`env -i` plus an explicit allow-list, or an explicit `unset` of every known provider var before exec, is the correct shape — pick one and document it in the worker script when built). + +**WORKER-ONLY — never an arbiter/review-panel seat (separate from the trust-tier question above).** opencode addresses models as `provider/model` strings, and the provider is allowed to differ **per tier cell** — so opencode's resolved model family is data-dependent, exactly like Devin. A `backend: opencode, model: "anthropic/claude-opus-4-6"` ballot would land in the SAME family bucket as the native Claude arbiter (`model_family()`'s existing substring heuristic), and a `backend: opencode, model: "openai/gpt-5.6-sol"` ballot would collapse with Codex's own bucket. Adding opencode to any arbiter panel without first keying family-dedup on the *resolved* model (never the backend name) would let a correlated ballot silently masquerade as an independent vote — **this adapter is worker dispatch only**; the family-dedup fix is a separate, later change. + +--- + +## The 6 load-bearing steps (draft — worker script not yet built) + +``` +1. ISOLATE git -C worktree add HEAD # clean diff baseline (NO kernel sandbox) +2. RUN cd && (write a scrubbed opencode.json into ) && \ + opencode run --dir --format json --auto \ + -m [--variant ] [-s ] \ + --title "compound-v-" "" --baseline # git-derived ∪ untracked ∪ ignored +4. ENFORCE every changed path ∉ write_allowed ⇒ violation ⇒ blocked (do NOT merge) +5. NORMALIZE → job_result (summary ← concat of `type:"text"` event parts; + session_id ← first JSONL line's `.sessionID`, a `ses_...`-shaped token, NOT a UUID) +6. MERGE caller, on PASS only: git -C add -A + git -C diff --cached --binary HEAD | (cd && git apply --index) → git worktree remove -f +``` + +Step 4 is the keystone — and because opencode has no kernel sandbox at all, worktree + `git diff` is the **only** enforcement this adapter relies on, exactly like Antigravity/Cursor. Steps 3–4 must be computed in git, never read from anything the model says it did, and would delegate to the deterministic authority [`scripts/compound-v-scope-check.py`](../../scripts/compound-v-scope-check.py) — the same gate the dispatcher runs after every job. A worker script for this backend must **not** re-implement glob matching in bash. + +**Only `write_allowed` would be enforced; `read_allowed` stays advisory** — same rule as every lower-trust adapter. + +--- + +## Worker-prompt planner/executor lock + +Every dispatched `prompt` MUST open with this lock (verbatim-in-spirit), exactly as the contract in `SKILL.md` requires: + +> You are an implementation worker, NOT the planner. Do not change architecture. Do not write outside WRITE_ALLOWED. If the task needs a forbidden file, STOP and report BLOCKED. + +This is the *instructed* half of planner/executor separation; the git-diff scope gate (steps 3–4) is the *enforced* half. Because opencode has no kernel sandbox and (per its own docs) a plausibly wide-open default posture, the lock carries the same weight it does for Antigravity/Cursor — instruction only. + +--- + +## Pinned `opencode run` invocation (opencode-ai 1.17.18 — RE-PROBE OFTEN, see version-churn note above) + +```bash +python3 "$SUPERVISOR" --timeout "$timeout_sec" --grace 3 -- \ + opencode run \ + --dir "$WT" \ + --format json \ + --auto \ + -m "$model" \ + ${effort:+--variant "$effort"} \ + ${session_id:+-s "$session_id"} \ + --title "compound-v-$job_id" \ + "$(cat "$prompt_file")" "$events_log" 2>"$stderr_log" +``` + +**Verified facts — load-bearing:** + +- **`--dir "$WT"`** — VERIFIED live flag: *"directory to run in"*. opencode has a real `--cd`-equivalent, unlike Antigravity/Cursor/Devin. +- **`--format json`** — VERIFIED live: JSONL event stream to stdout, one JSON object per line. Sample captured live: + ```json + {"type":"step_start","timestamp":...,"sessionID":"ses_0a775d09dffe4xAFfV57IyRwki","part":{...}} + {"type":"text","timestamp":...,"sessionID":"ses_...","part":{"id":"...","type":"text","text":"Hi!",...}} + {"type":"step_finish","timestamp":...,"sessionID":"ses_...","part":{"reason":"stop",...}} + ``` + Every event line carries `sessionID`. **There is no `--output-last-message` equivalent** — the worker must build `summary` by concatenating (or taking the last of) every `.part.text` from `type:"text"` events, and parse `session_id` from the **first** line's `.sessionID` field. +- **`--auto`** — VERIFIED live flag: *"auto-approve permissions that are not explicitly denied (dangerous!)"*. Required for any unattended write, paired with the mandatory pinned `opencode.json` above (see SAFETY). +- **`-m "$model"`** — VERIFIED live flag (`-m, --model`): *"model to use in the format of provider/model"*. Must be the **resolved** `provider/model` string (see Model resolution, below) — a bare name will likely fail opencode's own model resolution even though the flag accepts it syntactically. +- **`--variant "$effort"`** — VERIFIED live flag exists: *"model variant (provider-specific reasoning effort, e.g. high, max, minimal)"* — **NOT** the same vocabulary as Compound V's `low/medium/high/xhigh`. Optional; omit unless the resolved provider is known to accept the value (best-effort, provider-dependent — not independently verified live in this research pass whether an unrecognized value is silently ignored or errors). +- **`-s "$session_id"`** — VERIFIED live flag (`-s, --session`): resume a specific session id. +- **`--title`** — VERIFIED live flag; cosmetic, helps `opencode session list` correlate to Compound V's own job ids. +- **stdin ` [--config .claude/compound-v.json]`. **Design point (no schema change needed):** the resolver already treats every `{tier: model}` cell as an opaque string — opencode's convention is simply that each cell's value is a full `provider/model` string, and the **provider is allowed to differ per cell** (unlike every other backend's single-vendor map). The built-in curated map: + +``` +deep → anthropic/claude-opus-4-6 +standard → openai/gpt-5.6-terra +light → opencode/mimo-v2.5-free # a real, credential-free model — verified live via `opencode models` +``` + +`light` legitimately points at one of opencode's own curated **free** models — VERIFIED live via `opencode models` (works with zero configured credentials, backed by models.dev): `opencode/big-pickle`, `opencode/deepseek-v4-flash-free`, `opencode/hy3-free`, `opencode/mimo-v2.5-free`, `opencode/nemotron-3-ultra-free`, `opencode/north-mini-code-free`. This is the **one backend where a real free tier exists out of the box** — no other backend in this plugin offers that. `opencode models [provider]` is real live discovery (unlike Codex/Devin's curated-only pattern); `/v:models` shows the catalog, but assignment stays curated + user-confirmed (opencode's catalog spans unrelated vendor families with no shared naming convention, mirroring the Cursor precedent — Compound V does not auto-rank it). + +An explicit manifest `model` override skips resolution and wins. + +**`xhigh` is codex-only.** opencode's own effort vocabulary is `--variant` (provider-specific: high/max/minimal), never `xhigh`. `effort: xhigh` paired with `backend: opencode` is rejected by both [`compound-v-resolve-model.py`](../../scripts/compound-v-resolve-model.py) and [`compound-v-validate-manifest.py`](../../scripts/compound-v-validate-manifest.py) — the same backend-agnostic guard every non-codex backend gets. + +### Timeout — the shared process-group supervisor + +No built-in `--timeout` flag was found in `--help`. A worker script would run opencode under [`scripts/compound-v-run-with-timeout.py`](../../scripts/compound-v-run-with-timeout.py) exactly like Codex/Antigravity/Cursor/Devin — `killpg`s the whole `opencode` process tree on expiry and returns 124 → `status: "timeout"`. Small-sample live testing showed clean, fast (~2s) exit-1 behavior on a forced bad-model error (no hang), consistent with the supervisor's exit-code contract. + +--- + +## Worktree lifecycle / Merge-back (draft, mirrors Antigravity/Cursor exactly) + +Identical in shape to [`adapter-antigravity.md`](adapter-antigravity.md) / [`adapter-cursor.md`](adapter-cursor.md): a fresh `git worktree add "$WT" HEAD` gives a clean diff baseline; worktrees would live **outside the repo** under `"${TMPDIR:-/tmp}"/compound-v//`; scratch (`$WT.art`: captured JSONL events log + stderr + the pinned `opencode.json` + the expanded allow-globs file) lives **outside** the worktree so it stays pristine; idempotent on resume; never removed on success by the worker itself. The baseline SHA is captured **before** `worktree add` and passed as `--baseline ` to the scope gate. + +Merge-back is **caller-only, PASS-only**: + +```bash +# PASS +git -C "$WT" add -A +git -C "$WT" diff --cached --binary HEAD | (cd "$REPO" && git apply --index) +git -C "$REPO" worktree remove -f "$WT" +# BLOCKED / timeout / error: do not merge; leave the worktree for inspection; eligible for /v:resume +``` + +--- + +## Resume + +opencode exposes real session resumability — VERIFIED live: `-c`/`--continue` (last session), `-s`/`--session ` (a specific id), `--fork` (fork instead of continuing in place). Every JSONL event line carries `sessionID` (VERIFIED live, e.g. `ses_0a775d09dffe4xAFfV57IyRwki`). **This is a custom prefixed token, NOT an RFC-4122 UUID** — the Codex worker's UUID-anchor regex validator (`^[0-9a-fA-F]{8}-...$`) must NOT be reused verbatim for opencode; a new, looser validator (non-empty, `ses_` prefix, safe charset) would be needed. `opencode session list [--max-count N] [table|json]` and `opencode session delete ` also exist (VERIFIED live via `--help`) — useful for cleanup/liveness, analogous to the Codex worker's events-log. Compound V's default **git-wins / fresh re-dispatch** tie-break still applies regardless. + +--- + +## Backend-failure classification (not yet built — needs real per-provider error samples) + +opencode emits `{"type":"error","error":{"name":...,"data":{"message":...}}}` as a JSONL event on failure (VERIFIED live for an unknown-model case: exit 1, no hang, ~2s). A needle-set for [`scripts/compound-v-classify-failure.py`](../../scripts/compound-v-classify-failure.py) `--backend opencode` is **not yet built** — provider errors surface differently per-provider (an Anthropic 429 looks different from an OpenAI 429), which is a genuinely harder classification problem than any other backend faces, precisely BECAUSE opencode proxies multiple providers. Until built, a worker script should fail closed to `failure_class: "other"` for anything not exactly matched, same pattern the Cursor adapter already uses for its own "provisional" needles. **Flagged as follow-on work**, not part of this v1 change. + +--- + +## Worker script — draft only, not yet built + +Following the exact shape of [`scripts/compound-v-run-codex-worker.sh`](../../scripts/compound-v-run-codex-worker.sh) / `-antigravity-worker.sh` / `-cursor-worker.sh`, a `scripts/compound-v-run-opencode-worker.sh` would be a straightforward port of the Cursor worker's structure (worktree lifecycle, `write_allowed` expansion, `compound-v-scope-check.py` invocation, `emit_job_result` via `jq`) with FOUR backend-specific differences that make it more than a copy-paste: (1) it must **write a pinned, restrictive `opencode.json` into `$WT`** before invoking `opencode run` (Safety); (2) it must **explicitly scrub the dispatcher's own provider environment variables** before exec'ing `opencode` (the ambient-credential-leak finding — pick an `env -i` + allow-list shape or explicit `unset`s, and prove live that the worker still authenticates correctly with only the intended credential path); (3) `session_id` extraction parses the FIRST JSONL line's `.sessionID` and validates against a `ses_`-prefixed safe-charset pattern, NOT the Codex worker's UUID regex; (4) `summary` is built by concatenating every `.part.text` from `type:"text"` events, since there is no `--output-last-message` file. Given (2) is security-load-bearing and untested end-to-end, **this v1 change documents the invocation here rather than shipping the worker script** — building it now without live-verifying the env-scrub actually prevents credential leakage would risk encoding a false sense of safety. Building the script is real, scoped, buildable follow-on work. + +## Invoking the (future) script + +```bash +scripts/compound-v-run-opencode-worker.sh \ + --run-id 2026-07-13-some-feature \ + --job-id task-1-build \ + --repo /abs/path/to/repo \ + --prompt-file /abs/path/to/jobs/task-1-build.prompt.md \ + --model "anthropic/claude-sonnet-4-6" \ + --write-allowed "src/features/build/**" \ + --timeout-sec 900 \ + --network false +# optional: --effort medium (→ --variant medium, best-effort/provider-dependent) +# optional: --read-only true (advisory — enforced post-hoc via empty --write-allowed) +``` + +All file paths MUST be **absolute**. `--write-allowed` is a **colon-separated** glob list; an **empty** `--write-allowed` is a read-only/review job (any change ⇒ BLOCKED). `--timeout-sec` must be a **positive integer**. `--model` MUST be a genuine `provider/model` string (a bare name will likely fail opencode's own resolution). opencode requires **either** a stored provider credential (`opencode providers login`) **or** an intentionally-set provider env var — `/v:init` records which (see `commands/v-init.md` §1a-quinquies). diff --git a/skills/compound-v/execution-manifest.md b/skills/compound-v/execution-manifest.md index ab10660..4d90416 100644 --- a/skills/compound-v/execution-manifest.md +++ b/skills/compound-v/execution-manifest.md @@ -35,7 +35,7 @@ Worked example: [`examples/manifest.example.yaml`](../../examples/manifest.examp | `id` | string | yes | Unique job id within the run (e.g. `task-1-editor-ui`). | | `title` | string | yes | One-line job title. | | `type` | string | yes | Job-type token used by the routing policy (e.g. `shared_foundation`, `bounded_crud`, `large_isolated`, `core_slice`, `mechanical_refactor`, `docs`, `tests_new`, `external_api`, `review`). | -| `backend` | enum | yes | `claude` \| `codex` \| `antigravity` \| `cursor`. **Execution-layer data — NEVER appears in any frontmatter.** (`antigravity`/`cursor` are opt-in, lower-trust, no kernel sandbox ⇒ always `worktree`.) | +| `backend` | enum | yes | `claude` \| `codex` \| `antigravity` \| `cursor` \| `devin` \| `opencode`. **Execution-layer data — NEVER appears in any frontmatter.** (`antigravity`/`cursor`/`opencode` are opt-in, lower-trust, no kernel sandbox ⇒ always `worktree`; `devin` has a Research-Preview kernel sandbox treated as unverified/no-confinement for v1 ⇒ also always `worktree`. `devin`/`opencode` are **worker-only** — never a routable arbiter/review-panel seat, since both are multi-provider brokers whose resolved model family is data-dependent.) | | `tier` | enum | yes¹ | `deep` \| `standard` \| `light`. The **intent** the routing policy assigns; the dispatcher resolves it to a concrete model. Stable vocabulary that survives model churn. | | `effort` | enum | no | `low` \| `medium` \| `high` \| `xhigh`. Orthogonal reasoning-effort hint. Default pairing `deep→high`, `standard→medium`, `light→low`, but independently tunable per task-type. For `codex` it maps to `-c model_reasoning_effort=`; for `claude` it is advisory (the `Task` path has no separate effort flag). `xhigh` is valid **iff** `backend: codex`; every other backend rejects it with a clear error naming the rule (use `high` instead). | | `model` | string | no¹ | Explicit override, e.g. `opus`, `sonnet`, `gpt-5.6-sol`. When present it **skips resolution** (the manifest pins the model directly). Execution-layer data — never in frontmatter. Backward-compatible: pre-tier manifests carrying only `model` remain valid. | @@ -54,17 +54,17 @@ Worked example: [`examples/manifest.example.yaml`](../../examples/manifest.examp | Tier | Strongest fit | Routes to (Balanced) | |---|---|---| -| `deep` | Strongest reasoning: architecture, security/auth/payments, designing tests, external APIs, **ALL reviewers**, shared-foundation Task 0. | claude `opus`, codex `gpt-5.6-sol`, antigravity top model, cursor `auto`. | -| `standard` | Bounded core/feature build, incl. large isolated codex work. | claude `opus` (`sonnet` under the `cost-aware` stance), codex `gpt-5.6-terra`, antigravity mid model, cursor `auto`. | -| `light` | Mechanical single-file / docs / i18n. | claude `sonnet`, codex `gpt-5.6-luna`, antigravity flash model, cursor `auto`. | +| `deep` | Strongest reasoning: architecture, security/auth/payments, designing tests, external APIs, **ALL reviewers**, shared-foundation Task 0. | claude `opus`, codex `gpt-5.6-sol`, antigravity top model, cursor `auto`, devin `claude-opus-4.6`, opencode `anthropic/claude-opus-4-6`. | +| `standard` | Bounded core/feature build, incl. large isolated codex work. | claude `opus` (`sonnet` under the `cost-aware` stance), codex `gpt-5.6-terra`, antigravity mid model, cursor `auto`, devin `claude-sonnet-4`, opencode `openai/gpt-5.6-terra`. | +| `light` | Mechanical single-file / docs / i18n. | claude `sonnet`, codex `gpt-5.6-luna`, antigravity flash model, cursor `auto`, devin `gpt-5.5`, opencode `opencode/mimo-v2.5-free` (a real credential-free model). | `effort ∈ {low, medium, high, xhigh}` is orthogonal to tier. The default pairing (`deep→high`, `standard→medium`, `light→low`) is just a default — a task-type may pin a different effort independently. `xhigh` is valid **iff** `backend: codex`; every other backend rejects it with a clear error naming the rule (use `high` instead) — it maps to codex's `model_reasoning_effort=xhigh` (live-verified 2026-07-11 on codex-cli 0.144.1). -Resolution is **stance-aware**: the `standard` Claude row resolves to `opus` under the `balanced` stance and `sonnet` under `cost-aware` (the resolver's `cost-aware.claude.standard = sonnet`; `cost-aware.claude.deep` stays `opus`). The dispatcher reads the manifest's `routing_stance` and passes it (`--stance`) to the resolver on every resolve; omitting it defaults to `balanced`. Only the `standard` Claude cell shifts — `deep` (incl. all reviewers + sensitive surfaces) is `opus` in every stance, and `codex`/`antigravity`/`cursor` are identical across stances. +Resolution is **stance-aware**: the `standard` Claude row resolves to `opus` under the `balanced` stance and `sonnet` under `cost-aware` (the resolver's `cost-aware.claude.standard = sonnet`; `cost-aware.claude.deep` stays `opus`). The dispatcher reads the manifest's `routing_stance` and passes it (`--stance`) to the resolver on every resolve; omitting it defaults to `balanced`. Only the `standard` Claude cell shifts — `deep` (incl. all reviewers + sensitive surfaces) is `opus` in every stance, and `codex`/`antigravity`/`cursor`/`devin`/`opencode` are identical across stances. ### Config `models` map (project `.claude/compound-v.json`) -The concrete model behind each tier lives in a **refreshable** map in the project config — not hardcoded in any job. This is what lets the plugin survive model churn: when models change, refresh the map (`/v:models`), not the manifests. The map is **per-stance** — its shape is `{: {: {: model}}}`. Only the `claude` rows differ across stances (`cost-aware.claude.standard = sonnet`; everywhere else `standard` is `opus`); `codex`/`antigravity`/`cursor` are identical in every stance: +The concrete model behind each tier lives in a **refreshable** map in the project config — not hardcoded in any job. This is what lets the plugin survive model churn: when models change, refresh the map (`/v:models`), not the manifests. The map is **per-stance** — its shape is `{: {: {: model}}}`. Only the `claude` rows differ across stances (`cost-aware.claude.standard = sonnet`; everywhere else `standard` is `opus`); `codex`/`antigravity`/`cursor`/`devin`/`opencode` are identical in every stance. `opencode`'s cells are full `provider/model` strings (the provider may legitimately differ per tier — no schema change, the resolver already treats every cell as opaque): ```jsonc "models": { @@ -72,13 +72,17 @@ The concrete model behind each tier lives in a **refreshable** map in the projec "claude": { "deep": "opus", "standard": "opus", "light": "sonnet" }, "codex": { "deep": "gpt-5.6-sol", "standard": "gpt-5.6-terra", "light": "gpt-5.6-luna" }, "antigravity": { "deep": "Gemini 3.1 Pro (High)", "standard": "Gemini 3.1 Pro (Low)", "light": "Gemini 3.5 Flash (Low)" }, - "cursor": { "deep": "auto", "standard": "auto", "light": "auto" } + "cursor": { "deep": "auto", "standard": "auto", "light": "auto" }, + "devin": { "deep": "claude-opus-4.6", "standard": "claude-sonnet-4", "light": "gpt-5.5" }, + "opencode": { "deep": "anthropic/claude-opus-4-6", "standard": "openai/gpt-5.6-terra", "light": "opencode/mimo-v2.5-free" } }, "cost-aware": { "claude": { "deep": "opus", "standard": "sonnet", "light": "sonnet" }, "codex": { "deep": "gpt-5.6-sol", "standard": "gpt-5.6-terra", "light": "gpt-5.6-luna" }, "antigravity": { "deep": "Gemini 3.1 Pro (High)", "standard": "Gemini 3.1 Pro (Low)", "light": "Gemini 3.5 Flash (Low)" }, - "cursor": { "deep": "auto", "standard": "auto", "light": "auto" } + "cursor": { "deep": "auto", "standard": "auto", "light": "auto" }, + "devin": { "deep": "claude-opus-4.6", "standard": "claude-sonnet-4", "light": "gpt-5.5" }, + "opencode": { "deep": "anthropic/claude-opus-4-6", "standard": "openai/gpt-5.6-terra", "light": "opencode/mimo-v2.5-free" } } // conservative + claude-only mirror balanced } diff --git a/skills/compound-v/routing-policy.md b/skills/compound-v/routing-policy.md index 879514c..6c0a1c9 100644 --- a/skills/compound-v/routing-policy.md +++ b/skills/compound-v/routing-policy.md @@ -286,12 +286,19 @@ These hold in **every** stance and are checked by `compound-v-validate-manifest. tier — `tier: deep` **OR** an explicit `model: opus`. (`deep` resolves to `opus` for claude, so this mirrors the frontmatter rule that reviewers/agents always carry `model: opus`.) -2. **Codex / Antigravity / Cursor ⇒ worktree.** Any `backend: codex`, `backend: antigravity`, - **or** `backend: cursor` job MUST be `isolation: worktree`. All three are external workers - with no per-file enforcement of their own: Codex's sandbox restricts writes only to a - *directory*, and Antigravity and Cursor have **no kernel sandbox at all** (Cursor's headless - `-f` grants arbitrary write+shell) — so worktree + `git diff` is the only file-scope - enforcement they get. The validator rejects any of these backends with `isolation: direct`. +2. **Codex / Antigravity / Cursor / Devin / opencode ⇒ worktree.** Any `backend: codex`, + `backend: antigravity`, `backend: cursor`, `backend: devin`, **or** `backend: opencode` + job MUST be `isolation: worktree`. All five are external workers with no per-file + enforcement of their own: Codex's sandbox restricts writes only to a *directory*; + Antigravity, Cursor, and opencode have **no kernel sandbox at all** (Cursor's headless + `-f` grants arbitrary write+shell; opencode defaults to allowing all operations); Devin + has a live but Research-Preview `--sandbox` whose coverage is unverified and is treated + as no-confinement for enforcement purposes (v1) — so worktree + `git diff` is the only + file-scope enforcement any of the five external backends actually get. The validator + rejects any of these backends with `isolation: direct`. `devin` and `opencode` are also + **worker-only** — never a routable arbiter/review-panel seat (see + [`adapter-devin.md`](../backend-launcher/adapter-devin.md) / + [`adapter-opencode.md`](../backend-launcher/adapter-opencode.md)). 3. **Unclear scope ⇒ return to planning.** A job whose scope the planner cannot pin never dispatches with a guessed partition — it goes back to writing-plans. 4. **Model OR tier.** Every job MUST carry at least one of `model` or `tier`. A job From 52ce12ed662fc1e0be55d3539b4c31027a1c5725 Mon Sep 17 00:00:00 2001 From: Oleg Kopachovets Date: Mon, 13 Jul 2026 10:40:52 +0100 Subject: [PATCH 2/4] feat(backends): headless worker scripts for Devin + opencode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the codex/cursor worker template: worktree-at-HEAD (caller owns git base), timeout supervisor with error). bash -n + shellcheck clean; E2E verified with inert stand-ins (success/BLOCKED/timeout). Co-Authored-By: Claude Opus 4.8 --- scripts/compound-v-run-devin-worker.sh | 489 +++++++++++++++++ scripts/compound-v-run-opencode-worker.sh | 617 ++++++++++++++++++++++ 2 files changed, 1106 insertions(+) create mode 100755 scripts/compound-v-run-devin-worker.sh create mode 100755 scripts/compound-v-run-opencode-worker.sh diff --git a/scripts/compound-v-run-devin-worker.sh b/scripts/compound-v-run-devin-worker.sh new file mode 100755 index 0000000..0f58396 --- /dev/null +++ b/scripts/compound-v-run-devin-worker.sh @@ -0,0 +1,489 @@ +#!/usr/bin/env bash +# +# compound-v-run-devin-worker.sh — Compound V Backend Launcher: the headless Devin (`devin -p`) adapter. +# +# MIRRORS compound-v-run-antigravity-worker.sh (which mirrors the codex worker). Runs ONE +# file-scoped job on a headless `devin -p` worker inside a dedicated git worktree, then emits +# the canonical job_result (schemas/job_result.schema.json) on stdout as JSON. The enforcement +# fields (blocked / files_changed / violations) are GIT-DERIVED, never self-reported by the +# model — produced by DELEGATING to the deterministic Python authority +# (scripts/compound-v-scope-check.py), the same gate the dispatcher runs after every job. The +# worker does NOT re-implement glob matching in bash. Devin's printed stdout response feeds +# only `summary` (Devin has no `--output-last-message`/`--json` structured envelope). +# +# Contract: skills/backend-launcher/SKILL.md + skills/backend-launcher/adapter-devin.md +# +# !!! AUTH-PENDING — READ BEFORE TRUSTING THIS SCRIPT'S OUTPUT SEMANTICS !!! +# This script was built and syntax/logic-verified WITHOUT an authenticated Cognition account +# (no `devin auth login` / `COGNITION_API_KEY` available on the build host). Two things are +# explicitly DOC-CLAIMED / UNVERIFIED, not proven by a live run, and are marked TODO below: +# 1. session_id — Devin exposes real resume (`-r/--resume `), and the best +# VERIFIED-live candidate to capture an id is `devin list --format json` run in the +# worktree after the job, but the exact field name/timing is UNVERIFIED without a real +# account. This worker does NOT attempt that parse — session_id is always emitted "". +# 2. failure classification — no real Devin failure text (expired key, rate limit, ACU +# exhaustion) exists to build a `_DEVIN_RULES` table in compound-v-classify-failure.py +# (whose --backend flag does not even list "devin" yet). This worker does a BEST-EFFORT +# exit-code + empty-stdout heuristic only, and fails closed to failure_class: "other" on +# any non-zero-exit / empty-output outcome — exactly the interim rule adapter-devin.md +# documents. Re-verify both against a real account before promoting either to pinned. +# +# !!! SAFETY — LOWER-TRUST BACKEND (read before using) !!! +# Devin HAS a real kernel-sandbox flag (`--sandbox`, Research Preview) but its coverage of +# Devin's own (non-shell) file-edit tool calls, and its network-filtering, are both +# UNVERIFIED/self-described-unstable by Cognition — see adapter-devin.md SAFETY. This worker +# does NOT pass `--sandbox`; `--permission-mode dangerous` (REQUIRED for unattended writes) +# removes Devin's own approval rail entirely. The worktree + post-hoc `git diff` gate is +# therefore the ONLY enforcement this adapter relies on — same opt-in, lower-trust tier as +# Antigravity/Cursor. Prefer Codex (kernel-sandboxed) for untrusted / high-stakes work. +# +# Portability: stock-macOS bash 3.2.57 (NO associative arrays / mapfile / ${var,,}) +# + jq. shellcheck-clean. Absolute paths throughout. Caller (the dispatcher) owns the +# merge-back decision; this script only OBSERVES and REPORTS — it never merges. +# +# Usage: +# compound-v-run-devin-worker.sh \ +# --run-id --job-id --repo \ +# --prompt-file [--model ] \ +# --write-allowed "[:...]" \ +# [--timeout-sec ] [--network true|false] \ +# [--read-only true|false] [--output-schema ] +# +# --model is OPTIONAL: when empty, `--model` is omitted from the devin invocation and devin +# uses its configured default. --output-schema is ACCEPTED for CLI parity with the codex +# worker but IGNORED — devin has no output-schema flag. --network is advisory only (Devin's +# sandbox network-filtering is explicitly "currently unstable" per Cognition's own docs). +# --read-only is ADVISORY: enforced POST-HOC by the gate (empty --write-allowed => any write +# BLOCKS), same as every lower-trust adapter — devin has no propose-only headless mode. +# +# All file paths MUST be absolute. write_allowed is a colon-separated glob list, each glob +# matched repo-relative against the changed paths. An EMPTY --write-allowed (read-only / +# review job) means no writes are permitted, so ANY changed path is a violation => BLOCKED. +# +# Exit code: 0 when the job_result was produced (even for a BLOCKED/timeout/error job — those +# are reported IN job_result.status). Non-zero only on a usage / environment fault. + +set -euo pipefail + +# --- constants --------------------------------------------------------------- +TIMEOUT_EXIT_CODE=124 # GNU/BSD `timeout` convention when the limit fires +DEFAULT_TIMEOUT_SEC=900 +DEFAULT_NETWORK=false +DEFAULT_READ_ONLY=false +# Bound Devin's captured stdout/stderr on disk (compound-v-run-with-timeout.py's own +# --max-output-bytes: a bounded, drain-and-discard pump so a runaway/looping worker can +# never fill the disk or block on a full pipe — see that script's docstring). +MAX_OUTPUT_BYTES=5000000 + +# --- helpers ----------------------------------------------------------------- + +die() { + # Environment/usage fault: no job_result could be produced. + echo "compound-v-run-devin-worker: $1" >&2 + exit 2 +} + +# Emit the canonical job_result JSON on stdout, built entirely with jq so every +# field is correctly typed and escaped. files_changed / violations arrive as JSON +# ARRAYS (straight from the gate's NUL-correct output) and pass through untouched — +# no newline round-trip, so a path containing a newline stays ONE element. +emit_job_result() { + # $1 status $2 blocked(true|false) $3 files_json (JSON array) $4 violations_json (JSON array) + # $5 summary $6 session_id $7 worktree $8 exit_code(int) $9 failure_class ("" => null) + # ${10} retry_after_seconds(int, 0 when unknown) + jq -n \ + --arg status "$1" \ + --argjson blocked "$2" \ + --argjson files "$3" \ + --argjson violations "$4" \ + --arg summary "$5" \ + --arg session_id "$6" \ + --arg worktree "$7" \ + --argjson exit_code "$8" \ + --arg failure_class "$9" \ + --argjson retry_after_seconds "${10}" \ + '{ + status: $status, + blocked: $blocked, + files_changed: $files, + violations: $violations, + summary: $summary, + session_id: $session_id, + worktree: $worktree, + exit_code: $exit_code, + failure_class: (if $failure_class == "" then null else $failure_class end), + retry_after_seconds: $retry_after_seconds + }' +} + +# Validate an id (run_id / job_id) against a strict safe-character allow-list. +# These ids become PATH SEGMENTS under $TMPROOT/compound-v/, so a `../` or any +# separator could escape the tree and let the cleanup `rm -rf` delete arbitrary +# dirs. Allow only [A-Za-z0-9._-]; reject `.` and `..`; reject empty. Returns 0 +# when safe, 1 otherwise. bash 3.2-safe (case glob, no regex/associative arrays). +id_is_safe() { + _id="$1" + [ -n "$_id" ] || return 1 + case "$_id" in + .|..) return 1 ;; + *[!A-Za-z0-9._-]*) return 1 ;; + esac + return 0 +} + +# Directory of THIS script (resolves the sibling Python scope gate authority). +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# --- argument parsing -------------------------------------------------------- + +RUN_ID="" +JOB_ID="" +REPO="" +PROMPT_FILE="" +MODEL="" +WRITE_ALLOWED="" +TIMEOUT_SEC="$DEFAULT_TIMEOUT_SEC" +NETWORK="$DEFAULT_NETWORK" +READ_ONLY="$DEFAULT_READ_ONLY" +OUTPUT_SCHEMA="" + +while [ $# -gt 0 ]; do + case "$1" in + --run-id) RUN_ID="$2"; shift 2 ;; + --job-id) JOB_ID="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + --prompt-file) PROMPT_FILE="$2"; shift 2 ;; + --model) MODEL="$2"; shift 2 ;; + --write-allowed) WRITE_ALLOWED="$2"; shift 2 ;; + --timeout-sec) TIMEOUT_SEC="$2"; shift 2 ;; + --network) NETWORK="$2"; shift 2 ;; + --read-only) READ_ONLY="$2"; shift 2 ;; + --output-schema) OUTPUT_SCHEMA="$2"; shift 2 ;; + *) die "unknown argument: $1" ;; + esac +done + +# --- validation -------------------------------------------------------------- + +[ -n "$RUN_ID" ] || die "--run-id is required" +[ -n "$JOB_ID" ] || die "--job-id is required" +[ -n "$REPO" ] || die "--repo is required" +[ -n "$PROMPT_FILE" ] || die "--prompt-file is required" +# NOTE: --model is OPTIONAL for devin. When empty, --model is omitted from the devin +# invocation and devin uses its configured default. +# NOTE: --write-allowed may legitimately be EMPTY for a read-only / review job. +# An empty allow-list means NO writes are permitted, so ANY changed path is a +# violation (the scope gate, run with zero allowed globs, blocks everything). + +# --timeout-sec is interpolated into the supervisor invocation; pin it to a positive +# integer BEFORE it is ever used (defence against argv/arithmetic injection). +case "$TIMEOUT_SEC" in + ''|*[!0-9]*) die "--timeout-sec must be a positive integer: $TIMEOUT_SEC" ;; +esac +[ "$TIMEOUT_SEC" -gt 0 ] || die "--timeout-sec must be > 0 (got $TIMEOUT_SEC): a 0 cap would kill the job instantly" + +# Path-traversal guard: run_id / job_id become path segments under $TMPROOT, and +# the stale-worktree cleanup does `rm -rf` on that path. A `../` (or any path +# separator) in an id would escape the tree and delete arbitrary dirs. Validate +# BEFORE building any path or touching the filesystem. +id_is_safe "$RUN_ID" || die "--run-id has invalid characters (allowed: A-Za-z0-9._-, not . or ..): $RUN_ID" +id_is_safe "$JOB_ID" || die "--job-id has invalid characters (allowed: A-Za-z0-9._-, not . or ..): $JOB_ID" + +case "$REPO" in /*) : ;; *) die "--repo must be an absolute path: $REPO" ;; esac +case "$PROMPT_FILE" in /*) : ;; *) die "--prompt-file must be an absolute path: $PROMPT_FILE" ;; esac +[ -d "$REPO" ] || die "--repo is not a directory: $REPO" +[ -f "$PROMPT_FILE" ] || die "--prompt-file not found: $PROMPT_FILE" +# --output-schema is accepted for CLI parity with the codex worker but devin has no +# such flag, so it is IGNORED. We still validate the path shape if one is passed, +# to keep the contract honest (a caller passing a bogus path learns about it). +if [ -n "$OUTPUT_SCHEMA" ]; then + case "$OUTPUT_SCHEMA" in /*) : ;; *) die "--output-schema must be absolute: $OUTPUT_SCHEMA" ;; esac + [ -f "$OUTPUT_SCHEMA" ] || die "--output-schema not found: $OUTPUT_SCHEMA" +fi +# --read-only / --network are ACCEPTED for CLI parity with the codex worker but are +# ADVISORY ONLY for devin: --sandbox (Research Preview) is not relied on for enforcement +# here (see SAFETY), so neither flag changes the invocation. Reference them here so the +# contract is explicit and the parser-set vars are intentionally consumed. +: "advisory (devin --sandbox not relied on for enforcement): read_only=$READ_ONLY network=$NETWORK" + +command -v jq >/dev/null 2>&1 || die "jq not found on PATH" +command -v git >/dev/null 2>&1 || die "git not found on PATH" +command -v python3 >/dev/null 2>&1 || die "python3 not found on PATH (scope gate needs it)" +command -v devin >/dev/null 2>&1 || die "devin not found on PATH" + +# Wall-clock cap: run devin under the shared PROCESS-GROUP timeout supervisor +# (scripts/compound-v-run-with-timeout.py) — devin has NO built-in --timeout flag +# (verified live absence). On expiry the supervisor killpg's the whole devin process +# tree (not just the direct child) and returns 124. No external timeout/gtimeout needed. +SUPERVISOR="$SCRIPT_DIR/compound-v-run-with-timeout.py" +[ -f "$SUPERVISOR" ] || die "timeout supervisor not found: $SUPERVISOR" + +# --- worktree lifecycle ------------------------------------------------------ +# Worktrees live OUTSIDE the repo, under $TMPDIR, so no .gitignore change is needed. + +TMPROOT="${TMPDIR:-/tmp}" +TMPROOT="${TMPROOT%/}" +# Require an ABSOLUTE tmp root: a relative $TMPDIR would resolve the worktree against the +# caller's cwd (possibly INSIDE the repo), defeating isolation and the scope diff. +case "$TMPROOT" in + /*) : ;; + *) die "TMPDIR must be an absolute path (got: $TMPROOT)" ;; +esac +# Canonicalize the tmp root up front (resolve the /var → /private/var class of symlink) and +# build the parent from the REAL path, so no symlinked component can redirect the worktree. +TMPROOT_REAL="$(cd "$TMPROOT" 2>/dev/null && pwd -P)" || die "TMPDIR does not exist: $TMPROOT" +WT_PARENT="$TMPROOT_REAL/compound-v" +# Reject a pre-planted symlink at the parent (it could redirect writes/cleanup out of tmp). +# We keep the DETERMINISTIC $RUN_ID/$JOB_ID path (not a random `mktemp -d`) on purpose: +# idempotent re-dispatch + cleanup on resume locate the worktree by exactly that path. +[ -L "$WT_PARENT" ] && die "refusing: worktree parent is a symlink: $WT_PARENT" +WT="$WT_PARENT/$RUN_ID/$JOB_ID" + +# Defence-in-depth: even with the id-character guard above, ASSERT WT sits +# strictly under $TMPROOT/compound-v/ before any remove/rm. The destructive +# `rm -rf` fallback only runs after this assertion holds — so a path that +# somehow escaped can never be deleted. +# +# CANONICALIZE BOTH SIDES identically before comparing: on macOS $TMPDIR is +# `/var/folders/...` while `pwd -P` resolves it to `/private/var/folders/...` +# (the /var → /private/var symlink). Comparing a canonical parent against a raw +# $WT prefix would FALSELY reject every valid run. So we canonicalize the parent +# of $WT and compare it against the canonical $WT_PARENT. The real defense is the +# id-character regex above (no `/`, no `..` ⇒ no traversal); this is belt-and-braces. +mkdir -p "$(dirname "$WT")" +WT_PARENT_REAL="$(cd "$WT_PARENT" && pwd -P)" +WT_DIR_REAL="$(cd "$(dirname "$WT")" && pwd -P)" +case "$WT_DIR_REAL/" in + "$WT_PARENT_REAL"/*/) : ;; + *) die "refusing to operate on worktree path outside $WT_PARENT_REAL: $WT" ;; +esac +# ...and assert the worktree is NOT inside the repo — a worktree under the repo would make +# the `git diff` scope enforcement meaningless. +REPO_REAL="$(cd "$REPO" && pwd -P)" +case "$WT_DIR_REAL/" in + "$REPO_REAL"/*) die "refusing: worktree path is inside the repo: $WT" ;; +esac + +# Clean any stale worktree at this path (idempotent re-dispatch on resume). Safe +# now that WT is proven to live under $WT_PARENT_REAL. +if [ -e "$WT" ]; then + git -C "$REPO" worktree remove -f "$WT" >/dev/null 2>&1 || rm -rf "$WT" +fi + +# Capture the BASELINE commit BEFORE `git worktree add` — the worktree is created +# from HEAD, so this SHA is exactly the worktree's diff baseline. We pass this +# pinned SHA (not the literal "HEAD") to the scope gate: if the executor COMMITS +# inside its worktree, the working tree looks clean and a `git diff HEAD` would +# see nothing — but `git diff ` still includes the committed change, +# so a commit-to-hide-changes attempt is still detected and BLOCKED. +BASELINE_SHA="$(git -C "$REPO" rev-parse HEAD 2>/dev/null)" \ + || die "could not resolve baseline HEAD in $REPO" +[ -n "$BASELINE_SHA" ] || die "empty baseline HEAD in $REPO" + +# `git worktree add HEAD` — fresh checkout at HEAD = clean diff baseline. +git -C "$REPO" worktree add "$WT" HEAD >/dev/null 2>&1 \ + || die "git worktree add failed for $WT" + +# Adapter scratch lives OUTSIDE the worktree, in a sibling dir under $TMPDIR. This +# keeps the worktree PRISTINE: only the job's real output shows up in `git diff`, so +# the generic scope-gate (scripts/compound-v-scope-check.py) agrees with this worker's +# own git-derived enforcement WITHOUT needing any devin-specific ignore list. +# +# NOTE on the job_result capture path: adapter-devin.md's draft invocation informally +# says "capture to $WT/.job_result.txt", but writing it INSIDE the worktree would make +# it show up as an untracked file in the scope gate's own `git ls-files --others` scan +# — a false BLOCKED verdict on every single job. We deliberately place it in $ART +# (a sibling of $WT, never diffed) instead, exactly like the codex/antigravity/cursor +# workers' own `$ART/job_result.txt` convention. +ART="$WT.art" +mkdir -p "$ART" + +# --- run the headless Devin worker -------------------------------------------- +# Pinned invocation (devin-cli 3000.1.27 — auth-free flags VERIFIED live, task-execution +# behavior DOC-CLAIMED per adapter-devin.md): +# cd "$WT" && devin -p "$PROMPT" --permission-mode dangerous [--model "$MODEL"] \ +# --export "$ART/devin_export.json" "$ALLOW_FILE" +_OLDIFS="$IFS" +IFS=":" +for _glob in $WRITE_ALLOWED; do + IFS="$_OLDIFS" + [ -z "$_glob" ] && continue + printf '%s\n' "$_glob" >> "$ALLOW_FILE" +done +IFS="$_OLDIFS" + +# Run the gate. It prints a JSON verdict on stdout; exit 0 = pass, 1 = blocked, +# 2 = usage/git error. Capture both so a gate fault becomes status: error rather +# than a silently-clean result. +GATE_JSON="" +gate_rc=0 +set +e +GATE_JSON=$(python3 "$SCRIPT_DIR/compound-v-scope-check.py" \ + --worktree "$WT" --baseline "$BASELINE_SHA" --allow-file "$ALLOW_FILE" 2>"$ART/scope_check.err") +gate_rc=$? +set -e + +# Parse the gate verdict with jq (the gate's `changed`/`violations` arrays are the +# authority). On a gate error (rc 2 / unparseable) treat enforcement as empty and +# let the status logic below mark it an error. +files_json="[]" +violations_json="[]" +viol_count=0 +gate_verdict="" +if [ -n "$GATE_JSON" ] && printf '%s' "$GATE_JSON" | jq -e . >/dev/null 2>&1; then + gate_verdict=$(printf '%s' "$GATE_JSON" | jq -r '.verdict // ""') + # Pass the gate's arrays through as JSON (the gate is NUL-correct) — no newline + # round-trip, so a filename containing a newline survives as a single element. + files_json=$(printf '%s' "$GATE_JSON" | jq -c '.changed // []') + violations_json=$(printf '%s' "$GATE_JSON" | jq -c '.violations // []') + viol_count=$(printf '%s' "$GATE_JSON" | jq '(.violations // []) | length') +fi + +# --- derive status ----------------------------------------------------------- +# The gate's verdict decides blocked; the worker's exit code layers timeout/error +# on top. A blocked verdict ALWAYS wins (a scope leak is terminal). Then: a gate +# fault (rc 2 or unparseable) is an error; devin timeout (124) is a timeout; any +# other non-zero devin exit is an error; a clean-exit-but-empty-stdout is a +# best-effort AUTH-PENDING error signal (see above); otherwise success. + +blocked="false" +status="success" + +if [ "$gate_verdict" = "blocked" ] || [ "$viol_count" -gt 0 ]; then + blocked="true" + status="blocked" +elif [ "$gate_rc" != "0" ] && [ "$gate_rc" != "1" ]; then + # Gate could not produce a verdict (usage/git error) — fail closed as error. + status="error" +elif [ "$exit_code" = "$TIMEOUT_EXIT_CODE" ]; then + status="timeout" +elif [ "$exit_code" != "0" ]; then + status="error" +elif [ "$_summary_is_empty" = "true" ]; then + status="error" +fi + +# --- classify a backend failure (AUTH-PENDING best-effort — see header) ----- +# A non-success / non-blocked status carries a failure_class (out_of_credits / +# rate_limited / overloaded / auth / context_length / timeout / network / other) that +# drives the dispatcher's deterministic retry/reroute/halt policy. NO _DEVIN_RULES +# table exists yet in compound-v-classify-failure.py (its --backend flag does not even +# list "devin" — an unregistered backend is a CLI usage error, not a graceful fallback), +# because no authenticated failure sample was available to build real substring +# signatures. We deliberately do NOT call that script with a fabricated backend name — +# fail CLOSED to "other" directly, exactly the interim rule adapter-devin.md documents. +# TODO: once a live Cognition account can capture real failure text, add a _DEVIN_RULES +# table to compound-v-classify-failure.py and route through it like every other backend. +failure_class="" +retry_after="0" +if [ "$status" = "error" ] || [ "$status" = "timeout" ] || [ "$exit_code" != "0" ]; then + failure_class="other" +fi + +# --- emit -------------------------------------------------------------------- +# stdout = the canonical job_result JSON, and ONLY that. The caller (dispatcher) +# parses this and decides whether to merge (index-based patch including new files) +# or to leave the worktree for inspection on BLOCKED. We do NOT merge here. + +emit_job_result \ + "$status" \ + "$blocked" \ + "$files_json" \ + "$violations_json" \ + "$summary" \ + "$session_id" \ + "$WT" \ + "$exit_code" \ + "$failure_class" \ + "$retry_after" + +exit 0 diff --git a/scripts/compound-v-run-opencode-worker.sh b/scripts/compound-v-run-opencode-worker.sh new file mode 100755 index 0000000..0b803d0 --- /dev/null +++ b/scripts/compound-v-run-opencode-worker.sh @@ -0,0 +1,617 @@ +#!/usr/bin/env bash +# +# compound-v-run-opencode-worker.sh — Compound V Backend Launcher: the headless opencode +# (`opencode run`) adapter. +# +# MIRRORS compound-v-run-antigravity-worker.sh / -cursor-worker.sh (which mirror the codex +# worker). Runs ONE file-scoped job on a headless `opencode run` worker inside a dedicated +# git worktree, then emits the canonical job_result (schemas/job_result.schema.json) on +# stdout as JSON. The enforcement fields (blocked / files_changed / violations) are +# GIT-DERIVED, never self-reported by the model — produced by DELEGATING to the +# deterministic Python authority (scripts/compound-v-scope-check.py), the same gate the +# dispatcher runs after every job. The worker does NOT re-implement glob matching in bash. +# +# Contract: skills/backend-launcher/SKILL.md + skills/backend-launcher/adapter-opencode.md +# +# !!! CRITICAL SECURITY — MANDATORY provider-credential scrub (read first) !!! +# adapter-opencode.md documents a LIVE-OBSERVED finding: opencode successfully +# authenticated and completed a real request with ZERO stored credentials +# (`opencode providers list` → 0 credentials), purely by inheriting an ambient +# `ANTHROPIC_BASE_URL` from the parent shell. If this worker blindly inherited the +# DISPATCHER's own environment, a job could silently run AS the orchestrator's own +# provider credentials — an unintended privilege leak from the calling process into an +# isolated, lower-trust worker. compound-v-run-with-timeout.py has no `env=` override (it +# always inherits from whoever invokes it), so this worker wraps the ENTIRE supervisor +# invocation in `env -u NAME [-u NAME ...]` (BSD `env -u`, present on stock macOS — see +# $_SCRUB_VARS below) to strip every known provider auth/base-url var BEFORE python3 (and +# therefore opencode) ever sees the environment. This is REAL, not a comment: see the +# `run_opencode()` function. Verified live (see the accompanying scratch proof) that +# `env -u ANTHROPIC_BASE_URL -u ANTHROPIC_API_KEY ...` genuinely removes those vars from a +# child while leaving PATH and everything else intact. +# +# !!! SAFETY — LOWER-TRUST BACKEND (read before using) !!! +# opencode has NO kernel write-confinement (verified live by omission — no --sandbox / +# --read-only flag exists) and its own docs claim (DOC-CLAIMED, not independently +# live-verified against a real write attempt) it "allows all operations without explicit +# approval" by default. This worker does NOT trust that default: it writes a MANDATORY +# restrictive opencode.json (`{"permission": {"*": "ask"}}`) into the worktree before +# invoking opencode, then passes `--auto` to auto-approve exactly the non-denied subset — +# so the effective behavior matches every other lower-trust adapter (writes happen, but +# only inside a worktree the git-diff scope gate can observe), rather than trusting +# opencode's undocumented-in-practice wide-open default. The worktree + post-hoc `git +# diff` gate is still the ONLY real enforcement — same opt-in, lower-trust tier as +# Antigravity/Cursor/Devin. Prefer Codex (kernel-sandboxed) for untrusted / high-stakes work. +# +# Portability: stock-macOS bash 3.2.57 (NO associative arrays / mapfile / ${var,,}) +# + jq. shellcheck-clean. Absolute paths throughout. Caller (the dispatcher) owns the +# merge-back decision; this script only OBSERVES and REPORTS — it never merges. +# +# Usage: +# compound-v-run-opencode-worker.sh \ +# --run-id --job-id --repo \ +# --prompt-file --model \ +# --write-allowed "[:...]" \ +# [--timeout-sec ] [--network true|false] \ +# [--read-only true|false] [--output-schema ] [--effort ] +# +# --model is REQUIRED (unlike devin/antigravity/cursor): opencode addresses models as a +# `provider/model` string with no single-vendor default to fall back on. --effort is +# OPTIONAL and maps to opencode's own `--variant` flag (provider-specific vocabulary — +# high/max/minimal — NOT Compound V's low/medium/high/xhigh; passed through best-effort, +# per adapter-opencode.md). --output-schema is ACCEPTED for CLI parity but IGNORED — +# opencode's one-shot `run` has no CLI-level output-schema flag. --network is advisory +# only (no kernel toggle exists). +# +# All file paths MUST be absolute. write_allowed is a colon-separated glob list, each glob +# matched repo-relative against the changed paths. An EMPTY --write-allowed (read-only / +# review job) means no writes are permitted, so ANY changed path is a violation => BLOCKED. +# +# Exit code: 0 when the job_result was produced (even for a BLOCKED/timeout/error job — those +# are reported IN job_result.status). Non-zero only on a usage / environment fault. + +set -euo pipefail + +# --- constants --------------------------------------------------------------- +TIMEOUT_EXIT_CODE=124 # GNU/BSD `timeout` convention when the limit fires +DEFAULT_TIMEOUT_SEC=900 +DEFAULT_NETWORK=false +DEFAULT_READ_ONLY=false +# Bound opencode's captured JSONL stdout/stderr on disk (compound-v-run-with-timeout.py's +# own --max-output-bytes: a bounded, drain-and-discard pump so a runaway/looping worker can +# never fill the disk or block on a full pipe — see that script's docstring). +MAX_OUTPUT_BYTES=5000000 + +# The MANDATORY provider-credential scrub list (SAFETY, above). Every var here is a +# known provider auth token / base-url override across opencode's supported vendors +# (opencode is provider-agnostic and proxies many). Space-separated on purpose — this +# loops into repeated `env -u NAME` flags below (bash 3.2-safe: no arrays). +_SCRUB_VARS="ANTHROPIC_API_KEY ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN \ +OPENAI_API_KEY OPENAI_BASE_URL OPENAI_API_BASE OPENAI_ORG_ID \ +GOOGLE_API_KEY GOOGLE_GENERATIVE_AI_API_KEY GEMINI_API_KEY GOOGLE_APPLICATION_CREDENTIALS \ +AZURE_API_KEY AZURE_OPENAI_API_KEY AZURE_OPENAI_ENDPOINT \ +AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_BEARER_TOKEN_BEDROCK \ +OPENROUTER_API_KEY MISTRAL_API_KEY COHERE_API_KEY GROQ_API_KEY DEEPSEEK_API_KEY \ +XAI_API_KEY TOGETHER_API_KEY PERPLEXITY_API_KEY FIREWORKS_API_KEY CEREBRAS_API_KEY \ +OLLAMA_HOST" + +# --- helpers ----------------------------------------------------------------- + +die() { + # Environment/usage fault: no job_result could be produced. + echo "compound-v-run-opencode-worker: $1" >&2 + exit 2 +} + +# Emit the canonical job_result JSON on stdout, built entirely with jq so every +# field is correctly typed and escaped. files_changed / violations arrive as JSON +# ARRAYS (straight from the gate's NUL-correct output) and pass through untouched — +# no newline round-trip, so a path containing a newline stays ONE element. +emit_job_result() { + # $1 status $2 blocked(true|false) $3 files_json (JSON array) $4 violations_json (JSON array) + # $5 summary $6 session_id $7 worktree $8 exit_code(int) $9 failure_class ("" => null) + # ${10} retry_after_seconds(int, 0 when unknown) + jq -n \ + --arg status "$1" \ + --argjson blocked "$2" \ + --argjson files "$3" \ + --argjson violations "$4" \ + --arg summary "$5" \ + --arg session_id "$6" \ + --arg worktree "$7" \ + --argjson exit_code "$8" \ + --arg failure_class "$9" \ + --argjson retry_after_seconds "${10}" \ + '{ + status: $status, + blocked: $blocked, + files_changed: $files, + violations: $violations, + summary: $summary, + session_id: $session_id, + worktree: $worktree, + exit_code: $exit_code, + failure_class: (if $failure_class == "" then null else $failure_class end), + retry_after_seconds: $retry_after_seconds + }' +} + +# Validate an id (run_id / job_id) against a strict safe-character allow-list. +# These ids become PATH SEGMENTS under $TMPROOT/compound-v/, so a `../` or any +# separator could escape the tree and let the cleanup `rm -rf` delete arbitrary +# dirs. Allow only [A-Za-z0-9._-]; reject `.` and `..`; reject empty. Returns 0 +# when safe, 1 otherwise. bash 3.2-safe (case glob, no regex/associative arrays). +id_is_safe() { + _id="$1" + [ -n "$_id" ] || return 1 + case "$_id" in + .|..) return 1 ;; + *[!A-Za-z0-9._-]*) return 1 ;; + esac + return 0 +} + +# Directory of THIS script (resolves the sibling Python scope gate authority). +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# --- argument parsing -------------------------------------------------------- + +RUN_ID="" +JOB_ID="" +REPO="" +PROMPT_FILE="" +MODEL="" +WRITE_ALLOWED="" +TIMEOUT_SEC="$DEFAULT_TIMEOUT_SEC" +NETWORK="$DEFAULT_NETWORK" +READ_ONLY="$DEFAULT_READ_ONLY" +OUTPUT_SCHEMA="" +EFFORT="" + +while [ $# -gt 0 ]; do + case "$1" in + --run-id) RUN_ID="$2"; shift 2 ;; + --job-id) JOB_ID="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + --prompt-file) PROMPT_FILE="$2"; shift 2 ;; + --model) MODEL="$2"; shift 2 ;; + --write-allowed) WRITE_ALLOWED="$2"; shift 2 ;; + --timeout-sec) TIMEOUT_SEC="$2"; shift 2 ;; + --network) NETWORK="$2"; shift 2 ;; + --read-only) READ_ONLY="$2"; shift 2 ;; + --output-schema) OUTPUT_SCHEMA="$2"; shift 2 ;; + --effort) EFFORT="$2"; shift 2 ;; + *) die "unknown argument: $1" ;; + esac +done + +# --- validation -------------------------------------------------------------- + +[ -n "$RUN_ID" ] || die "--run-id is required" +[ -n "$JOB_ID" ] || die "--job-id is required" +[ -n "$REPO" ] || die "--repo is required" +[ -n "$PROMPT_FILE" ] || die "--prompt-file is required" +# --model is REQUIRED for opencode (unlike devin/antigravity/cursor): opencode addresses +# models as a `provider/model` string and has no single coherent "configured default" +# across its many proxied vendors the way a single-vendor CLI does. +[ -n "$MODEL" ] || die "--model is required for opencode (must be a provider/model string, e.g. anthropic/claude-opus-4-6)" +# NOTE: --write-allowed may legitimately be EMPTY for a read-only / review job. +# An empty allow-list means NO writes are permitted, so ANY changed path is a +# violation (the scope gate, run with zero allowed globs, blocks everything). + +# --timeout-sec is interpolated into the supervisor invocation; pin it to a positive +# integer BEFORE it is ever used (defence against argv/arithmetic injection). +case "$TIMEOUT_SEC" in + ''|*[!0-9]*) die "--timeout-sec must be a positive integer: $TIMEOUT_SEC" ;; +esac +[ "$TIMEOUT_SEC" -gt 0 ] || die "--timeout-sec must be > 0 (got $TIMEOUT_SEC): a 0 cap would kill the job instantly" + +# Path-traversal guard: run_id / job_id become path segments under $TMPROOT, and +# the stale-worktree cleanup does `rm -rf` on that path. A `../` (or any path +# separator) in an id would escape the tree and delete arbitrary dirs. Validate +# BEFORE building any path or touching the filesystem. +id_is_safe "$RUN_ID" || die "--run-id has invalid characters (allowed: A-Za-z0-9._-, not . or ..): $RUN_ID" +id_is_safe "$JOB_ID" || die "--job-id has invalid characters (allowed: A-Za-z0-9._-, not . or ..): $JOB_ID" + +case "$REPO" in /*) : ;; *) die "--repo must be an absolute path: $REPO" ;; esac +case "$PROMPT_FILE" in /*) : ;; *) die "--prompt-file must be an absolute path: $PROMPT_FILE" ;; esac +[ -d "$REPO" ] || die "--repo is not a directory: $REPO" +[ -f "$PROMPT_FILE" ] || die "--prompt-file not found: $PROMPT_FILE" +# --output-schema is accepted for CLI parity with the codex worker but opencode's one-shot +# `run` has no such flag at the CLI level, so it is IGNORED. We still validate the path +# shape if one is passed, to keep the contract honest. +if [ -n "$OUTPUT_SCHEMA" ]; then + case "$OUTPUT_SCHEMA" in /*) : ;; *) die "--output-schema must be absolute: $OUTPUT_SCHEMA" ;; esac + [ -f "$OUTPUT_SCHEMA" ] || die "--output-schema not found: $OUTPUT_SCHEMA" +fi +# `xhigh` is codex-only; opencode's own vocabulary is high/max/minimal via --variant, never +# `xhigh`. compound-v-resolve-model.py / -validate-manifest.py already reject that pairing +# upstream of this worker — this is a defence-in-depth check, not the primary gate. +if [ "$EFFORT" = "xhigh" ]; then + die "--effort xhigh is codex-only and is rejected for opencode: $EFFORT" +fi +# --read-only / --network are ACCEPTED for CLI parity with the codex worker but are +# ADVISORY ONLY for opencode: no kernel sandbox toggle exists. Reference them here so the +# contract is explicit and the parser-set vars are intentionally consumed. +: "advisory (opencode has no kernel sandbox): read_only=$READ_ONLY network=$NETWORK" + +command -v jq >/dev/null 2>&1 || die "jq not found on PATH" +command -v git >/dev/null 2>&1 || die "git not found on PATH" +command -v python3 >/dev/null 2>&1 || die "python3 not found on PATH (scope gate needs it)" +command -v opencode >/dev/null 2>&1 || die "opencode not found on PATH" +# `env` is the vehicle for the MANDATORY credential scrub (SAFETY, above) — if it is +# missing we must NOT silently fall through to an unscrubbed invocation. +command -v env >/dev/null 2>&1 || die "env not found on PATH (required for the mandatory provider-credential scrub)" + +# Wall-clock cap: run opencode under the shared PROCESS-GROUP timeout supervisor +# (scripts/compound-v-run-with-timeout.py) — opencode has NO built-in --timeout flag +# (verified live absence). On expiry the supervisor killpg's the whole opencode process +# tree (not just the direct child) and returns 124. No external timeout/gtimeout needed. +SUPERVISOR="$SCRIPT_DIR/compound-v-run-with-timeout.py" +[ -f "$SUPERVISOR" ] || die "timeout supervisor not found: $SUPERVISOR" + +# --- worktree lifecycle ------------------------------------------------------ +# Worktrees live OUTSIDE the repo, under $TMPDIR, so no .gitignore change is needed. + +TMPROOT="${TMPDIR:-/tmp}" +TMPROOT="${TMPROOT%/}" +# Require an ABSOLUTE tmp root: a relative $TMPDIR would resolve the worktree against the +# caller's cwd (possibly INSIDE the repo), defeating isolation and the scope diff. +case "$TMPROOT" in + /*) : ;; + *) die "TMPDIR must be an absolute path (got: $TMPROOT)" ;; +esac +# Canonicalize the tmp root up front (resolve the /var → /private/var class of symlink) and +# build the parent from the REAL path, so no symlinked component can redirect the worktree. +TMPROOT_REAL="$(cd "$TMPROOT" 2>/dev/null && pwd -P)" || die "TMPDIR does not exist: $TMPROOT" +WT_PARENT="$TMPROOT_REAL/compound-v" +# Reject a pre-planted symlink at the parent (it could redirect writes/cleanup out of tmp). +# We keep the DETERMINISTIC $RUN_ID/$JOB_ID path (not a random `mktemp -d`) on purpose: +# idempotent re-dispatch + cleanup on resume locate the worktree by exactly that path. +[ -L "$WT_PARENT" ] && die "refusing: worktree parent is a symlink: $WT_PARENT" +WT="$WT_PARENT/$RUN_ID/$JOB_ID" + +# Defence-in-depth: even with the id-character guard above, ASSERT WT sits +# strictly under $TMPROOT/compound-v/ before any remove/rm. The destructive +# `rm -rf` fallback only runs after this assertion holds — so a path that +# somehow escaped can never be deleted. +# +# CANONICALIZE BOTH SIDES identically before comparing: on macOS $TMPDIR is +# `/var/folders/...` while `pwd -P` resolves it to `/private/var/folders/...` +# (the /var → /private/var symlink). Comparing a canonical parent against a raw +# $WT prefix would FALSELY reject every valid run. So we canonicalize the parent +# of $WT and compare it against the canonical $WT_PARENT. The real defense is the +# id-character regex above (no `/`, no `..` ⇒ no traversal); this is belt-and-braces. +mkdir -p "$(dirname "$WT")" +WT_PARENT_REAL="$(cd "$WT_PARENT" && pwd -P)" +WT_DIR_REAL="$(cd "$(dirname "$WT")" && pwd -P)" +case "$WT_DIR_REAL/" in + "$WT_PARENT_REAL"/*/) : ;; + *) die "refusing to operate on worktree path outside $WT_PARENT_REAL: $WT" ;; +esac +# ...and assert the worktree is NOT inside the repo — a worktree under the repo would make +# the `git diff` scope enforcement meaningless. +REPO_REAL="$(cd "$REPO" && pwd -P)" +case "$WT_DIR_REAL/" in + "$REPO_REAL"/*) die "refusing: worktree path is inside the repo: $WT" ;; +esac + +# Clean any stale worktree at this path (idempotent re-dispatch on resume). Safe +# now that WT is proven to live under $WT_PARENT_REAL. +if [ -e "$WT" ]; then + git -C "$REPO" worktree remove -f "$WT" >/dev/null 2>&1 || rm -rf "$WT" +fi + +# Capture the BASELINE commit BEFORE `git worktree add` — the worktree is created +# from HEAD, so this SHA is exactly the worktree's diff baseline. We pass this +# pinned SHA (not the literal "HEAD") to the scope gate: if the executor COMMITS +# inside its worktree, the working tree looks clean and a `git diff HEAD` would +# see nothing — but `git diff ` still includes the committed change, +# so a commit-to-hide-changes attempt is still detected and BLOCKED. +BASELINE_SHA="$(git -C "$REPO" rev-parse HEAD 2>/dev/null)" \ + || die "could not resolve baseline HEAD in $REPO" +[ -n "$BASELINE_SHA" ] || die "empty baseline HEAD in $REPO" + +# `git worktree add HEAD` — fresh checkout at HEAD = clean diff baseline. +git -C "$REPO" worktree add "$WT" HEAD >/dev/null 2>&1 \ + || die "git worktree add failed for $WT" + +# Adapter scratch lives OUTSIDE the worktree, in a sibling dir under $TMPDIR. This +# keeps the worktree PRISTINE: only the job's real output shows up in `git diff`, so +# the generic scope-gate (scripts/compound-v-scope-check.py) agrees with this worker's +# own git-derived enforcement WITHOUT needing any opencode-specific ignore list. +# +# NOTE on the job_result capture path: adapter-opencode.md's draft invocation informally +# says "capture to $WT/.job_result.txt", but writing it INSIDE the worktree would make it +# show up as an untracked file in the scope gate's own `git ls-files --others` scan — a +# false BLOCKED verdict on every single job. We deliberately place it in $ART (a sibling +# of $WT, never diffed) instead, exactly like the codex/antigravity/cursor workers' own +# `$ART/job_result.txt` convention. +ART="$WT.art" +mkdir -p "$ART" + +# --- MANDATORY: pin a restrictive opencode.json into the worktree (SAFETY) -------------- +# opencode discovers its config from the run directory (--dir "$WT"), so the restrictive +# permission file MUST physically live inside $WT for opencode to see it — there is no +# out-of-tree config-discovery path to exploit instead. To keep the worktree PRISTINE for +# the scope gate (a stray untracked/modified opencode.json would itself look like a job +# write), we: (a) back up any REPO-TRACKED opencode.json that already exists at this +# worktree's HEAD, (b) overwrite it with our pinned restrictive config for the run, then +# (c) restore the original content (or delete it, if none existed) BEFORE the scope gate +# runs — so the gate observes a worktree with ZERO trace of this scratch file. +OPENCODE_CONFIG="$WT/opencode.json" +OPENCODE_CONFIG_BACKUP="$ART/opencode.json.orig" +_opencode_config_preexisted="false" +if [ -f "$OPENCODE_CONFIG" ]; then + _opencode_config_preexisted="true" + cp "$OPENCODE_CONFIG" "$OPENCODE_CONFIG_BACKUP" +fi +# `permission: {"*": "ask"}` = deny-by-default; paired with `--auto` below (which +# auto-approves exactly the non-denied subset), this is the "ask, but headlessly +# auto-approved" posture documented as MANDATORY in adapter-opencode.md SAFETY — it +# never trusts opencode's own DOC-CLAIMED-but-unverified wide-open default. +cat > "$OPENCODE_CONFIG" <<'JSONEOF' +{ + "permission": { + "*": "ask" + } +} +JSONEOF + +restore_opencode_config() { + if [ "$_opencode_config_preexisted" = "true" ]; then + cp "$OPENCODE_CONFIG_BACKUP" "$OPENCODE_CONFIG" + else + rm -f "$OPENCODE_CONFIG" + fi +} + +# --- run the headless opencode worker ----------------------------------------- +# Pinned invocation (opencode-ai 1.17.18 — RE-PROBE OFTEN per adapter-opencode.md, this +# package ships new builds multiple times a day): +# opencode run --dir "$WT" --format json --auto -m "$MODEL" [--variant "$EFFORT"] \ +# --title "compound-v-$JOB_ID" "$PROMPT" /dev/null) || _etype="" + if [ -z "$session_id" ]; then + _sid=$(printf '%s' "$_ev_line" | jq -r '.sessionID? // empty' 2>/dev/null) || _sid="" + if [ -n "$_sid" ]; then session_id="$_sid"; fi + fi + if [ "$_etype" = "text" ]; then + _txt=$(printf '%s' "$_ev_line" | jq -r '.part.text? // empty' 2>/dev/null) || _txt="" + if [ -n "$_txt" ]; then summary="$_txt"; fi + fi + done < "$EVENTS_LOG" +fi + +# session_id shape gate: opencode's own token is `ses_...`-prefixed, NOT an RFC-4122 UUID +# (adapter-opencode.md — explicitly warns the codex worker's UUID regex must NOT be +# reused verbatim here). Accept only a safe charset after the prefix; anything else ⇒ +# empty ("resume fresh"), never a token that could inject into a future `-s` argv. +case "$session_id" in + ses_*) + case "$session_id" in + *[!A-Za-z0-9_-]*) session_id="" ;; + esac + ;; + *) session_id="" ;; +esac + +[ -n "$summary" ] || summary="(no summary emitted by worker)" +printf '%s' "$summary" > "$RESULT_TXT" 2>/dev/null || true + +# opencode's own JSONL failure signal (adapter-opencode.md): `{"type":"error",...}` can +# appear even on a clean-looking exit. Detected the same line-by-line way, never via a +# whole-file jq parse that a malformed line could sink entirely. +_opencode_saw_error="false" +if [ -f "$EVENTS_LOG" ]; then + while IFS= read -r _ev_line || [ -n "$_ev_line" ]; do + [ -z "$_ev_line" ] && continue + _etype=$(printf '%s' "$_ev_line" | jq -r '.type? // empty' 2>/dev/null) || _etype="" + if [ "$_etype" = "error" ]; then + _opencode_saw_error="true" + break + fi + done < "$EVENTS_LOG" +fi + +# --- git-derived enforcement (delegated to the Python scope gate) ------------ +# The authoritative enforcement (files_changed / violations / blocked) comes from +# scripts/compound-v-scope-check.py — the SAME deterministic gate the dispatcher +# runs after every job. The worker does NOT re-implement glob matching in bash. +# +# write_allowed is a colon-separated glob list; the gate wants one glob per line, +# so we expand it into an allow-file under $ART (outside the worktree → never seen +# in the diff). An EMPTY --write-allowed (read-only / review job) yields an EMPTY +# allow-file, which makes the gate treat EVERY changed path as a violation — a +# review job that writes anything is correctly BLOCKED. Baseline = the pinned SHA +# captured before `worktree add` (so an in-worktree commit is still diffed). + +ALLOW_FILE="$ART/write_allowed.globs" +: > "$ALLOW_FILE" +_OLDIFS="$IFS" +IFS=":" +for _glob in $WRITE_ALLOWED; do + IFS="$_OLDIFS" + [ -z "$_glob" ] && continue + printf '%s\n' "$_glob" >> "$ALLOW_FILE" +done +IFS="$_OLDIFS" + +# Run the gate. It prints a JSON verdict on stdout; exit 0 = pass, 1 = blocked, +# 2 = usage/git error. Capture both so a gate fault becomes status: error rather +# than a silently-clean result. +GATE_JSON="" +gate_rc=0 +set +e +GATE_JSON=$(python3 "$SCRIPT_DIR/compound-v-scope-check.py" \ + --worktree "$WT" --baseline "$BASELINE_SHA" --allow-file "$ALLOW_FILE" 2>"$ART/scope_check.err") +gate_rc=$? +set -e + +# Parse the gate verdict with jq (the gate's `changed`/`violations` arrays are the +# authority). On a gate error (rc 2 / unparseable) treat enforcement as empty and +# let the status logic below mark it an error. +files_json="[]" +violations_json="[]" +viol_count=0 +gate_verdict="" +if [ -n "$GATE_JSON" ] && printf '%s' "$GATE_JSON" | jq -e . >/dev/null 2>&1; then + gate_verdict=$(printf '%s' "$GATE_JSON" | jq -r '.verdict // ""') + # Pass the gate's arrays through as JSON (the gate is NUL-correct) — no newline + # round-trip, so a filename containing a newline survives as a single element. + files_json=$(printf '%s' "$GATE_JSON" | jq -c '.changed // []') + violations_json=$(printf '%s' "$GATE_JSON" | jq -c '.violations // []') + viol_count=$(printf '%s' "$GATE_JSON" | jq '(.violations // []) | length') +fi + +# --- derive status ----------------------------------------------------------- +# The gate's verdict decides blocked; the worker's exit code layers timeout/error +# on top. A blocked verdict ALWAYS wins (a scope leak is terminal). Then: a gate +# fault (rc 2 or unparseable) is an error; opencode timeout (124) is a timeout; any +# other non-zero opencode exit is an error; an in-band `type:"error"` event (even on +# exit 0) is an error (don't report a false success); otherwise success. + +blocked="false" +status="success" + +if [ "$gate_verdict" = "blocked" ] || [ "$viol_count" -gt 0 ]; then + blocked="true" + status="blocked" +elif [ "$gate_rc" != "0" ] && [ "$gate_rc" != "1" ]; then + # Gate could not produce a verdict (usage/git error) — fail closed as error. + status="error" +elif [ "$exit_code" = "$TIMEOUT_EXIT_CODE" ]; then + status="timeout" +elif [ "$exit_code" != "0" ]; then + status="error" +elif [ "$_opencode_saw_error" = "true" ]; then + status="error" +fi + +# --- classify a backend failure (fail-closed — no per-provider table yet) --- +# A non-success / non-blocked status carries a failure_class (out_of_credits / +# rate_limited / overloaded / auth / context_length / timeout / network / other) that +# drives the dispatcher's deterministic retry/reroute/halt policy. NO _OPENCODE_RULES +# table exists yet in compound-v-classify-failure.py (its --backend flag does not even +# list "opencode" — an unregistered backend is a CLI usage error, not a graceful +# fallback) because opencode proxies MANY providers whose error text differs per-vendor +# (an Anthropic 429 looks nothing like an OpenAI 429) — a genuinely harder classification +# problem than any single-vendor backend faces. We deliberately do NOT call that script +# with a fabricated backend name — fail CLOSED to "other" directly, exactly the interim +# rule adapter-opencode.md documents (the same pattern the Cursor adapter already used +# for its own "provisional" needles before real signatures existed). +# TODO: once real per-provider failure samples are available, add an _OPENCODE_RULES +# table to compound-v-classify-failure.py and route through it like every other backend. +failure_class="" +retry_after="0" +if [ "$status" = "error" ] || [ "$status" = "timeout" ] || [ "$exit_code" != "0" ]; then + failure_class="other" +fi + +# --- emit -------------------------------------------------------------------- +# stdout = the canonical job_result JSON, and ONLY that. The caller (dispatcher) +# parses this and decides whether to merge (index-based patch including new files) +# or to leave the worktree for inspection on BLOCKED. We do NOT merge here. + +emit_job_result \ + "$status" \ + "$blocked" \ + "$files_json" \ + "$violations_json" \ + "$summary" \ + "$session_id" \ + "$WT" \ + "$exit_code" \ + "$failure_class" \ + "$retry_after" + +exit 0 From dff4307dd889ce8ea0489973ce23ca3167d5b33f Mon Sep 17 00:00:00 2001 From: Oleg Kopachovets Date: Mon, 13 Jul 2026 11:20:36 +0100 Subject: [PATCH 3/4] fix(backends): close 8 Codex-review findings on the Devin/opencode PR HIGH: validator now REJECTS a reviewer job on backend devin/opencode (worker-only enforced, not just documented); opencode.json backup/restore is symlink-safe + exit-trap-guarded (no write outside the worktree); opencode false-success closed (exit 0 with zero valid JSONL events / no final text -> error). MED: env-scrub is now an env -i ALLOWLIST (leak-canary proven vs ANTHROPIC_BASE_URL/API_KEY + OPENCODE_API_KEY); opencode provider/model shape enforced in resolver + validator; '--' terminator before the opencode prompt (live-verified leading-dash prompts); Devin --export dropped (was outside the byte cap); glob-safe WRITE_ALLOWED split (set -f). Selftests + bash-n + shellcheck green. Co-Authored-By: Claude Opus 4.8 --- scripts/compound-v-resolve-model.py | 59 +++++++ scripts/compound-v-run-devin-worker.sh | 28 ++-- scripts/compound-v-run-opencode-worker.sh | 169 ++++++++++++++----- scripts/compound-v-validate-manifest.py | 189 ++++++++++++++++++++++ 4 files changed, 394 insertions(+), 51 deletions(-) diff --git a/scripts/compound-v-resolve-model.py b/scripts/compound-v-resolve-model.py index df02adf..22dce36 100755 --- a/scripts/compound-v-resolve-model.py +++ b/scripts/compound-v-resolve-model.py @@ -220,6 +220,19 @@ def _config_cell(config_models, stance, backend, tier): return None +def _is_provider_model_shaped(model): + """True iff ``model`` is a genuine non-empty 'provider/model' string: exactly the + shape opencode's own `-m`/`--model` flag requires (see adapter-opencode.md). A + bare name (no '/') or a malformed value with an empty side ('anthropic/', '/x') + would silently resolve here but fail opencode's own model resolution at run + time, so every opencode resolution — regardless of source (explicit override, + config override, or the built-in default map) — is shape-checked.""" + if not isinstance(model, str): + return False + provider, sep, rest = model.partition("/") + return bool(sep) and bool(provider.strip()) and bool(rest.strip()) + + def resolve(backend, tier, effort=None, config_models=None, explicit_model=None, stance="balanced"): """Resolve to a concrete model. Precedence: explicit_model > config_models > the stance's built-in default map. Raises ValueError on unknown backend/tier/effort/stance @@ -241,6 +254,13 @@ def resolve(backend, tier, effort=None, config_models=None, explicit_model=None, resolved_effort = effort if effort is not None else DEFAULT_EFFORT_FOR_TIER[tier] if explicit_model: + if backend == "opencode" and not _is_provider_model_shaped(explicit_model): + raise ValueError( + "opencode explicit model override '%s' is not a valid " + "'provider/model' string (must be non-empty on both sides of " + "exactly one '/'); bare or malformed model names are rejected" + % explicit_model + ) return {"backend": backend, "tier": tier, "model": explicit_model, "effort": resolved_effort} model = _config_cell(config_models, stance, backend, tier) @@ -253,6 +273,14 @@ def resolve(backend, tier, effort=None, config_models=None, explicit_model=None, "(no config override and no built-in default)" % (stance, backend, tier) ) + if backend == "opencode" and not _is_provider_model_shaped(model): + raise ValueError( + "opencode resolved to model '%s' (stance '%s' backend '%s' tier " + "'%s'), which is not a valid 'provider/model' string; a config " + "override that isn't shaped as provider/model is rejected" + % (model, stance, backend, tier) + ) + return {"backend": backend, "tier": tier, "model": model, "effort": resolved_effort} @@ -374,6 +402,37 @@ def raises(fn): all("/" in _OPENCODE[t] for t in TIERS), ) + # opencode provider/model shape enforcement: a bare or malformed model is + # REJECTED regardless of where it came from (explicit override or config + # override) — never silently accepted and passed to the worker's -m flag. + expect( + "opencode explicit bare model rejected", + raises(lambda: resolve("opencode", "deep", explicit_model="gpt-5.6")), + ) + expect( + "opencode explicit malformed model rejected (empty right side)", + raises(lambda: resolve("opencode", "deep", explicit_model="anthropic/")), + ) + expect( + "opencode explicit malformed model rejected (empty left side)", + raises(lambda: resolve("opencode", "deep", explicit_model="/claude-opus")), + ) + expect( + "opencode config-override bare model rejected", + raises(lambda: resolve( + "opencode", "deep", + config_models={"opencode": {"deep": "gpt-5.6"}})), + ) + expect( + "opencode explicit provider/model accepted", + resolve("opencode", "deep", explicit_model="anthropic/claude-opus-4-6")["model"] + == "anthropic/claude-opus-4-6", + ) + expect( + "non-opencode backend is NOT shape-checked (bare model fine)", + resolve("codex", "deep", explicit_model="gpt-5.6")["model"] == "gpt-5.6", + ) + # No 'haiku' anywhere in any stance map. flat = json.dumps(DEFAULT_MODELS_BY_STANCE).lower() expect("no haiku in any stance map", "haiku" not in flat) diff --git a/scripts/compound-v-run-devin-worker.sh b/scripts/compound-v-run-devin-worker.sh index 0f58396..9848e11 100755 --- a/scripts/compound-v-run-devin-worker.sh +++ b/scripts/compound-v-run-devin-worker.sh @@ -301,8 +301,7 @@ mkdir -p "$ART" # --- run the headless Devin worker -------------------------------------------- # Pinned invocation (devin-cli 3000.1.27 — auth-free flags VERIFIED live, task-execution # behavior DOC-CLAIMED per adapter-devin.md): -# cd "$WT" && devin -p "$PROMPT" --permission-mode dangerous [--model "$MODEL"] \ -# --export "$ART/devin_export.json" "$ALLOW_FILE" _OLDIFS="$IFS" IFS=":" +# `set -f` (noglob) around the unquoted split below: WRITE_ALLOWED entries are LITERAL +# glob patterns destined for the scope gate's own matcher, not shell globs to be +# expanded HERE. With globbing left on, a glob-char-bearing entry could expand against +# files in the launcher's cwd and silently corrupt the allow-list before the gate ever +# sees it — `set -f` makes the unquoted `for _glob in $WRITE_ALLOWED` split on IFS=":" +# only, never pathname-expand the results. +set -f for _glob in $WRITE_ALLOWED; do IFS="$_OLDIFS" [ -z "$_glob" ] && continue printf '%s\n' "$_glob" >> "$ALLOW_FILE" done +set +f IFS="$_OLDIFS" # Run the gate. It prints a JSON verdict on stdout; exit 0 = pass, 1 = blocked, diff --git a/scripts/compound-v-run-opencode-worker.sh b/scripts/compound-v-run-opencode-worker.sh index 0b803d0..f7414d3 100755 --- a/scripts/compound-v-run-opencode-worker.sh +++ b/scripts/compound-v-run-opencode-worker.sh @@ -81,18 +81,18 @@ DEFAULT_READ_ONLY=false # never fill the disk or block on a full pipe — see that script's docstring). MAX_OUTPUT_BYTES=5000000 -# The MANDATORY provider-credential scrub list (SAFETY, above). Every var here is a -# known provider auth token / base-url override across opencode's supported vendors -# (opencode is provider-agnostic and proxies many). Space-separated on purpose — this -# loops into repeated `env -u NAME` flags below (bash 3.2-safe: no arrays). -_SCRUB_VARS="ANTHROPIC_API_KEY ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN \ -OPENAI_API_KEY OPENAI_BASE_URL OPENAI_API_BASE OPENAI_ORG_ID \ -GOOGLE_API_KEY GOOGLE_GENERATIVE_AI_API_KEY GEMINI_API_KEY GOOGLE_APPLICATION_CREDENTIALS \ -AZURE_API_KEY AZURE_OPENAI_API_KEY AZURE_OPENAI_ENDPOINT \ -AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_BEARER_TOKEN_BEDROCK \ -OPENROUTER_API_KEY MISTRAL_API_KEY COHERE_API_KEY GROQ_API_KEY DEEPSEEK_API_KEY \ -XAI_API_KEY TOGETHER_API_KEY PERPLEXITY_API_KEY FIREWORKS_API_KEY CEREBRAS_API_KEY \ -OLLAMA_HOST" +# The MANDATORY provider-credential scrub, as an ALLOWLIST (not a blacklist). A +# blacklist enumerating known provider vars misses ANY unlisted credential — including +# OPENCODE_API_KEY itself (opencode's own token was never in the old list) or a +# brand-new provider's *_API_KEY the list was never updated for. `env -i` (below) +# clears EVERY inherited variable, then only the names in $_SAFE_ENV_VARS are injected +# back in with THEIR OWN parent values — so a credential can leak into the child ONLY +# if it is explicitly named here, never by omission from a denylist. opencode's own +# provider credentials come from its persisted auth store under $HOME (`opencode auth +# login`), never from env vars, so HOME must pass through for that store to resolve; +# nothing provider-specific needs to. Space-separated on purpose (bash 3.2-safe: no +# arrays) — see the `set --`-based allow-list build below run_opencode(). +_SAFE_ENV_VARS="PATH HOME TMPDIR LANG LC_ALL TERM" # --- helpers ----------------------------------------------------------------- @@ -341,10 +341,50 @@ mkdir -p "$ART" OPENCODE_CONFIG="$WT/opencode.json" OPENCODE_CONFIG_BACKUP="$ART/opencode.json.orig" _opencode_config_preexisted="false" + +# SECURITY: symlink-safety guard. A pre-existing $OPENCODE_CONFIG that is a symlink +# (or any non-regular file) must NEVER be `cp`'d or redirected-into below — both +# operations FOLLOW the link, so backing it up or overwriting it could read/write a +# path OUTSIDE the worktree entirely (a repo-tracked opencode.json symlink, or one +# the lower-trust worker plants mid-run, could point anywhere on disk). Reject +# BEFORE touching it at all — this is an environment/usage fault (die, exit 2), not +# a job outcome, exactly like every other precondition check in this script. +if [ -L "$OPENCODE_CONFIG" ]; then + die "refusing: pre-existing opencode.json is a symlink (cp/redirection would follow it out of the worktree): $OPENCODE_CONFIG" +fi +if [ -e "$OPENCODE_CONFIG" ] && [ ! -f "$OPENCODE_CONFIG" ]; then + die "refusing: pre-existing opencode.json is not a regular file: $OPENCODE_CONFIG" +fi + if [ -f "$OPENCODE_CONFIG" ]; then _opencode_config_preexisted="true" cp "$OPENCODE_CONFIG" "$OPENCODE_CONFIG_BACKUP" fi + +restore_opencode_config() { + # SECURITY: never `cp`/redirect ONTO $OPENCODE_CONFIG without first removing + # whatever is there. The lower-trust opencode worker could have replaced the file + # with a symlink pointing OUTSIDE the worktree during its run — `cp`/`>` onto a + # symlink FOLLOWS it, so a naive restore could write the original content (or the + # deletion) to that external target instead of the worktree. `rm -f` removes the + # directory ENTRY itself and never follows a symlink to reach its target, so this + # is safe regardless of what opencode left behind. Idempotent: safe to call more + # than once (explicit call below, plus the EXIT/INT/TERM trap as a safety net). + rm -f "$OPENCODE_CONFIG" + if [ "$_opencode_config_preexisted" = "true" ]; then + # Write a fresh REGULAR file from the backup's content. This never opens + # $OPENCODE_CONFIG for read and never `cp`s onto it — redirection creates a + # brand-new inode at a path that, thanks to the `rm -f` immediately above, is + # guaranteed not to exist yet (so it cannot be a symlink at write time). + cat "$OPENCODE_CONFIG_BACKUP" > "$OPENCODE_CONFIG" + fi +} +# Install the restoration trap BEFORE the worktree's opencode.json is ever +# modified (the `cat > "$OPENCODE_CONFIG"` pin below is the first modification), so +# ANY exit path — normal return, `die`, or a signal — restores the worktree to its +# pre-run state, not just the single explicit call on the happy path further down. +trap restore_opencode_config EXIT INT TERM + # `permission: {"*": "ask"}` = deny-by-default; paired with `--auto` below (which # auto-approves exactly the non-denied subset), this is the "ask, but headlessly # auto-approved" posture documented as MANDATORY in adapter-opencode.md SAFETY — it @@ -357,19 +397,11 @@ cat > "$OPENCODE_CONFIG" <<'JSONEOF' } JSONEOF -restore_opencode_config() { - if [ "$_opencode_config_preexisted" = "true" ]; then - cp "$OPENCODE_CONFIG_BACKUP" "$OPENCODE_CONFIG" - else - rm -f "$OPENCODE_CONFIG" - fi -} - # --- run the headless opencode worker ----------------------------------------- # Pinned invocation (opencode-ai 1.17.18 — RE-PROBE OFTEN per adapter-opencode.md, this # package ships new builds multiple times a day): # opencode run --dir "$WT" --format json --auto -m "$MODEL" [--variant "$EFFORT"] \ -# --title "compound-v-$JOB_ID" "$PROMPT" /dev/null 2>&1; then + # Not valid JSON at all (truncated/garbage line) — contributes nothing, but + # does NOT count toward _valid_event_count either. + continue + fi + _valid_event_count=$((_valid_event_count + 1)) _etype=$(printf '%s' "$_ev_line" | jq -r '.type? // empty' 2>/dev/null) || _etype="" if [ -z "$session_id" ]; then _sid=$(printf '%s' "$_ev_line" | jq -r '.sessionID? // empty' 2>/dev/null) || _sid="" @@ -472,6 +535,16 @@ if [ -f "$EVENTS_LOG" ]; then done < "$EVENTS_LOG" fi +# false-success guard: an exit-0 run with NO valid JSONL events at all, or with +# events but no usable final text, must not be reported as success — it hides a +# silent no-op (empty/malformed transcript) behind a placeholder summary. Computed +# BEFORE the "(no summary...)" placeholder is substituted in below, so it reflects +# what opencode ACTUALLY emitted, not the placeholder text. +_summary_is_empty="false" +if [ -z "$(printf '%s' "$summary" | tr -d '[:space:]')" ]; then + _summary_is_empty="true" +fi + # session_id shape gate: opencode's own token is `ses_...`-prefixed, NOT an RFC-4122 UUID # (adapter-opencode.md — explicitly warns the codex worker's UUID regex must NOT be # reused verbatim here). Accept only a safe charset after the prefix; anything else ⇒ @@ -519,11 +592,19 @@ ALLOW_FILE="$ART/write_allowed.globs" : > "$ALLOW_FILE" _OLDIFS="$IFS" IFS=":" +# `set -f` (noglob) around the unquoted split below: WRITE_ALLOWED entries are LITERAL +# glob patterns destined for the scope gate's own matcher, not shell globs to be +# expanded HERE. With globbing left on, a glob-char-bearing entry could expand against +# files in the launcher's cwd and silently corrupt the allow-list before the gate ever +# sees it — `set -f` makes the unquoted `for _glob in $WRITE_ALLOWED` split on IFS=":" +# only, never pathname-expand the results. +set -f for _glob in $WRITE_ALLOWED; do IFS="$_OLDIFS" [ -z "$_glob" ] && continue printf '%s\n' "$_glob" >> "$ALLOW_FILE" done +set +f IFS="$_OLDIFS" # Run the gate. It prints a JSON verdict on stdout; exit 0 = pass, 1 = blocked, @@ -558,7 +639,9 @@ fi # on top. A blocked verdict ALWAYS wins (a scope leak is terminal). Then: a gate # fault (rc 2 or unparseable) is an error; opencode timeout (124) is a timeout; any # other non-zero opencode exit is an error; an in-band `type:"error"` event (even on -# exit 0) is an error (don't report a false success); otherwise success. +# exit 0) is an error (don't report a false success); an exit-0 run with ZERO valid +# JSONL events, or with events but no usable final text, is ALSO an error (a false +# success would hide a silent no-op/empty-transcript run); otherwise success. blocked="false" status="success" @@ -575,6 +658,8 @@ elif [ "$exit_code" != "0" ]; then status="error" elif [ "$_opencode_saw_error" = "true" ]; then status="error" +elif [ "$_valid_event_count" -eq 0 ] || [ "$_summary_is_empty" = "true" ]; then + status="error" fi # --- classify a backend failure (fail-closed — no per-provider table yet) --- diff --git a/scripts/compound-v-validate-manifest.py b/scripts/compound-v-validate-manifest.py index eed41c9..26b7667 100755 --- a/scripts/compound-v-validate-manifest.py +++ b/scripts/compound-v-validate-manifest.py @@ -1718,6 +1718,42 @@ def validate(manifest, mode=None, repo_root=None, config_path=None, % (jid, backend_lc, job.get("isolation"), backend_lc) ) + # WORKER-ONLY enforcement: devin/opencode are lower-trust, opt-in + # backends (see adapter-devin.md / adapter-opencode.md) meant for + # IMPLEMENTER jobs only. A reviewer job routed to either would + # silently satisfy the Review Gate's opus/deep guarantee through a + # low-trust external router instead of Claude Opus, defeating the + # guarantee entirely. Reject unconditionally, independent of + # tier/model — a reviewer job must never carry backend: devin or + # backend: opencode, full stop. + if _is_reviewer(job) and backend_lc in ("devin", "opencode"): + problems.append( + "reviewer job '%s' uses backend '%s' — devin/opencode are " + "lower-trust, opt-in, WORKER-ONLY backends (see " + "adapter-devin.md / adapter-opencode.md) and must never be " + "used for a reviewer job; route reviewers to backend: " + "claude with tier: deep or model: opus" + % (jid, backend_lc) + ) + + # opencode provider/model shape: every EXPLICIT opencode model + # override must be a genuine non-empty "provider/model" string (a + # bare name would silently pass here but fail opencode's own model + # resolution / the worker's `-m` argument at run time). Only checked + # when a model is actually present — a tier-only job resolves through + # compound-v-resolve-model.py's own shape check at resolution time. + if backend_lc == "opencode": + m_val = job.get("model") + if isinstance(m_val, str) and m_val.strip(): + _prov, _sep, _rest = m_val.partition("/") + if not _sep or not _prov.strip() or not _rest.strip(): + problems.append( + "job '%s' backend opencode has model '%s' which is " + "not a valid 'provider/model' string (must be " + "non-empty on both sides of exactly one '/')" + % (jid, m_val) + ) + # Invariant 3: reviewers => deep/opus (strongest reasoning). Satisfied # by either tier: deep or model: opus. if _is_reviewer(job): @@ -2468,6 +2504,128 @@ def main(argv): """ +# A complete, otherwise-valid manifest whose ONE defect is a REVIEWER job routed to +# backend: devin. devin/opencode are lower-trust, opt-in, WORKER-ONLY backends (see +# adapter-devin.md / adapter-opencode.md) -- a reviewer job must never resolve its +# Review-Gate opus/deep guarantee through a low-trust external router. tier: deep + +# isolation: worktree are otherwise satisfied, so ONLY the WORKER-ONLY violation fires. +DEVIN_REVIEWER_MANIFEST = """ +run_id: 2026-07-13-devin-reviewer +feature: "devin-reviewer" +spec_path: docs/superpowers/specs/2026-07-13-devin-reviewer.md +plan_path: docs/superpowers/plans/2026-07-13-devin-reviewer.md +audits: + archaeology: docs/superpowers/archaeology/2026-07-13-devin-reviewer.md + domain: docs/superpowers/expert/2026-07-13-devin-reviewer.md + library: docs/superpowers/library-audit/2026-07-13-devin-reviewer.md +routing_stance: balanced +max_parallel: 2 +acceptance_criteria: + - "ships" +jobs: + - id: task-1-spec-review + title: "spec review pass" + type: spec_review + backend: devin + tier: deep + isolation: worktree + run: serial + write_allowed: [] + read_allowed: [src/**] + acceptance: ["reviewed"] +""" + + +# Same defect, opencode backend (same rationale as DEVIN_REVIEWER_MANIFEST above). +OPENCODE_REVIEWER_MANIFEST = """ +run_id: 2026-07-13-opencode-reviewer +feature: "opencode-reviewer" +spec_path: docs/superpowers/specs/2026-07-13-opencode-reviewer.md +plan_path: docs/superpowers/plans/2026-07-13-opencode-reviewer.md +audits: + archaeology: docs/superpowers/archaeology/2026-07-13-opencode-reviewer.md + domain: docs/superpowers/expert/2026-07-13-opencode-reviewer.md + library: docs/superpowers/library-audit/2026-07-13-opencode-reviewer.md +routing_stance: balanced +max_parallel: 2 +acceptance_criteria: + - "ships" +jobs: + - id: task-1-quality-review + title: "quality review pass" + type: quality_review + backend: opencode + tier: deep + isolation: worktree + run: serial + write_allowed: [] + read_allowed: [src/**] + acceptance: ["reviewed"] +""" + + +# A complete, otherwise-valid manifest whose ONE defect is an opencode job with a BARE +# (slash-less) explicit model override. opencode addresses models as a "provider/model" +# string with no single-vendor default (see adapter-opencode.md); a bare name would +# silently pass a naive validator but fail opencode's own model resolution / the +# worker's `-m` argument at run time, so it must be rejected here, before dispatch. +OPENCODE_BARE_MODEL_MANIFEST = """ +run_id: 2026-07-13-opencode-bare-model +feature: "opencode-bare-model" +spec_path: docs/superpowers/specs/2026-07-13-opencode-bare-model.md +plan_path: docs/superpowers/plans/2026-07-13-opencode-bare-model.md +audits: + archaeology: docs/superpowers/archaeology/2026-07-13-opencode-bare-model.md + domain: docs/superpowers/expert/2026-07-13-opencode-bare-model.md + library: docs/superpowers/library-audit/2026-07-13-opencode-bare-model.md +routing_stance: balanced +max_parallel: 2 +acceptance_criteria: + - "ships" +jobs: + - id: task-1-opencode-bare + title: "opencode slice" + type: large_isolated + backend: opencode + model: "gpt-5.6" + isolation: worktree + run: serial + write_allowed: [src/opencode/**] + read_allowed: [src/**] + acceptance: ["builds"] +""" + + +# Same defect, malformed shape variant: a trailing slash with an EMPTY model half +# ("anthropic/") — the '/' is present but one side is empty, still not a valid +# provider/model pair. +OPENCODE_MALFORMED_MODEL_MANIFEST = """ +run_id: 2026-07-13-opencode-malformed-model +feature: "opencode-malformed-model" +spec_path: docs/superpowers/specs/2026-07-13-opencode-malformed-model.md +plan_path: docs/superpowers/plans/2026-07-13-opencode-malformed-model.md +audits: + archaeology: docs/superpowers/archaeology/2026-07-13-opencode-malformed-model.md + domain: docs/superpowers/expert/2026-07-13-opencode-malformed-model.md + library: docs/superpowers/library-audit/2026-07-13-opencode-malformed-model.md +routing_stance: balanced +max_parallel: 2 +acceptance_criteria: + - "ships" +jobs: + - id: task-1-opencode-malformed + title: "opencode slice" + type: large_isolated + backend: opencode + model: "anthropic/" + isolation: worktree + run: serial + write_allowed: [src/opencode/**] + read_allowed: [src/**] + acceptance: ["builds"] +""" + + # --------------------------------------------------------------------------- # # v2.9 fast-path self-test — builds on-disk fixtures in a temp repo root so the # containment + cross-artifact-binding checks exercise real files. Digests are @@ -3526,6 +3684,37 @@ def expect(name, cond): expect("opencode+worktree manifest is valid (provider/model string accepted)", opencode_ok == []) + # WORKER-ONLY: a reviewer job MUST NEVER resolve to backend devin/opencode, + # even when tier: deep + isolation: worktree are otherwise satisfied. + devin_reviewer_bad = validate_text(DEVIN_REVIEWER_MANIFEST) + expect( + "devin reviewer job REJECTED (WORKER-ONLY)", + any("reviewer job 'task-1-spec-review'" in p + and "backend 'devin'" in p + and "WORKER-ONLY" in p for p in devin_reviewer_bad), + ) + opencode_reviewer_bad = validate_text(OPENCODE_REVIEWER_MANIFEST) + expect( + "opencode reviewer job REJECTED (WORKER-ONLY)", + any("reviewer job 'task-1-quality-review'" in p + and "backend 'opencode'" in p + and "WORKER-ONLY" in p for p in opencode_reviewer_bad), + ) + + # opencode provider/model shape: a bare (slash-less) or malformed (empty-side) + # explicit model override is REJECTED before dispatch. + opencode_bare_bad = validate_text(OPENCODE_BARE_MODEL_MANIFEST) + expect( + "bare opencode model 'gpt-5.6' REJECTED (not provider/model)", + any("not a valid 'provider/model' string" in p for p in opencode_bare_bad), + ) + opencode_malformed_bad = validate_text(OPENCODE_MALFORMED_MODEL_MANIFEST) + expect( + "malformed opencode model 'anthropic/' REJECTED (empty right side)", + any("not a valid 'provider/model' string" in p + for p in opencode_malformed_bad), + ) + # Reviewer satisfied by tier: deep (no model) — GOOD manifest task-3 uses # tier: deep and must not trip the reviewer invariant. expect( From 936a4a269c678e10e5cabbc2d5e837adee27947f Mon Sep 17 00:00:00 2001 From: Oleg Kopachovets Date: Mon, 13 Jul 2026 11:31:38 +0100 Subject: [PATCH 4/4] fix(backends): close 2 round-2 edge blockers (symlink-safe backup, non-string model) opencode.json BACKUP artifact is now symlink-safe: private artifact dir validated non-symlink, backup path must not pre-exist (rm -f before cp), and restore refuses a symlink/tampered backup (fail-closed -> scope gate BLOCKS rather than reading attacker-redirected content). Validator now rejects a NON-STRING opencode model (42/list/dict) that previously bypassed the provider/model shape check. Live-proven (mid-run symlink swap -> blocked) + 3 negative selftests. Co-Authored-By: Claude Opus 4.8 --- scripts/compound-v-run-opencode-worker.sh | 37 ++++++ scripts/compound-v-validate-manifest.py | 137 ++++++++++++++++++++-- 2 files changed, 163 insertions(+), 11 deletions(-) diff --git a/scripts/compound-v-run-opencode-worker.sh b/scripts/compound-v-run-opencode-worker.sh index f7414d3..fe39802 100755 --- a/scripts/compound-v-run-opencode-worker.sh +++ b/scripts/compound-v-run-opencode-worker.sh @@ -342,6 +342,15 @@ OPENCODE_CONFIG="$WT/opencode.json" OPENCODE_CONFIG_BACKUP="$ART/opencode.json.orig" _opencode_config_preexisted="false" +# SECURITY: the BACKUP itself must be as symlink-safe as the config destination. +# $ART is created by THIS script's own `mkdir -p "$ART"` above, but assert it is a +# real, non-symlink directory before trusting it as a private scratch dir — if a +# symlinked $ART (or a symlinked component) somehow pre-existed, every `cp`/`cat` +# through it would follow the link out of the intended tree. This is belt-and-braces +# on top of the deterministic $RUN_ID/$JOB_ID path already asserted under $TMPROOT. +[ -L "$ART" ] && die "refusing: artifact dir is a symlink: $ART" +[ -d "$ART" ] || die "artifact dir is missing or not a directory: $ART" + # SECURITY: symlink-safety guard. A pre-existing $OPENCODE_CONFIG that is a symlink # (or any non-regular file) must NEVER be `cp`'d or redirected-into below — both # operations FOLLOW the link, so backing it up or overwriting it could read/write a @@ -356,6 +365,18 @@ if [ -e "$OPENCODE_CONFIG" ] && [ ! -f "$OPENCODE_CONFIG" ]; then die "refusing: pre-existing opencode.json is not a regular file: $OPENCODE_CONFIG" fi +# SECURITY: the BACKUP path must not pre-exist. The deterministic $RUN_ID/$JOB_ID +# path is reused across idempotent re-dispatch, so a stale (or maliciously planted) +# backup file/symlink could already sit here — and `cp SRC "$OPENCODE_CONFIG_BACKUP"` +# would FOLLOW a symlink at that destination, writing the tracked config's content +# through the link to an arbitrary external path. Remove any stale entry with +# `rm -f` (removes the directory ENTRY, never follows a symlink to its target) so the +# `cp` below always lands on a fresh, non-existent, in-$ART path. +rm -f "$OPENCODE_CONFIG_BACKUP" +if [ -e "$OPENCODE_CONFIG_BACKUP" ] || [ -L "$OPENCODE_CONFIG_BACKUP" ]; then + die "refusing: backup path still present after rm (unexpected): $OPENCODE_CONFIG_BACKUP" +fi + if [ -f "$OPENCODE_CONFIG" ]; then _opencode_config_preexisted="true" cp "$OPENCODE_CONFIG" "$OPENCODE_CONFIG_BACKUP" @@ -372,6 +393,22 @@ restore_opencode_config() { # than once (explicit call below, plus the EXIT/INT/TERM trap as a safety net). rm -f "$OPENCODE_CONFIG" if [ "$_opencode_config_preexisted" = "true" ]; then + # SECURITY: the lower-trust worker runs BETWEEN backup and restore, so it could + # have swapped $OPENCODE_CONFIG_BACKUP for a symlink pointing outside $ART — and + # `cat "$OPENCODE_CONFIG_BACKUP"` would then FOLLOW that link, reading arbitrary + # external content back INTO the restored worktree config. Refuse to read the + # backup unless it is still a REGULAR file (never a symlink) immediately before + # the restore. A tampered backup is FAIL-CLOSED: skip the restore entirely rather + # than copy attacker-chosen content in. The `rm -f "$OPENCODE_CONFIG"` above has + # already deleted the scratch config, so the worktree is left with the tracked + # opencode.json DELETED (not restored) — the scope gate then sees that deletion as + # an out-of-scope change and BLOCKS the job. Blocking a job whose worker tampered + # with our private backup is the correct, safe outcome; the one thing we must + # never do is write the worker's redirected content back into the tree. + if [ -L "$OPENCODE_CONFIG_BACKUP" ] || [ ! -f "$OPENCODE_CONFIG_BACKUP" ]; then + echo "compound-v-run-opencode-worker: refusing to restore from a non-regular/symlinked backup (tampered mid-run): $OPENCODE_CONFIG_BACKUP" >&2 + return 0 + fi # Write a fresh REGULAR file from the backup's content. This never opens # $OPENCODE_CONFIG for read and never `cp`s onto it — redirection creates a # brand-new inode at a path that, thanks to the `rm -f` immediately above, is diff --git a/scripts/compound-v-validate-manifest.py b/scripts/compound-v-validate-manifest.py index 26b7667..5b13290 100755 --- a/scripts/compound-v-validate-manifest.py +++ b/scripts/compound-v-validate-manifest.py @@ -1737,22 +1737,27 @@ def validate(manifest, mode=None, repo_root=None, config_path=None, ) # opencode provider/model shape: every EXPLICIT opencode model - # override must be a genuine non-empty "provider/model" string (a + # override must be a genuine non-empty "provider/model" STRING (a # bare name would silently pass here but fail opencode's own model # resolution / the worker's `-m` argument at run time). Only checked - # when a model is actually present — a tier-only job resolves through - # compound-v-resolve-model.py's own shape check at resolution time. - if backend_lc == "opencode": + # when a model key is actually present — a tier-only job resolves + # through compound-v-resolve-model.py's own shape check at resolution + # time. A NON-STRING model (int/list/dict from YAML) is itself a + # violation: it can never be a valid provider/model string and must + # NOT slip through by skipping the check. + if backend_lc == "opencode" and "model" in job: m_val = job.get("model") + _shaped = False if isinstance(m_val, str) and m_val.strip(): _prov, _sep, _rest = m_val.partition("/") - if not _sep or not _prov.strip() or not _rest.strip(): - problems.append( - "job '%s' backend opencode has model '%s' which is " - "not a valid 'provider/model' string (must be " - "non-empty on both sides of exactly one '/')" - % (jid, m_val) - ) + _shaped = bool(_sep) and bool(_prov.strip()) and bool(_rest.strip()) + if not _shaped: + problems.append( + "job '%s' backend opencode has model %r which is not a " + "valid 'provider/model' string (must be a non-empty " + "string, non-empty on both sides of exactly one '/')" + % (jid, m_val) + ) # Invariant 3: reviewers => deep/opus (strongest reasoning). Satisfied # by either tier: deep or model: opus. @@ -2626,6 +2631,96 @@ def main(argv): """ +# A family of opencode jobs whose explicit `model` is a NON-STRING (int / inline list +# / inline mapping). A non-string can never be a valid 'provider/model' string, and the +# shape check must NOT skip it (an earlier version only ran the check for str models, so +# a `model: 42` slipped through with zero violations). Each carries `tier: standard` so +# the model override is present-but-invalid without also tripping the model-or-tier +# requirement — isolating the shape violation as the guaranteed one. +OPENCODE_INT_MODEL_MANIFEST = """ +run_id: 2026-07-13-opencode-int-model +feature: "opencode-int-model" +spec_path: docs/superpowers/specs/2026-07-13-opencode-int-model.md +plan_path: docs/superpowers/plans/2026-07-13-opencode-int-model.md +audits: + archaeology: docs/superpowers/archaeology/2026-07-13-opencode-int-model.md + domain: docs/superpowers/expert/2026-07-13-opencode-int-model.md + library: docs/superpowers/library-audit/2026-07-13-opencode-int-model.md +routing_stance: balanced +max_parallel: 2 +acceptance_criteria: + - "ships" +jobs: + - id: task-1-opencode-int + title: "opencode slice" + type: large_isolated + backend: opencode + model: 42 + tier: standard + isolation: worktree + run: serial + write_allowed: [src/opencode/**] + read_allowed: [src/**] + acceptance: ["builds"] +""" + + +OPENCODE_LIST_MODEL_MANIFEST = """ +run_id: 2026-07-13-opencode-list-model +feature: "opencode-list-model" +spec_path: docs/superpowers/specs/2026-07-13-opencode-list-model.md +plan_path: docs/superpowers/plans/2026-07-13-opencode-list-model.md +audits: + archaeology: docs/superpowers/archaeology/2026-07-13-opencode-list-model.md + domain: docs/superpowers/expert/2026-07-13-opencode-list-model.md + library: docs/superpowers/library-audit/2026-07-13-opencode-list-model.md +routing_stance: balanced +max_parallel: 2 +acceptance_criteria: + - "ships" +jobs: + - id: task-1-opencode-list + title: "opencode slice" + type: large_isolated + backend: opencode + model: ["anthropic/claude-opus-4-6"] + tier: standard + isolation: worktree + run: serial + write_allowed: [src/opencode/**] + read_allowed: [src/**] + acceptance: ["builds"] +""" + + +OPENCODE_DICT_MODEL_MANIFEST = """ +run_id: 2026-07-13-opencode-dict-model +feature: "opencode-dict-model" +spec_path: docs/superpowers/specs/2026-07-13-opencode-dict-model.md +plan_path: docs/superpowers/plans/2026-07-13-opencode-dict-model.md +audits: + archaeology: docs/superpowers/archaeology/2026-07-13-opencode-dict-model.md + domain: docs/superpowers/expert/2026-07-13-opencode-dict-model.md + library: docs/superpowers/library-audit/2026-07-13-opencode-dict-model.md +routing_stance: balanced +max_parallel: 2 +acceptance_criteria: + - "ships" +jobs: + - id: task-1-opencode-dict + title: "opencode slice" + type: large_isolated + backend: opencode + model: {} + tier: standard + isolation: worktree + run: serial + write_allowed: [src/opencode/**] + read_allowed: [src/**] + acceptance: ["builds"] +""" + + # --------------------------------------------------------------------------- # # v2.9 fast-path self-test — builds on-disk fixtures in a temp repo root so the # containment + cross-artifact-binding checks exercise real files. Digests are @@ -3714,6 +3809,26 @@ def expect(name, cond): any("not a valid 'provider/model' string" in p for p in opencode_malformed_bad), ) + # NON-STRING opencode model (int / list / dict) must ALSO be rejected — it can + # never be a provider/model string and must not skip the shape check. + opencode_int_bad = validate_text(OPENCODE_INT_MODEL_MANIFEST) + expect( + "non-string opencode model (int 42) REJECTED", + any("not a valid 'provider/model' string" in p + for p in opencode_int_bad), + ) + opencode_list_bad = validate_text(OPENCODE_LIST_MODEL_MANIFEST) + expect( + "non-string opencode model (list) REJECTED", + any("not a valid 'provider/model' string" in p + for p in opencode_list_bad), + ) + opencode_dict_bad = validate_text(OPENCODE_DICT_MODEL_MANIFEST) + expect( + "non-string opencode model (dict) REJECTED", + any("not a valid 'provider/model' string" in p + for p in opencode_dict_bad), + ) # Reviewer satisfied by tier: deep (no model) — GOOD manifest task-3 uses # tier: deep and must not trip the reviewer invariant.