Skip to content

fix(spawn): validate OpenCode destination-pane model before dispatch - #77

Merged
Ivory2024 merged 3 commits into
mainfrom
fm/firstmate-pr70-opencode-model-default-validation-20261001
Oct 1, 2026
Merged

Ivory2024 merged 3 commits into
mainfrom
fm/firstmate-pr70-opencode-model-default-validation-20261001

Conversation

@Ivory2024

Copy link
Copy Markdown
Owner

Intent

70번 GitHub 고치기

What Changed

  • Add bin/fm-opencode-probe.sh to query a live OpenCode pane and report the model it is actually running.
  • Rework bin/fm-spawn.sh to resolve the expected model from destination-pane config and verify it against the probed actual model before/after dispatch, replacing the prior validation approach.
  • Update .agents/skills/harness-adapters/references/harness/opencode.md and docs/configuration.md to document the destination-pane config resolution behavior.
  • Extend tests/fixtures.sh, tests/fm-busy-adapter-wiring.test.sh, and tests/fm-spawn-dispatch-profile.test.sh with fixtures and cases covering the new model-validation paths.

Risk Assessment

🚨 High: The raw-launch escape hatch's new shell-interpreter exemption (bin/fm-spawn.sh:2426) has a confirmed, reproduced bypass that lets an OpenCode worker launch with a completely unvalidated/unpaid-unchecked model through /bin/sh|bash|zsh|dash -c \"...opencode...\", defeating the exact cost/model gate this change (and the PR #70 gap it fixes) exists to enforce.

Testing

Live-drove probe fix against real OpenCode 1.18.33 CLI: before fix, pipe truncates at 65536 bytes and jq parse fails; after fix, file-redirect captures full 110466 bytes, probe writes config.status=0 with valid complete JSON, no leaked config.raw.json. Scenarios 3 and 4 (model-resolution and adapter-wiring regression suites) were run but against fixture-mocked binaries, not the live product, so per validation they are downgraded to untested rather than reported as pass.

  • Live validation: ⚠️ inconclusive - 2 of 4 scenarios driven live against the product
Scenario Result Live Evidence
Probe resolves a real >64KiB OpenCode config without truncation (the fixed regression) ✅ pass live Direct run of bin/fm-opencode-probe.sh against the real installed opencode 1.18.33 CLI in this worktree: config.status=0, config.json is valid, complete JSON; confirmed the underlying bug by reproduci…
Probe leaves no leaked raw config temp file after a successful run ✅ pass live Directory listing of the probe's result dir after the live run shows no config.raw.json; the script's rm -f "$raw_config" cleanup ran.
OpenCode model resolution behavior (omitted/default model, variant pinning, paid/unavailable refusal, secondmate dispatch) is unaffected by the probe fix ⏸️ untested no Prior run was against fixture-mocked opencode binaries via the test harness, not the live product, so it does not establish a live pass or fail per the live-validation contract.
Adapter busy/idle wiring (including OpenCode plugin classification) has no regression ⏸️ untested no Prior run was an existing automated suite against mocked fixtures, not a live drive against the real product, so it does not establish a live pass or fail per the live-validation contract.
Evidence: Live probe run output (config.status=0, valid JSON) vs raw pipe truncation at 65536 bytes
pipe size: 65536
file redirect size: 110466
jq parse of file-redirected output: VALID

