From 25853b5f8f25c539c5669887dcec4f39e78fb471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=8F=89=EC=8B=9D?= Date: Thu, 1 Oct 2026 21:19:01 +0900 Subject: [PATCH] Revert "fix(spawn): validate OpenCode destination-pane model before dispatch (#77)" This reverts commit 77bcc72417c38aaba5e91676aeadf89db7a1c3b8. --- .../references/harness/opencode.md | 2 +- bin/fm-opencode-probe.sh | 141 --- bin/fm-spawn.sh | 664 ++------------ docs/configuration.md | 6 +- tests/fixtures.sh | 59 -- tests/fm-busy-adapter-wiring.test.sh | 4 - tests/fm-spawn-dispatch-profile.test.sh | 833 +----------------- 7 files changed, 72 insertions(+), 1637 deletions(-) delete mode 100755 bin/fm-opencode-probe.sh diff --git a/.agents/skills/harness-adapters/references/harness/opencode.md b/.agents/skills/harness-adapters/references/harness/opencode.md index 51358a35ebc..4373fefc561 100644 --- a/.agents/skills/harness-adapters/references/harness/opencode.md +++ b/.agents/skills/harness-adapters/references/harness/opencode.md @@ -14,7 +14,7 @@ The worker busy-state lifecycle below describes the generated OpenCode 2 plugin. | Resume | Relaunch with `--continue` to resume the most recent session for the current directory, then send the next instruction after the TUI is ready because `--prompt` does not auto-submit alongside `--continue`. | | Interactive task launch | Firstmate probes `opencode mini --help` for a `Usage: opencode mini` line; OpenCode v2 uses `opencode mini`, while legacy releases use the top-level command. Both receive the requested `--model ` and worker brief through `--prompt`. | | Effort flag | None for the interactive task launch; `opencode run` has `--variant`, but that is a different, non-interactive path. | -| Model discovery | Run `opencode models` with no argument to list every available `provider/model` identifier. The legacy `opencode models ` form is rejected by OpenCode v2 ("Unexpected positional argument") and prints usage text rather than a catalog, so `../../../bin/fm-spawn.sh` reads the whole catalog and matches the exact base id. An omitted or `default` model, and any `#variant` suffix, instead resolve through `opencode debug config` read in the destination pane's settled task worktree, precedence-ranked the same way OpenCode itself ranks config sources; see `opencode_resolve_effective_model` and `opencode_model_validate` in `../../../bin/fm-spawn.sh`. | +| Model discovery | Run `opencode models` with no argument to list every available `provider/model` identifier. The legacy `opencode models ` form is rejected by OpenCode v2 ("Unexpected positional argument") and prints usage text rather than a catalog, so `../../../bin/fm-spawn.sh` reads the whole catalog and matches the exact id. | | Trust dialog | None. | | Marker | None; OpenCode publishes no identity marker, so `../../../bin/fm-harness.sh` identifies it from process ancestry. | diff --git a/bin/fm-opencode-probe.sh b/bin/fm-opencode-probe.sh deleted file mode 100755 index 55d7f75baa1..00000000000 --- a/bin/fm-opencode-probe.sh +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env bash -# Run OpenCode discovery inside the destination pane's launch environment. -# Only model-related config fields leave the debug-config process. -set -u - -if [ "$#" -ne 5 ]; then - echo "usage: fm-opencode-probe.sh " >&2 - exit 2 -fi - -bin=$1 -worktree=$2 -result_dir=$3 -bound=$4 -resolve_config=$5 -case "$bound" in ''|*[!0-9]*|0*) exit 2 ;; esac -case "$resolve_config" in 0|1) ;; *) exit 2 ;; esac -[ -x "$bin" ] && [ -d "$worktree" ] && [ -d "$result_dir" ] || exit 2 - -# Resolve this script's own directory BEFORE changing directory, from -# BASH_SOURCE rather than $0: fm-spawn invokes it with an absolute path, but a -# relative one would be resolved against the worktree it is about to enter. -script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) || exit 2 -cd "$worktree" || exit 2 -# shellcheck source=bin/fm-timeout-lib.sh -# shellcheck disable=SC1091 -. "$script_dir/fm-timeout-lib.sh" - -if [ "$resolve_config" = 1 ]; then - if [ -n "${OPENCODE_CONFIG:-}" ] || [ -n "${OPENCODE_CONFIG_DIR:-}" ]; then - printf '%s\n' 'config-location-override' > "$result_dir/config.reason" - printf '78\n' > "$result_dir/config.status" - else - if [ -n "${XDG_CONFIG_HOME:-}" ]; then - global_config="$XDG_CONFIG_HOME/opencode" - elif [ -n "${HOME:-}" ]; then - global_config="$HOME/.config/opencode" - else - printf '78\n' > "$result_dir/config.status" - fi - if [ ! -f "$result_dir/config.status" ]; then - # shellcheck disable=SC2016 # single quotes are deliberate: $provider/$model and $global are jq's, not the shell's - normalize_filter=' - def model_value: - if type == "string" then . - elif type == "object" then - (.providerID // .provider) as $provider - | (.model // .modelID) as $model - | if ($provider | type) == "string" and ($model | type) == "string" then - "\($provider)/\($model)" + - (if (.variant | type) == "string" and .variant != "" then "#\(.variant)" else "" end) - else null end - else null end; - # `opencode models` lists only base ids, never a "#variant" form, so the - # only source that can confirm a selected variant is the provider - # metadata in the resolved config. Both published shapes of that field - # are normalized to the same list, because which one appears is a - # property of the OpenCode version rather than a choice by the caller: - # array of objects v2 - each entry carries its own `id` - # object keyed by id v1 - the variant id is the key - # Any id that cannot be read as a string yields no entry at all, which - # leaves the exact-match requirement in the caller unsatisfied and keeps - # the launch closed. - # Both the DECLARED ids and the SELECTABLE subset are kept, per config - # document and keyed by base id, because a document that disables one - # variant still DECLARES it while selecting none of it. A union across - # documents would let a lower-precedence enabled variant survive a - # higher-precedence disable, so the caller ranks these per document - # rather than merging them here. - def variant_entries: - if type == "array" then [.[] | {id: .id, disabled: .disabled}] - elif type == "object" then [to_entries[] - | {id: .key, disabled: (.value | if type == "object" then .disabled else null end)}] - else [] end; - def variant_map: - if type == "object" then - [ (.providers // {} | to_entries[]) as $provider - | ($provider.value.models // {} | to_entries[]) as $model - | ($model.value.variants // {}) as $declared - # `[]` iterates the normalized entries; without it `as $entry` - # would bind the whole array and the field reads below would fail. - | ($declared | variant_entries)[] - | select((.id | type) == "string" and .id != "") - | {key: "\($provider.key)/\($model.key)", id: .id, off: (.disabled == true)} ] - | group_by(.key) - | map({key: .[0].key, - value: {declared: (map(.id) | unique), - selectable: (map(select(.off | not) | .id) | unique)}}) - | from_entries - else {} end; - def clean_info: - {model: (.model | model_value)} - | with_entries(select(.value != null)); - def entries: - if type == "array" then [.[] | {type, path, info:(.info // {})}] - elif type == "object" then [{type:"document", path:($global + "/opencode.json"), info:.}] - else error("unsupported debug config JSON shape") end; - # The docs list keeps only the decoded model of each source and drops - # everything else: the provider metadata the variant list above reads - # stays behind here, because a config source can carry arbitrary - # provider content the ranking step has no use for. Both passes carry - # their own load. - (entries) as $docs - | {shape:(if type == "array" then "source-array" else "resolved-object" end), - global:$global, - docs:[$docs[] | {type, path, info:(.info | clean_info), variants:(.info | variant_map)}]} - ' - # `debug config` output is redirected to a real file rather than piped - # straight into jq: against the installed OpenCode CLI, piping its stdout - # silently truncates at 64KiB, which breaks jq on any config past that - # size. A regular file does not have that limit. - raw_config="$result_dir/config.raw.json" - # shellcheck disable=SC2016 # single quotes are deliberate: ${...} expands in the probe's bash -c, not here - if fm_run_timed "$bound" /bin/bash -c ' - "$1" debug config > "$2" 2>/dev/null - status=$? - [ "$status" -eq 0 ] || exit "$status" - jq -c --arg global "$3" "$4" < "$2" - ' _ "$bin" "$raw_config" "$global_config" "$normalize_filter" > "$result_dir/config.json"; then - printf '0\n' > "$result_dir/config.status" - else - status=$? - [ "$status" -ne 0 ] || status=65 - printf '%s\n' "$status" > "$result_dir/config.status" - fi - rm -f "$raw_config" 2>/dev/null || true - fi - fi -else - printf '{"shape":"skip"}\n' > "$result_dir/config.json" - printf '0\n' > "$result_dir/config.status" -fi - -if fm_run_timed "$bound" "$bin" models > "$result_dir/models.txt" 2>/dev/null < /dev/null; then - printf '0\n' > "$result_dir/models.status" -else - status=$? - printf '%s\n' "$status" > "$result_dir/models.status" -fi - -: > "$result_dir/done" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index cb658a68cfa..f4912c53b6a 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1083,7 +1083,6 @@ SPAWN_TASK_SET_LOCK_HELD=0 SPAWN_TREEHOUSE_PROJECT_LOCK= SPAWN_TREEHOUSE_PROJECT_LOCK_HELD=0 SPAWN_SLOT_CLAIMED=0 -OPENCODE_PROBE_DIR= RELAUNCH_REPLACEMENT_PENDING=0 RELAUNCH_REPLACEMENT_BUSY_GEN= RELAUNCH_REPLACEMENT_HARNESS= @@ -1121,9 +1120,6 @@ parse_orca_worktree_result() { spawn_abort_cleanup() { local status=$? - if [ -n "${OPENCODE_PROBE_DIR:-}" ] && [ -f "$OPENCODE_PROBE_DIR/done" ]; then - opencode_probe_cleanup || true - fi if [ "$RELAUNCH_REPLACEMENT_PENDING" = 1 ] && [ "$SPAWN_META_PUBLISH_STARTED" = 1 ] && [ -n "$SPAWN_META_TMP" ] && @@ -1534,8 +1530,6 @@ PROJ= ARG3= FIRSTMATE_HOME= RAW_LAUNCH=0 -RAW_OPENCODE=0 -OPENCODE_BIN= # --relaunch adoption: every identity axis comes from the task's own validated # durable record, never from the command line, so a relaunch can only ever @@ -1793,352 +1787,10 @@ agy_model_validate() { # return 1 } -# Build the same destination-pane environment expression used by the launch. -spawn_build_launch_env_prefix() { # - local include_trace=${1:-0} prefix env_name env_arg - [ "$LAUNCH_ENV_ENABLED" = 1 ] || return 0 - prefix='/usr/bin/env -i' - for env_name in HOME PATH USER LOGNAME SHELL TERM COLORTERM LANG LC_ALL LC_CTYPE \ - TMPDIR TMP TEMP GOTMPDIR TMUX TMUX_PANE HERDR_ENV HERDR_SESSION HERDR_SOCKET_PATH \ - HERDR_PANE_ID CMUX_WORKSPACE_ID CMUX_SURFACE_ID CMUX_TAB_ID CMUX_PANEL_ID \ - CMUX_SOCKET_PATH ZELLIJ ZELLIJ_SESSION_NAME ZELLIJ_PANE_ID FM_ZELLIJ_SESSION \ - FM_TASK_ID COMPACT_ADVISER_DISABLE \ - $LAUNCH_ENV_NAMES; do - # Only validated names enter shell syntax. Values expand once, quoted, in - # the pane shell and never become source text or spawn-process snapshots. - # shellcheck disable=SC2016 # single quotes are deliberate: ${...} expands in the pane shell, not here - printf -v env_arg '${%s+"%s=$%s"}' "$env_name" "$env_name" "$env_name" - prefix="$prefix $env_arg" - done - prefix="$prefix COMPACT_ADVISER_DISABLE=1" - if [ "$include_trace" = 1 ] && [ -n "${SPAWN_TRACEPARENT:-}" ]; then - # shellcheck disable=SC2016 # single quotes are deliberate: ${...} expands in the pane shell, not here - prefix="$prefix "'${TRACEPARENT+"TRACEPARENT=$TRACEPARENT"}' - fi - printf '%s' "$prefix" -} - -# Probe in the destination pane so the binary, worktree, allowlisted credentials, -# config variables, and OpenCode server route are the same ones the worker gets. -opencode_worker_probe() { # - local bin=$1 dir=$2 resolve_config=$3 bound=${FM_OPENCODE_MODELS_TIMEOUT:-30} - local q_dir q_output q_helper q_bin q_config q_id env_prefix script command max_checks i - case "$bound" in ''|*[!0-9]*|0*) bound=30 ;; esac - case "$resolve_config" in 0|1) ;; *) return 1 ;; esac - OPENCODE_PROBE_DIR="$dir/.fm-opencode-probe-$ID-${BASHPID:-$$}-$RANDOM" - if ! (umask 077 && mkdir "$OPENCODE_PROBE_DIR"); then - echo "error: could not create a private OpenCode probe directory in $dir" >&2 -OPENCODE_PROBE_DIR= - return 1 - fi - q_dir=$(shell_quote "$dir") - q_output=$(shell_quote "$OPENCODE_PROBE_DIR") - q_helper=$(shell_quote "$SCRIPT_DIR/fm-opencode-probe.sh") - q_bin=$(shell_quote "$bin") - q_config=$(shell_quote '{"permission":{"*":"allow"}}') - q_id=$(shell_quote "$ID") - env_prefix=$(spawn_build_launch_env_prefix 0) - script="$OPENCODE_PROBE_DIR/run.sh" - { - printf '#!/bin/sh\ncd -- %s || exit 1\n' "$q_dir" - [ -z "$env_prefix" ] || printf '%s ' "$env_prefix" - printf 'COMPACT_ADVISER_DISABLE=1 ' - [ "$KIND" = secondmate ] || printf 'FM_TASK_ID=%s ' "$q_id" - printf 'OPENCODE_CONFIG_CONTENT=%s /bin/bash %s %s %s %s %s %s\n' \ - "$q_config" "$q_helper" "$q_bin" "$q_dir" "$q_output" "$bound" "$resolve_config" - } > "$script" || return 1 - chmod 700 "$script" || return 1 - command="/bin/sh $(shell_quote "$script")" - spawn_send_text_line "$WT_TARGET" "$command" || { - echo "error: could not run OpenCode model discovery in the destination pane for $dir" >&2 - return 1 - } - max_checks=$((bound * 20 + 100)) - i=0 - while [ "$i" -lt "$max_checks" ] && [ ! -f "$OPENCODE_PROBE_DIR/done" ]; do - sleep 0.1 - i=$((i + 1)) - done - if [ ! -f "$OPENCODE_PROBE_DIR/done" ]; then - echo "error: OpenCode model discovery did not return from the destination pane within $((bound * 2 + 10))s; refusing to launch" >&2 - return 1 - fi - return 0 -} - -opencode_probe_cleanup() { - local dir=${OPENCODE_PROBE_DIR:-} - [ -n "$dir" ] && [ -f "$dir/done" ] || return 1 - rm -f "$dir/run.sh" "$dir/config.json" "$dir/config.status" \ - "$dir/config.reason" "$dir/models.txt" "$dir/models.status" \ - "$dir/effective-variants.json" "$dir/done" || return 1 - rmdir "$dir" || return 1 - OPENCODE_PROBE_DIR= -} - -opencode_model_validate() { # - local result_dir=$1 model=$2 base variant provider id metadata models_status - case "$model" in - *'#'*) - base=${model%%#*} - variant=${model#*#} - case "$variant" in - '' | *'#'*) - echo "error: OpenCode model '$model' has an unsupported variant form; use at most one nonempty #variant" >&2 - return 1 - ;; - esac - ;; - *) - base=$model - variant= - ;; - esac - models_status=$(cat "$result_dir/models.status" 2>/dev/null || true) - if [ "$models_status" != 0 ] || [ ! -f "$result_dir/models.txt" ]; then - echo "error: could not verify OpenCode model '$model' because 'opencode models' failed in the destination pane (exit ${models_status:-unknown}); choose a model listed there" >&2 - return 1 - fi - if ! grep -F -x -- "$base" "$result_dir/models.txt" >/dev/null; then - echo "error: OpenCode model '$model' is not available from 'opencode models' in the destination pane; choose an id listed by that command" >&2 - return 1 - fi - # `opencode models` prints only base ids, so it cannot confirm a variant. The - # declaring source is the provider metadata in the resolved config, so an - # available base does NOT carry an arbitrary variant: require the exact - # "base#variant" the destination config SELECTS. - if [ -n "$variant" ]; then - if [ ! -s "$result_dir/config.json" ]; then - echo "error: could not verify OpenCode model '$model' because the destination pane returned no OpenCode config to declare its variants; pass the base model without #variant" >&2 - return 1 - fi - # The effective variant list is precedence-resolved across every config - # source, so this checks what the worker can actually select rather than - # what any single document mentions. A variant only a lower-precedence - # source declares, or one a higher-precedence source disables, is absent - # here and refused. - if ! jq -e --arg base "$base" --arg variant "$variant" \ - '.effective_variants[$base] | index($variant) != null' \ - "$result_dir/effective-variants.json" >/dev/null 2>&1; then - echo "error: OpenCode model '$model' is not available in the destination pane; no config source there selects the '#$variant' variant of '$base'" >&2 - return 1 - fi - fi - provider=${base%%/*} - id=${base#*/} - if [ "$provider" = "$base" ] || [ -z "$id" ] \ - || ! metadata=$(curl --fail --silent --show-error --location --max-time 10 https://models.dev/api.json); then - echo "error: could not verify OpenCode model '$model' free pricing metadata; refusing dispatch" >&2 - return 1 - fi - if ! printf '%s\n' "$metadata" | jq -e --arg provider "$provider" --arg model "$id" ' - .[$provider].models[$model].cost as $cost - | ($cost | type == "object") - and ($cost.input == 0) - and ($cost.output == 0) - and ([$cost[]] | all(. == 0)) - ' >/dev/null; then - echo "error: OpenCode model '$model' is not classified as free by models.dev; choose a zero-cost model" >&2 - return 1 - fi - return 0 -} - -# OpenCode picks its own model when the launch carries none, so a launch without -# an explicit --model still has an EFFECTIVE model that must clear the same gate. -# `opencode debug config` is a resolved object on v1 and a source inventory on -# v2. The v1 object already reflects the worker's effective config; for v2 this -# reads each listed document and applies the documented precedence itself -# (https://opencode.ai/v2/docs/config): -# the global config is lowest, direct opencode.json(c) files merge from the -# farthest ancestor to the worker directory, and every discovered .opencode -# config overrides every direct config. The root `model` is the default for new -# work; primary-agent selection does not change a session's model, so this -# resolver pins the root model into the canonical launch's --model flag -# (https://opencode.ai/v2/docs/models and https://opencode.ai/v2/docs/agents). -# -# Everything else fails closed rather than guessing: a source this resolver -# cannot rank, a second source disagreeing inside one precedence step, or no -# root model at all. The resolution runs in the worker's own directory with the -# credential and configuration environment the launch itself gets, so a project -# or global config the worker would read is a source this sees too. -# -# The ranked result is the single owner of BOTH answers this gate needs, so they -# can never be computed from different views of the same config: the effective -# root model (`.root`) and the effective selectable variants -# (`effective-variants.json`, which the variant gate reads). Ranking runs even -# when the model was explicit, because an explicit model can still carry a -# "#variant" that has to clear the same precedence. -# -# : prints the ranked object on stdout. -opencode_resolve_effective_model() { # - local probe_dir=$1 dir=$2 need_root=${3:-1} inventory global_config ancestors merged unranked resolved config_status config_reason - command -v jq >/dev/null 2>&1 || { - echo "error: jq is required to resolve the effective OpenCode model; install jq or pass an explicit --model" >&2 - return 1 - } - config_status=$(cat "$probe_dir/config.status" 2>/dev/null || true) - config_reason=$(cat "$probe_dir/config.reason" 2>/dev/null || true) - if [ "$config_reason" = config-location-override ]; then - echo "error: OPENCODE_CONFIG or OPENCODE_CONFIG_DIR is set in the worker environment, but dispatch cannot resolve that custom config location safely; unset the override or pass an explicit --model" >&2 - return 1 - fi - if [ "$config_status" != 0 ] || [ ! -s "$probe_dir/config.json" ]; then - echo "error: could not verify the effective OpenCode model because 'opencode debug config' failed or returned an unsupported shape in the destination pane (exit ${config_status:-unknown}); supported forms are a v1 resolved object and a v2 source array" >&2 - return 1 - fi - global_config=$(jq -r '.global // empty' "$probe_dir/config.json" 2>/dev/null) || global_config= - inventory=$(jq -c '.docs // empty' "$probe_dir/config.json" 2>/dev/null) || inventory= - if [ -z "$global_config" ] || [ -z "$inventory" ] || ! printf '%s' "$inventory" | jq -e 'type == "array"' >/dev/null 2>&1; then - echo "error: 'opencode debug config' did not provide a supported v1 resolved object or v2 config-source list in $dir" >&2 - return 1 - fi - # Farthest ancestor first, so the worker's own directory is the last direct - # config and therefore the highest-precedence one of that step. The upward walk - # is emitted nearest-first and reversed by the jq below, which both orders the - # list and serializes it for --argjson. tac would do the reversal in one step - # but is not a stock macOS command, and jq is already a hard requirement here. - ancestors=$( - walk=$dir - while :; do - printf '%s\n' "$walk" - [ "$walk" = / ] && break - walk=${walk%/*} - [ -n "$walk" ] || walk=/ - done - ) - if ! ancestors_json=$(printf '%s\n' "$ancestors" | jq -Rsc 'split("\n") | map(select(length > 0)) | reverse'); then - echo "error: could not rank OpenCode's config sources in $dir; refusing to launch an OpenCode worker whose effective model cannot be established" >&2 - return 1 - fi - if ! merged=$(printf '%s' "$inventory" | jq -c --argjson ancestors "$ancestors_json" --arg global "$global_config" ' - # The probe already normalized every model into one canonical - # "provider/model#variant" string, so this ranks and compares those strings - # directly rather than decoding the shape a second time. - def rootid: select(type == "string" and length > 0); - def place($path): - if ($path == ($global + "/opencode.json") or $path == ($global + "/opencode.jsonc")) then {tier: 0, step: 0} - else ([$ancestors | to_entries[] - | select($path == (.value + "/opencode.json") or $path == (.value + "/opencode.jsonc")) | .key] | first) as $direct - | if $direct != null then {tier: 1, step: $direct} - else ([$ancestors | to_entries[] - | select($path == (.value + "/.opencode/opencode.json") or $path == (.value + "/.opencode/opencode.jsonc")) | .key] | first) as $dot - | if $dot != null then {tier: 2, step: $dot} else null end - end - end; - [.[] | select(.type == "document") - | {path: .path, info: (.info // {}), variants: (.variants // {}), rank: place(.path)}] as $docs - | ($docs | map(select(.rank != null)) | sort_by([.rank.tier, .rank.step])) as $ranked - # Sources sharing one tier and step are one precedence step, so different - # root models there are unresolvable. Agent model preferences do not select - # the primary session model; the canonical launch pins the root default. - | ($ranked | group_by([.rank.tier, .rank.step])) as $steps - | {unranked: [$docs[] | select(.rank == null) | .path], - root_conflict: (([$steps[] - | [.[] | .info.model | rootid] | unique | length] | max) > 1), - root: ([$ranked[].info.model | rootid] | last // null), - # The ranked documents themselves, grouped by precedence step and already - # in ascending precedence order, so the caller resolves selectable - # variants through the same order without re-deriving the ranking. - steps: [$steps[]]} - ' 2>/dev/null) || ! printf '%s' "$merged" | jq -e 'type == "object"' >/dev/null 2>&1; then - echo "error: could not rank OpenCode's config sources in $dir; refusing to launch an OpenCode worker whose effective model cannot be established" >&2 - return 1 - fi - unranked=$(printf '%s' "$merged" | jq -r '.unranked | join(", ")') - if [ -n "$unranked" ]; then - echo "error: OpenCode reports config source(s) '$unranked' that fall outside the documented precedence this resolver applies, so the effective model in $dir cannot be established; pass an explicit --model" >&2 - return 1 - fi - # Variants resolve through the SAME ranked order as the root model, so a - # higher-precedence source that DISABLES a variant is what decides it: the - # last document that mentions a base id wins for that id, and a document whose - # selectable set is empty therefore clears that id entirely. Two documents in - # ONE precedence step that declare different variant sets for the same base are - # unresolvable, exactly like a root-model disagreement in one step. - if ! printf '%s' "$merged" | jq -S ' - # `.steps` holds the ranked documents grouped by precedence step, in - # ASCENDING precedence order (group_by keeps equal-rank documents adjacent - # and in order). Two rules, because the two situations mean different things: - # - # WITHIN one step the documents are simultaneous alternatives, so their - # selectable sets UNION: two sources of equal precedence are both read, so - # a variant either one selects is selectable. - # ACROSS steps a later step REPLACES the earlier one, INCLUDING with an - # empty selectable set. That empty set is how a higher-precedence document - # that disables every variant of a model clears it, so unioning across - # steps would resurrect exactly what the disable removed. - def union_variants($group): - reduce $group[] as $doc ({}; - reduce (($doc.variants // {}) | to_entries[]) as $kv - (.; .[$kv.key] = (((.[$kv.key] // []) + ($kv.value.selectable // [])) | unique))); - def effective($steps): - reduce $steps[] as $group ({}; - ($group | union_variants($group)) as $step - | reduce ($step | to_entries[]) as $kv (.; .[$kv.key] = $kv.value)); - # Two documents in ONE precedence step must AGREE about a base model, and - # "agree" has to be checked on the SELECTABLE set, not only on the declared - # one. Comparing `declared` alone misses the case that matters: both - # documents declare `low`, one enables it and the other disables it. Their - # declared sets are identical, so that comparison passes, while - # union_variants then hands back the enabled variant as selectable even - # though the effective state is genuinely ambiguous. Requiring identical - # selectable sets refuses that instead. - def step_conflicts($steps): - [ $steps[] as $group - | ( [ $group[] | (.variants // {}) | keys[] ] | unique ) as $keys - | $keys[] as $k - | select( ([ $group[] - | (.variants // {})[$k].selectable // [] ] | unique | length) > 1 ) - | $k ]; - (.steps) as $steps - | {effective_variants: effective($steps), - variant_conflict: ((step_conflicts($steps) | length) > 0), - variant_conflict_base: (step_conflicts($steps) | first)} - ' > "$probe_dir/effective-variants.json" 2>/dev/null; then - echo "error: could not resolve OpenCode's effective model variants in $dir; refusing to launch an OpenCode worker whose selected variant cannot be established" >&2 - return 1 - fi - if jq -e '.variant_conflict' "$probe_dir/effective-variants.json" >/dev/null 2>&1; then - echo "error: two OpenCode config sources of equal precedence declare different variants of '$(jq -r '.variant_conflict_base // "a model"' "$probe_dir/effective-variants.json")' in $dir, so the selectable variants are ambiguous; remove one or pass an explicit --model" >&2 - return 1 - fi - # The root-model answer only matters when dispatch must pick the model itself. - # An explicit model that carries a variant still ranks above for the variant - # gate, but a disagreement between two equal-precedence sources about which - # model to USE is irrelevant to a model the captain already chose. - if [ "$need_root" = 1 ]; then - if printf '%s' "$merged" | jq -e '.root_conflict' >/dev/null; then - echo "error: two OpenCode config sources of equal precedence disagree in $dir, so the effective model is ambiguous; remove one or pass an explicit --model" >&2 - return 1 - fi - resolved=$(printf '%s' "$merged" | jq -r '.root // empty') - if [ -z "$resolved" ]; then - echo "error: no OpenCode root 'model' could be resolved in $dir, so OpenCode would choose an unchecked default; set a root model in its config or pass an explicit --model" >&2 - return 1 - fi - fi - printf '%s\n' "$merged" -} - -# Resolve OpenCode once so the probe and eventual launch use the same executable. -resolve_opencode_binary() { - local candidate dir - candidate=$(command -v opencode 2>/dev/null) || return 1 - [ -x "$candidate" ] || return 1 - case "$candidate" in - /*) printf '%s\n' "$candidate" ;; - *) - dir=$(cd "$(dirname "$candidate")" 2>/dev/null && pwd -P) || return 1 - printf '%s/%s\n' "$dir" "$(basename "$candidate")" - ;; - esac -} - # The verified launch command per adapter. The knowledge half of each adapter # (busy-state source, exit command, dialogs, quirks) lives in the harness-adapters skill. launch_template() { - local harness=$1 kind=${2:-ship} opencode_bin + local harness=$1 kind=${2:-ship} # shellcheck disable=SC2016 # single quotes are deliberate: $(cat ...) expands in the crewmate pane, not here case "$harness" in # CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false disables claude's interactive @@ -2214,13 +1866,11 @@ launch_template() { fi ;; opencode) - opencode_bin=$(resolve_opencode_binary) || return 1 - OPENCODE_BIN=$opencode_bin - mini_help=$("$opencode_bin" mini --help 2>&1 || :) + mini_help=$(opencode mini --help 2>&1 || :) if printf '%s\n' "$mini_help" | grep -Eiq '^[[:space:]]*usage:[[:space:]]*opencode mini([[:space:]]|$)'; then - printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' __OPENCODEBIN__ mini __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode mini __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' else - printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' __OPENCODEBIN__ __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' fi ;; pi | pi-signed) @@ -2398,194 +2048,14 @@ case "$ARG3" in *' '*) # raw launch command (unverified-adapter escape hatch) RAW_LAUNCH=1 LAUNCH=$ARG3 - # Resolve the EXECUTABLE a raw command actually runs, never arbitrary - # argument text: the structure is leading NAME=value assignments, then - # optionally an `env` wrapper with its own options, then the executable. - # Matching the whole command string instead classified - # `claude --prompt 'review opencode'` as OpenCode and refused an unrelated - # command over prompt text, while stopping at `env` would let - # `env -u FOO opencode ...` skip model validation entirely. HARNESS="" - raw_ambiguous=0 - raw_quoted=0 - raw_expanded=0 - raw_shell_script=0 - raw_expect_value=0 - raw_in_env=0 - raw_wrapper= - # A shell interpreter is unresolvable by construction: `sh -c opencode ...` - # names a real executable and then runs a different command from its - # arguments. The one legitimate form is running a SCRIPT PATH, where the - # interpreter's argument is a file to read rather than a command string, so - # the script's own contents decide what runs. Detect that here, on the raw - # text, because by the time the walk reaches an executable word the - # interpreter's arguments have not been examined. Note this matches the - # ABSOLUTE interpreter paths the repo's own fixtures use; a bare `sh -c ...` - # carries no absolute path, so it stays refused. - case "$LAUNCH" in - */bin/sh\ * | */bin/bash\ * | */bin/zsh\ * | */bin/dash\ *) - # A shell whose first argument is an absolute script path. - raw_shell_script=1 - ;; - esac - for raw_word in $LAUNCH; do - if [ "$raw_expect_value" = 1 ]; then - raw_expect_value=0 - continue - fi - if [ "$raw_in_env" = 1 ]; then - case "$raw_word" in - # Only value-taking options consume the following word. The no-argument - # flags below consume nothing, so `env -i opencode ...` still reaches the - # executable instead of swallowing it. - -u | --unset | -C | --chdir | -S | --split-string | -n | -p | -g | \ - --nadjust | --adjustment | --priority | -t | --signal | -k | --kill-after) - raw_expect_value=1 - continue - ;; - -i | -0 | --ignore-environment | --null | -- | -v | --verbose | -f | \ - --foreground | -a | --all | --preserve-status | --foreground-only | \ - --kill-when-unfinished | --kill-when-initiated) - continue ;; - # `timeout` is the one wrapper on the list whose DURATION is a bare - # positional rather than an option value, so consume it here. Consuming it - # generically would eat the executable of every other wrapper. - [0-9]*) - if [ "${raw_wrapper:-}" = timeout ]; then - continue - fi - ;; - [A-Za-z_]*=*) continue ;; - -*) - # An option this resolver does not know how to consume. Whether the next - # word is a flag VALUE or the executable decides the whole answer, so it - # refuses rather than guessing. - raw_ambiguous=1 - break - ;; - esac - raw_in_env=0 - fi - case "$raw_word" in - [A-Za-z_]*=*) continue ;; - env | /usr/bin/env | /bin/env | command | exec | nohup | nice | ionice | \ - stdbuf | timeout | setsid | time | sudo | su | xargs | watch | open | caffeinate) - # A wrapper that runs ANOTHER command is transparent here: walk through it - # to the executable it would run. Without this, `command opencode ...` and - # `exec opencode ...` classified as the WRAPPER, walked past every - # `if [ "$HARNESS" = opencode ]` guard, and launched OpenCode unchecked. - # This is a named list on purpose: it is the set of commands whose whole - # job is to run some other command. Each takes its options, and only - # `timeout` also takes a BARE positional (its duration), which is why that - # one is consumed below rather than by a general rule: `timeout 30 - # opencode` is a wrapper plus a value plus the executable, while `nice - # opencode` is a wrapper plus the executable, so a general "wrapper takes - # one bare positional" rule would swallow the executable of every other - # wrapper on the list. - raw_wrapper=$(basename "$raw_word") - raw_in_env=1 - continue - ;; - \'* | \"*) - # This walk splits on whitespace, so a QUOTED executable arrives here with - # its quotes still attached: basename would return `'opencode'`, which - # matches no harness, and the command would run OpenCode anyway behind an - # unrelated-harness classification with every model guard skipped. The real - # spelling is a shell expansion this walk cannot see, so it is - # unclassifiable rather than merely unquoted. - raw_quoted=1 + for word in $LAUNCH; do + case "$word" in [A-Za-z_]*=*) continue ;; *) + HARNESS=$(basename "$word") break ;; esac - HARNESS=$(basename "$raw_word") - break - done - # The walk resolves ONE executable, but a compound command can run others: - # `echo x | opencode ...` and `sh -c opencode` both name an executable this - # walk resolves happily (echo, sh) while running OpenCode anyway. Refusing - # every compound command would break the legitimate forms this escape hatch - # exists for (`cd && ./probe`), so the check is narrower and more - # precise: if OpenCode appears as an executable ANYWHERE in the command, the - # command is refused, because then the resolved harness cannot be the whole - # story and the model guard below would apply to the wrong command. A command - # that never mentions OpenCode keeps whatever structure the captain asked for. - # A word only ever RUNS as an executable; inside quotes it is inert text. So the - # question is whether the name `opencode` appears as a BARE word anywhere in - # the command, which means removing every quoted span first and then looking at - # what remains: - # - # claude --prompt 'review opencode' -> quotes removed, no bare name, accepted - # echo x | opencode --prompt -> bare name after a pipe, refused - # $(which opencode) --prompt -> bare name inside a substitution, refused - # cd /tmp && ./probe -> no name at all, accepted - # - # This is what keeps an unrelated launch that merely MENTIONS OpenCode working - # while refusing every form that could actually execute it. - # - # shellcheck disable=SC1003 # the quoted-span delimiters are matched as - # literal characters here; this strips text, it does not emit quoting. - raw_scan_bare=$(printf '%s\n' "$LAUNCH" | sed -e "s/'[^']*'//g" -e 's/"[^"]*"//g') - for raw_scan_word in $raw_scan_bare; do - # Reduce to a command NAME: drop any directory prefix, then strip the - # characters a name cannot contain that word splitting leaves attached, so - # `opencode)` inside `$(which opencode)` still compares equal. Stated as a - # positive test for what may remain rather than a list of punctuation to - # strip, which also avoids a single-quote case pattern (a ShellCheck SC1003 - # hazard). - raw_scan_base=${raw_scan_word##*/} - while [ -n "$raw_scan_base" ]; do - case "${raw_scan_base: -1}" in - [A-Za-z0-9._+-]) break ;; - *) raw_scan_base=${raw_scan_base%?} ;; - esac - done - if [ "$raw_scan_base" = opencode ]; then - # The resolved executable ITSELF is the ordinary case, handled by - # RAW_OPENCODE below with a more specific refusal. This scan is about - # OpenCode appearing somewhere the walk did not resolve as the executable, - # which is what makes the resolved harness an incomplete description of - # what runs. - [ "$HARNESS" = opencode ] || raw_expanded=1 - break - fi done - # A shell INTERPRETER is the same problem with no special character to detect: - # `sh -c opencode --prompt hi` carries a plain executable word, so the walk - # resolves `sh` and stops, while the shell it names runs OpenCode from the very - # next argument. There is no way to read what a shell will do with its - # arguments, so a shell in executable position is unresolvable by construction - # rather than by accident. - raw_shell=0 - case "$HARNESS" in - sh | bash | zsh | dash | ksh | mksh | pdksh | ash | fish | csh | tcsh | busybox) - [ "$raw_shell_script" = 1 ] || raw_shell=1 - ;; - esac - if [ "$raw_quoted" = 1 ]; then - echo "error: raw launch '$LAUNCH' quotes or expands its executable, so dispatch cannot tell which harness and model it runs; use the canonical --harness launch" >&2 - exit 1 - fi - if [ "$raw_ambiguous" = 1 ] || { [ -z "$HARNESS" ] && [ "$raw_in_env" = 1 ]; }; then - # Checked BEFORE the composed-executable refusal so the diagnostic names the - # more specific cause: an unconsumable wrapper option means the executable is - # unknown, which is a narrower and more actionable statement than "this - # command composes something". - # - # Refuse here rather than downstream: every OpenCode model guard lives under - # `if [ "$HARNESS" = opencode ]`, so leaving an unresolved wrapped command - # on the unrelated-harness path would walk straight past all of it. An - # executable this walk cannot name could be OpenCode, so it fails closed - # instead of being waved through as unrelated. - echo "error: raw launch '$LAUNCH' wraps its executable in a form dispatch cannot resolve, so its harness and model cannot be verified; use the canonical --harness launch" >&2 - exit 1 - fi - if [ "$raw_expanded" = 1 ] || [ "$raw_shell" = 1 ]; then - echo "error: raw launch '$LAUNCH' builds or composes its executable, so dispatch cannot tell which harness and model it runs; use the canonical --harness launch" >&2 - exit 1 - fi - if [ "$HARNESS" = opencode ]; then - RAW_OPENCODE=1 - fi ;; '') # No explicit harness: resolve from config. A secondmate AGENT launches on the @@ -2717,13 +2187,37 @@ if [ "$KIND" = secondmate ] && [ -z "$ARG3" ]; then fi fi if [ "$HARNESS" = opencode ]; then - OPENCODE_BIN=${OPENCODE_BIN:-$(resolve_opencode_binary)} || { + OPENCODE_BIN=$(command -v opencode) || { echo "error: opencode executable not found on PATH; install it or select a different verified harness" >&2 exit 1 } - # Model resolution and validation both run below, once the worker directory - # has settled: that directory, not this one, is the config and provider scope - # the launched worker actually gets. + if [ -n "$MODEL" ] && [ "$MODEL" != default ]; then + if ! OPENCODE_MODELS=$("$OPENCODE_BIN" models); then + echo "error: could not verify OpenCode model '$MODEL' because '$OPENCODE_BIN models' failed; rerun 'opencode models' and choose a listed id" >&2 + exit 1 + fi + if ! printf '%s\n' "$OPENCODE_MODELS" | grep -F -x -- "$MODEL" >/dev/null; then + echo "error: OpenCode model '$MODEL' is not available from 'opencode models'; choose an id listed by that command or omit --model" >&2 + exit 1 + fi + OPENCODE_MODEL_PROVIDER=${MODEL%%/*} + OPENCODE_MODEL_ID=${MODEL#*/} + if [ "$OPENCODE_MODEL_PROVIDER" = "$MODEL" ] || [ -z "$OPENCODE_MODEL_ID" ] \ + || ! OPENCODE_MODEL_METADATA=$(curl --fail --silent --show-error --location --max-time 10 https://models.dev/api.json); then + echo "error: could not verify OpenCode model '$MODEL' free pricing metadata; refusing dispatch" >&2 + exit 1 + fi + if ! printf '%s\n' "$OPENCODE_MODEL_METADATA" | jq -e --arg provider "$OPENCODE_MODEL_PROVIDER" --arg model "$OPENCODE_MODEL_ID" ' + .[$provider].models[$model].cost as $cost + | ($cost | type == "object") + and ($cost.input == 0) + and ($cost.output == 0) + and ([$cost[]] | all(. == 0)) + ' >/dev/null; then + echo "error: OpenCode model '$MODEL' is not classified as free by models.dev; choose a zero-cost model" >&2 + exit 1 + fi + fi fi # Ultra is an explicit native capability, never a Pi thinking-level alias. # Validate the fully resolved profile before worktree or endpoint provisioning. @@ -4450,59 +3944,6 @@ if [ "$RELAUNCH" -eq 0 ] && [ "$KIND" != secondmate ]; then freshen_spawn_worktree_base "$WT" || exit 1 fi -# Every OpenCode launch runs on one effective model, and OpenCode decides it -# from the configuration and providers it reads in the directory the worker -# starts in. That directory is known only now, so this is the earliest point -# where the model can be resolved in the same scope the worker will use, and the -# last chance to refuse before any per-task state is created. BOTH paths run -# here, because `opencode models` is itself project-scoped: a model available -# only through a provider the worktree config declares is listed in this -# directory and not in the launcher's, so validating an explicit --model -# anywhere else would refuse a model the worker can actually run. -if [ "$HARNESS" = opencode ]; then - # The config inventory is needed to resolve an omitted/default model AND to - # resolve the SELECTABLE variant set a selected "#variant" is checked against. - # A plain explicit base model needs neither, so it skips both. - # A raw OpenCode command is refused before any probe: fm-spawn cannot inject - # the model it validated into an arbitrary raw command string, so the worker - # would run whatever model that command names. Refusing here rather than after - # resolution keeps the cause the actual one, instead of reporting an unrelated - # missing project model for a launch that is refused either way. - if [ "$RAW_LAUNCH" = 1 ] || [ "$RAW_OPENCODE" = 1 ]; then - echo "error: a raw OpenCode launch cannot be pinned to the model validated for this task; use the canonical --harness opencode launch so the checked model is passed to the worker" >&2 - exit 1 - fi - # The effective-variant gate needs the ranked config pass for an EXPLICIT - # "#variant" too, not only for an omitted model: the variant has to clear the - # same precedence resolution. A plain explicit base model needs neither the - # ranked pass nor the extra read, so it skips them. - OPENCODE_RESOLVE_CONFIG=0 - case "$MODEL" in - '' | default | *'#'*) OPENCODE_RESOLVE_CONFIG=1 ;; - esac - opencode_worker_probe "$OPENCODE_BIN" "$WT" "$OPENCODE_RESOLVE_CONFIG" || exit 1 - if [ "$OPENCODE_RESOLVE_CONFIG" = 1 ]; then - # Ranked once: this writes the effective variant list that - # opencode_model_validate reads for a selected variant, and publishes the - # effective root model the omitted path then pins. An explicit model that - # carries a variant still needs this, so it passes need-root=0 and is not - # refused for an absent root model it does not depend on. - OPENCODE_NEED_ROOT=0 - if [ -z "$MODEL" ] || [ "$MODEL" = default ]; then - OPENCODE_NEED_ROOT=1 - fi - OPENCODE_RANKED=$(opencode_resolve_effective_model "$OPENCODE_PROBE_DIR" "$WT" "$OPENCODE_NEED_ROOT") || exit 1 - if [ "$OPENCODE_NEED_ROOT" = 1 ]; then - MODEL=$(printf '%s' "$OPENCODE_RANKED" | jq -r '.root') - fi - fi - opencode_model_validate "$OPENCODE_PROBE_DIR" "$MODEL" || exit 1 - opencode_probe_cleanup || { - echo "error: could not retire the completed private OpenCode preflight in $WT" >&2 - exit 1 - } -fi - # Pre-register Claude's workspace trust for the directory this launch starts in, # at the first point that directory is known and before any per-task state is # created below. The dialog gates the pane before the brief is ever read, and it @@ -5231,10 +4672,6 @@ sq_ompext=$(shell_quote "$STATE/$ID.omp-ext.ts") sq_ompcfg=$(shell_quote "${OMP_WORKER_CFG:-$FM_ROOT/.omp/fm-worker-overlay.yml}") sq_opinput=$(shell_quote "$FM_ROOT/bin/fm-operational-input.sh") sq_worktree=$(shell_quote "$WT") -if [ "$HARNESS" = opencode ] && [ "$RAW_LAUNCH" = 0 ]; then - sq_opencode_bin=$(shell_quote "$OPENCODE_BIN") - LAUNCH=${LAUNCH//__OPENCODEBIN__/$sq_opencode_bin} -fi MODELFLAG=$(model_flag_for_harness "$HARNESS" "$MODEL") EFFORTFLAG=$(effort_flag_for_harness "$HARNESS" "$EFFORT" "$MODEL") || exit 1 LAUNCH=${LAUNCH//__MODELFLAG__/$MODELFLAG} @@ -5378,7 +4815,36 @@ if [ -n "$SPAWN_TRACEPARENT" ]; then fi fi if [ "$LAUNCH_ENV_ENABLED" = 1 ]; then - LAUNCH_ENV_PREFIX=$(spawn_build_launch_env_prefix 1) + LAUNCH_ENV_PREFIX='/usr/bin/env -i' + # COMPACT_ADVISER_DISABLE is the intentional declarative floor-membership + # entry; the explicit COMPACT_ADVISER_DISABLE=1 assignment below is the + # authoritative setter. + for env_name in HOME PATH USER LOGNAME SHELL TERM COLORTERM LANG LC_ALL LC_CTYPE \ + TMPDIR TMP TEMP GOTMPDIR TMUX TMUX_PANE HERDR_ENV HERDR_SESSION HERDR_SOCKET_PATH \ + HERDR_PANE_ID CMUX_WORKSPACE_ID CMUX_SURFACE_ID CMUX_TAB_ID CMUX_PANEL_ID \ + CMUX_SOCKET_PATH ZELLIJ ZELLIJ_SESSION_NAME ZELLIJ_PANE_ID FM_ZELLIJ_SESSION \ + FM_TASK_ID COMPACT_ADVISER_DISABLE \ + $LAUNCH_ENV_NAMES; do + # Only validated names enter shell syntax. Values expand once, quoted, in + # the pane shell and never become source text or spawn-process snapshots. + # shellcheck disable=SC2016 + printf -v env_arg '${%s+"%s=$%s"}' "$env_name" "$env_name" "$env_name" + LAUNCH_ENV_PREFIX="$LAUNCH_ENV_PREFIX $env_arg" + done + # COMPACT_ADVISER_DISABLE is retained by the floor loop above, which forwards + # whatever the pane export set, and then pinned here to the one value Firstmate + # launches on. The literal assignment comes last deliberately: `env` applies + # assignments left to right, so this one wins over a forwarded pane value, and + # it still delivers the switch on a pane whose export never landed. Unlike the + # trace carrier below it carries no gate, so it is appended unconditionally. + # Setting it here rather than relying on the assignment already carried by + # $LAUNCH is what gives the wrapping `/bin/sh` itself the switch, not only the + # agent command it runs. + LAUNCH_ENV_PREFIX="$LAUNCH_ENV_PREFIX COMPACT_ADVISER_DISABLE=1" + if [ -n "$SPAWN_TRACEPARENT" ]; then + # shellcheck disable=SC2016 + LAUNCH_ENV_PREFIX="$LAUNCH_ENV_PREFIX "'${TRACEPARENT+"TRACEPARENT=$TRACEPARENT"}' + fi LAUNCH="$LAUNCH_ENV_PREFIX /bin/sh -c $(shell_quote "$LAUNCH")" fi # Implement the launch-delivery contract in this script's header. The full diff --git a/docs/configuration.md b/docs/configuration.md index 078eb7fa63a..f75dd051f6b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -499,9 +499,9 @@ Every profile array is an implicit quota-aware choice resolved through `quota-ar If no dispatch rule fits, firstmate resolves `default` through the same object-or-array path before falling back to `config/crew-harness`. Except for `ultra`, which refuses unsupported profiles under the native-effort contract above, an effort value the chosen harness does not accept is recorded as `effort=` in task meta for traceability but omitted from the launch flags. Bootstrap reports unsupported harness/model/effort combinations as a `CREW_DISPATCH` diagnostic when they are visible in the file. -See [`docs/examples/crew-dispatch.json`](examples/crew-dispatch.json) for a starting point to copy into local `config/crew-dispatch.json`; its default pins `opencode-go/space-bunny-free`, declares the `opencode-go` provider, and is rechecked against `opencode models` and `opencode debug config` at spawn time, inside the destination pane's settled task worktree rather than the launcher's own directory. -An OpenCode spawn fails before launch if that pane's `opencode models` cannot be read or does not list the resolved model's base ID, if a `#variant` suffix is not selected by that pane's resolved config, or if an omitted or `default` model resolves to no root model at all; refresh the catalog with `opencode models` and choose a listed ID, or set a root model in the destination config. -An explicitly designated per-task model overrides the configured profile model and is checked the same way before launch. +See [`docs/examples/crew-dispatch.json`](examples/crew-dispatch.json) for a starting point to copy into local `config/crew-dispatch.json`; its default pins `opencode-go/space-bunny-free`, declares the `opencode-go` provider, and is rechecked against `opencode models` at spawn time. +An OpenCode spawn with a resolved model fails before launch if `opencode models` cannot be read or does not list that exact ID; refresh the catalog with `opencode models` and choose a listed ID. +An explicitly designated per-task model overrides the configured profile model and is checked against that live catalog before launch. OpenCode dispatch also requires every pricing field in the model metadata to be zero; the [OpenCode harness reference](../.agents/skills/harness-adapters/references/harness/opencode.md#free-model-selection) documents data-retention limits. When the file exists, bootstrap validates it with `jq`. Valid files stay silent by default; with `FM_BOOTSTRAP_VERBOSE_FACTS=1`, bootstrap emits `BOOTSTRAP_INFO: crew dispatch active config/crew-dispatch.json`, one `BOOTSTRAP_INFO:` fact per rule, and one fact for the optional default profile set. diff --git a/tests/fixtures.sh b/tests/fixtures.sh index 50d7cb434ec..559cd661eef 100755 --- a/tests/fixtures.sh +++ b/tests/fixtures.sh @@ -120,29 +120,6 @@ case "${1:-}" in ;; has-session|new-session|new-window|kill-window|set-window-option) exit 0 ;; send-keys) - # A real destination pane EXECUTES the OpenCode preflight command fm-spawn - # types into it and waits on, so a fake pane has to execute it too. This is - # the shared fake tmux every spawn suite uses, which is why it runs whenever - # the payload is a probe script: gating it behind an opt-in that only one - # suite sets left every other OpenCode spawn suite hanging on a probe that - # never wrote its `done` marker, and then failing on a timeout instead of on - # the behavior it meant to test. A suite opts OUT with - # FM_FAKE_EXEC_OPENCODE_PROBES=0 when it asserts the refusing path instead. - if [ "${FM_FAKE_EXEC_OPENCODE_PROBES:-1}" = 1 ]; then - for a in "$@"; do - case "$a" in - "/bin/sh '"*) - probe_script=${a#/bin/sh \'} - probe_script=${probe_script%\'} - case "$probe_script" in - */.fm-opencode-probe-*/run.sh) - (cd "${FM_FAKE_PANE_PATH:?}" && /bin/sh "$probe_script") || true - ;; - esac - ;; - esac - done - fi if [ -n "${FM_FAKE_LAUNCH_LOG:-}" ]; then prev= for a in "$@"; do @@ -314,42 +291,6 @@ Exercise the spawn behavior under test. EOF } -# fm_test_make_opencode_preflight_stub -# Replaces a bare exit-0 `opencode` with one that answers the two commands -# fm-spawn's OpenCode model preflight reads before it launches a worker: -# `opencode models` (the catalog) and `opencode debug config` (the config-source -# inventory). Every OpenCode launch verifies its model first, so a suite that -# spawns OpenCode through the fake pane needs both answers or its spawn is -# refused on the preflight rather than on the behavior it meant to test. -# FM_FAKE_OPENCODE_PREFLIGHT_MODEL (default opencode-go/space-bunny-free) names -# the model the inventory declares and the catalog lists; FM_TEST_OPENCODE_PREFLIGHT_MODELS -# overrides the catalog when a case needs a different set. -fm_test_make_opencode_preflight_stub() { - local fakebin=$1 - cat > "$fakebin/opencode" <<'SH' -#!/usr/bin/env bash -model=${FM_FAKE_OPENCODE_PREFLIGHT_MODEL:-opencode-go/space-bunny-free} -case "${1:-}" in -models) - [ -n "${FM_FAKE_OPENCODE_PREFLIGHT_MODELS:-}" ] && printf '%b\n' "$FM_FAKE_OPENCODE_PREFLIGHT_MODELS" - printf '%s\n' "$model" - exit 0 - ;; -debug) - # A v2 source inventory: one document at the directory OpenCode reports it - # read, declaring the model as the object form the v2 debug output publishes. - if [ "${2:-}" = config ]; then - printf '[{"type":"document","path":"%s/opencode.json","info":{"model":{"providerID":"%s","model":"%s"}}}]\n' \ - "$PWD" "${model%%/*}" "${model#*/}" - exit 0 - fi - ;; -esac -exit 0 -SH - chmod +x "$fakebin/opencode" -} - # fm_test_make_spawn_fakebin [extra-exit0-tool...] # Creates /fakebin with the spawn tmux stub, a no-op treehouse, and any # extra exit-0 tools. Echoes the fakebin path. diff --git a/tests/fm-busy-adapter-wiring.test.sh b/tests/fm-busy-adapter-wiring.test.sh index addda6448ba..7a93af888fa 100755 --- a/tests/fm-busy-adapter-wiring.test.sh +++ b/tests/fm-busy-adapter-wiring.test.sh @@ -24,10 +24,6 @@ make_spawn_case() { # proj="$case_dir/project" wt="$case_dir/wt" fakebin=$(make_spawn_fakebin "$case_dir/fake" pi opencode claude codex gemini) - # The OpenCode cases here spawn without a model, so fm-spawn resolves and - # validates the effective model before launch. A bare exit-0 opencode cannot - # answer that preflight, so give it a catalog and a config inventory. - fm_test_make_opencode_preflight_stub "$fakebin" fm_test_spawn_home "$home" "$harness" fm_git_worktree "$proj" "$wt" "wt-$name" fm_test_spawn_brief "$home" "$id" diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 0889c71daac..6aefac037c7 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -34,29 +34,8 @@ SH make_spawn_fakebin() { local dir=$1 fakebin fakebin=$(fm_test_make_spawn_fakebin "$dir") -cat > "$fakebin/timeout" <<'SH' + cat > "$fakebin/timeout" <<'SH' #!/usr/bin/env bash -# bin/fm-timeout-lib.sh invokes a real timeout as `-k ` -# (fm_run_external_timeout), so the stub drops the kill-after flag and its value -# plus the duration. A stub that only shifted once left every bounded probe -# failing with a 127 that no assertion could explain. -# -# This stub only RECORDS the arguments it was given and then execs: it cannot -# enforce a deadline, so an assertion about it proves the bound was passed, not -# that a hung command is killed. test_opencode_probe_enforces_the_catalog_deadline -# covers the enforcement itself against the real runner. -[ -n "${FM_FAKE_TIMEOUT_ARGS:-}" ] && printf '%s\n' "$*" >> "$FM_FAKE_TIMEOUT_ARGS" -while [ $# -gt 0 ]; do - case "$1" in - -k | --kill-after) - [ "$#" -ge 3 ] || exit 125 - shift 2 - ;; - -k* | --kill-after=*) shift ;; - *) break ;; - esac -done -case "${1:-}" in ''|*[!0-9]*) exit 125 ;; esac shift exec "$@" SH @@ -70,20 +49,7 @@ exit 0 SH cat > "$fakebin/opencode" <<'SH' #!/usr/bin/env bash -record_probe_context() { - [ -n "${FM_FAKE_OPENCODE_CONTEXT_LOG:-}" ] || return 0 - printf '%s\t%s\t%s\t%s\n' "$*" "$PWD" "${OPENCODE_SERVER-unset}" \ - "${FM_TEST_SHOULD_NOT_LEAK-unset}" >> "$FM_FAKE_OPENCODE_CONTEXT_LOG" -} if [ "${1:-}" = models ]; then - record_probe_context "$@" - # A catalog that never answers, for proving the preflight's deadline is - # enforced rather than merely passed as an argument. The bound kills this - # child, so it only has to outlast the configured timeout. - if [ "${FM_FAKE_OPENCODE_MODELS_HANG:-0}" = 1 ]; then - sleep 600 - exit 0 - fi # OpenCode v2 refuses a provider positional argument ("Unexpected positional # argument") and prints usage text instead of a catalog, so this stub does # the same. A regression to the legacy `opencode models ` form @@ -93,55 +59,12 @@ if [ "${1:-}" = models ]; then exit 1 fi [ -n "${FM_FAKE_OPENCODE_MODELS_ARGS:-}" ] && printf '%s\n' "$@" > "$FM_FAKE_OPENCODE_MODELS_ARGS" - # `opencode models` is project-scoped: a provider the worktree config declares - # is listed only when the probe runs in that worktree. FM_FAKE_OPENCODE_MODELS_IN_DIR - # supplies that per-directory catalog, keyed by the directory the probe ran in, - # so a probe from the wrong directory is distinguishable from a right one. - [ -n "${FM_FAKE_OPENCODE_MODELS_CWD:-}" ] && pwd > "$FM_FAKE_OPENCODE_MODELS_CWD" - if [ -n "${FM_FAKE_OPENCODE_MODELS_IN_DIR:-}" ]; then - scoped=$(printf '%s' "$FM_FAKE_OPENCODE_MODELS_IN_DIR" | jq -r --arg d "$PWD" '.[$d] // empty') - if [ -n "$scoped" ]; then - printf '%b\n' "$scoped" - exit 0 - fi - fi [ "${FM_FAKE_OPENCODE_MODELS_STATUS:-0}" -eq 0 ] || exit "${FM_FAKE_OPENCODE_MODELS_STATUS}" printf '%b\n' "${FM_FAKE_OPENCODE_MODELS:-anthropic/claude-sonnet-4-5\\nopencode-go/space-bunny-free}" fi -if [ "${1:-}" = debug ] && [ "${2:-}" = config ]; then - record_probe_context "$@" - # The source inventory `opencode debug config` reports: one document entry per - # config file, carrying that file's own parsed content. It is an inventory, not - # a merged answer, so a case supplies the sources it wants ranked and fm-spawn - # resolves precedence over them. A case supplies entries already carrying - # absolute paths, because reading real config files would leave the pooled - # worktree dirty and the spawn's own cleanliness guard would refuse first. - [ -n "${FM_FAKE_OPENCODE_DEBUG_ARGS:-}" ] && printf '%s\n' "$@" > "$FM_FAKE_OPENCODE_DEBUG_ARGS" - [ -n "${FM_FAKE_OPENCODE_DEBUG_CWD:-}" ] && pwd > "$FM_FAKE_OPENCODE_DEBUG_CWD" - [ "${FM_FAKE_OPENCODE_DEBUG_STATUS:-0}" -eq 0 ] || exit "${FM_FAKE_OPENCODE_DEBUG_STATUS}" - if [ -n "${FM_FAKE_OPENCODE_DEBUG_OBJECT:-}" ]; then - printf '%s\n' "$FM_FAKE_OPENCODE_DEBUG_OBJECT" - exit 0 - fi - if [ "${FM_FAKE_OPENCODE_DEBUG_NOT_A_LIST:-0}" = 1 ]; then - printf '%s\n' 'not a config source list' - exit 0 - fi - printf '%s\n' "${FM_FAKE_OPENCODE_DEBUG_DOCS:-[]}" -fi exit 0 SH chmod +x "$fakebin/timeout" "$fakebin/cursor-agent" "$fakebin/opencode" - # `tac` is GNU coreutils and is NOT a stock macOS command, so an ancestor - # ordering that shells out to it works on a Homebrew host and breaks on a - # plain one. This stub fails the call, which turns any such dependency into a - # test failure here instead of a launch-time surprise on the captain's machine. - cat > "$fakebin/tac" <<'SH' -#!/usr/bin/env bash -echo 'tac: unavailable, as on stock macOS' >&2 -exit 127 -SH - chmod +x "$fakebin/tac" cat > "$fakebin/curl" <<'SH' #!/usr/bin/env bash [ "${FM_FAKE_MODELS_DEV_STATUS:-0}" -eq 0 ] || exit "${FM_FAKE_MODELS_DEV_STATUS}" @@ -198,26 +121,11 @@ run_spawn() { # A test opts in to the set case via FM_TEST_CLAUDE_CONFIG_DIR. CLAUDE_CONFIG_DIR="${FM_TEST_CLAUDE_CONFIG_DIR:-}" \ FM_FAKE_LAUNCH_LOG="$launchlog" FM_FAKE_PI_VERSION="${FM_TEST_PI_VERSION:-0.84.0}" \ - FM_FAKE_EXEC_OPENCODE_PROBES="${FM_TEST_EXEC_OPENCODE_PROBES:-1}" \ FM_FAKE_CURSOR_MODELS="${FM_TEST_CURSOR_MODELS:-}" \ FM_FAKE_CURSOR_LIST_STATUS="${FM_TEST_CURSOR_LIST_STATUS:-0}" \ FM_FAKE_OPENCODE_MODELS="${FM_TEST_OPENCODE_MODELS:-}" \ FM_FAKE_OPENCODE_MODELS_STATUS="${FM_TEST_OPENCODE_MODELS_STATUS:-0}" \ FM_FAKE_OPENCODE_MODELS_ARGS="${FM_TEST_OPENCODE_MODELS_ARGS:-}" \ - FM_FAKE_OPENCODE_MODELS_HANG="${FM_TEST_OPENCODE_MODELS_HANG:-}" \ - FM_FAKE_OPENCODE_MODELS_CWD="${FM_TEST_OPENCODE_MODELS_CWD:-}" \ - FM_FAKE_OPENCODE_MODELS_IN_DIR="${FM_TEST_OPENCODE_MODELS_IN_DIR:-}" \ - FM_FAKE_OPENCODE_DEBUG_STATUS="${FM_TEST_OPENCODE_DEBUG_STATUS:-0}" \ - FM_FAKE_OPENCODE_DEBUG_NOT_A_LIST="${FM_TEST_OPENCODE_DEBUG_NOT_A_LIST:-0}" \ - FM_FAKE_OPENCODE_DEBUG_DOCS="${FM_TEST_OPENCODE_DEBUG_DOCS:-}" \ - FM_FAKE_OPENCODE_DEBUG_OBJECT="${FM_TEST_OPENCODE_DEBUG_OBJECT:-}" \ - FM_FAKE_OPENCODE_DEBUG_ARGS="${FM_TEST_OPENCODE_DEBUG_ARGS:-}" \ - FM_FAKE_OPENCODE_DEBUG_CWD="${FM_TEST_OPENCODE_DEBUG_CWD:-}" \ - FM_FAKE_OPENCODE_CONTEXT_LOG="${FM_TEST_OPENCODE_CONTEXT_LOG:-}" \ - FM_FAKE_TIMEOUT_ARGS="${FM_TEST_TIMEOUT_ARGS:-}" \ - OPENCODE_CONFIG="${FM_TEST_OPENCODE_CONFIG:-}" \ - OPENCODE_CONFIG_DIR="${FM_TEST_OPENCODE_CONFIG_DIR:-}" \ - OPENCODE_SERVER="${FM_TEST_OPENCODE_SERVER:-}" \ FM_FAKE_MODELS_DEV_JSON="${FM_TEST_MODELS_DEV_JSON:-}" \ FM_FAKE_MODELS_DEV_STATUS="${FM_TEST_MODELS_DEV_STATUS:-0}" \ GROK_HOME="$home/grok-home" \ @@ -504,7 +412,7 @@ test_explicit_opencode_task_model_overrides_configured_fallback() { expect_code 0 "$status" "listed task-specific OpenCode model should override the configured fallback: $out" assert_meta_profile "$HOME_DIR/state/$id.meta" opencode opencode-go/longcat-2.5-preview-free default launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "'$FAKEBIN_DIR/opencode' --model 'opencode-go/longcat-2.5-preview-free' --prompt" \ + assert_contains "$launch" "opencode --model 'opencode-go/longcat-2.5-preview-free' --prompt" \ "task-specific OpenCode model was not passed to the worker" assert_not_contains "$launch" "--model 'opencode-go/space-bunny-free'" \ "configured fallback replaced the explicitly designated task model" @@ -938,722 +846,6 @@ test_opencode_catalog_probe_uses_no_provider_argument() { pass "OpenCode reads the whole model catalog without a provider argument" } -# An OpenCode launch with no --model still has an effective model, decided by the -# config OpenCode reads in the worker's own directory. These cases prove the -# omitted/default path resolves that model, validates it like an explicit one, and -# pins it on the launch, so no unchecked implicit default reaches a worker. -# opencode_config_docs builds the `opencode debug config` inventory -# a case reports: one document entry, at that exact config path, with that file's -# own parsed content. Writing a real file into the pooled worktree is not an -# option because the spawn's own cleanliness guard refuses a dirty worktree first. -opencode_config_docs() { - jq -cn --arg path "$1" --argjson info "$2" \ - '[{"type":"document","path":$path,"info":$info}]' -} - -test_opencode_omitted_model_resolves_validated_project_model() { - local rec id out status launch cwd_file docs - id=profile-opencode-default-model-z7h - rec=$(make_spawn_case profile-opencode-default-model opencode "$id") - read_case_record "$rec" - docs=$(opencode_config_docs "$WT_DIR/opencode.json" '{"model":"opencode-go/longcat-2.5-preview-free"}') - cwd_file="$CASE_DIR/debug-cwd" - - out=$(FM_TEST_OPENCODE_MODELS=$'opencode-go/space-bunny-free\nopencode-go/longcat-2.5-preview-free' \ - FM_TEST_OPENCODE_DEBUG_DOCS="$docs" FM_TEST_OPENCODE_DEBUG_CWD="$cwd_file" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --harness opencode) - status=$? - expect_code 0 "$status" "an OpenCode launch with no --model should resolve and validate its project model: $out" - assert_meta_profile "$HOME_DIR/state/$id.meta" opencode opencode-go/longcat-2.5-preview-free default - launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "'$FAKEBIN_DIR/opencode' --model 'opencode-go/longcat-2.5-preview-free' --prompt" \ - "the resolved effective model was not pinned on the launch" - [ "$(cat "$cwd_file" 2>/dev/null)" = "$WT_DIR" ] \ - || fail "effective-model resolution did not read the config in the worker's own directory" - pass "an omitted OpenCode model resolves in the worker directory, is validated, and is pinned on the launch" -} - -test_opencode_default_model_token_resolves_like_an_omitted_one() { - local rec id out status docs - id=profile-opencode-default-token-z7i - rec=$(make_spawn_case profile-opencode-default-token opencode "$id") - read_case_record "$rec" - docs=$(opencode_config_docs "$WT_DIR/opencode.json" '{"model":"opencode-go/longcat-2.5-preview-free"}') - - # `--model default` is the recorded "no choice made" value, not a model id, so - # it takes the resolution path instead of being validated as a literal id. - out=$(FM_TEST_OPENCODE_MODELS=$'opencode-go/space-bunny-free\nopencode-go/longcat-2.5-preview-free' \ - FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ - --harness opencode --model default) - status=$? - expect_code 0 "$status" "--model default should resolve the effective model, not be validated as an id: $out" - assert_meta_profile "$HOME_DIR/state/$id.meta" opencode opencode-go/longcat-2.5-preview-free default - pass "an explicit 'default' model token takes the same resolved-model path as an omitted one" -} - -test_opencode_omitted_model_honors_documented_config_precedence() { - local rec id out status docs - id=profile-opencode-precedence-z7j - rec=$(make_spawn_case profile-opencode-precedence opencode "$id") - read_case_record "$rec" - # Documented precedence: direct configs merge farthest to nearest, and every - # .opencode config outranks every direct config. The nearest direct config here - # is the worker's own, and the .opencode config below outranks it. - docs=$(jq -cn \ - --arg far "$CASE_DIR/opencode.json" --arg near "$WT_DIR/opencode.json" --arg dot "$WT_DIR/.opencode/opencode.json" \ - '[{"type":"document","path":$far,"info":{"model":"opencode-go/space-bunny-free"}}, - {"type":"document","path":$near,"info":{"model":"opencode-go/longcat-2.5-preview-free"}}, - {"type":"document","path":$dot,"info":{"model":"opencode-go/space-bunny-free"}}]') - - out=$(FM_TEST_OPENCODE_MODELS=$'opencode-go/space-bunny-free\nopencode-go/longcat-2.5-preview-free' \ - FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --harness opencode) - status=$? - expect_code 0 "$status" "a .opencode config should win over the direct config: $out" - assert_meta_profile "$HOME_DIR/state/$id.meta" opencode opencode-go/space-bunny-free default - pass "effective-model resolution applies the documented OpenCode config precedence" -} - -test_opencode_primary_agent_model_does_not_override_the_session_model() { - local rec id out status docs launch - id=profile-opencode-agent-model-z7k - rec=$(make_spawn_case profile-opencode-agent-model opencode "$id") - read_case_record "$rec" - docs=$(jq -cn --arg far "$CASE_DIR/opencode.json" --arg near "$WT_DIR/opencode.json" \ - '[{"type":"document","path":$far,"info":{"model":"opencode-go/longcat-2.5-preview-free","agents":{"review":{"model":{"providerID":"opencode-go","model":"paid-candidate"}}}}}, - {"type":"document","path":$near,"info":{"default_agent":"review","agents":{"review":{"mode":"primary"}}}}]') - - out=$(FM_TEST_OPENCODE_MODELS=$'opencode-go/space-bunny-free\nopencode-go/longcat-2.5-preview-free' \ - FM_TEST_MODELS_DEV_JSON='{"opencode-go":{"models":{"longcat-2.5-preview-free":{"cost":{"input":0,"output":0}}}}}' \ - FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --harness opencode) - status=$? - expect_code 0 "$status" "a custom primary agent should keep the configured session model: $out" - assert_meta_profile "$HOME_DIR/state/$id.meta" opencode opencode-go/longcat-2.5-preview-free default - launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "--model 'opencode-go/longcat-2.5-preview-free'" \ - "a primary agent's model preference replaced the session model in the launch" - pass "v2 custom primary agent selection leaves the configured session model in force" -} - -test_opencode_omitted_model_refuses_when_no_effective_model_exists() { - local rec id out status docs - id=profile-opencode-no-model-z7l - rec=$(make_spawn_case profile-opencode-no-model opencode "$id") - read_case_record "$rec" - docs=$(opencode_config_docs "$WT_DIR/opencode.json" '{"shell":"bash"}') - - # No config declares any model, so OpenCode would fall back to its newest - # available supported model. That is exactly the unchecked implicit default this gate - # exists to refuse. - out=$(FM_TEST_OPENCODE_MODELS=$'opencode-go/space-bunny-free\nopencode-go/longcat-2.5-preview-free' \ - FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --harness opencode) - status=$? - expect_code 1 "$status" "an OpenCode launch with no resolvable effective model must refuse" - assert_contains "$out" "no OpenCode root 'model' could be resolved" \ - "unresolvable-model refusal did not name the resolution failure" - [ ! -e "$HOME_DIR/state/$id.meta" ] || fail "an unresolvable effective model published metadata" - [ ! -s "$LAUNCH_LOG" ] || fail "an unresolvable effective model launched an agent" - pass "OpenCode dispatch refuses when no effective model can be resolved" -} - -test_opencode_omitted_model_refuses_when_config_sources_are_ambiguous() { - local rec id out status docs - id=profile-opencode-ambiguous-z7m - rec=$(make_spawn_case profile-opencode-ambiguous opencode "$id") - read_case_record "$rec" - # opencode.json and opencode.jsonc in the same directory are the same - # precedence step, so two different root models there are unresolvable. - docs=$(jq -cn --arg json "$WT_DIR/opencode.json" --arg jsonc "$WT_DIR/opencode.jsonc" \ - '[{"type":"document","path":$json,"info":{"model":"opencode-go/space-bunny-free"}}, - {"type":"document","path":$jsonc,"info":{"model":"opencode-go/longcat-2.5-preview-free"}}]') - - out=$(FM_TEST_OPENCODE_MODELS=$'opencode-go/space-bunny-free\nopencode-go/longcat-2.5-preview-free' \ - FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --harness opencode) - status=$? - expect_code 1 "$status" "equal-precedence config sources must refuse rather than pick one" - assert_contains "$out" "of equal precedence disagree" \ - "ambiguity refusal did not explain the disagreement" - [ ! -e "$HOME_DIR/state/$id.meta" ] || fail "ambiguous config sources published metadata" - [ ! -s "$LAUNCH_LOG" ] || fail "ambiguous config sources launched an agent" - pass "OpenCode dispatch refuses when two equal-precedence config sources disagree" -} - -test_opencode_custom_primary_agent_without_a_model_inherits_the_session_model() { - local rec id out status docs - id=profile-opencode-inherit-agent-model-z7n - rec=$(make_spawn_case profile-opencode-inherit-agent-model opencode "$id") - read_case_record "$rec" - # A custom primary agent without its own model inherits the configured - # session model; the agent ID itself does not change what --prompt receives. - docs=$(opencode_config_docs "$WT_DIR/opencode.json" \ - '{"default_agent":"review","model":"opencode-go/space-bunny-free","agents":{"review":{"mode":"primary"}}}') - - out=$(FM_TEST_OPENCODE_MODELS=$'opencode-go/space-bunny-free\nopencode-go/longcat-2.5-preview-free' \ - FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --harness opencode) - status=$? - expect_code 0 "$status" "a custom primary agent should inherit the resolved session model: $out" - assert_meta_profile "$HOME_DIR/state/$id.meta" opencode opencode-go/space-bunny-free default - [ -s "$LAUNCH_LOG" ] || fail "a resolvable custom primary agent did not launch" - pass "a custom primary agent without a model inherits the resolved session model" -} - -test_opencode_omitted_model_refuses_paid_and_unavailable_effective_models() { - local rec id out status docs - for case in paid unavailable; do - if [ "$case" = paid ]; then - id=profile-opencode-default-paid-z7o - model=opencode-go/paid-candidate - else - id=profile-opencode-default-unavailable-z7p - model=opencode-go/space-bunny-free - fi - rec=$(make_spawn_case "profile-opencode-default-$case" opencode "$id") - read_case_record "$rec" - docs=$(opencode_config_docs "$WT_DIR/opencode.json" "{\"model\":\"$model\"}") - - if [ "$case" = paid ]; then - out=$(FM_TEST_OPENCODE_MODELS='opencode-go/paid-candidate' \ - FM_TEST_MODELS_DEV_JSON='{"opencode-go":{"models":{"paid-candidate":{"cost":{"input":0.15,"output":0.6}}}}}' \ - FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --harness opencode) - status=$? - expect_code 1 "$status" "a paid effective model must be refused even with no --model" - assert_contains "$out" "is not classified as free by models.dev" \ - "a paid effective model was not refused by pricing" - else - out=$(FM_TEST_OPENCODE_MODELS='opencode-go/longcat-2.5-preview-free' \ - FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --harness opencode) - status=$? - expect_code 1 "$status" "an effective model absent from the live catalog must be refused" - assert_contains "$out" "is not available from 'opencode models'" \ - "an unavailable effective model was not refused by the catalog" - fi - [ ! -e "$HOME_DIR/state/$id.meta" ] || fail "a refused effective model ($case) published metadata" - [ ! -s "$LAUNCH_LOG" ] || fail "a refused effective model ($case) launched an agent" - done - pass "the resolved effective model clears the same availability and zero-cost gate as an explicit one" -} - -test_opencode_omitted_model_refuses_when_the_config_inventory_is_unusable() { - local rec id out status docs - for case in failing not-a-list; do - id="profile-opencode-inventory-$case-z7q" - rec=$(make_spawn_case "profile-opencode-inventory-$case" opencode "$id") - read_case_record "$rec" - docs=$(opencode_config_docs "$WT_DIR/opencode.json" '{"model":"opencode-go/space-bunny-free"}') - - # Each branch captures the spawn's own status before anything else runs, so - # `$?` is the spawn and not the assignment that follows it. - if [ "$case" = failing ]; then - out=$(FM_TEST_OPENCODE_DEBUG_STATUS=1 FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --harness opencode) - status=$? - expected="could not verify the effective OpenCode model" - else - out=$(FM_TEST_OPENCODE_DEBUG_NOT_A_LIST=1 FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --harness opencode) - status=$? - expected="could not verify the effective OpenCode model" - fi - expect_code 1 "$status" "an unusable config inventory ($case) must refuse the spawn" - assert_contains "$out" "$expected" \ - "an unusable config inventory ($case) did not refuse on its own cause" - [ ! -e "$HOME_DIR/state/$id.meta" ] || fail "an unusable config inventory ($case) published metadata" - [ ! -s "$LAUNCH_LOG" ] || fail "an unusable config inventory ($case) launched an agent" - done - pass "OpenCode dispatch refuses when debug config fails or has an unsupported shape" -} - -test_opencode_raw_launch_cannot_bypass_a_different_model() { - local rec id out status docs - id=profile-opencode-raw-launch-z7r - rec=$(make_spawn_case profile-opencode-raw-launch opencode "$id") - read_case_record "$rec" - docs=$(opencode_config_docs "$WT_DIR/opencode.json" '{"model":"opencode-go/space-bunny-free"}') - - # The positional command selects a paid model while the separate --model is - # nonempty, available, and zero cost. The dispatch must refuse the raw launch - # after validating that separate value instead of allowing the mismatch. - out=$(FM_TEST_OPENCODE_MODELS=$'opencode-go/space-bunny-free\nopencode-go/paid-candidate' \ - FM_TEST_MODELS_DEV_JSON='{"opencode-go":{"models":{"space-bunny-free":{"cost":{"input":0,"output":0}},"paid-candidate":{"cost":{"input":0.15,"output":0.6}}}}}' \ - FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ - "opencode --model opencode-go/paid-candidate --prompt hello" \ - --model opencode-go/space-bunny-free --mode no-mistakes --yolo off) - status=$? - expect_code 1 "$status" "a raw OpenCode launch with a different model must refuse" - assert_contains "$out" "raw OpenCode launch cannot be pinned" \ - "raw-launch mismatch refusal did not name the pinning failure" - [ ! -e "$HOME_DIR/state/$id.meta" ] || fail "a refused raw OpenCode launch published metadata" - [ ! -s "$LAUNCH_LOG" ] || fail "a refused raw OpenCode launch launched an agent" - pass "a raw OpenCode launch cannot bypass validation with a different explicit model" -} - -test_opencode_raw_launch_classification_reads_the_executable_not_its_arguments() { - local rec id out status launch raw wrap_index - # A raw command is classified from the executable it actually runs, never - # from arbitrary argument text: `--prompt 'review opencode'` mentions OpenCode - # inside an unrelated Claude launch and must stay accepted, while an OpenCode - # command hidden behind the supported `env` wrapper prefix must still be - # refused. Matching the whole command string got both of these backwards. - id=profile-opencode-raw-exec-z7q - rec=$(make_spawn_case profile-opencode-raw-exec claude "$id") - read_case_record "$rec" - out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ - "claude --prompt 'review opencode'" --mode no-mistakes --yolo off) - status=$? - expect_code 0 "$status" "a Claude launch whose prompt merely mentions opencode must not be classified as OpenCode: $out" - launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "claude --prompt 'review opencode'" \ - "the unrelated launch did not deliver its own command" - - # Each form gets its own case directory and task id: the fixture builds a - # worktree and a remote clone, so reusing one name across iterations fails on - # the second pass for reasons unrelated to the behavior under test. - wrap_index=0 - for raw in "env -u FM_PANE opencode --prompt hi" "/usr/bin/env -i opencode --prompt hi"; do - wrap_index=$((wrap_index + 1)) - id="profile-opencode-raw-wrapped-z7r$wrap_index" - rec=$(make_spawn_case "profile-opencode-raw-wrapped-z7r$wrap_index" opencode "$id") - read_case_record "$rec" - # An explicit, available, zero-cost model so the dispatch reaches the - # raw-launch pinning refusal: reaching THAT refusal proves the wrapped - # command was classified as OpenCode and validated, not skipped. - out=$(FM_TEST_OPENCODE_MODELS='opencode-go/space-bunny-free' \ - FM_TEST_MODELS_DEV_JSON='{"opencode-go":{"models":{"space-bunny-free":{"cost":{"input":0,"output":0}}}}}' \ - run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ - "$raw" --model opencode-go/space-bunny-free --mode no-mistakes --yolo off) - status=$? - expect_code 1 "$status" "an OpenCode command behind an env wrapper must refuse, not skip validation: $out ($raw)" - assert_contains "$out" "raw OpenCode launch cannot be pinned" \ - "wrapped raw OpenCode refusal did not name the pinning failure: $raw" - [ ! -s "$LAUNCH_LOG" ] || fail "a refused wrapped raw OpenCode launch launched an agent: $raw" - done - - # A QUOTED executable is the same bypass by another route: this walk splits on - # whitespace, so `'opencode'` keeps its quotes, basename returns `'opencode'`, - # and the command would match no harness and run OpenCode anyway with every - # model guard skipped. Unclassifiable, so it fails closed. Reached with an - # explicit, available, zero-cost model: were the quoted form still classified - # as OpenCode, this launch would instead reach the pinning refusal. - quoted_index=0 - for raw in "'opencode' --prompt hi" "\"\$HOME/bin/opencode\" --prompt hi"; do - quoted_index=$((quoted_index + 1)) - id="profile-opencode-raw-quoted-z7t$quoted_index" - rec=$(make_spawn_case "profile-opencode-raw-quoted-z7t$quoted_index" opencode "$id") - read_case_record "$rec" - out=$(FM_TEST_OPENCODE_MODELS='opencode-go/space-bunny-free' \ - FM_TEST_MODELS_DEV_JSON='{"opencode-go":{"models":{"space-bunny-free":{"cost":{"input":0,"output":0}}}}}' \ - run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ - "$raw" --model opencode-go/space-bunny-free --mode no-mistakes --yolo off) - status=$? - expect_code 1 "$status" "a quoted OpenCode executable must fail closed, not skip validation: $out ($raw)" - assert_contains "$out" "quotes or expands its executable" \ - "the quoted-executable refusal did not name the unresolvable executable: $raw" - [ ! -s "$LAUNCH_LOG" ] || fail "a refused quoted raw OpenCode launch launched an agent: $raw" - done - - # A wrapper whose whole job is to run ANOTHER command is the same bypass: with - # `command`/`exec`/`nohup` unrecognized, HARNESS became the wrapper, so every - # `if [ "$HARNESS" = opencode ]` guard was skipped while OpenCode still ran. - # Each is walked through to the executable it would run, so reaching the - # PINNING refusal (not a generic refusal) proves the wrapper resolved and the - # model was validated on the way. - wrap2_index=0 - for raw in "command opencode --prompt hi" "exec opencode --prompt hi" \ - "nohup opencode --prompt hi" "nice opencode --prompt hi" "timeout 30 opencode --prompt hi"; do - wrap2_index=$((wrap2_index + 1)) - id="profile-opencode-raw-cmdwrap-z7u$wrap2_index" - rec=$(make_spawn_case "profile-opencode-raw-cmdwrap-z7u$wrap2_index" opencode "$id") - read_case_record "$rec" - out=$(FM_TEST_OPENCODE_MODELS='opencode-go/space-bunny-free' \ - FM_TEST_MODELS_DEV_JSON='{"opencode-go":{"models":{"space-bunny-free":{"cost":{"input":0,"output":0}}}}}' \ - run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ - "$raw" --model opencode-go/space-bunny-free --mode no-mistakes --yolo off) - status=$? - expect_code 1 "$status" "an OpenCode command behind a command wrapper must refuse, not skip validation: $out ($raw)" - assert_contains "$out" "raw OpenCode launch cannot be pinned" \ - "command-wrapped OpenCode refusal did not name the pinning failure: $raw" - [ ! -s "$LAUNCH_LOG" ] || fail "a refused command-wrapped raw OpenCode launch launched an agent: $raw" - done - - # A command that runs OpenCode somewhere other than the one executable this walk - # resolves cannot have its model validated by that resolution, so it fails - # closed. The pipe case is the important one: its executable word (`echo`) is - # perfectly resolvable and the OpenCode launch is on the far side of it. A - # shell interpreter is the same bypass with no special character at all, since - # `sh -c opencode ...` names a real executable and then runs a different - # command from its arguments. - # - # This must NOT refuse compound commands in general: `cd && ./probe` is a - # legitimate raw launch that mentions no OpenCode, and the companion suite - # tests/fm-spawn-compact-adviser-disable.test.sh covers exactly that form. - compose_index=0 - # shellcheck disable=SC2016 # single quotes are deliberate: these are the literal raw-command text under test, not expansions of this shell - for raw in '$(which opencode) --prompt hi' '`which opencode` --prompt hi' \ - 'echo x | opencode --prompt hi' 'sh -c opencode' 'bash -c "opencode --prompt hi"' \ - 'zsh -c opencode' 'env sh -c opencode' 'cd /tmp && ./opencode' \ - 'true; opencode --prompt hi'; do - compose_index=$((compose_index + 1)) - id="profile-opencode-raw-compose-z7v$compose_index" - rec=$(make_spawn_case "profile-opencode-raw-compose-z7v$compose_index" opencode "$id") - read_case_record "$rec" - out=$(FM_TEST_OPENCODE_MODELS='opencode-go/space-bunny-free' \ - FM_TEST_MODELS_DEV_JSON='{"opencode-go":{"models":{"space-bunny-free":{"cost":{"input":0,"output":0}}}}}' \ - run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ - "$raw" --model opencode-go/space-bunny-free --mode no-mistakes --yolo off) - status=$? - expect_code 1 "$status" "a composed or expanded raw executable must fail closed: $out ($raw)" - assert_contains "$out" "builds or composes its executable" \ - "the composed-executable refusal did not name the unresolvable executable: $raw" - [ ! -s "$LAUNCH_LOG" ] || fail "a refused composed raw launch launched an agent: $raw" - done - - id=profile-opencode-raw-ambiguous-z7s - rec=$(make_spawn_case profile-opencode-raw-ambiguous opencode "$id") - read_case_record "$rec" - out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ - "env --not-a-real-env-flag opencode --prompt hi" --mode no-mistakes --yolo off) - status=$? - expect_code 1 "$status" "a wrapped raw command whose executable cannot be resolved must fail closed: $out" - assert_contains "$out" "wraps its executable in a form dispatch cannot resolve" \ - "the ambiguous-wrapper refusal did not name the unresolved executable" - [ ! -s "$LAUNCH_LOG" ] || fail "an ambiguous wrapped raw command launched an agent" - pass "raw OpenCode classification follows the executable through the env wrapper, never prompt text" -} - -test_opencode_validates_the_selected_variant_not_just_the_base_model() { - local rec id out status docs base='opencodex/anthropic/claude-fable-5' - # `opencode models` lists only base ids, so it cannot confirm a variant. An - # available base must NOT carry an arbitrary variant: the exact - # "base#variant" has to be one the destination config actually declares. - id=profile-opencode-variant-bad-z7t - rec=$(make_spawn_case profile-opencode-variant opencode "$id") - read_case_record "$rec" - docs=$(opencode_config_docs "$WT_DIR/opencode.json" \ - '{"model":"opencodex/anthropic/claude-fable-5", - "providers":{"opencodex":{"models":{"anthropic/claude-fable-5": - {"variants":[{"id":"low"},{"id":"high"}]}}}}}') - - out=$(FM_TEST_OPENCODE_MODELS='opencodex/anthropic/claude-fable-5' \ - FM_TEST_MODELS_DEV_JSON='{"opencodex":{"models":{"anthropic/claude-fable-5":{"cost":{"input":0,"output":0}}}}}' \ - FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ - --harness opencode --model 'opencodex/anthropic/claude-fable-5#nonexistent' --mode no-mistakes --yolo off) - status=$? - expect_code 1 "$status" "an available base with a nonexistent variant must refuse: $out" - assert_contains "$out" "no config source there selects the '#nonexistent' variant" \ - "the nonexistent-variant refusal did not name the undeclared variant" - [ ! -s "$LAUNCH_LOG" ] || fail "a launch with an undeclared variant started an agent" - - id=profile-opencode-variant-ok-z7u - rec=$(make_spawn_case profile-opencode-variant-ok opencode "$id") - read_case_record "$rec" - docs=$(opencode_config_docs "$WT_DIR/opencode.json" \ - '{"model":"opencodex/anthropic/claude-fable-5", - "providers":{"opencodex":{"models":{"anthropic/claude-fable-5": - {"variants":[{"id":"low"},{"id":"high"}]}}}}}') - out=$(FM_TEST_OPENCODE_MODELS='opencodex/anthropic/claude-fable-5' \ - FM_TEST_MODELS_DEV_JSON='{"opencodex":{"models":{"anthropic/claude-fable-5":{"cost":{"input":0,"output":0}}}}}' \ - FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ - --harness opencode --model 'opencodex/anthropic/claude-fable-5#low') - status=$? - expect_code 0 "$status" "a variant the destination config declares must be accepted: $out" - assert_meta_profile "$HOME_DIR/state/$id.meta" opencode 'opencodex/anthropic/claude-fable-5#low' default - assert_contains "$(cat "$LAUNCH_LOG")" "--model 'opencodex/anthropic/claude-fable-5#low'" \ - "the declared variant was not pinned on the launch" - - # Precedence, not union: a LOWER-precedence config declares `low` and enables - # it, while the worker's OWN higher-precedence config declares the same model - # and disables `low`. Only the effective (highest-precedence) declaration - # decides, so `#low` must be refused even though some config declares it. - id=profile-opencode-variant-precedence-z7v - rec=$(make_spawn_case profile-opencode-variant-precedence opencode "$id") - read_case_record "$rec" - docs=$(jq -cn --arg far "$CASE_DIR/opencode.json" --arg near "$WT_DIR/opencode.json" \ - --arg base 'opencodex/anthropic/claude-fable-5' \ - '[{"type":"document","path":$far,"info":{"model":$base, - "providers":{"opencodex":{"models":{"anthropic/claude-fable-5": - {"variants":[{"id":"low"},{"id":"high"}]}}}}}}, - {"type":"document","path":$near,"info":{"model":$base, - "providers":{"opencodex":{"models":{"anthropic/claude-fable-5": - {"variants":[{"id":"low","disabled":true},{"id":"high"}]}}}}}}]') - out=$(FM_TEST_OPENCODE_MODELS='opencodex/anthropic/claude-fable-5' \ - FM_TEST_MODELS_DEV_JSON='{"opencodex":{"models":{"anthropic/claude-fable-5":{"cost":{"input":0,"output":0}}}}}' \ - FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ - --harness opencode --model "$base#low" --mode no-mistakes --yolo off) - status=$? - expect_code 1 "$status" "a variant disabled by the higher-precedence config must refuse: $out" - assert_contains "$out" "no config source there selects the '#low' variant" \ - "the disabled-by-precedence refusal did not name the variant" - [ ! -s "$LAUNCH_LOG" ] || fail "a variant disabled by the higher-precedence config launched an agent" - - # Two EQUAL-precedence sources that DISAGREE about one model are as - # unresolvable as two disagreeing root models. The disagreeing state that is - # easiest to miss is the one where both sources DECLARE the same variant id and - # differ only in whether it is enabled: comparing declared sets alone passes, - # while the effective state is genuinely ambiguous, so that case must refuse - # too rather than select the enabled one. - conflict_index=0 - for conflict in differing-same-declared differing-sets; do - conflict_index=$((conflict_index + 1)) - id="profile-opencode-variant-conflict-z7w$conflict_index" - rec=$(make_spawn_case "profile-opencode-variant-conflict$conflict_index" opencode "$id") - read_case_record "$rec" - if [ "$conflict" = differing-same-declared ]; then - # Both declare `low`; the worker's own config disables it. - near_variants='{"variants":[{"id":"low","disabled":true}]}' - jsonc_variants='{"variants":[{"id":"low"}]}' - requested='low' - else - near_variants='{"variants":[{"id":"low"}]}' - jsonc_variants='{"variants":[{"id":"high"}]}' - requested='low' - fi - docs=$(jq -cn --arg near "$WT_DIR/opencode.json" --arg jsonc "$WT_DIR/opencode.jsonc" \ - --arg base "$base" --argjson nv "$near_variants" --argjson jv "$jsonc_variants" \ - '[{"type":"document","path":$near,"info":{"model":$base, - "providers":{"opencodex":{"models":{"anthropic/claude-fable-5":$nv}}}}}, - {"type":"document","path":$jsonc,"info":{"model":$base, - "providers":{"opencodex":{"models":{"anthropic/claude-fable-5":$jv}}}}}]') - out=$(FM_TEST_OPENCODE_MODELS='opencodex/anthropic/claude-fable-5' \ - FM_TEST_MODELS_DEV_JSON='{"opencodex":{"models":{"anthropic/claude-fable-5":{"cost":{"input":0,"output":0}}}}}' \ - FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ - --harness opencode --model "$base#$requested" --mode no-mistakes --yolo off) - status=$? - expect_code 1 "$status" "equal-precedence variant disagreement must refuse rather than pick one ($conflict)" - assert_contains "$out" "declare different variants" \ - "the ambiguous-variant refusal did not explain the disagreement ($conflict)" - [ ! -s "$LAUNCH_LOG" ] || fail "an ambiguous variant selection launched an agent ($conflict)" - done - pass "OpenCode validates the exact selected variant against the effective, precedence-resolved variants" -} - -test_opencode_v1_resolved_debug_config_object_is_supported() { - local rec id out status launch resolved - id=profile-opencode-v1-debug-object-z7u - rec=$(make_spawn_case profile-opencode-v1-debug-object opencode "$id") - read_case_record "$rec" - resolved='{"model":{"providerID":"opencode-go","model":"longcat-2.5-preview-free"},"default_agent":"review","agents":{"review":{"mode":"primary","model":{"providerID":"opencode-go","model":"paid-candidate"}}}}' - - out=$(FM_TEST_OPENCODE_MODELS=$'opencode-go/longcat-2.5-preview-free\nopencode-go/paid-candidate' \ - FM_TEST_OPENCODE_DEBUG_OBJECT="$resolved" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --harness opencode) - status=$? - expect_code 0 "$status" "v1 resolved-object debug output should resolve and validate its root model: $out" - assert_meta_profile "$HOME_DIR/state/$id.meta" opencode opencode-go/longcat-2.5-preview-free default - launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "--model 'opencode-go/longcat-2.5-preview-free'" \ - "v1 debug output did not pin its root model on the worker launch" - pass "v1 resolved-object debug config output is supported and pins its root model" -} - -test_opencode_v1_map_form_variants_are_read_and_disabled_ones_excluded() { - local rec id out status resolved - # `variants` is published in two shapes: v2 uses an array of objects carrying - # `id`, v1 uses an object keyed by the variant id. Which one appears is a - # property of the OpenCode version, so a launch must not depend on which one - # this machine happens to run. A variant explicitly disabled is not - # selectable, so it must never be the evidence that admits a "#variant" form. - id=profile-opencode-v1-map-variants-z7v - rec=$(make_spawn_case profile-opencode-v1-map-variants opencode "$id") - read_case_record "$rec" - resolved='{"model":"opencode-go/space-bunny-free","providers":{"opencode-go":{"models":{"space-bunny-free":{"variants":{"low":{"options":{}},"disabled-one":{"disabled":true},"high":null}}}}}}' - - out=$(FM_TEST_OPENCODE_MODELS='opencode-go/space-bunny-free' \ - FM_TEST_MODELS_DEV_JSON='{"opencode-go":{"models":{"space-bunny-free":{"cost":{"input":0,"output":0}}}}}' \ - FM_TEST_OPENCODE_DEBUG_OBJECT="$resolved" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ - --harness opencode --model 'opencode-go/space-bunny-free#low') - status=$? - expect_code 0 "$status" "a variant declared in the v1 map form must be accepted: $out" - assert_contains "$(cat "$LAUNCH_LOG")" "--model 'opencode-go/space-bunny-free#low'" \ - "the accepted v1 map-form variant was not passed to the worker" - - # A separate case, because the launch log appends and the first half of this - # test deliberately launched a worker. - id=profile-opencode-v1-map-variant-disabled-z7w - rec=$(make_spawn_case profile-opencode-v1-map-variant-disabled opencode "$id") - read_case_record "$rec" - - out=$(FM_TEST_OPENCODE_MODELS='opencode-go/space-bunny-free' \ - FM_TEST_MODELS_DEV_JSON='{"opencode-go":{"models":{"space-bunny-free":{"cost":{"input":0,"output":0}}}}}' \ - FM_TEST_OPENCODE_DEBUG_OBJECT="$resolved" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ - --harness opencode --model 'opencode-go/space-bunny-free#disabled-one') - status=$? - expect_code 1 "$status" "a variant the config disables must refuse even though its base model is valid" - assert_contains "$out" "is not available in the destination pane" \ - "a disabled variant refusal did not name the missing variant" - [ ! -s "$LAUNCH_LOG" ] || fail "a disabled variant launched OpenCode" - pass "v1 map-form variants are read, and an explicitly disabled variant is not accepted as evidence" -} - -test_opencode_custom_config_environment_overrides_fail_closed() { - local rec id out status name - for name in OPENCODE_CONFIG OPENCODE_CONFIG_DIR; do - id="profile-opencode-config-env-${name}" - rec=$(make_spawn_case "profile-opencode-config-env-${name}" opencode "$id") - read_case_record "$rec" - printf '%s\n' OPENCODE_CONFIG OPENCODE_CONFIG_DIR > "$HOME_DIR/config/launch-env-allowlist" - if [ "$name" = OPENCODE_CONFIG ]; then - out=$(FM_TEST_OPENCODE_CONFIG="$CASE_DIR/custom-opencode.json" \ - FM_TEST_OPENCODE_DEBUG_DOCS="$(opencode_config_docs "$WT_DIR/opencode.json" '{"model":"opencode-go/space-bunny-free"}')" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --harness opencode) - else - out=$(FM_TEST_OPENCODE_CONFIG_DIR="$CASE_DIR/custom-config-dir" \ - FM_TEST_OPENCODE_DEBUG_DOCS="$(opencode_config_docs "$WT_DIR/opencode.json" '{"model":"opencode-go/space-bunny-free"}')" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --harness opencode) - fi - status=$? - expect_code 1 "$status" "$name must fail closed when effective config sources cannot be ranked" - assert_contains "$out" "cannot resolve that custom config location safely" \ - "$name refusal did not name the unsupported config override" - [ ! -e "$HOME_DIR/state/$id.meta" ] || fail "$name override published task metadata" - [ ! -s "$LAUNCH_LOG" ] || fail "$name override launched OpenCode" - if find "$WT_DIR" -maxdepth 1 -name ".fm-opencode-probe-$id-*" -print -quit | grep -q .; then - fail "$name refusal left a completed OpenCode probe directory behind" - fi - done - pass "implicit OpenCode model resolution fails closed for OPENCODE_CONFIG and OPENCODE_CONFIG_DIR overrides" -} - -test_opencode_probe_uses_launch_environment_and_bounded_debug_calls() { - local rec id out status docs context timeout_args launch - id=profile-opencode-pane-scope-z7v - rec=$(make_spawn_case profile-opencode-pane-scope opencode "$id") - read_case_record "$rec" - docs=$(opencode_config_docs "$WT_DIR/opencode.json" '{"model":"opencode-go/space-bunny-free"}') - context="$CASE_DIR/opencode-context.tsv" - timeout_args="$CASE_DIR/timeout-args.log" - cat > "$HOME_DIR/config/launch-env-allowlist" <<'EOF' -FM_FAKE_OPENCODE_MODELS -FM_FAKE_OPENCODE_DEBUG_DOCS -FM_FAKE_OPENCODE_CONTEXT_LOG -FM_FAKE_TIMEOUT_ARGS -OPENCODE_SERVER -EOF - - out=$(FM_TEST_OPENCODE_MODELS='opencode-go/space-bunny-free' \ - FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - FM_TEST_OPENCODE_CONTEXT_LOG="$context" \ - FM_TEST_TIMEOUT_ARGS="$timeout_args" \ - FM_TEST_OPENCODE_SERVER='https://worker-context.invalid' \ - FM_TEST_SHOULD_NOT_LEAK='must-not-reach-worker' \ - FM_OPENCODE_MODELS_TIMEOUT=3 \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --harness opencode) - status=$? - expect_code 0 "$status" "OpenCode probes should run in the launch allowlist context: $out" - [ "$(wc -l < "$context" | tr -d ' ')" = 2 ] \ - || fail "expected one context record for debug config and one for models" - while IFS="$(printf '\t')" read -r command cwd server leak; do - [ "$cwd" = "$WT_DIR" ] || fail "probe command $command ran in $cwd instead of $WT_DIR" - [ "$server" = 'https://worker-context.invalid' ] || fail "probe command $command lost the worker server route" - [ "$leak" = unset ] || fail "an unallowlisted supervisor variable reached probe command $command" - done < "$context" - [ "$(grep -c '^-k 1 3 bash -c ' "$timeout_args")" -ge 1 ] \ - || fail "bounded debug config did not consume timeout's -k 1 and 3-second arguments" - [ "$(grep -c '^-k 1 3 ' "$timeout_args")" -ge 2 ] \ - || fail "debug config and model catalog were not both bounded" - - launch=$(cat "$LAUNCH_LOG") - # shellcheck disable=SC2016 # single quotes are deliberate: this asserts the literal launch text carries the pane-side expansion, not this shell's - assert_contains "$launch" 'OPENCODE_SERVER=$OPENCODE_SERVER' \ - "the launch did not carry the same allowlisted server context as its probe" - pass "OpenCode debug and catalog probes use the worker cwd, server route, allowlist, and finite timeout" -} - -test_opencode_probe_enforces_the_catalog_deadline() { - local rec id out status docs started elapsed - id=profile-opencode-probe-timeout-z7x - rec=$(make_spawn_case profile-opencode-probe-timeout opencode "$id") - read_case_record "$rec" - docs=$(opencode_config_docs "$WT_DIR/opencode.json" '{"model":"opencode-go/space-bunny-free"}') - # The fake `timeout` in this suite only records the arguments it is given and - # then execs, so it cannot enforce a deadline and an assertion against it would - # prove nothing about enforcement. This case opts out of that stub and uses the - # real bounded runner instead (FM_TIMEOUT_MECHANISM_OVERRIDE=bash selects the - # dependency-free fallback in bin/fm-timeout-lib.sh), against a catalog that - # never answers. The deadline is then real: the spawn must be refused as a - # timeout quickly instead of waiting on the hung command. - cat > "$HOME_DIR/config/launch-env-allowlist" <<'EOF' -FM_TIMEOUT_MECHANISM_OVERRIDE -FM_FAKE_OPENCODE_MODELS -FM_FAKE_OPENCODE_MODELS_HANG -FM_FAKE_OPENCODE_DEBUG_DOCS -EOF - started=$(date +%s) - out=$(FM_TEST_OPENCODE_MODELS_HANG=1 FM_TIMEOUT_MECHANISM_OVERRIDE=bash FM_OPENCODE_MODELS_TIMEOUT=2 \ - FM_TEST_OPENCODE_MODELS='opencode-go/space-bunny-free' \ - FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --harness opencode) - status=$? - elapsed=$(($(date +%s) - started)) - expect_code 1 "$status" "a model catalog that never answers must be refused, not waited on: $out" - assert_contains "$out" "'opencode models' failed in the destination pane (exit 124)" \ - "the bounded catalog refusal did not report the timeout exit status" - [ "$elapsed" -lt 60 ] \ - || fail "the bounded catalog probe waited ${elapsed}s, so its deadline was not enforced" - [ ! -s "$LAUNCH_LOG" ] || fail "a hung catalog probe still launched an agent" - [ ! -e "$HOME_DIR/state/$id.meta" ] || fail "a hung catalog probe published task metadata" - pass "a hung OpenCode catalog probe is cut off at its bound and refused as a timeout" -} - -test_opencode_explicit_model_is_validated_in_the_worker_worktree() { - local rec id out status cwd_file scoped - id=profile-opencode-explicit-in-wt-z7s - rec=$(make_spawn_case profile-opencode-explicit-in-wt opencode "$id") - read_case_record "$rec" - cwd_file="$CASE_DIR/models-cwd" - # A model a worktree-scoped provider makes available. It is absent from the - # catalog the launcher would see, so validating anywhere but the settled - # worktree refuses a model the worker can actually run. - scoped=$(jq -cn --arg wt "$WT_DIR" '{($wt): "projectscope/local-free"}') - - out=$(FM_TEST_OPENCODE_MODELS='opencode-go/space-bunny-free' \ - FM_TEST_OPENCODE_MODELS_IN_DIR="$scoped" FM_TEST_OPENCODE_MODELS_CWD="$cwd_file" \ - FM_TEST_MODELS_DEV_JSON='{"projectscope":{"models":{"local-free":{"cost":{"input":0,"output":0}}}}}' \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" \ - --harness opencode --model projectscope/local-free) - status=$? - expect_code 0 "$status" "an explicit model a project-scoped provider supplies must validate: $out" - [ "$(cat "$cwd_file" 2>/dev/null)" = "$WT_DIR" ] \ - || fail "an explicit OpenCode model was validated outside the worker worktree: $(cat "$cwd_file" 2>/dev/null)" - assert_meta_profile "$HOME_DIR/state/$id.meta" opencode projectscope/local-free default - pass "an explicit OpenCode model is validated against the catalog read in the worker worktree" -} - -test_opencode_effective_model_resolves_without_nonstock_tools() { - local rec id out status docs - id=profile-opencode-no-tac-z7t - rec=$(make_spawn_case profile-opencode-no-tac opencode "$id") - read_case_record "$rec" - # Two direct configs make ancestor ordering load-bearing for the answer. The - # fakebin's `tac` stub - # exits 127, so any ordering that shells out to tac (GNU coreutils, not a - # stock macOS command) fails here instead of on the captain's machine. - docs=$(jq -cn --arg far "$CASE_DIR/opencode.json" --arg near "$WT_DIR/opencode.json" \ - '[{"type":"document","path":$far,"info":{"model":"opencode-go/space-bunny-free"}}, - {"type":"document","path":$near,"info":{"model":"opencode-go/longcat-2.5-preview-free"}}]') - - out=$(FM_TEST_OPENCODE_MODELS=$'opencode-go/space-bunny-free\nopencode-go/longcat-2.5-preview-free' \ - FM_TEST_OPENCODE_DEBUG_DOCS="$docs" \ - run_ship_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --harness opencode) - status=$? - expect_code 0 "$status" "ancestor ordering must not depend on a non-stock command: $out" - assert_meta_profile "$HOME_DIR/state/$id.meta" opencode opencode-go/longcat-2.5-preview-free default - pass "effective-model ancestor ordering uses no command stock macOS lacks" -} - test_opencode_secondmate_config_model_uses_live_catalog() { local rec id sm out status launch id=profile-opencode-secondmate-config-z7c @@ -1668,7 +860,7 @@ test_opencode_secondmate_config_model_uses_live_catalog() { expect_code 0 "$status" "configured OpenCode secondmate model should pass when listed" assert_meta_profile "$HOME_DIR/state/$id.meta" opencode opencode-go/space-bunny-free default launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "'$FAKEBIN_DIR/opencode' --model 'opencode-go/space-bunny-free' --prompt" \ + assert_contains "$launch" "opencode --model 'opencode-go/space-bunny-free' --prompt" \ "configured OpenCode secondmate model was not launched" pass "configured OpenCode secondmate models are checked and launched" } @@ -2529,25 +1721,6 @@ test_opencode_refuses_unreadable_live_catalog test_opencode_refuses_paid_catalog_model test_opencode_refuses_when_free_pricing_metadata_is_unavailable test_opencode_catalog_probe_uses_no_provider_argument -test_opencode_omitted_model_resolves_validated_project_model -test_opencode_default_model_token_resolves_like_an_omitted_one -test_opencode_omitted_model_honors_documented_config_precedence -test_opencode_primary_agent_model_does_not_override_the_session_model -test_opencode_omitted_model_refuses_when_no_effective_model_exists -test_opencode_omitted_model_refuses_when_config_sources_are_ambiguous -test_opencode_custom_primary_agent_without_a_model_inherits_the_session_model -test_opencode_omitted_model_refuses_paid_and_unavailable_effective_models -test_opencode_omitted_model_refuses_when_the_config_inventory_is_unusable -test_opencode_raw_launch_cannot_bypass_a_different_model -test_opencode_raw_launch_classification_reads_the_executable_not_its_arguments -test_opencode_validates_the_selected_variant_not_just_the_base_model -test_opencode_v1_resolved_debug_config_object_is_supported -test_opencode_v1_map_form_variants_are_read_and_disabled_ones_excluded -test_opencode_custom_config_environment_overrides_fail_closed -test_opencode_probe_uses_launch_environment_and_bounded_debug_calls -test_opencode_probe_enforces_the_catalog_deadline -test_opencode_explicit_model_is_validated_in_the_worker_worktree -test_opencode_effective_model_resolves_without_nonstock_tools test_opencode_secondmate_config_model_uses_live_catalog test_opencode_secondmate_config_refuses_model_absent_from_live_catalog test_native_effort_validator_keeps_axes_separate