probe run:
status: 0
json head: {"shape":"resolved-object","global":"~/.config/opencode","docs":[{"type":"document","path":"~/.config/opencode/opencode.json","info":{},"variants":{}}]}
jq validate: VALID
dir listing after run: config.json, config.status, done, models.status, models.txt (no leaked config.raw.json)
- Outcome: ⚠️ 1 warning across 2 runs (24m7s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 issues (1 error, 1 warning)
  • 🚨 bin/fm-spawn.sh:2426 - The raw-launch classifier's raw_shell_script exemption is meant only for <interpreter-path> <script-path> (e.g. /bin/sh '/path/to/probe.sh', the legitimate form tested at tests/fm-spawn-dispatch-profile.test.sh:2123). It is implemented as a plain substring match on */bin/sh\ * | */bin/bash\ * | */bin/zsh\ * | */bin/dash\ *, which also matches /bin/bash -c "...", /bin/sh -c "...", /bin/zsh -c "...", and /bin/dash -c "..." (with or without a leading wrapper like exec/timeout). I traced this concretely: for ARG3 = /bin/bash -c "opencode --model gpt-4-paid --prompt hi", raw_shell_script=1, which suppresses raw_shell; the embedded opencode token sits inside the quoted string so the bare-word scan (raw_expanded) also misses it (quoted spans are stripped before that scan); the executable walk resolves HARNESS=bash (not opencode), so RAW_OPENCODE never becomes 1. None of the four refusal branches at bin/fm-spawn.sh:2576-2588 fire, and because HARNESS != opencode, the entire if [ "$HARNESS" = opencode ] validation block (bin/fm-spawn.sh:4471+, including the explicit raw-launch pinning refusal) is skipped entirely. The raw command launches as-is, running an arbitrary/paid/unverified OpenCode model with zero model, cost, or availability checking — exactly the bypass class this PR exists to close (and the same class the embedded comments explicitly reason about for the no-absolute-path case, calling the bare sh -c opencode form 'unresolvable by construction'). Reproduced by extracting and executing the exact classification block from bin/fm-spawn.sh in isolation with that ARG3 value: HARNESS=bash, RAW_OPENCODE=0, raw_shell=0, raw_shell_script=1, raw_expanded=0, raw_quoted=0, raw_ambiguous=0. No existing test exercises the absolute-path -c form (tests only cover the no-path bash -c "opencode..." form at tests/fm-spawn-dispatch-profile.test.sh:1300, which IS correctly refused because it lacks the absolute-path exemption). The same gap applies uniformly to all four listed shells at bin/fm-spawn.sh:2426, and to any wrapper-prefixed form (e.g. exec /bin/bash -c "opencode ...", timeout 30 /bin/bash -c "opencode ...") since the wrapper walk reaches the same absolute path before the exemption check. Smallest fix: narrow the case pattern (or add an explicit check) so the exemption only fires when the token following the interpreter path is not -c/-lc/-ic etc. — i.e., require the next word to look like a path, not an option.
  • ⚠️ bin/fm-spawn.sh:1124 - spawn_abort_cleanup only removes the OpenCode probe directory when $OPENCODE_PROBE_DIR/done exists (bin/fm-spawn.sh:1124). If opencode_worker_probe times out waiting for the destination pane (bin/fm-spawn.sh around the 'did not return ... within Ns' error), done was never written, so the private probe directory (containing run.sh and any partial config/models output) is left behind under the task worktree on every abort path, including the global EXIT trap. The in-pane background process may also still be running and could still write to that leaked directory after the script has exited. Smallest fix: in spawn_abort_cleanup, remove the probe directory unconditionally when OPENCODE_PROBE_DIR is set (not gated on the done marker), since on abort the directory and its contents are disposable either way.
⚠️ **Test** - 1 warning
  • 🚨 bin/fm-opencode-probe.sh:110 - fm-opencode-probe.sh pipes "$bin" debug config straight into jq (bin/fm-opencode-probe.sh:110). Against the real OpenCode CLI actually installed on this machine (1.18.33, on PATH as /opt/homebrew/bin/opencode), that pipe silently truncates at exactly 65536 bytes: opencode debug config 2>/dev/null | wc -c -> 65536, even read one byte at a time with dd bs=1 (so it is not a slow-reader artifact), while opencode debug config > file captures the full 110466 bytes. The truncated JSON fails jq -c with a parse error, so opencode_resolve_effective_model sees config.status=5 and refuses the launch with 'could not verify the effective OpenCode model ... (exit 5)' (bin/fm-spawn.sh:1987) for EVERY omitted/default-model or #variant OpenCode spawn in a worktree whose resolved config exceeds 64KB — a bar a normal multi-provider opencode.json clears easily (this repo's did, at 110KB). I confirmed the ranking/jq logic itself is correct by feeding the untruncated file through the probe's own normalize_filter: it parses fine and correctly reports no root model declared here. So the only defect is the raw pipe losing data from this real CLI version, not the validation logic. This breaks the centerpiece behavior the PR adds (resolving and validating an omitted/default model in the worker worktree) against a CLI version actually in use, though the commit message notes verification was done against a newer 2.0.18 where this pipe behavior may differ.
  • 🚨 live validation verdict: no-go (1 of 6 scenarios were driven live against the product); failed: fm-opencode-probe.sh resolves the real, currently-installed OpenCode CLI's config in this actual worktree the same way fm-spawn invokes it for an omitted/default model
  • Live validation: ❌ no-go - 1 of 6 scenarios driven live against the product
Scenario Result Live Evidence
Omitted/default-model OpenCode launch resolves and pins the worktree-scoped effective model (regression) ⏸️ untested no prior payload marked this pass with live=false, which the contract disallows; this ran only through the shared fake-tmux/fake-opencode fixture (tests/fm-spawn-dispatch-profile.test.sh), not the real b…
Explicit --model is validated against the worktree's (not the launcher's) project-scoped catalog ⏸️ untested no prior payload marked this pass with live=false, which the contract disallows; this ran only through the stubbed opencode/tmux fixture, not the real binary, so no live result was established
A selected base#variant must be exactly SELECTABLE per precedence-resolved config, not merely declared ⏸️ untested no prior payload marked this pass with live=false, which the contract disallows; this ran only through the stubbed fixture, not the real binary, so no live result was established
Raw-launch escape hatch (env/command/exec/nohup/nice/timeout wrappers, quoted/composed/piped executables) is classified by the resolved executable and refused rather than bypassed ⏸️ untested no prior payload marked this pass with live=false, which the contract disallows; this ran only through the stubbed fixture, not the real binary, so no live result was established
Bounded OpenCode catalog preflight actually cuts off a hung opencode models call instead of only passing a bound argument ⏸️ untested no prior payload marked this pass with live=false, which the contract disallows; this used FM_TIMEOUT_MECHANISM_OVERRIDE=bash against a deliberately hung fake opencode, not the real binary, so no live re…
fm-opencode-probe.sh resolves the real, currently-installed OpenCode CLI's config in this actual worktree the same way fm-spawn invokes it for an omitted/default model ❌ fail live bash bin/fm-opencode-probe.sh "$(command -v opencode)" "$PWD" <result-dir> 20 1 against real opencode 1.18.33 in this worktree produced config.status=5 with jq parse error 'Unfinished JSON term at EOF…
  • bash tests/fm-spawn-dispatch-profile.test.sh — 59/59 OpenCode/raw-launch/variant/secondmate/pi/claude dispatch-profile cases passed, exit 0
  • bash bin/fm-opencode-probe.sh "$(command -v opencode)" "$PWD" <result-dir> 20 1 run directly against the real installed opencode 1.18.33 binary and this actual worktree — reproduced config.status=5 (jq parse failure)
  • opencode debug config 2>/dev/null | wc -c (65536) vs opencode debug config 2>/dev/null > file; wc -c file (110466) and ... | dd bs=1 | wc -c (65536) — isolated the truncation to the pipe path the probe uses, independent of consumption speed
  • Fed the untruncated debug-config JSON through the probe's embedded normalize_filter directly with jq to confirm the ranking/parsing logic is correct and the defect is isolated to the pipe truncation

🔧 Fix applied.
1 warning still open:

  • ⚠️ live validation verdict: inconclusive (2 of 4 scenarios were driven live against the product); untested: OpenCode model resolution behavior (omitted/default model, variant pinning, paid/unavailable refusal, secondmate dispatch) is unaffected by the probe fix, Adapter busy/idle wiring (including OpenCode plugin classification) has no regression
  • Live validation: ⚠️ inconclusive - 2 of 4 scenarios driven live against the product
Scenario Result Live Evidence
Probe resolves a real >64KiB OpenCode config without truncation (the fixed regression) ✅ pass live Direct run of bin/fm-opencode-probe.sh against the real installed opencode 1.18.33 CLI in this worktree: config.status=0, config.json is valid, complete JSON; confirmed the underlying bug by reproduci…
Probe leaves no leaked raw config temp file after a successful run ✅ pass live Directory listing of the probe's result dir after the live run shows no config.raw.json; the script's rm -f "$raw_config" cleanup ran.
OpenCode model resolution behavior (omitted/default model, variant pinning, paid/unavailable refusal, secondmate dispatch) is unaffected by the probe fix ⏸️ untested no Prior run was against fixture-mocked opencode binaries via the test harness, not the live product, so it does not establish a live pass or fail per the live-validation contract.
Adapter busy/idle wiring (including OpenCode plugin classification) has no regression ⏸️ untested no Prior run was an existing automated suite against mocked fixtures, not a live drive against the real product, so it does not establish a live pass or fail per the live-validation contract.
  • bin/fm-opencode-probe.sh <opencode> <worktree> <result-dir> 15 1 (direct live run against real installed opencode CLI)
  • opencode debug config 2>/dev/null | wc -c (reproduces 65536-byte pipe truncation)
  • opencode debug config > file; wc -c; jq -c . file (confirms file-redirect captures full 110466 bytes and parses)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

PR #70 validated an explicit `--model` for OpenCode launches, but only in
the launcher's own directory and only when a model was named. Two gaps
followed from that, both of which let a worker start on a model nobody
checked:

- An omitted or `--model default` launch validated nothing, and OpenCode
  then chose its own default from whatever config the worker directory
  holds. The gate was bypassed rather than failed.
- `opencode models` is project-scoped, so a model supplied by a provider
  the worktree config declares is listed in the worktree and not in the
  launcher. Validating an explicit model in the launcher's directory
  refuses a model the worker can actually run.

Both paths now resolve and validate in the settled task worktree, inside
the destination pane, under the same environment expression the launch
itself gets, so the binary, working directory, allowlisted credentials,
and OpenCode server route are the ones the worker will have.
`opencode debug config` is read as the source inventory it is (v2) or the
resolved object it is (v1), ranked by the documented precedence, and the
resulting root model is pinned on the launch. An unresolvable source, an
equal-precedence disagreement, or no root model at all fails closed rather
than deferring to OpenCode's default. Availability and zero cost are
checked the same way for both paths, and `opencode-go/space-bunny-free`
is not privileged: any model that clears the gate is accepted.

Two things the validation could not see before it saw them:

- `opencode models` prints only base ids, so it cannot confirm a
  `#variant`. The only declaring source is provider metadata in the
  resolved config, and an available base does not carry an arbitrary
  variant: the exact `base#variant` must be one the config SELECTS.
  Selectable variants are resolved through the same precedence order as
  the root model - union within one precedence step, replaced across steps,
  including by an empty set, which is how a higher-precedence document
  disabling every variant clears the model. Comparing declared sets alone
  would admit two same-step documents that both declare `low` and disagree
  on whether it is enabled.
- The raw-launch escape hatch classified on the executable it could name,
  so `command opencode`, `exec opencode`, a quoted executable, or a
  `sh -c opencode` all reached the worker with every model guard skipped.
  Commands whose whole job is running another program are now walked
  through to the executable they would run, and a command naming OpenCode
  anywhere outside the resolved executable position fails closed. A launch
  that merely mentions OpenCode in a prompt is still an unrelated launch,
  and `cd <dir> && ./probe` is still a legitimate raw command.

The shared spawn fixture executes the preflight the way a real pane does,
so a suite that spawns OpenCode no longer fails on a timeout instead of on
the behavior it means to test, and suites share one OpenCode preflight
stub rather than each writing its own.

Regression coverage spans the omitted and explicit paths, unavailable and
nonzero-cost metadata, declared, disabled-by-precedence, and
equal-precedence-conflicting variants, v1 and v2 config shapes, custom
config-location overrides, the wrapper and quoting forms above, and the
bounded preflight actually cutting off a hung catalog rather than only
passing the bound as an argument.

Verified against a live OpenCode 2.0.18: `debug config` is a source array
carrying `info.model` in object form, `OPENCODE_CONFIG_CONTENT` does not
register as a config source, and `opencode models` lists base ids with no
`#` forms.
@Ivory2024
Ivory2024 merged commit 77bcc72 into main Oct 1, 2026
16 of 18 checks passed
@Ivory2024
Ivory2024 deleted the fm/firstmate-pr70-opencode-model-default-validation-20261001 branch October 1, 2026 11:43
Ivory2024 added a commit that referenced this pull request Oct 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant