* fix(#2486): runtime-branch the settings worktrees question + W020 health diagnostic On non-Claude runtimes /gsd:settings offered "Yes (Recommended)" for worktree isolation and persisted workflow.use_worktrees: true — the exact value the execution workflows fail closed on (#1521 guards). Branch the question on the same stamped config-get runtime read the guards use: Claude keeps the unchanged question; non-Claude offers only "No (Recommended)" / "Leave unchanged", never persists true, and warns when the config carries an inherited explicit true. /gsd:health gains W020, surfacing such a config with the guards' own predicate before execution-time failure. Docs state the runtime-conditional default. Fixes #2486 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(#2486): add changeset for PR #2531 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#2486): reassign the health worktrees check W020 -> W024 (verify.cts namespace collision) The workflow-level check collided with the live W020 (git-worktree-list health) emitted by cmdValidateHealth in src/verify.cts — invisible from health.md's error_codes table, which stops at W019 and under-represents the real namespace (W010-W017, W020-W023 all live). W024 verified free. Adds a regression test pinning the chosen code against src/verify.cts so a future assignment cannot silently collide, a table note naming the namespace owner, and the changeset body reworded to house style. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#2486): pre-select the recommended repair in the broken-inheritance case Review round 2: at settings.md:142 the pre-selection rule left "Leave unchanged" as the default when the config carried an explicit non-false use_worktrees — the exact broken state the adjacent notice warns about, so accepting the default kept a config that fails closed at execution time. "Leave unchanged" is now the default only when the key is absent (nothing to repair); explicit false AND explicit non-false both pre-select "No (Recommended)", aligning the default, the label, and the notice. Pinned by two source-contract assertions in the #2486 regression block. Goldens (settings.md hash x19) + size baseline regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#2486): gate the worktrees question on dispatch.isolation, not the runtime name Review round 2: #2584 Phase 3 replaced the runtime-name test with a declared `dispatch.isolation` capability, invalidating this PR's premise. cursor declares harness-worktree and codex/opencode/kimi/ kimi-code declare orchestrator-worktree, so a `RUNTIME != claude` gate blocked a supported configuration on five runtimes and false-warned in health. - settings.md + health.md read `query dispatch-isolation` and branch on `ISOLATION = none`; the runtime-name read is gone from both, and the capability read needs no per-runtime stamping (it fail-closes unknown/ undocumented internally) - all "Claude Code-only primitive" prose rewritten, including the two gates the shell-syntax check missed (config-key list, JSON schema comment) - W024 reconciled across health.md + CONFIGURATION.md + planning-config.md (docs still said W020, which collides with a verify.cts code) - health.md error-codes table fixed: the namespace note no longer sits between rows orphaning I001 - the asymmetry note for the two workflows #2584 has not migrated (quick.md, diagnose-issues.md) is enforced by a set-equality test with a self-check table, so it cannot go stale in either direction Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(#2486): restore the Executor isolation section clobbered by #2661 `46ba02ac` (feat(#2630), the current next tip) reverted docs/CONFIGURATION.md to a pre-#2584 state: it restored the old "Non-Claude note" wording on the workflow.use_worktrees row and deleted the whole "Executor isolation per runtime" section. The change is unrelated to that PR's phase-estimation feature and looks like a stale-copy edit. This PR's use_worktrees row links to #executor-isolation-per-runtime, so the deletion leaves a dangling anchor. Restored byte-for-byte froma40ee8a5(the text #2584 Phase 3 originally shipped). No link/anchor checker exists in scripts/ or tests/, so CI would not have caught the dead link. The reverted row wording is outside this PR's scope and is still wrong on next; reported upstream as #2668. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(#2486): acknowledge the emitted size growth for settings.md and health.md The #2723 differential attribution check (epic #2719 Phase 3) landed on next after this branch opened and gates emitted-file growth behind a committed acknowledgment. Both grown files are attributable to source this PR changes; the ack names them and says why, per ADR-2719 §3. Verified load-bearing: removing tests/emitted-drift-ack.json reproduces the same failure; restoring it passes 59/59. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#2486): reword the W024 remediation so it names no bare gsd-tools call The W024 warning told the user to run `gsd-tools query config-set`, a command-position bare invocation that fails with "command not found" on a shim-only install (#2751) — the diagnostic sent the user into a second, more confusing error than the one it reported. The remediation now names only `/gsd:settings` and the config key itself, both of which work on every install layout, and the #2751 command-position gate goes green. Fixes #2486 * fix(#2486): drop the Known-asymmetry note and its guard test — #2728 migrates both workflows Review Major 2: once #2728 lands, the note describes a gap that no longer exists and instructs maintainers not to do the thing that was just done — with the set-equality guard test pinning the stale prose green. This PR now depends on #2728 (declared in the PR body), so the note and its guard go. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(#2486): make the #2728 merge order enforceable instead of advisory settings.md recommends and persists `workflow.use_worktrees: true` for every runtime whose declared dispatch.isolation is not `none` — cursor (harness-worktree) and codex/opencode/kimi/kimi-code (orchestrator-worktree). quick.md and diagnose-issues.md consume that value at dispatch time and, while they still gate on the runtime NAME, FATAL for all five. Merged first, this PR reintroduces #2486's own shape for the exact runtimes it exists to help — on the path it now labels "Recommended". The dependency was stated only as prose in the PR body. A merge-order note is not a gate: an automated batch merge never reads it. This adds the check that makes the ordering structural — red while either sibling is still name-gated, green the moment #2728 lands. The predicate matches a RUNTIME-vs-"claude" comparison, not the legitimate runtime-identity read, and accepts either the inline canonical dispatch-isolation read or a reference to dispatch-isolation-gate.md, which is the shape #2728 gives quick.md. Verified both directions against real sources: red against the current workflows, green against #2728's. This also closes W024's coverage window. W024 fires only when ISOLATION is `none`, so it is structurally blind to these five runtimes — /gsd:health would report healthy right up until quick.md FATALs. W024 cannot see the hazard, so the hazard is prevented by making the unsafe ordering unmergeable rather than by warning after the fact. Separately, the W-code namespace-collision test now declares its source read instead of passing lint silently: verify.cts emits its codes as inline string literals across ~25 addIssue() calls and exports no enumerable registry, so there is nothing to require() and assert against. The gap, and what it costs, are stated in the test. * docs(#2486): use the hyphen command form for /gsd-health in CONFIGURATION.md docs/ is never passed through the install-time slash-form converters, so the colon form names a command no runtime registers. Clears the sole lint-docs-command-form violation attributable to this PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#2486): resolve isolation via new side-effect-free inspect-dispatch-isolation query; scope the settings change to isolation-none runtimes Review round 4: - B1: /gsd:health and /gsd:settings no longer call the recording dispatch-isolation query — on current next it persists the resolved decision to the executor-isolation sentinel as an unconditional #3045 side effect, letting a read-only diagnostic hard-block executor dispatch for the sentinel's lifetime across sessions. Both surfaces now use inspect-dispatch-isolation, a new read-only verb sharing the exact resolution implementation (extracted resolveDispatchIsolationDecision) with zero writes. Behavioral tests pin: no sentinel write, per-runtime parity with the recording verb, recording knobs ignored, --json shape. - B2: the #2728 merge-order interlock test is deleted — a repo test cannot sequence merges; it only made this PR unmergeable on its own schedule. The settings behavior change is scoped entirely to the ISOLATION=none branch, which needs nothing from #2728; the != none path is base behavior unchanged. - M1: the W024-vs-verify.cts namespace test (an admitted source-grep) is deleted per RULESET.TESTS.delete-bad-tests, without a standing exemption; the namespace claim lives as guidance in health.md. - M2: remaining allow-test-rule exemptions re-derived into documented categories (source-text-is-the-product, integration-test-input) with issue refs per ADR-456. - Minor: settings.md's current-value read drops the stampable --default/fallback so key-absence stays distinguishable from an explicit false on non-Claude emits (the pre-selection rule depends on the tri-state); docs now present inspect-dispatch-isolation as the inspection command and name dispatch-isolation as the recording resolver. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#2486): put the install-marker rung in the canonical runtime resolver Round-7 review (independent cross-AI pass over the whole PR). BLOCKER — the gate did not fire in the default case. `resolveRuntime` stopped at GSD_RUNTIME > config.runtime > 'claude', and `config-new-project` writes NO `runtime` key — so on a real non-Claude install every consumer believed it was on Claude: isolation reported `harness-worktree`, /gsd:settings still offered "Yes (Recommended)" and W024 stayed silent. That is #2486's own symptom, surviving the fix meant to remove it. Verified end-to-end on a real `--qwen` install with a runtime-neutral config and GSD_RUNTIME unset: pre-fix `harness-worktree`, fixed `none`. The rung lives in `resolveRuntime` (src/runtime-slash.cts), the ONE canonical resolver, not in the isolation call site. A per-consumer fix forks precedence: `inspect-dispatch-isolation` would answer `cursor` while `dispatch-should-flatten` and `resolve-dispatch-type` still answered `claude` for the same install. All three now agree. `readInstallRuntimeMarker`, its cache and its test seams MOVED from model-resolver.cts to runtime-slash.cts, with model-resolver re-exporting the seams — one marker read and one cache, not two that drift. Deliberately NOT `resolveActiveRuntime`/`loadConfig`: an intermediate revision of this fix routed through `loadConfig`, which normalizes and rewrites legacy keys back to disk. That gave `inspect-dispatch-isolation` — the verb whose entire purpose is being side-effect-free — a write side effect, which is the defect the verb exists to avoid. `resolveRuntime` reads .planning/config.json directly. Re-verified: the inspect query against a real install creates no `.gsd/`. Tests, both of which were too weak in the first attempt and are now fail-first proven: - The W024 behavioral stub returned success-with-empty-output for an absent key. Real `config-get` EXITS NON-ZERO, and the `|| echo "true"` fallback only triggers on failure — so reintroducing the fallback would have passed. The stub now returns 1 for the absent case. - The marker regression asserted on the exported helper, so reverting gsd-tools.cjs to a marker-blind resolver still passed. It now drives a real `--qwen` install through the shipped `inspect-dispatch-isolation` query. (GSD_TEST_MODE must be cleared for that child, or install.js no-ops while still exiting 0 — a green test over an install that wrote nothing.) Spawned through `installSpawnEnv()` so ambient GSD_HOME cannot leak in. Also: the three PR-added `try/finally` test bodies converted to `t.after()` per CONTRIBUTING; CONTEXT.md's `worktree create` UNCONSUMED claim corrected (Phase 3 calls it in executor-isolation-dispatch.md); docs/CONFIGURATION.md now states the current non-Claude `use_worktrees` default rather than describing capability-scoped stamping that lands with #2652; resolver precedence comments updated to name the marker rung. Refs #2486 Refs #2668 * fix(#2486): split the marker rung out; make the use_worktrees doc row order-independent Round-8 review. trek-e's Blocker was procedural — commit 10fba7b8 moved the per-install .gsd-runtime marker rung into the canonical resolver, a large blast-radius change that arrived undisclosed and unreviewed. They offered two remedies; taking the second: SPLIT IT OUT. src/runtime-slash.cts and src/model-resolver.cts are reverted to their next state and the marker regression test is removed. What remains is what this PR was filed for: the settings.md / health.md / gsd-tools.cjs isolation query, W024, and the #2668 docs restoration. COST, stated plainly rather than buried: without that rung this PR's gate resolves 'claude' on a non-Claude install whose project config carries no `runtime` key — which is every config config-new-project writes. On those installs W024 stays quiet and /gsd-settings still offers Worktrees. The gate is correct whenever the runtime IS resolvable (GSD_RUNTIME set, or an explicit config.runtime). That is why `Fixes #2486` is already downgraded to `Refs` — #2486 must not close until the residual lands. A KNOWN LIMITATION comment at the resolver call site names #2395 so this does not read as an oversight. The rung itself belongs to #2395, which reports this exact defect and was closed by #2446 — a PR that touched only bin/install.js and fixtures and never runtime-slash.cts, so it persisted the identity into ~/.gsd/defaults.json, a tier resolveRuntime does not read. Evidence and a reopen request are posted there. That same tier is #2566's B1. DOCS — the reviewer flagged that this row and #2728's are order-dependent: whichever merges second falsifies the other. Removed the dependency instead of picking an order. The "Current default … until #2652" paragraph is now a plain troubleshooting note, true before and after #2728 lands. CONFLICT — one hunk in CONTEXT.md: next added the #2596 scope-conformance interface to the same Worktree-Safety paragraph where this PR corrected the `worktree create` UNCONSUMED claim. Resolved keeping both. Verified: lint:ci green. Full suite clean apart from the pre-existing #1160 installed-runtime capability surface. (emitted-attribution also failed until the fork's stale next was fast-forwarded — it defaults to origin/next, which was 131 commits behind; 175/175 against the current base.) Refs #2486 Refs #2668 * fix(#2486): address round-9 review — distinguish "cannot resolve" from "declares none", reject recording-only args on the read verb Codex review of the whole PR, five findings, all verified against source first. Major 3 (the one real defect). Both surfaces read isolation as `ISOLATION=$(… || echo "none")`, so a resolver failure became indistinguishable from a genuine `dispatch.isolation: none` declaration — and W024's text then asserts the latter, telling a user their runtime declares no executor-isolation primitive when GSD simply could not find out. health.md and settings.md now capture the raw value and track ISOLATION_RESOLVED, the same shape references/dispatch-isolation-gate.md already uses (#2652 review). W024 gained a second message for the unresolved case; settings still fails closed there — it must never persist a `true` it cannot justify — but reports what happened rather than a verdict it never reached. Major 4. `dispatch-isolation` applies --force-isolation AFTER the shared resolver returns; inspection accepted the flag and silently ignored it, so the same argv yielded 'none' from one verb and the declared capability from the other. inspect-dispatch-isolation now rejects --force-isolation/--phase/--plan as usage errors. Verified by mutation: disabling the guard reds exactly the three new rejection tests. Major 1. settings.md claimed this flow and the execution guards "always reach the same verdict". False while quick.md still gates on the runtime name: an orchestrator-worktree host can be offered a `true` that /gsd:quick rejects as fatal, W024 silent because isolation is not none. Scoped the claim to the capability gate, named #2728 as the conversion, and added the same caveat to the CONFIGURATION.md use_worktrees row. Not a behavior change — the `!= none` branch is untouched base behavior. Major 2. "side-effect-free" overstated it: every gsd-tools invocation runs the shared bootstrap, and getActiveWorkstream unlinks a stale workstream pointer. That is pre-existing, verb-independent and cannot block a dispatch; writing the sentinel can. Renamed the claim to "sentinel-free" everywhere and documented precisely what is and is not asserted. Minor 5. The JSON parity test asserted against a handwritten key list and never invoked the recording verb, so the two could diverge and stay green. It now runs `dispatch-isolation --json` in a separate project dir and deep-compares, with a control asserting that verb DID write a sentinel. Added the orchestrator exec branch (--cwd-target) the registry parity test never covered. runtime-converters.test.cjs pinned the old `|| echo "none"` line as canonical — that literal was the Major 3 defect. Repinned as two invariants (the raw read is present; the collapsing fallback is absent) plus an ISOLATION_RESOLVED requirement, so reformatting does not fail the test but a semantic regression does. settings.md sits at 40777 bytes against the 40960 DEFAULT hard cap. The round-9 prose was compressed to fit rather than raising the cap. Validated: lint:ci clean; full suite green except the two failures that reproduce identically on pristine next @33fca50d(#1160 _resolveManifest, and the #3053 quick_id tests, which compare a local-time expectation against a TZ=UTC child and so only pass on a UTC host). * fix(#2486): second review pass — drop a dangling reference, correct the whole unresolved branch, pin the branch behaviorally Codex re-review of the full PR after the first round-9 pass. It confirmed Majors 1, 2 and 4 and Minor 5 fixed, and found seven more. All verified against source. Minor 5 was mine and the worst of them: settings.md and health.md pointed at `gsd-core/references/dispatch-isolation-gate.md`, which exists only on the #2728 branch — not in this PR and not on the merge-base. Merging this alone would have shipped three dangling canonical-source references. Removed; the surrounding text now stands on its own. Major 2. The unresolved branch corrected only the two option descriptions. The pre-selection rationale still claimed an absent key already resolves to false on this runtime, and the explicit-true notice still stated the runtime declares no primitive — both capability verdicts that were never reached. The substitution rule now covers every place in that branch that asserts one. Major 3. The repinned invariants could not catch the mutation they existed to catch: flipping the shipped block's ISOLATION_RESOLVED=true to false left all three green, since they only assert the raw read is present, the token appears, and the collapsing fallback is gone. Added a behavioral test that drives the shipped W024 block twice — resolver answering vs resolver exiting non-zero — and asserts the two emit different text, that the unresolved branch says "could not resolve", and that it does NOT claim the runtime has no primitive. Verified: the true->false mutation now reds it. Major 1. The verb fail-closes an unknown or undeclared runtime to `none` and exits 0, so ISOLATION_RESOLVED=true means "the query answered", not "the runtime published a declaration" — the shell cannot see an internal fallback. Exposing provenance is an API change and out of scope here, so W024's resolved-case text is instead written to be true of every path that reaches it ("no usable executor-isolation primitive — declared or fail-closed from an unknown value"), and both workflows state the limit of the signal explicitly. Minor 4. The rejection tests regex-matched human prose, so swapping ERROR_REASON.USAGE for UNKNOWN would have kept them green while breaking machine consumers. They now pass --json-errors and assert reason === 'usage' on the parsed envelope. Minor 6. CONTEXT.md still described the verb as side-effect-free. Now sentinel-free, consistent with the router comment and the other two surfaces. Minor 7. 183 bytes of headroom under the 40960 hard cap was called unacceptable, and it was — a routine 184-byte edit would have failed CI. Compressed this PR's own settings.md prose (the #2486 block was carrying ~7.1KB of rationale) to 40499 bytes, 458 free. Two phrases other tests pin verbatim were restored after the first compression pass reworded them. Validated: lint:ci clean; full suite green except the two failures that reproduce identically on pristine next @33fca50d. * fix(#2486): third review pass — placeholder table replaces the fragile substitution rule Codex round 3 blocked the push on two Majors. Both verified and real. Major 2 was the substantive one and my error. The round-2 fix told the model to find and replace the string "this runtime declares no executor-isolation primitive (dispatch.isolation: none)" — text that does not appear anywhere in the branch. The first option description has no parenthetical, the second asserts "absent, it already resolves to false on this runtime" (not covered at all), and the pre-selection rationale and notice carried three more unsupported assertions. A model following the instruction literally would have found no match and changed nothing. Replaced with three named placeholders — {FINDING}, {CONSEQUENCE}, {ABSENCE} — and a two-column table giving each one's resolved and unresolved wording. Every assertion in the branch now flows from the table, so none of them can outrun what was actually established, and there is no string-matching to get wrong. It is also shorter than the prose it replaced. Major 1: the resolver catches thrown errors and returns none successfully, a path the round-2 wording ("declared, or unknown/undeclared") did not cover. Both surfaces now say "declared as none, or fail-closed because the capability could not be determined", which is true of the thrown path too, and both state that an internal resolution error is among the things the verb fail-closes. Still not provenance — the verb cannot distinguish these for the caller — but no longer a claim the code contradicts. Minor 3: the behavioral test asserted only that the two branches differ and that the resolved one omits "could not resolve", so arbitrary resolved text stayed green. Now pins what it must positively say: the capability finding, the fail-closed consequence, the offending key, and (both branches) the repair command. Minor 4: two stale artifacts the round-2 sweep missed — a test comment still citing the #2728-only references/dispatch-isolation-gate.md, and the emitted drift ack still describing inspection as side-effect-free. Also aligned the two remaining router comments to sentinel-free. settings.md 40420 bytes, 540 free under the cap. Validated: lint:ci clean; full suite green except the two failures that reproduce identically on pristine next @33fca50d. * fix(#2486): fourth review pass — stop recommending a repair whose effect depends on the emit Codex round 4, one Major and three Minors. The Major is a real hole and worth the round. W024 advised "remove the key from .planning/config.json so the runtime default (false) applies". That default is not false everywhere: execute-phase.md:102, quick.md:155 and diagnose-issues.md:62 all read the key as `|| echo "true"`, and only `_stampNonClaudeRuntimeDefaults` rewrites them to `--default false` on a non-Claude emit. So on an un-stamped emit the advised repair leaves use_worktrees resolving to TRUE against isolation none — exactly the state W024 exists to flag. Reachable in the round-3 gap: a caught internal resolver error returns none with rc=0, so ISOLATION_RESOLVED is true and the confident branch fires on a host whose emit was never stamped. Fixed by removing the dependence rather than the symptom: both surfaces now say to set workflow.use_worktrees false explicitly, and say why deleting the key is not equivalent. The {ABSENCE} placeholder no longer claims absence resolves to false unconditionally — it is scoped to an emit that stamped that default. Minor: the notice and W024 hardcoded .planning/config.json, which is the wrong file in an active workstream (settings itself resolves $GSD_CONFIG_PATH correctly). Both now say "the project config", naming the workstream case. Minor: the new repair pin required the literal /gsd:settings, a form documented as no longer routable. Relaxed to accept the canonical slash and $-prefixed forms so a correct rewording cannot fail the test. Nit: two test descriptions still said side-effect-free. Not fixed, deliberately: the verb still cannot tell a caught resolver error from a declared none, so ISOLATION_RESOLVED remains a signal about the CALL, not the declaration. Both workflows now state that limit outright. Exposing provenance is a change to the query contract that belongs with the runtime-resolution work in #2395, not here. Validated: lint:ci clean; full suite green except the two failures that reproduce identically on pristine next @33fca50d. The only change after that suite run was removing a redundant regex escape flagged by eslint; that test was re-run individually and eslint is clean. * fix(#2486): fifth review pass — stop recommending "leave it absent" as the safe default Codex round 5, one Major: the round-4 fix corrected the {ABSENCE} wording but left the pre-selection rule built on the assumption it had just falsified. An absent key still pre-selected "Leave unchanged" on the rationale that there was nothing to repair. Absence resolves to false only where the emit stamped that default; on an un-stamped emit it reads as true, so accepting the recommended default could preserve the exact isolation-none-plus-true state this branch exists to prevent. The isolation-none branch now pre-selects "No (Recommended)" in every case, including an absent key. Writing an explicit false is correct under either emit; "Leave unchanged" stays available for the deliberate shared-config case but is never the default. The test that pinned the old rule pinned a falsified assumption, so it now pins the new one and asserts the old sentence is gone. Also tightened the round-4 repair regex, which had been relaxed far enough to accept `$gsd:settings` and `/gsd:settings-bogus`. It now matches only the canonical `/gsd-settings`, `/gsd:settings` and `$gsd-settings` forms. settings.md 40685 bytes, 275 free. Validated: lint:ci clean; full suite green except the two failures that reproduce identically on pristine next @33fca50d. * fix(#2486): make the inspection exemption block-scoped, and drop the last pre-#2728 claim Codex review of the conflict resolution: one Major, two Minors, all real. Major. The inspection-surface exemption I added to #2728's "every dispatch-site degrade block re-records" guard was FILE-wide. Codex probed it by adding an unrecorded executor block to health.md: the exemption assertions still passed, the whole file was skipped, and the offender went unreported. That is the same hole the hand-listed revision of this guard had — a promise of "every dispatch site" with a silent carve-out. Now scoped per block. A block earns the exemption only by resolving through `inspect-dispatch-isolation` AND carrying no dispatch primitive (`Agent(`, `harnessFlag`/`HARNESS_FLAG`, `isolation="worktree"`, or the recording verb). The file-level assertions remain as a precondition on top. Verified with Codex's own probe: the appended dispatch block is now reported at health.md:285, while the legitimate inspection block still passes. Minor. settings.md still carried the caveat saying `quick.md` gates on the runtime name and "#2728 converts that surface". #2728 has landed. Same obsolete premise already removed from docs/CONFIGURATION.md in the merge commit; this was the copy I missed. Removing it also returns 413 bytes, so settings.md now has 688 free under the 40960 cap rather than 275. Minor. Four scratch-directory prefixes still read `w024` after the renumber. Non-functional, but the whole point of the rename was that W024 now means someone else's warning. Validated: lint:ci clean; full suite green except the two failures that reproduce identically on pristine next. * fix(#2486): name the diagnostics' state INSPECTED_ISOLATION and delete the exemption Codex probed the per-block exemption and it was still escapable: DISPATCH_PRIMITIVE matched only `Agent(`, two harness-flag spellings, `isolation="worktree"` and the recording verb, so `Task(`, `spawn_agent(`, a `codex exec` process dispatch, and — unfixably by any regex — the repo's normal split shape (isolation resolved in one bash fence, dispatch in a later one) all still qualified as exempt. Took Codex's suggested fix, which is better than the one it replaces. The diagnostics now name their state `INSPECTED_ISOLATION` / `INSPECTED_RESOLVED` / `_INSPECTED_RAW` instead of reusing the dispatch-site names. #2728's guard scans for a literal `ISOLATION=none`, so health.md and settings.md fall outside it BY CONSTRUCTION and the exemption is deleted outright — no carve-out to escape, and a diagnostic that ever writes a real `ISOLATION=none` is caught like any other site. The name is also just more accurate: an inspection result is not a dispatch decision. Pinned so the reasoning cannot be lost: the #2486 suite now asserts neither diagnostic assigns the bare `ISOLATION` name, with the rationale in the comment. Verified by mutation — renaming back makes #2728's guard flag health.md:107 AND fails the new naming pin. Net effect on the guard's coverage is positive: before this PR it scanned two fewer files by exemption; now it scans everything. settings.md 40352 bytes, 608 free. Validated: lint:ci clean; full suite green except the two failures that reproduce identically on pristine next. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2614 lines
118 KiB
JavaScript
2614 lines
118 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Unit tests for host-integration.cjs (ADR-1239 Phase A).
|
|
* Pure, additive, no-I/O module — no temp dirs needed.
|
|
* Uses node:test + node:assert/strict.
|
|
* Requires the COMPILED artifact: ../gsd-core/bin/lib/host-integration.cjs
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const fc = require('fast-check');
|
|
|
|
const hi = require('../gsd-core/bin/lib/host-integration.cjs');
|
|
const {
|
|
PROTOCOL_VERSION,
|
|
HOST_INTEGRATION_AXES,
|
|
INTERFACE_POINTS,
|
|
PROFILE_BASELINES,
|
|
DEFAULT_ENGINE,
|
|
UNDOCUMENTED,
|
|
degradationFor,
|
|
profileOf,
|
|
negotiateHostCapabilities,
|
|
hookEventSurfaceFor,
|
|
HOOK_EVENT_SURFACES,
|
|
extensionEventSurfaceFor,
|
|
EXTENSION_EVENT_SURFACES,
|
|
resolveOrchestratorExec,
|
|
} = hi;
|
|
|
|
const {
|
|
_HOST_INTEGRATION_VOCAB,
|
|
validateCapability,
|
|
} = require('../gsd-core/bin/lib/capability-validator.cjs');
|
|
const { cleanup, readFileNormalized } = require('./helpers.cjs');
|
|
|
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
|
|
/**
|
|
* A real shipped runtime descriptor with its `dispatch.isolation` value
|
|
* stripped, so validator behavioral tests exercise the actual dispatch shape
|
|
* shipped for a host rather than a hand-modeled fixture (fixture-provenance,
|
|
* #2371 — mirrors `shippedDescriptorWithout` in tests/effort-surface-axis.test.cjs).
|
|
*/
|
|
function shippedClaudeCapabilityWithoutIsolation() {
|
|
const cap = JSON.parse(
|
|
fs.readFileSync(path.join(REPO_ROOT, 'capabilities', 'claude', 'capability.json'), 'utf8'),
|
|
);
|
|
delete cap.runtime.hostIntegration.dispatch.isolation;
|
|
return cap;
|
|
}
|
|
|
|
describe('hookEventSurfaceFor (MANAGED-hook dialect consumer — claude/gemini only)', () => {
|
|
test('returns the full Claude managed-hook surface for "claude"', () => {
|
|
const s = hookEventSurfaceFor('claude');
|
|
assert.ok(s && s.includes('PreToolUse') && s.includes('PostToolUse') && s.includes('Stop'));
|
|
});
|
|
test('returns the Gemini BeforeTool/AfterTool managed-hook surface for "gemini"', () => {
|
|
const s = hookEventSurfaceFor('gemini');
|
|
assert.ok(s && s.includes('BeforeTool') && s.includes('AfterTool'));
|
|
});
|
|
test('hookEvents is the MANAGED-hook dialect only — opencode-subset is NOT here (#1943)', () => {
|
|
assert.equal(hookEventSurfaceFor('opencode-subset'), null,
|
|
'opencode-subset is not a hookEvents value — it moved to the extensionEvents vocabulary');
|
|
});
|
|
test('returns null for unknown / missing / non-string dialect (fail-closed)', () => {
|
|
assert.equal(hookEventSurfaceFor('nope'), null);
|
|
assert.equal(hookEventSurfaceFor(undefined), null);
|
|
assert.equal(hookEventSurfaceFor(123), null);
|
|
});
|
|
test('HOOK_EVENT_SURFACES is frozen + covers exactly the 2 managed-hook dialects', () => {
|
|
assert.equal(Object.isFrozen(HOOK_EVENT_SURFACES), true);
|
|
assert.deepEqual(Object.keys(HOOK_EVENT_SURFACES).sort(), ['claude', 'gemini']);
|
|
});
|
|
});
|
|
|
|
describe('extensionEventSurfaceFor (extension-system event dialect — #1943)', () => {
|
|
test('opencode = OpenCode plugin event subset with NO workflow-phase events', () => {
|
|
const s = extensionEventSurfaceFor('opencode');
|
|
assert.ok(s, 'opencode must resolve (non-null) — it is a consumed extensionEvents value');
|
|
assert.ok(s.includes('experimental.session.compacting'));
|
|
assert.ok(s.includes('session.idle'));
|
|
assert.ok(s.includes('tool.execute.before') && s.includes('tool.execute.after'));
|
|
assert.ok(!s.some((e) => /plan:|verify:|ship:/.test(e)),
|
|
'opencode extension events include no workflow-phase events (engine owns phase sequencing)');
|
|
});
|
|
test('pi resolves (extension-system dialect)', () => {
|
|
const s = extensionEventSurfaceFor('pi');
|
|
assert.ok(Array.isArray(s), 'pi is a consumed extensionEvents value');
|
|
});
|
|
test('none = empty surface (host exposes no extension events; engine owns the bus)', () => {
|
|
assert.deepEqual(extensionEventSurfaceFor('none'), []);
|
|
});
|
|
test('returns null for unknown / missing / non-string dialect (fail-closed)', () => {
|
|
assert.equal(extensionEventSurfaceFor('opencode-subset'), null,
|
|
'the old opencode-subset name is gone — use extensionEventSurfaceFor("opencode")');
|
|
assert.equal(extensionEventSurfaceFor('nope'), null);
|
|
assert.equal(extensionEventSurfaceFor(undefined), null);
|
|
});
|
|
test('EXTENSION_EVENT_SURFACES is frozen + covers opencode/pi/hermes/kilo/none', () => {
|
|
assert.equal(Object.isFrozen(EXTENSION_EVENT_SURFACES), true);
|
|
assert.deepEqual(Object.keys(EXTENSION_EVENT_SURFACES).sort(), ['hermes', 'kilo', 'none', 'opencode', 'pi']);
|
|
});
|
|
test('kilo reuses the IDENTICAL event array as opencode (Kilo is an OpenCode fork, same bus — #2093)', () => {
|
|
const kiloSurface = extensionEventSurfaceFor('kilo');
|
|
const opencodeSurface = extensionEventSurfaceFor('opencode');
|
|
assert.ok(kiloSurface, 'kilo must resolve (non-null) — it is a consumed extensionEvents value');
|
|
assert.deepEqual(kiloSurface, opencodeSurface);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CONTRACT-PIN: constants and vocabulary
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('CONTRACT-PIN', () => {
|
|
test('PROTOCOL_VERSION === 1', () => {
|
|
assert.strictEqual(PROTOCOL_VERSION, 1);
|
|
});
|
|
|
|
test('HOST_INTEGRATION_AXES is frozen', () => {
|
|
assert.ok(Object.isFrozen(HOST_INTEGRATION_AXES), 'HOST_INTEGRATION_AXES must be frozen');
|
|
});
|
|
|
|
test('each axis sub-array is frozen', () => {
|
|
for (const [axis, arr] of Object.entries(HOST_INTEGRATION_AXES)) {
|
|
assert.ok(Object.isFrozen(arr), `HOST_INTEGRATION_AXES.${axis} must be frozen`);
|
|
}
|
|
});
|
|
|
|
test('embeddingMode values (sorted)', () => {
|
|
assert.deepStrictEqual(
|
|
[...HOST_INTEGRATION_AXES.embeddingMode].sort(),
|
|
['declarative', 'imperative'],
|
|
);
|
|
});
|
|
|
|
test('commandSurface values (sorted)', () => {
|
|
assert.deepStrictEqual(
|
|
[...HOST_INTEGRATION_AXES.commandSurface].sort(),
|
|
['palette', 'prose-only', 'slash-file', 'slash-programmatic', 'slash-toml'],
|
|
);
|
|
});
|
|
|
|
test('modelMode values (sorted)', () => {
|
|
assert.deepStrictEqual(
|
|
[...HOST_INTEGRATION_AXES.modelMode].sort(),
|
|
['active', 'passive'],
|
|
);
|
|
});
|
|
|
|
test('hookBus values (sorted)', () => {
|
|
assert.deepStrictEqual(
|
|
[...HOST_INTEGRATION_AXES.hookBus].sort(),
|
|
['engine', 'host', 'none'],
|
|
);
|
|
});
|
|
|
|
test('stateIO values (sorted)', () => {
|
|
assert.deepStrictEqual(
|
|
[...HOST_INTEGRATION_AXES.stateIO].sort(),
|
|
['filesystem', 'sandboxed-storage', 'session-log-append'],
|
|
);
|
|
});
|
|
|
|
test('transport values (sorted)', () => {
|
|
assert.deepStrictEqual(
|
|
[...HOST_INTEGRATION_AXES.transport].sort(),
|
|
['mcp', 'native-extension'],
|
|
);
|
|
});
|
|
|
|
test('runtime values (sorted) — 8 documented values', () => {
|
|
assert.deepStrictEqual(
|
|
[...HOST_INTEGRATION_AXES.runtime].sort(),
|
|
['bun', 'electron', 'go', 'node', 'other', 'python', 'rust', 'sandboxed-web'],
|
|
);
|
|
});
|
|
|
|
test('UNDOCUMENTED === "undocumented"', () => {
|
|
assert.equal(UNDOCUMENTED, 'undocumented');
|
|
});
|
|
|
|
test('subagentToolkit values (sorted)', () => {
|
|
assert.deepStrictEqual(
|
|
[...HOST_INTEGRATION_AXES.subagentToolkit].sort(),
|
|
['built-in-only', 'full', 'read-only'],
|
|
);
|
|
});
|
|
|
|
test('isolation values (sorted) — #2584 ADR-1239 Codex-binding amendment', () => {
|
|
assert.deepStrictEqual(
|
|
[...HOST_INTEGRATION_AXES.isolation].sort(),
|
|
['harness-worktree', 'none', 'orchestrator-worktree'],
|
|
);
|
|
});
|
|
|
|
test('isolation: "undocumented" is NOT a vocabulary member — it is the corpus sentinel', () => {
|
|
assert.ok(!HOST_INTEGRATION_AXES.isolation.includes('undocumented'));
|
|
});
|
|
|
|
test('INTERFACE_POINTS frozen and contains expected values', () => {
|
|
assert.ok(Object.isFrozen(INTERFACE_POINTS), 'INTERFACE_POINTS must be frozen');
|
|
const expected = ['command', 'dispatch', 'model', 'hooks', 'state', 'artifact'].sort();
|
|
assert.deepStrictEqual([...INTERFACE_POINTS].sort(), expected);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// degradationFor — happy path per enum value
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('degradationFor — happy path', () => {
|
|
test('command: slash-file → full', () => {
|
|
const r = degradationFor('command', { commandSurface: 'slash-file' });
|
|
assert.strictEqual(r.level, 'full');
|
|
assert.strictEqual(typeof r.fallback, 'string');
|
|
});
|
|
|
|
test('command: slash-programmatic → full', () => {
|
|
const r = degradationFor('command', { commandSurface: 'slash-programmatic' });
|
|
assert.strictEqual(r.level, 'full');
|
|
});
|
|
|
|
test('command: slash-toml → degraded', () => {
|
|
const r = degradationFor('command', { commandSurface: 'slash-toml' });
|
|
assert.strictEqual(r.level, 'degraded');
|
|
});
|
|
|
|
test('command: palette → degraded', () => {
|
|
const r = degradationFor('command', { commandSurface: 'palette' });
|
|
assert.strictEqual(r.level, 'degraded');
|
|
});
|
|
|
|
test('command: prose-only → absent', () => {
|
|
const r = degradationFor('command', { commandSurface: 'prose-only' });
|
|
assert.strictEqual(r.level, 'absent');
|
|
assert.ok(r.fallback.length > 0, 'fallback must be non-empty for prose-only');
|
|
});
|
|
|
|
test('model: active → full', () => {
|
|
const r = degradationFor('model', { modelMode: 'active' });
|
|
assert.strictEqual(r.level, 'full');
|
|
});
|
|
|
|
test('model: passive → degraded', () => {
|
|
const r = degradationFor('model', { modelMode: 'passive' });
|
|
assert.strictEqual(r.level, 'degraded');
|
|
});
|
|
|
|
test('hooks: host → full', () => {
|
|
const r = degradationFor('hooks', { hookBus: 'host' });
|
|
assert.strictEqual(r.level, 'full');
|
|
});
|
|
|
|
test('hooks: engine → degraded', () => {
|
|
const r = degradationFor('hooks', { hookBus: 'engine' });
|
|
assert.strictEqual(r.level, 'degraded');
|
|
});
|
|
|
|
test('hooks: none → absent', () => {
|
|
const r = degradationFor('hooks', { hookBus: 'none' });
|
|
assert.strictEqual(r.level, 'absent');
|
|
});
|
|
|
|
test('state: filesystem → full', () => {
|
|
const r = degradationFor('state', { stateIO: 'filesystem' });
|
|
assert.strictEqual(r.level, 'full');
|
|
});
|
|
|
|
test('state: sandboxed-storage → degraded', () => {
|
|
const r = degradationFor('state', { stateIO: 'sandboxed-storage' });
|
|
assert.strictEqual(r.level, 'degraded');
|
|
});
|
|
|
|
test('state: session-log-append → degraded', () => {
|
|
const r = degradationFor('state', { stateIO: 'session-log-append' });
|
|
assert.strictEqual(r.level, 'degraded');
|
|
});
|
|
|
|
test('artifact: slash-file → full', () => {
|
|
const r = degradationFor('artifact', { commandSurface: 'slash-file' });
|
|
assert.strictEqual(r.level, 'full');
|
|
});
|
|
|
|
test('artifact: slash-programmatic → full', () => {
|
|
const r = degradationFor('artifact', { commandSurface: 'slash-programmatic' });
|
|
assert.strictEqual(r.level, 'full');
|
|
});
|
|
|
|
test('artifact: slash-toml → degraded', () => {
|
|
const r = degradationFor('artifact', { commandSurface: 'slash-toml' });
|
|
assert.strictEqual(r.level, 'degraded');
|
|
});
|
|
|
|
test('artifact: prose-only → degraded', () => {
|
|
const r = degradationFor('artifact', { commandSurface: 'prose-only' });
|
|
assert.strictEqual(r.level, 'degraded');
|
|
});
|
|
|
|
test('artifact: palette → absent', () => {
|
|
const r = degradationFor('artifact', { commandSurface: 'palette' });
|
|
assert.strictEqual(r.level, 'absent');
|
|
});
|
|
|
|
// dispatch variants
|
|
test('dispatch: no namedDispatch → absent', () => {
|
|
const r = degradationFor('dispatch', { dispatch: { namedDispatch: false, nested: false, maxDepth: 0, background: false, subagentToolkit: 'full' } });
|
|
assert.strictEqual(r.level, 'absent');
|
|
});
|
|
|
|
test('dispatch: maxDepth===0 → absent', () => {
|
|
const r = degradationFor('dispatch', { dispatch: { namedDispatch: true, nested: true, maxDepth: 0, background: true, subagentToolkit: 'full' } });
|
|
assert.strictEqual(r.level, 'absent');
|
|
});
|
|
|
|
test('dispatch: unbounded (-1) nested → full', () => {
|
|
const r = degradationFor('dispatch', { dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full' } });
|
|
assert.strictEqual(r.level, 'full');
|
|
});
|
|
|
|
test('dispatch: nested maxDepth>=2 → full', () => {
|
|
const r = degradationFor('dispatch', { dispatch: { namedDispatch: true, nested: true, maxDepth: 2, background: true, subagentToolkit: 'full' } });
|
|
assert.strictEqual(r.level, 'full');
|
|
});
|
|
|
|
test('dispatch: full but subagentToolkit read-only → degraded', () => {
|
|
const r = degradationFor('dispatch', { dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'read-only' } });
|
|
assert.strictEqual(r.level, 'degraded');
|
|
});
|
|
|
|
test('dispatch: flat (maxDepth===1) → degraded', () => {
|
|
const r = degradationFor('dispatch', { dispatch: { namedDispatch: true, nested: false, maxDepth: 1, background: false, subagentToolkit: 'full' } });
|
|
assert.strictEqual(r.level, 'degraded');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// degradationFor — EVERY enum value returns a defined result with valid level
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('degradationFor — all enum values return valid level', () => {
|
|
const VALID_LEVELS = new Set(['full', 'degraded', 'absent']);
|
|
|
|
test('command — all commandSurface values', () => {
|
|
for (const v of HOST_INTEGRATION_AXES.commandSurface) {
|
|
const r = degradationFor('command', { commandSurface: v });
|
|
assert.ok(VALID_LEVELS.has(r.level), `command/${v}: level '${r.level}' invalid`);
|
|
assert.strictEqual(typeof r.fallback, 'string');
|
|
}
|
|
});
|
|
|
|
test('model — all modelMode values', () => {
|
|
for (const v of HOST_INTEGRATION_AXES.modelMode) {
|
|
const r = degradationFor('model', { modelMode: v });
|
|
assert.ok(VALID_LEVELS.has(r.level), `model/${v}: level '${r.level}' invalid`);
|
|
}
|
|
});
|
|
|
|
test('hooks — all hookBus values', () => {
|
|
for (const v of HOST_INTEGRATION_AXES.hookBus) {
|
|
const r = degradationFor('hooks', { hookBus: v });
|
|
assert.ok(VALID_LEVELS.has(r.level), `hooks/${v}: level '${r.level}' invalid`);
|
|
}
|
|
});
|
|
|
|
test('state — all stateIO values', () => {
|
|
for (const v of HOST_INTEGRATION_AXES.stateIO) {
|
|
const r = degradationFor('state', { stateIO: v });
|
|
assert.ok(VALID_LEVELS.has(r.level), `state/${v}: level '${r.level}' invalid`);
|
|
}
|
|
});
|
|
|
|
test('artifact — all commandSurface values', () => {
|
|
for (const v of HOST_INTEGRATION_AXES.commandSurface) {
|
|
const r = degradationFor('artifact', { commandSurface: v });
|
|
assert.ok(VALID_LEVELS.has(r.level), `artifact/${v}: level '${r.level}' invalid`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// degradationFor — unknown / missing axis → absent + unknown:true, never throws
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('degradationFor — unknown / missing axis', () => {
|
|
test('unknown commandSurface value for command → absent + unknown:true', () => {
|
|
const r = degradationFor('command', { commandSurface: 'zzz' });
|
|
assert.strictEqual(r.level, 'absent');
|
|
assert.strictEqual(r.unknown, true);
|
|
});
|
|
|
|
test('missing commandSurface for command → absent + unknown:true', () => {
|
|
const r = degradationFor('command', {});
|
|
assert.strictEqual(r.level, 'absent');
|
|
assert.strictEqual(r.unknown, true);
|
|
});
|
|
|
|
test('unknown modelMode → absent + unknown:true', () => {
|
|
const r = degradationFor('model', { modelMode: 'zzz' });
|
|
assert.strictEqual(r.level, 'absent');
|
|
assert.strictEqual(r.unknown, true);
|
|
});
|
|
|
|
test('missing hookBus for hooks → absent + unknown:true', () => {
|
|
const r = degradationFor('hooks', {});
|
|
assert.strictEqual(r.level, 'absent');
|
|
assert.strictEqual(r.unknown, true);
|
|
});
|
|
|
|
test('no throw on unknown axis value', () => {
|
|
assert.doesNotThrow(() => degradationFor('dispatch', { dispatch: 'not-an-object' }));
|
|
});
|
|
|
|
test('no throw on completely empty axes', () => {
|
|
for (const point of INTERFACE_POINTS) {
|
|
assert.doesNotThrow(() => degradationFor(point, {}));
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// profileOf
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('profileOf', () => {
|
|
test('profileOf(PROFILE_BASELINES["programmatic-cli"]) === "programmatic-cli"', () => {
|
|
assert.strictEqual(profileOf(PROFILE_BASELINES['programmatic-cli']), 'programmatic-cli');
|
|
});
|
|
|
|
test('profileOf(PROFILE_BASELINES["declarative-cli"]) === "declarative-cli"', () => {
|
|
assert.strictEqual(profileOf(PROFILE_BASELINES['declarative-cli']), 'declarative-cli');
|
|
});
|
|
|
|
test('profileOf(PROFILE_BASELINES["ide"]) === "ide"', () => {
|
|
assert.strictEqual(profileOf(PROFILE_BASELINES['ide']), 'ide');
|
|
});
|
|
|
|
test('imperative + sandboxed-web → ide', () => {
|
|
assert.strictEqual(
|
|
profileOf({ embeddingMode: 'imperative', runtime: 'sandboxed-web' }),
|
|
'ide',
|
|
);
|
|
});
|
|
|
|
test('imperative + node → programmatic-cli', () => {
|
|
assert.strictEqual(
|
|
profileOf({ embeddingMode: 'imperative', runtime: 'node' }),
|
|
'programmatic-cli',
|
|
);
|
|
});
|
|
|
|
test('declarative → declarative-cli', () => {
|
|
assert.strictEqual(
|
|
profileOf({ embeddingMode: 'declarative' }),
|
|
'declarative-cli',
|
|
);
|
|
});
|
|
|
|
test('empty axes → null', () => {
|
|
assert.strictEqual(profileOf({}), null);
|
|
});
|
|
|
|
test('PROFILE_BASELINES are frozen', () => {
|
|
assert.ok(Object.isFrozen(PROFILE_BASELINES), 'PROFILE_BASELINES must be frozen');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// negotiateHostCapabilities — HAPPY PATH
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('negotiateHostCapabilities — happy path', () => {
|
|
test('declarative-cli baseline → effective matches, no warnings, points.command.effectiveLevel===full', () => {
|
|
const baseline = PROFILE_BASELINES['declarative-cli'];
|
|
const result = negotiateHostCapabilities(baseline);
|
|
|
|
// No warnings
|
|
assert.deepStrictEqual(result.warnings, [], 'Expected no warnings for full declarative-cli baseline');
|
|
|
|
// Key points
|
|
assert.strictEqual(result.points.command.effectiveLevel, 'full');
|
|
assert.strictEqual(result.points.hooks.effectiveLevel, 'full');
|
|
assert.strictEqual(result.points.state.effectiveLevel, 'full');
|
|
|
|
// protocolVersion
|
|
assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION);
|
|
|
|
// effective axes match baseline (scalar)
|
|
assert.strictEqual(result.effective.embeddingMode, baseline.embeddingMode);
|
|
assert.strictEqual(result.effective.commandSurface, baseline.commandSurface);
|
|
assert.strictEqual(result.effective.modelMode, baseline.modelMode);
|
|
assert.strictEqual(result.effective.hookBus, baseline.hookBus);
|
|
assert.strictEqual(result.effective.stateIO, baseline.stateIO);
|
|
|
|
// effective dispatch has maxDepth resolved (declarative has maxDepth:1)
|
|
assert.strictEqual(result.effective.dispatch.maxDepth, 1);
|
|
assert.strictEqual(result.effective.dispatch.namedDispatch, true);
|
|
});
|
|
|
|
test('all INTERFACE_POINTS are present in result.points', () => {
|
|
const result = negotiateHostCapabilities(PROFILE_BASELINES['programmatic-cli']);
|
|
for (const point of INTERFACE_POINTS) {
|
|
assert.ok(point in result.points, `Missing point: ${point}`);
|
|
assert.ok(['full', 'degraded', 'absent'].includes(result.points[point].effectiveLevel),
|
|
`Invalid effectiveLevel for ${point}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// negotiateHostCapabilities — SECURITY / HOSTILE
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('negotiateHostCapabilities — security / hostile', () => {
|
|
test('(1) host declares future commandSurface at protocolVersion 99 → effective is KNOWN value, NOT the unknown one', () => {
|
|
const result = negotiateHostCapabilities({
|
|
...PROFILE_BASELINES['programmatic-cli'],
|
|
commandSurface: 'future-surface',
|
|
protocolVersion: 99,
|
|
});
|
|
// effective.commandSurface must be a KNOWN value
|
|
assert.ok(
|
|
HOST_INTEGRATION_AXES.commandSurface.includes(result.effective.commandSurface),
|
|
`effective.commandSurface '${result.effective.commandSurface}' is not in known vocabulary`,
|
|
);
|
|
assert.notStrictEqual(result.effective.commandSurface, 'future-surface',
|
|
'future-surface must NOT appear in effective');
|
|
// A warning mentioning protocolVersion
|
|
const warnText = result.warnings.join(' ');
|
|
assert.ok(warnText.includes('protocolVersion') || warnText.includes('unknown'),
|
|
`Expected a warning about protocolVersion or unknown value; got: ${warnText}`);
|
|
});
|
|
|
|
test('(2) host modelMode active but engine passive → effective.modelMode === passive', () => {
|
|
const restrictedEngine = {
|
|
...DEFAULT_ENGINE,
|
|
axes: { ...DEFAULT_ENGINE.axes, modelMode: 'passive' },
|
|
};
|
|
const result = negotiateHostCapabilities(
|
|
{ ...PROFILE_BASELINES['programmatic-cli'], modelMode: 'active' },
|
|
restrictedEngine,
|
|
);
|
|
assert.strictEqual(result.effective.modelMode, 'passive');
|
|
});
|
|
|
|
test('(3) host dispatch maxDepth:5 nested:true but engine dispatch maxDepth:1 → effective.dispatch.maxDepth===1', () => {
|
|
const restrictedEngine = {
|
|
...DEFAULT_ENGINE,
|
|
axes: {
|
|
...DEFAULT_ENGINE.axes,
|
|
dispatch: { ...DEFAULT_ENGINE.axes.dispatch, maxDepth: 1, nested: false },
|
|
},
|
|
};
|
|
const result = negotiateHostCapabilities(
|
|
{
|
|
...PROFILE_BASELINES['programmatic-cli'],
|
|
dispatch: { namedDispatch: true, nested: true, maxDepth: 5, background: true, subagentToolkit: 'full' },
|
|
},
|
|
restrictedEngine,
|
|
);
|
|
assert.strictEqual(result.effective.dispatch.maxDepth, 1);
|
|
});
|
|
|
|
test('(4) host omits hookBus → effective.hookBus is safe default + warning present', () => {
|
|
const hostWithoutHookBus = { ...PROFILE_BASELINES['declarative-cli'] };
|
|
delete hostWithoutHookBus.hookBus;
|
|
|
|
const result = negotiateHostCapabilities(hostWithoutHookBus);
|
|
// effective hookBus must be a known value
|
|
assert.ok(
|
|
HOST_INTEGRATION_AXES.hookBus.includes(result.effective.hookBus),
|
|
`effective.hookBus '${result.effective.hookBus}' is not known`,
|
|
);
|
|
// points.hooks must be present
|
|
assert.ok('hooks' in result.points, 'points.hooks must be present');
|
|
// a warning mentioning hookBus
|
|
const warnText = result.warnings.join(' ');
|
|
assert.ok(warnText.includes('hookBus'), `Expected warning about hookBus; got: ${warnText}`);
|
|
});
|
|
|
|
test('(5) INVARIANT: every effective scalar ∈ engine.known[axis] for hostile hosts', () => {
|
|
const hostileHosts = [
|
|
// All unknown values
|
|
{
|
|
embeddingMode: 'future-mode',
|
|
commandSurface: 'future-surface',
|
|
modelMode: 'quantum',
|
|
hookBus: 'blockchain',
|
|
stateIO: 'cloud-magic',
|
|
transport: 'telepathy',
|
|
runtime: 'wasm',
|
|
protocolVersion: 999,
|
|
},
|
|
// Mix of known and unknown
|
|
{
|
|
embeddingMode: 'imperative',
|
|
commandSurface: 'palette',
|
|
modelMode: 'active',
|
|
hookBus: 'none',
|
|
stateIO: 'unknown-future',
|
|
transport: 'mcp',
|
|
runtime: 'sandboxed-web',
|
|
},
|
|
// Empty host
|
|
{},
|
|
// Only dispatch with extreme values
|
|
{
|
|
dispatch: { namedDispatch: true, nested: true, maxDepth: 9999, background: true, subagentToolkit: 'full' },
|
|
},
|
|
];
|
|
|
|
const scalarAxes = ['embeddingMode', 'commandSurface', 'modelMode', 'hookBus', 'stateIO', 'transport', 'runtime'];
|
|
|
|
for (const host of hostileHosts) {
|
|
const result = negotiateHostCapabilities(host);
|
|
for (const axis of scalarAxes) {
|
|
const effectiveVal = result.effective[axis];
|
|
assert.ok(
|
|
HOST_INTEGRATION_AXES[axis].includes(effectiveVal),
|
|
`INVARIANT VIOLATION: effective.${axis}='${effectiveVal}' is NOT in known vocabulary for host=${JSON.stringify(host)}`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('host protocolVersion > engine → warning mentioning protocolVersion', () => {
|
|
const result = negotiateHostCapabilities({
|
|
...PROFILE_BASELINES['declarative-cli'],
|
|
protocolVersion: 99,
|
|
});
|
|
const warnText = result.warnings.join(' ');
|
|
assert.ok(warnText.includes('protocolVersion'), `Expected protocolVersion warning; got: ${warnText}`);
|
|
assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// INDEPENDENCE: mutation safety
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('independence / mutation safety', () => {
|
|
test('mutating returned result does not affect second call', () => {
|
|
const host = PROFILE_BASELINES['declarative-cli'];
|
|
const r1 = negotiateHostCapabilities(host);
|
|
// Mutate r1
|
|
r1.warnings.push('injected');
|
|
r1.effective.modelMode = 'active';
|
|
r1.points.command.effectiveLevel = 'absent';
|
|
|
|
const r2 = negotiateHostCapabilities(host);
|
|
// r2 must not be affected
|
|
assert.deepStrictEqual(r2.warnings, [], 'r2.warnings must not include injected warning');
|
|
assert.strictEqual(r2.effective.modelMode, host.modelMode, 'r2.effective.modelMode must be original value');
|
|
assert.strictEqual(r2.points.command.effectiveLevel, 'full', 'r2.points.command.effectiveLevel must be full');
|
|
});
|
|
|
|
test('all exports are present on the module', () => {
|
|
const expectedExports = [
|
|
'PROTOCOL_VERSION', 'HOST_INTEGRATION_AXES', 'INTERFACE_POINTS',
|
|
'PROFILE_BASELINES', 'DEFAULT_ENGINE', 'UNDOCUMENTED',
|
|
'degradationFor', 'profileOf', 'negotiateHostCapabilities',
|
|
];
|
|
for (const exp of expectedExports) {
|
|
assert.ok(exp in hi, `Missing export: ${exp}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Decision 1: undocumented sentinel — fail-closed in negotiation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Decision 1: UNDOCUMENTED sentinel — fail-closed negotiation', () => {
|
|
test('negotiate with embeddingMode:"undocumented" → effective is safe default (documented value), NOT "undocumented"', () => {
|
|
const host = {
|
|
...PROFILE_BASELINES['declarative-cli'],
|
|
embeddingMode: 'undocumented',
|
|
};
|
|
const result = negotiateHostCapabilities(host);
|
|
// effective.embeddingMode must be a documented value, NOT 'undocumented'
|
|
assert.ok(
|
|
HOST_INTEGRATION_AXES.embeddingMode.includes(result.effective.embeddingMode),
|
|
`effective.embeddingMode must be a documented value; got '${result.effective.embeddingMode}'`,
|
|
);
|
|
assert.notStrictEqual(result.effective.embeddingMode, 'undocumented',
|
|
'effective.embeddingMode must not be "undocumented"');
|
|
// A warning mentioning "undocumented"
|
|
const warnText = result.warnings.join(' ');
|
|
assert.ok(warnText.includes('undocumented'),
|
|
`Expected a warning mentioning "undocumented"; got: ${warnText}`);
|
|
});
|
|
|
|
test('negotiate with dispatch fields all "undocumented" → fail-closed dispatch', () => {
|
|
const host = {
|
|
...PROFILE_BASELINES['programmatic-cli'],
|
|
dispatch: {
|
|
namedDispatch: 'undocumented',
|
|
nested: 'undocumented',
|
|
maxDepth: 'undocumented',
|
|
background: 'undocumented',
|
|
subagentToolkit: 'undocumented',
|
|
},
|
|
};
|
|
const result = negotiateHostCapabilities(host);
|
|
const d = result.effective.dispatch;
|
|
assert.strictEqual(d.namedDispatch, false, 'namedDispatch must be false when "undocumented"');
|
|
assert.strictEqual(d.nested, false, 'nested must be false when "undocumented"');
|
|
assert.strictEqual(d.background, false, 'background must be false when "undocumented"');
|
|
assert.strictEqual(d.subagentToolkit, 'read-only', 'subagentToolkit must be "read-only" when "undocumented"');
|
|
assert.strictEqual(d.maxDepth, 0, 'maxDepth must be 0 when "undocumented"');
|
|
// points.dispatch must be absent
|
|
assert.strictEqual(result.points.dispatch.effectiveLevel, 'absent',
|
|
'points.dispatch.effectiveLevel must be "absent" when dispatch is all undocumented');
|
|
});
|
|
|
|
test('degradationFor dispatch with namedDispatch:"undocumented" → level "absent"', () => {
|
|
const r = degradationFor('dispatch', {
|
|
dispatch: {
|
|
namedDispatch: 'undocumented',
|
|
nested: false,
|
|
maxDepth: 0,
|
|
background: false,
|
|
subagentToolkit: 'full',
|
|
},
|
|
});
|
|
assert.strictEqual(r.level, 'absent',
|
|
`degradationFor with namedDispatch:"undocumented" must return absent; got "${r.level}"`);
|
|
});
|
|
|
|
test('subagentToolkit "undocumented" (truthy string) fails closed to read-only', () => {
|
|
const host = {
|
|
...PROFILE_BASELINES['programmatic-cli'],
|
|
dispatch: {
|
|
namedDispatch: true,
|
|
nested: true,
|
|
maxDepth: -1,
|
|
background: true,
|
|
subagentToolkit: 'undocumented',
|
|
},
|
|
};
|
|
const result = negotiateHostCapabilities(host);
|
|
assert.strictEqual(result.effective.dispatch.subagentToolkit, 'read-only',
|
|
'subagentToolkit "undocumented" must degrade to "read-only"');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Decision 2: expanded runtime vocabulary (8 documented values)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Decision 2: expanded runtime vocabulary', () => {
|
|
const newRuntimes = ['python', 'go', 'rust', 'electron', 'other'];
|
|
|
|
for (const rt of newRuntimes) {
|
|
test(`negotiate with runtime:"${rt}" → effective.runtime === "${rt}" (no warn about unknown)`, () => {
|
|
const host = {
|
|
...PROFILE_BASELINES['programmatic-cli'],
|
|
runtime: rt,
|
|
};
|
|
const result = negotiateHostCapabilities(host);
|
|
assert.strictEqual(result.effective.runtime, rt,
|
|
`effective.runtime must be "${rt}"; got "${result.effective.runtime}"`);
|
|
// Must NOT have an unknown-value warning for this runtime
|
|
const runtimeWarnings = result.warnings.filter((w) => w.includes('runtime') && w.includes('not trusted'));
|
|
assert.strictEqual(runtimeWarnings.length, 0,
|
|
`Must not warn about unknown runtime "${rt}"; warnings: ${result.warnings.join(', ')}`);
|
|
});
|
|
}
|
|
|
|
test('runtime "undocumented" (sentinel) → fail-closed to safe default', () => {
|
|
const host = {
|
|
...PROFILE_BASELINES['programmatic-cli'],
|
|
runtime: 'undocumented',
|
|
};
|
|
const result = negotiateHostCapabilities(host);
|
|
// Must be a documented value, not "undocumented"
|
|
assert.ok(
|
|
HOST_INTEGRATION_AXES.runtime.includes(result.effective.runtime),
|
|
`effective.runtime must be documented; got "${result.effective.runtime}"`,
|
|
);
|
|
assert.notStrictEqual(result.effective.runtime, 'undocumented');
|
|
const warnText = result.warnings.join(' ');
|
|
assert.ok(warnText.includes('undocumented'), `Expected undocumented warning; got: ${warnText}`);
|
|
});
|
|
|
|
test('"wasm" (genuinely unknown, not sentinel) → still fails closed with "not trusted" warning', () => {
|
|
const host = {
|
|
...PROFILE_BASELINES['programmatic-cli'],
|
|
runtime: 'wasm',
|
|
};
|
|
const result = negotiateHostCapabilities(host);
|
|
assert.ok(HOST_INTEGRATION_AXES.runtime.includes(result.effective.runtime),
|
|
`effective.runtime must be documented; got "${result.effective.runtime}"`);
|
|
const warnText = result.warnings.join(' ');
|
|
assert.ok(warnText.includes('not trusted') || warnText.includes('unknown'),
|
|
`Expected not-trusted/unknown warning; got: ${warnText}`);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fix 1: degradationFor('dispatch') fail-closed on non-'full' subagentToolkit
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Fix 1: degradationFor dispatch fails closed on non-full subagentToolkit', () => {
|
|
const FULL_DEPTH_DISPATCH = { namedDispatch: true, nested: true, maxDepth: -1, background: true };
|
|
|
|
test('subagentToolkit:"full" + full depth → level "full"', () => {
|
|
const r = degradationFor('dispatch', { dispatch: { ...FULL_DEPTH_DISPATCH, subagentToolkit: 'full' } });
|
|
assert.strictEqual(r.level, 'full',
|
|
'subagentToolkit:"full" with full depth must return level "full"');
|
|
});
|
|
|
|
test('subagentToolkit:"read-only" + full depth → level "degraded"', () => {
|
|
const r = degradationFor('dispatch', { dispatch: { ...FULL_DEPTH_DISPATCH, subagentToolkit: 'read-only' } });
|
|
assert.strictEqual(r.level, 'degraded',
|
|
'subagentToolkit:"read-only" must return level "degraded"');
|
|
assert.ok(r.fallback.length > 0, 'fallback must be non-empty');
|
|
});
|
|
|
|
test('subagentToolkit:"undocumented" + full depth → level "degraded" (fail-closed)', () => {
|
|
const r = degradationFor('dispatch', { dispatch: { ...FULL_DEPTH_DISPATCH, subagentToolkit: 'undocumented' } });
|
|
assert.strictEqual(r.level, 'degraded',
|
|
'subagentToolkit:"undocumented" must fail closed to level "degraded"; got "' + r.level + '"');
|
|
assert.ok(r.fallback.length > 0, 'fallback must be non-empty');
|
|
});
|
|
|
|
test('subagentToolkit:"future-xyz" + full depth → level "degraded" (fail-closed)', () => {
|
|
const r = degradationFor('dispatch', { dispatch: { ...FULL_DEPTH_DISPATCH, subagentToolkit: 'future-xyz' } });
|
|
assert.strictEqual(r.level, 'degraded',
|
|
'subagentToolkit:"future-xyz" (unknown) must fail closed to level "degraded"; got "' + r.level + '"');
|
|
assert.ok(r.fallback.length > 0, 'fallback must be non-empty');
|
|
});
|
|
|
|
test('subagentToolkit:"" (empty string) + full depth → level "degraded" (fail-closed)', () => {
|
|
const r = degradationFor('dispatch', { dispatch: { ...FULL_DEPTH_DISPATCH, subagentToolkit: '' } });
|
|
assert.strictEqual(r.level, 'degraded',
|
|
'subagentToolkit:"" must fail closed to level "degraded"; got "' + r.level + '"');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// New fixes: M1 maxDepth NaN, M2 struct consistency, L1 SAFE_DEFAULTS,
|
|
// L2 protocolVersion warn, N1 undocumented dispatch warnings
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Fix M1: maxDepth NaN bypasses number guard', () => {
|
|
test('negotiate with dispatch.maxDepth NaN → effective.dispatch.maxDepth === 0 AND warning about maxDepth AND Number.isFinite', () => {
|
|
const result = negotiateHostCapabilities({
|
|
dispatch: { namedDispatch: true, nested: false, maxDepth: NaN, background: false, subagentToolkit: 'full' },
|
|
});
|
|
const d = result.effective.dispatch;
|
|
assert.strictEqual(d.maxDepth, 0, 'NaN maxDepth must be normalized to 0');
|
|
assert.ok(Number.isFinite(d.maxDepth), 'effective.dispatch.maxDepth must be finite (Number.isFinite)');
|
|
const warnText = result.warnings.join(' ');
|
|
assert.ok(warnText.includes('maxDepth'), `Expected a warning about maxDepth; got: ${warnText}`);
|
|
});
|
|
|
|
test('degradationFor dispatch with maxDepth NaN → level "degraded" (not NaN-dependent, not "full")', () => {
|
|
const r = degradationFor('dispatch', {
|
|
dispatch: { namedDispatch: true, nested: true, maxDepth: NaN, subagentToolkit: 'full' },
|
|
});
|
|
// After fix: depth=(NaN not finite)→0; NaN===0 is false so initial check doesn't fire;
|
|
// isUnbounded=false; isFullDepth = false || (nested:true && 0>=2) = false → 'degraded' (flat)
|
|
assert.strictEqual(r.level, 'degraded',
|
|
`NaN maxDepth with nested:true must yield 'degraded' (depth=0, not full-depth); got: ${r.level}`);
|
|
assert.notStrictEqual(r.level, 'full', 'NaN maxDepth must NOT yield "full"');
|
|
});
|
|
});
|
|
|
|
describe('Fix M2: cap nested/background when namedDispatch is false', () => {
|
|
test('negotiate with namedDispatch:"undocumented" → namedDispatch false, nested false, background false, maxDepth 0; warnings include namedDispatch undocumented note', () => {
|
|
const result = negotiateHostCapabilities({
|
|
dispatch: { namedDispatch: 'undocumented', nested: true, background: true, maxDepth: 5, subagentToolkit: 'full' },
|
|
});
|
|
const d = result.effective.dispatch;
|
|
assert.strictEqual(d.namedDispatch, false, 'namedDispatch must be false');
|
|
assert.strictEqual(d.nested, false, 'nested must be false when namedDispatch is false');
|
|
assert.strictEqual(d.background, false, 'background must be false when namedDispatch is false');
|
|
assert.strictEqual(d.maxDepth, 0, 'maxDepth must be 0 when namedDispatch is false');
|
|
const warnText = result.warnings.join(' ');
|
|
assert.ok(warnText.includes('namedDispatch') || warnText.includes('dispatch.namedDispatch'),
|
|
`Expected a warning about namedDispatch being undocumented; got: ${warnText}`);
|
|
});
|
|
});
|
|
|
|
describe('Fix L1: SAFE_DEFAULTS.dispatch.subagentToolkit is read-only', () => {
|
|
// CONTRACT: negotiate({}) uses SAFE_DEFAULTS for each axis; dispatch uses its floor
|
|
test('negotiate({}) → effective axes match documented SAFE_DEFAULTS (CONTRACT)', () => {
|
|
const result = negotiateHostCapabilities({});
|
|
const eff = result.effective;
|
|
assert.strictEqual(eff.embeddingMode, 'declarative');
|
|
assert.strictEqual(eff.commandSurface, 'prose-only');
|
|
assert.strictEqual(eff.modelMode, 'passive');
|
|
assert.strictEqual(eff.hookBus, 'none');
|
|
assert.strictEqual(eff.stateIO, 'session-log-append');
|
|
assert.strictEqual(eff.transport, 'mcp');
|
|
assert.strictEqual(eff.runtime, 'node');
|
|
assert.strictEqual(eff.dispatch.subagentToolkit, 'read-only',
|
|
'SAFE_DEFAULTS dispatch floor must be read-only');
|
|
});
|
|
});
|
|
|
|
describe('Fix L2: warn on present-but-non-number protocolVersion', () => {
|
|
test('negotiate with protocolVersion:"beta" → warnings include protocolVersion note; result.protocolVersion === engine default (1)', () => {
|
|
const result = negotiateHostCapabilities({
|
|
embeddingMode: 'declarative',
|
|
commandSurface: 'slash-file',
|
|
modelMode: 'passive',
|
|
hookBus: 'host',
|
|
stateIO: 'filesystem',
|
|
transport: 'mcp',
|
|
runtime: 'node',
|
|
dispatch: { namedDispatch: true, nested: false, maxDepth: 1, background: false, subagentToolkit: 'full' },
|
|
protocolVersion: 'beta',
|
|
});
|
|
const warnText = result.warnings.join(' ');
|
|
assert.ok(warnText.includes('protocolVersion'),
|
|
`Expected a warning about protocolVersion being non-finite/non-number; got: ${warnText}`);
|
|
assert.strictEqual(result.protocolVersion, 1,
|
|
'result.protocolVersion must fall back to engine default (1)');
|
|
});
|
|
});
|
|
|
|
describe('Fix N1: symmetric observability warnings for undocumented dispatch fields', () => {
|
|
test('dispatch.subagentToolkit:"undocumented" → warning includes "dispatch.subagentToolkit is undocumented"', () => {
|
|
const result = negotiateHostCapabilities({
|
|
dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'undocumented' },
|
|
});
|
|
const warnText = result.warnings.join(' ');
|
|
assert.ok(warnText.includes('subagentToolkit') && warnText.includes('undocumented'),
|
|
`Expected warning about dispatch.subagentToolkit undocumented; got: ${warnText}`);
|
|
});
|
|
});
|
|
|
|
describe('Fix: degradationFor unknown point → {level:"absent", unknown:true}', () => {
|
|
test('degradationFor("totally-unknown-point", {}) → {level:"absent", unknown:true}', () => {
|
|
const r = degradationFor('totally-unknown-point', {});
|
|
assert.strictEqual(r.level, 'absent', 'unknown point must return absent');
|
|
assert.strictEqual(r.unknown, true, 'unknown point must have unknown:true');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase B: shouldFlattenDispatch — ADR-1239 Phase B / #1708
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Phase B: shouldFlattenDispatch — contract pin', () => {
|
|
const { shouldFlattenDispatch } = hi;
|
|
|
|
test('shouldFlattenDispatch is exported as a function', () => {
|
|
assert.strictEqual(typeof shouldFlattenDispatch, 'function',
|
|
'shouldFlattenDispatch must be exported from host-integration module');
|
|
});
|
|
|
|
test('{background:true, backgroundDispatch:true} → true (no depth budget declared → fail-closed/flatten)', () => {
|
|
// #2939: can background AT ALL, but declares no nested/toolkit/maxDepth, so the depth
|
|
// budget is unknown → fail-closed to inline (flatten). A real background-eligible host
|
|
// also carries nested:true + subagentToolkit:"full" + a sufficient maxDepth.
|
|
assert.strictEqual(shouldFlattenDispatch({ background: true, backgroundDispatch: true }), true,
|
|
'canBackground=true but no depth budget declared → fail-closed/flatten=true');
|
|
});
|
|
|
|
test('{background:true, backgroundDispatch:false} → true (must flatten)', () => {
|
|
assert.strictEqual(shouldFlattenDispatch({ background: true, backgroundDispatch: false }), true,
|
|
'backgroundDispatch===false → canBackground=false → flatten=true');
|
|
});
|
|
|
|
test('{background:true, backgroundDispatch:"undocumented"} → true (undocumented is not === true)', () => {
|
|
assert.strictEqual(shouldFlattenDispatch({ background: true, backgroundDispatch: 'undocumented' }), true,
|
|
'"undocumented" is not === true → canBackground=false → flatten=true');
|
|
});
|
|
|
|
test('{background:false, backgroundDispatch:true} → true (background is false)', () => {
|
|
assert.strictEqual(shouldFlattenDispatch({ background: false, backgroundDispatch: true }), true,
|
|
'background===false → canBackground=false → flatten=true');
|
|
});
|
|
|
|
test('{} (empty) → true (missing fields → fail-closed)', () => {
|
|
assert.strictEqual(shouldFlattenDispatch({}), true,
|
|
'empty dispatch → canBackground=false → flatten=true');
|
|
});
|
|
|
|
test('missing fields individually', () => {
|
|
assert.strictEqual(shouldFlattenDispatch({ background: true }), true,
|
|
'backgroundDispatch missing → not === true → flatten=true');
|
|
assert.strictEqual(shouldFlattenDispatch({ backgroundDispatch: true }), true,
|
|
'background missing → not === true → flatten=true');
|
|
});
|
|
|
|
// M1: null-safety — null/undefined/non-object dispatch must fail-closed (not throw)
|
|
test('null dispatch → true (fail-closed, no throw)', () => {
|
|
assert.strictEqual(shouldFlattenDispatch(null), true,
|
|
'null dispatch must fail-closed to true');
|
|
});
|
|
|
|
test('undefined dispatch → true (fail-closed, no throw)', () => {
|
|
assert.strictEqual(shouldFlattenDispatch(undefined), true,
|
|
'undefined dispatch must fail-closed to true');
|
|
});
|
|
|
|
test('string dispatch → true (fail-closed, no throw)', () => {
|
|
assert.strictEqual(shouldFlattenDispatch('x'), true,
|
|
'non-object dispatch (string) must fail-closed to true');
|
|
});
|
|
|
|
// #2939: the codex-like profile with maxDepth:1 now FLATTENS. A depth budget of 1 is consumed
|
|
// by the backgrounded orchestrator itself (depth 1) and leaves no room for the delegated leaf
|
|
// (depth 2) its own contract requires. This corrects the prior pin, which asserted the buggy
|
|
// shouldFlatten:false output that permitted a depth-2 tree the descriptor cannot support.
|
|
test('#2939 codex-like: {namedDispatch:true,nested:true,maxDepth:1,background:true,subagentToolkit:"full",backgroundDispatch:true} → true (flatten — depth budget insufficient)', () => {
|
|
assert.strictEqual(
|
|
shouldFlattenDispatch({ namedDispatch: true, nested: true, maxDepth: 1, background: true, subagentToolkit: 'full', backgroundDispatch: true }),
|
|
true,
|
|
'maxDepth:1 is insufficient for a backgrounded orchestrator plus a delegated leaf → flatten=true',
|
|
);
|
|
});
|
|
|
|
// #2939 negative-space: the same codex-like profile with a SUFFICIENT depth budget backgrounds.
|
|
test('#2939 codex-like maxDepth:2 → false (background OK — depth budget sufficient)', () => {
|
|
assert.strictEqual(
|
|
shouldFlattenDispatch({ namedDispatch: true, nested: true, maxDepth: 2, background: true, subagentToolkit: 'full', backgroundDispatch: true }),
|
|
false,
|
|
'maxDepth:2 leaves room for a backgrounded orchestrator plus a leaf → background OK',
|
|
);
|
|
});
|
|
|
|
// #853 claude-like profile: backgroundDispatch:false → must flatten
|
|
test('#853 claude-like: {...,background:true,backgroundDispatch:false} → true (inline)', () => {
|
|
assert.strictEqual(
|
|
shouldFlattenDispatch({ namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: false }),
|
|
true,
|
|
'claude-like dispatch with backgroundDispatch:false must flatten inline',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase B: negotiateHostCapabilities — backgroundDispatch field
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Phase B: negotiateHostCapabilities — backgroundDispatch', () => {
|
|
test('host dispatch.backgroundDispatch:true against DEFAULT_ENGINE → effective.dispatch.backgroundDispatch===true', () => {
|
|
const result = negotiateHostCapabilities({
|
|
...PROFILE_BASELINES['programmatic-cli'],
|
|
dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: true },
|
|
});
|
|
assert.strictEqual(result.effective.dispatch.backgroundDispatch, true,
|
|
'backgroundDispatch:true on host AND engine must yield effective backgroundDispatch===true');
|
|
});
|
|
|
|
test('host dispatch.backgroundDispatch:"undocumented" → effective.dispatch.backgroundDispatch===false + warning', () => {
|
|
const result = negotiateHostCapabilities({
|
|
...PROFILE_BASELINES['programmatic-cli'],
|
|
dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: 'undocumented' },
|
|
});
|
|
assert.strictEqual(result.effective.dispatch.backgroundDispatch, false,
|
|
'"undocumented" must fail-closed to false');
|
|
const warnText = result.warnings.join(' ');
|
|
assert.ok(warnText.includes('backgroundDispatch') && warnText.includes('undocumented'),
|
|
`Expected warning about backgroundDispatch being undocumented; got: ${warnText}`);
|
|
});
|
|
|
|
test('host dispatch.backgroundDispatch:false → effective.dispatch.backgroundDispatch===false', () => {
|
|
const result = negotiateHostCapabilities({
|
|
...PROFILE_BASELINES['programmatic-cli'],
|
|
dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: false },
|
|
});
|
|
assert.strictEqual(result.effective.dispatch.backgroundDispatch, false);
|
|
});
|
|
|
|
test('host dispatch without backgroundDispatch key → effective.dispatch.backgroundDispatch===false (fail-closed)', () => {
|
|
const result = negotiateHostCapabilities({
|
|
...PROFILE_BASELINES['programmatic-cli'],
|
|
dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full' },
|
|
});
|
|
assert.strictEqual(result.effective.dispatch.backgroundDispatch, false,
|
|
'Missing backgroundDispatch key must fail-closed to false');
|
|
});
|
|
|
|
test('negotiateHostCapabilities({}) → effective.dispatch.backgroundDispatch===false', () => {
|
|
const result = negotiateHostCapabilities({});
|
|
assert.strictEqual(result.effective.dispatch.backgroundDispatch, false,
|
|
'Empty host must produce backgroundDispatch===false (SAFE_DEFAULTS)');
|
|
});
|
|
|
|
test('SAFE_DEFAULTS.dispatch.backgroundDispatch is false', () => {
|
|
// Verified via negotiation with empty host
|
|
const result = negotiateHostCapabilities({});
|
|
assert.strictEqual(result.effective.dispatch.backgroundDispatch, false);
|
|
});
|
|
|
|
test('DEFAULT_ENGINE.axes.dispatch.backgroundDispatch is true', () => {
|
|
assert.strictEqual(DEFAULT_ENGINE.axes.dispatch.backgroundDispatch, true,
|
|
'DEFAULT_ENGINE (full engine) must declare backgroundDispatch:true');
|
|
});
|
|
|
|
test('existing dispatch tests still pass — effective.dispatch.namedDispatch present alongside backgroundDispatch', () => {
|
|
const result = negotiateHostCapabilities(PROFILE_BASELINES['programmatic-cli']);
|
|
const d = result.effective.dispatch;
|
|
assert.ok('namedDispatch' in d, 'namedDispatch must still be present');
|
|
assert.ok('backgroundDispatch' in d, 'backgroundDispatch must be present');
|
|
assert.ok('nested' in d && 'maxDepth' in d && 'background' in d && 'subagentToolkit' in d,
|
|
'all original dispatch fields must still be present');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fix 2: negotiateHostCapabilities — host omitting 'dispatch' → subagentToolkit 'read-only'
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Fix 2: negotiate — host omits dispatch → subagentToolkit read-only (fail-closed)', () => {
|
|
test('negotiateHostCapabilities({}) → effective.dispatch.subagentToolkit === "read-only"', () => {
|
|
const result = negotiateHostCapabilities({});
|
|
assert.strictEqual(result.effective.dispatch.subagentToolkit, 'read-only',
|
|
'When host omits dispatch, subagentToolkit must fail-closed to "read-only"; got "' + result.effective.dispatch.subagentToolkit + '"');
|
|
});
|
|
|
|
test('negotiateHostCapabilities({}) → effective.dispatch.namedDispatch===false, maxDepth===0, nested===false, background===false', () => {
|
|
const result = negotiateHostCapabilities({});
|
|
const d = result.effective.dispatch;
|
|
assert.strictEqual(d.namedDispatch, false);
|
|
assert.strictEqual(d.maxDepth, 0);
|
|
assert.strictEqual(d.nested, false);
|
|
assert.strictEqual(d.background, false);
|
|
});
|
|
|
|
test('negotiateHostCapabilities({}) → points.dispatch.effectiveLevel === "absent"', () => {
|
|
const result = negotiateHostCapabilities({});
|
|
assert.strictEqual(result.points.dispatch.effectiveLevel, 'absent',
|
|
'dispatch absent when host omits it');
|
|
});
|
|
|
|
test('host with all axes but no dispatch → subagentToolkit "read-only"', () => {
|
|
const hostWithoutDispatch = {
|
|
embeddingMode: 'imperative',
|
|
commandSurface: 'slash-file',
|
|
modelMode: 'passive',
|
|
hookBus: 'host',
|
|
stateIO: 'filesystem',
|
|
transport: 'mcp',
|
|
runtime: 'node',
|
|
// no dispatch key
|
|
};
|
|
const result = negotiateHostCapabilities(hostWithoutDispatch);
|
|
assert.strictEqual(result.effective.dispatch.subagentToolkit, 'read-only',
|
|
'Host missing dispatch must produce subagentToolkit "read-only"; got "' + result.effective.dispatch.subagentToolkit + '"');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #2584 — ADR-1239 Codex-binding amendment: dispatch.isolation sub-field
|
|
// (Phase 1 — declared and negotiated, but NOT consumed by any scheduler yet).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('#2584 dispatch.isolation — negotiation', () => {
|
|
const BASE_DISPATCH = {
|
|
namedDispatch: true, nested: false, maxDepth: 1, background: false,
|
|
subagentToolkit: 'full', backgroundDispatch: false,
|
|
};
|
|
|
|
for (const value of HOST_INTEGRATION_AXES.isolation) {
|
|
test(`host declares isolation:"${value}" → effective.dispatch.isolation === "${value}"`, () => {
|
|
const result = negotiateHostCapabilities({
|
|
dispatch: { ...BASE_DISPATCH, isolation: value },
|
|
});
|
|
assert.strictEqual(result.effective.dispatch.isolation, value);
|
|
});
|
|
}
|
|
|
|
test('isolation:"undocumented" → effective "none" + a warning naming dispatch.isolation', () => {
|
|
const result = negotiateHostCapabilities({
|
|
dispatch: { ...BASE_DISPATCH, isolation: 'undocumented' },
|
|
});
|
|
assert.strictEqual(result.effective.dispatch.isolation, 'none');
|
|
const warnText = result.warnings.join(' ');
|
|
assert.ok(warnText.includes('dispatch.isolation') && warnText.includes('undocumented'),
|
|
`Expected a warning naming dispatch.isolation as undocumented; got: ${warnText}`);
|
|
});
|
|
|
|
test('isolation: unknown/garbage value (not the sentinel) → effective "none", no throw', () => {
|
|
const result = negotiateHostCapabilities({
|
|
dispatch: { ...BASE_DISPATCH, isolation: 'quantum-worktree' },
|
|
});
|
|
assert.strictEqual(result.effective.dispatch.isolation, 'none');
|
|
});
|
|
|
|
test('isolation: non-string value (number/object/array/null) → effective "none", no throw', () => {
|
|
for (const bogus of [42, {}, [], null, true]) {
|
|
const result = negotiateHostCapabilities({
|
|
dispatch: { ...BASE_DISPATCH, isolation: bogus },
|
|
});
|
|
assert.strictEqual(result.effective.dispatch.isolation, 'none',
|
|
`isolation=${JSON.stringify(bogus)} must degrade to "none"`);
|
|
}
|
|
});
|
|
|
|
test('host declares dispatch but omits isolation entirely → effective "none"', () => {
|
|
const result = negotiateHostCapabilities({ dispatch: { ...BASE_DISPATCH } });
|
|
assert.strictEqual(result.effective.dispatch.isolation, 'none');
|
|
});
|
|
|
|
test('host omits dispatch entirely → effective.dispatch.isolation === "none"', () => {
|
|
const result = negotiateHostCapabilities({});
|
|
assert.strictEqual(result.effective.dispatch.isolation, 'none');
|
|
});
|
|
|
|
test('negotiateHostCapabilities({}) → SAFE_DEFAULTS floor carries isolation "none"', () => {
|
|
// FAIL_CLOSED_FLOOR.dispatch.isolation (src/host-integration.cts SAFE_DEFAULTS)
|
|
const result = negotiateHostCapabilities({});
|
|
assert.strictEqual(result.effective.dispatch.isolation, 'none');
|
|
});
|
|
|
|
test('isolation is NOT gated by namedDispatch:false — unlike nested/background/backgroundDispatch, it is not capped', () => {
|
|
// orchestrator-worktree fan-out is OS-level (process-spawn), independent of
|
|
// the host's native named-subagent dispatch (ADR-1239 §2584: "does not use
|
|
// the host's native subagent tool"). A host may plausibly declare
|
|
// namedDispatch:false yet still have isolation info; either way it must not
|
|
// silently flip to a DIFFERENT valid value or throw.
|
|
const result = negotiateHostCapabilities({
|
|
dispatch: { ...BASE_DISPATCH, namedDispatch: false, isolation: 'orchestrator-worktree' },
|
|
});
|
|
assert.strictEqual(result.effective.dispatch.isolation, 'orchestrator-worktree');
|
|
});
|
|
|
|
// ─── Boundary: exact valid-set membership ──────────────────────────────────
|
|
|
|
describe('boundary — a value one character off a valid member fails closed to "none"', () => {
|
|
const NEAR_MISSES = [
|
|
'harness-worktre', // missing trailing 'e' (limit-1)
|
|
'harness-worktreee', // extra trailing 'e' (limit+1)
|
|
'Harness-Worktree', // case mismatch
|
|
'orchestrator-worktre', // missing trailing 'e'
|
|
'orchestrator-worktrees', // extra trailing 's'
|
|
'non', // missing trailing 'e' of "none"
|
|
'nonee', // extra trailing 'e'
|
|
' none', // leading space
|
|
'none ', // trailing space
|
|
];
|
|
for (const nearMiss of NEAR_MISSES) {
|
|
test(`isolation:${JSON.stringify(nearMiss)} → "none"`, () => {
|
|
const result = negotiateHostCapabilities({
|
|
dispatch: { ...BASE_DISPATCH, isolation: nearMiss },
|
|
});
|
|
assert.strictEqual(result.effective.dispatch.isolation, 'none');
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── Property: valid-set-passthrough-else-none contract ─────────────────────
|
|
|
|
test('property: effective.dispatch.isolation equals the declared value iff it is a known vocabulary member, else "none"', () => {
|
|
const declaredArb = fc.oneof(
|
|
fc.constantFrom(...HOST_INTEGRATION_AXES.isolation, 'undocumented'),
|
|
fc.string(),
|
|
);
|
|
fc.assert(
|
|
fc.property(declaredArb, (declared) => {
|
|
const result = negotiateHostCapabilities({
|
|
dispatch: { ...BASE_DISPATCH, isolation: declared },
|
|
});
|
|
const eff = result.effective.dispatch.isolation;
|
|
assert.ok(HOST_INTEGRATION_AXES.isolation.includes(eff),
|
|
`effective.dispatch.isolation '${eff}' must always be a known vocabulary member`);
|
|
if (HOST_INTEGRATION_AXES.isolation.includes(declared)) {
|
|
assert.strictEqual(eff, declared, `a valid declared value ('${declared}') must pass through unchanged`);
|
|
} else {
|
|
assert.strictEqual(eff, 'none', `an invalid/sentinel declared value ('${declared}') must degrade to "none"`);
|
|
}
|
|
}),
|
|
{ numRuns: 200, seed: 2584 },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('#2584 dispatch.isolation — validator', () => {
|
|
test('_HOST_INTEGRATION_VOCAB.isolation matches HOST_INTEGRATION_AXES.isolation (parity guard)', () => {
|
|
assert.deepEqual(
|
|
[..._HOST_INTEGRATION_VOCAB.isolation].sort(),
|
|
[...HOST_INTEGRATION_AXES.isolation].sort(),
|
|
);
|
|
});
|
|
|
|
test('a descriptor that omits dispatch.isolation entirely still validates clean (added after existing descriptors)', () => {
|
|
const cap = shippedClaudeCapabilityWithoutIsolation();
|
|
const errors = validateCapability(cap, 'claude');
|
|
assert.deepEqual(errors, [], `omitted isolation must validate clean, got: ${JSON.stringify(errors)}`);
|
|
});
|
|
|
|
for (const value of ['harness-worktree', 'orchestrator-worktree', 'none', 'undocumented']) {
|
|
test(`dispatch.isolation:"${value}" → ZERO validator errors`, () => {
|
|
const cap = shippedClaudeCapabilityWithoutIsolation();
|
|
cap.runtime.hostIntegration.dispatch.isolation = value;
|
|
const errors = validateCapability(cap, 'claude');
|
|
const isoErrors = errors.filter((e) => e.includes('dispatch.isolation'));
|
|
assert.strictEqual(isoErrors.length, 0,
|
|
`"${value}" must produce no validator errors; got: ${JSON.stringify(isoErrors)}`);
|
|
});
|
|
}
|
|
|
|
test('a present invalid dispatch.isolation value is rejected', () => {
|
|
const cap = shippedClaudeCapabilityWithoutIsolation();
|
|
cap.runtime.hostIntegration.dispatch.isolation = 'quantum-worktree';
|
|
const errors = validateCapability(cap, 'claude');
|
|
assert.ok(
|
|
errors.some((e) => e.includes('dispatch.isolation')),
|
|
`an invalid dispatch.isolation must produce a validator error; got: ${JSON.stringify(errors)}`,
|
|
);
|
|
});
|
|
|
|
test('a reserved-name dispatch.isolation value ("__proto__") is rejected', () => {
|
|
const cap = shippedClaudeCapabilityWithoutIsolation();
|
|
cap.runtime.hostIntegration.dispatch.isolation = '__proto__';
|
|
const errors = validateCapability(cap, 'claude');
|
|
assert.ok(
|
|
errors.some((e) => e.includes('dispatch.isolation') && e.includes('reserved name')),
|
|
`"__proto__" must produce a reserved-name validator error; got: ${JSON.stringify(errors)}`,
|
|
);
|
|
});
|
|
|
|
test('every shipped runtime descriptor with an isolation value passes validateCapability', () => {
|
|
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
|
for (const [id, cap] of Object.entries(registry.runtimes)) {
|
|
const iso = cap && cap.runtime && cap.runtime.hostIntegration && cap.runtime.hostIntegration.dispatch
|
|
&& cap.runtime.hostIntegration.dispatch.isolation;
|
|
if (iso === undefined) continue;
|
|
const errors = validateCapability(cap, id);
|
|
const isoErrors = errors.filter((e) => e.includes('dispatch.isolation'));
|
|
assert.strictEqual(isoErrors.length, 0,
|
|
`${id}: shipped dispatch.isolation:"${iso}" must validate clean; got: ${JSON.stringify(isoErrors)}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #2584 — ADR-1239 Codex-binding amendment, Phase 2: resolveOrchestratorExec
|
|
// + the `runtime.orchestratorExec` descriptor field (sibling of hostBehaviors).
|
|
// UNCONSUMED in Phase 2 — no scheduler calls this yet (Phase 3 wires it).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('resolveOrchestratorExec — the 4 shipped orchestrator-worktree descriptors', () => {
|
|
const CWD = '/repo/.claude/worktrees/agent-a1';
|
|
|
|
test('codex: exec --cd <cwd>', () => {
|
|
const result = resolveOrchestratorExec({ command: 'codex', args: ['exec'], cwdFlag: '--cd' }, CWD);
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.command, 'codex');
|
|
assert.deepEqual(result.args, ['exec', '--cd', CWD]);
|
|
assert.equal(result.cwd, CWD);
|
|
});
|
|
|
|
test('opencode: run --dir <cwd>', () => {
|
|
const result = resolveOrchestratorExec({ command: 'opencode', args: ['run'], cwdFlag: '--dir' }, CWD);
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.command, 'opencode');
|
|
assert.deepEqual(result.args, ['run', '--dir', CWD]);
|
|
assert.equal(result.cwd, CWD);
|
|
});
|
|
|
|
// #2627: `args` carries --print because kimi's working mode is otherwise the
|
|
// interactive TUI — an orchestrator spawning a TUI hangs forever rather than
|
|
// returning a completed plan.
|
|
test('kimi: --print --work-dir <cwd> (headless flag leads)', () => {
|
|
const result = resolveOrchestratorExec({ command: 'kimi', args: ['--print'], cwdFlag: '--work-dir' }, CWD);
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.command, 'kimi');
|
|
assert.deepEqual(result.args, ['--print', '--work-dir', CWD]);
|
|
assert.equal(result.cwd, CWD);
|
|
});
|
|
|
|
// #2627: the binary is `kimi`, NOT `kimi-code` — Moonshot's TypeScript Kimi
|
|
// Code installs its binary as `kimi`; `kimi-code` is only the npm package and
|
|
// config-home name, so spawning it is an immediate ENOENT.
|
|
test('kimi-code: command is "kimi"; cwdFlag:null appends NO flag, cwd still returned (process-cwd case)', () => {
|
|
const result = resolveOrchestratorExec({ command: 'kimi', args: [], cwdFlag: null }, CWD);
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.command, 'kimi');
|
|
assert.deepEqual(result.args, []);
|
|
assert.equal(result.cwd, CWD);
|
|
});
|
|
});
|
|
|
|
describe('resolveOrchestratorExec — prompt passing (#2627, Phase 3)', () => {
|
|
const CWD = '/repo/.claude/worktrees/agent-a1';
|
|
const PROMPT = 'Execute plan 2 of phase 3.';
|
|
|
|
test('promptFlag:null → prompt appended POSITIONALLY, last (codex shape)', () => {
|
|
const result = resolveOrchestratorExec(
|
|
{ command: 'codex', args: ['exec'], cwdFlag: '--cd', promptFlag: null }, CWD, PROMPT,
|
|
);
|
|
assert.equal(result.ok, true);
|
|
assert.deepEqual(result.args, ['exec', '--cd', CWD, PROMPT]);
|
|
});
|
|
|
|
test('promptFlag absent → prompt appended POSITIONALLY (opencode shape)', () => {
|
|
const result = resolveOrchestratorExec(
|
|
{ command: 'opencode', args: ['run'], cwdFlag: '--dir' }, CWD, PROMPT,
|
|
);
|
|
assert.equal(result.ok, true);
|
|
assert.deepEqual(result.args, ['run', '--dir', CWD, PROMPT]);
|
|
});
|
|
|
|
test('promptFlag string → [flag, prompt] appended (kimi shape)', () => {
|
|
const result = resolveOrchestratorExec(
|
|
{ command: 'kimi', args: ['--print'], cwdFlag: '--work-dir', promptFlag: '--prompt' }, CWD, PROMPT,
|
|
);
|
|
assert.equal(result.ok, true);
|
|
assert.deepEqual(result.args, ['--print', '--work-dir', CWD, '--prompt', PROMPT]);
|
|
});
|
|
|
|
test('promptFlag string + cwdFlag null → prompt flag only, cwd via process cwd (kimi-code shape)', () => {
|
|
const result = resolveOrchestratorExec(
|
|
{ command: 'kimi', args: [], cwdFlag: null, promptFlag: '--prompt' }, CWD, PROMPT,
|
|
);
|
|
assert.equal(result.ok, true);
|
|
assert.deepEqual(result.args, ['--prompt', PROMPT]);
|
|
assert.equal(result.cwd, CWD, 'cwd is returned even with no cwd flag — caller binds it on the subprocess');
|
|
});
|
|
|
|
test('omitting prompt is byte-identical to the Phase-2 two-arg resolution', () => {
|
|
const descriptor = { command: 'codex', args: ['exec'], cwdFlag: '--cd', promptFlag: null };
|
|
assert.deepEqual(
|
|
resolveOrchestratorExec(descriptor, CWD),
|
|
resolveOrchestratorExec(descriptor, CWD, undefined),
|
|
);
|
|
assert.deepEqual(resolveOrchestratorExec(descriptor, CWD).args, ['exec', '--cd', CWD]);
|
|
});
|
|
|
|
test('empty prompt → invalid_prompt (a prompt-less executor hangs, not degrades)', () => {
|
|
const result = resolveOrchestratorExec({ command: 'codex', args: ['exec'] }, CWD, '');
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.reason, 'invalid_prompt');
|
|
});
|
|
|
|
test('non-string promptFlag → invalid_prompt_flag', () => {
|
|
for (const bogus of [42, {}, []]) {
|
|
const result = resolveOrchestratorExec(
|
|
{ command: 'codex', args: ['exec'], promptFlag: bogus }, CWD, PROMPT,
|
|
);
|
|
assert.equal(result.ok, false, `promptFlag=${JSON.stringify(bogus)} must fail`);
|
|
assert.equal(result.reason, 'invalid_prompt_flag');
|
|
}
|
|
});
|
|
|
|
// Parity with worktree-safety.cts's `unsafe_leading_dash` guard on git args:
|
|
// a dash-leading positional is parsed by the spawned CLI as a flag, not a
|
|
// value. Same hazard, same rejection — these two surfaces must not diverge.
|
|
test('a dash-leading prompt is rejected (would be parsed as a flag, not a prompt)', () => {
|
|
for (const hostile of ['--dangerously-skip-permissions', '-p', '--help']) {
|
|
const result = resolveOrchestratorExec(
|
|
{ command: 'codex', args: ['exec'], cwdFlag: '--cd' }, CWD, hostile,
|
|
);
|
|
assert.equal(result.ok, false, `prompt=${hostile} must be rejected`);
|
|
assert.equal(result.reason, 'unsafe_leading_dash_prompt');
|
|
}
|
|
});
|
|
|
|
test('a dash-leading cwd is rejected', () => {
|
|
const result = resolveOrchestratorExec(
|
|
{ command: 'codex', args: ['exec'], cwdFlag: '--cd' }, '-oProxyCommand=x', 'ok prompt',
|
|
);
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.reason, 'unsafe_leading_dash_cwd');
|
|
});
|
|
|
|
test('a prompt merely CONTAINING a dash is fine — only a leading dash is a flag', () => {
|
|
const result = resolveOrchestratorExec(
|
|
{ command: 'codex', args: ['exec'], cwdFlag: '--cd' }, CWD, 'Execute plan 2 --verbose style',
|
|
);
|
|
assert.equal(result.ok, true);
|
|
assert.ok(result.args.includes('Execute plan 2 --verbose style'));
|
|
});
|
|
|
|
test('empty-string promptFlag falls back to positional rather than emitting a bare ""', () => {
|
|
const result = resolveOrchestratorExec(
|
|
{ command: 'codex', args: ['exec'], promptFlag: '' }, CWD, PROMPT,
|
|
);
|
|
assert.equal(result.ok, true);
|
|
assert.deepEqual(result.args, ['exec', PROMPT]);
|
|
});
|
|
});
|
|
|
|
describe('resolveOrchestratorExec — fail-closed', () => {
|
|
test('undefined descriptor → missing_command', () => {
|
|
const result = resolveOrchestratorExec(undefined, '/repo/wt');
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.reason, 'missing_command');
|
|
});
|
|
|
|
test('{} (no command) → missing_command', () => {
|
|
const result = resolveOrchestratorExec({}, '/repo/wt');
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.reason, 'missing_command');
|
|
});
|
|
|
|
test('command: "" (empty string) → missing_command', () => {
|
|
const result = resolveOrchestratorExec({ command: '' }, '/repo/wt');
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.reason, 'missing_command');
|
|
});
|
|
|
|
test('command: non-string → missing_command', () => {
|
|
for (const bogus of [42, null, {}, []]) {
|
|
const result = resolveOrchestratorExec({ command: bogus }, '/repo/wt');
|
|
assert.equal(result.ok, false, `command=${JSON.stringify(bogus)} must fail`);
|
|
assert.equal(result.reason, 'missing_command');
|
|
}
|
|
});
|
|
|
|
test('empty cwd → invalid_cwd', () => {
|
|
const result = resolveOrchestratorExec({ command: 'codex' }, '');
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.reason, 'invalid_cwd');
|
|
});
|
|
|
|
test('non-string cwd → invalid_cwd', () => {
|
|
for (const bogus of [undefined, null, 42, {}]) {
|
|
const result = resolveOrchestratorExec({ command: 'codex' }, bogus);
|
|
assert.equal(result.ok, false, `cwd=${JSON.stringify(bogus)} must fail`);
|
|
assert.equal(result.reason, 'invalid_cwd');
|
|
}
|
|
});
|
|
|
|
test('args not an array → invalid_args', () => {
|
|
const result = resolveOrchestratorExec({ command: 'codex', args: 'exec' }, '/repo/wt');
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.reason, 'invalid_args');
|
|
});
|
|
|
|
test('args array with a non-string element → invalid_args', () => {
|
|
const result = resolveOrchestratorExec({ command: 'codex', args: ['exec', 42] }, '/repo/wt');
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.reason, 'invalid_args');
|
|
});
|
|
|
|
test('cwdFlag a number → invalid_cwd_flag', () => {
|
|
const result = resolveOrchestratorExec({ command: 'codex', cwdFlag: 42 }, '/repo/wt');
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.reason, 'invalid_cwd_flag');
|
|
});
|
|
|
|
test('cwdFlag an object/array → invalid_cwd_flag', () => {
|
|
for (const bogus of [{}, []]) {
|
|
const result = resolveOrchestratorExec({ command: 'codex', cwdFlag: bogus }, '/repo/wt');
|
|
assert.equal(result.ok, false, `cwdFlag=${JSON.stringify(bogus)} must fail`);
|
|
assert.equal(result.reason, 'invalid_cwd_flag');
|
|
}
|
|
});
|
|
|
|
test('a reserved-name command string ("__proto__") still resolves at the resolver layer', () => {
|
|
// The resolver only checks "is it a non-empty string" — it never does a
|
|
// property lookup keyed by `command`, so there is no prototype-pollution
|
|
// surface here. The VALIDATOR (capability-validator.cjs) is what rejects
|
|
// "__proto__" at descriptor-load time — see the validator describe block below.
|
|
const result = resolveOrchestratorExec({ command: '__proto__' }, '/repo/wt');
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.command, '__proto__');
|
|
});
|
|
});
|
|
|
|
describe('resolveOrchestratorExec — fast-check property test', () => {
|
|
const commandArb = fc.string({ minLength: 1 }).filter((s) => s.length > 0);
|
|
const argsArb = fc.array(fc.string());
|
|
const cwdFlagArb = fc.oneof(
|
|
fc.constant(null),
|
|
fc.constant(undefined),
|
|
fc.string({ minLength: 1 }).filter((s) => s.length > 0),
|
|
);
|
|
// #2627: a dash-leading cwd is now REJECTED (unsafe_leading_dash_cwd) —
|
|
// the spawned CLI would parse it as a flag, the same hazard worktree-safety's
|
|
// git-argument guard rejects. That is intentional new fail-closed behavior,
|
|
// so the ok:true property below is stated over the domain it actually holds
|
|
// on: real working directories. The rejected half is asserted explicitly in
|
|
// its own property immediately after, so narrowing here loses no coverage.
|
|
const cwdArb = fc.string({ minLength: 1 }).filter((s) => s.length > 0 && !s.startsWith('-'));
|
|
|
|
test('property: ok:true, command preserved, cwdFlag appended exactly once (or never for null/absent)', () => {
|
|
fc.assert(
|
|
fc.property(commandArb, argsArb, cwdFlagArb, cwdArb, (command, args, cwdFlag, cwd) => {
|
|
// Guard against the astronomically-rare but non-zero case where the
|
|
// arbitrary `args` already happens to contain the exact `cwd` string —
|
|
// that would make "cwd appears exactly once, at the tail" a false
|
|
// assertion about pre-existing data rather than the resolver's own
|
|
// behavior. Deterministic given the seed; not a flakiness workaround.
|
|
fc.pre(!args.includes(cwd));
|
|
const descriptor = cwdFlag === undefined ? { command, args } : { command, args, cwdFlag };
|
|
const result = resolveOrchestratorExec(descriptor, cwd);
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.command, command);
|
|
assert.equal(result.cwd, cwd);
|
|
if (typeof cwdFlag === 'string' && cwdFlag.length > 0) {
|
|
assert.deepEqual(result.args.slice(-2), [cwdFlag, cwd]);
|
|
// exactly once: cwdFlag/cwd do not appear anywhere earlier in args
|
|
const earlier = result.args.slice(0, -2);
|
|
assert.ok(!earlier.includes(cwd), 'cwd must not appear before the trailing pair');
|
|
} else {
|
|
assert.ok(!result.args.includes(cwd), 'cwd must not appear in args when cwdFlag is null/absent');
|
|
}
|
|
}),
|
|
{ numRuns: 200, seed: 2584 },
|
|
);
|
|
});
|
|
|
|
// The complementary half of the narrowed domain above (#2627): every
|
|
// dash-leading cwd fails closed, for ANY descriptor shape.
|
|
test('property: a dash-leading cwd is always rejected, never silently passed through', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
commandArb,
|
|
argsArb,
|
|
cwdFlagArb,
|
|
fc.string().map((s) => `-${s}`),
|
|
(command, args, cwdFlag, cwd) => {
|
|
const descriptor = cwdFlag === undefined ? { command, args } : { command, args, cwdFlag };
|
|
const result = resolveOrchestratorExec(descriptor, cwd);
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.reason, 'unsafe_leading_dash_cwd');
|
|
},
|
|
),
|
|
{ numRuns: 200, seed: 2627 },
|
|
);
|
|
});
|
|
|
|
// Same shape for the prompt argument, which the resolver appends to argv.
|
|
test('property: a dash-leading prompt is always rejected', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
commandArb,
|
|
argsArb,
|
|
fc.string().map((s) => `-${s}`),
|
|
(command, args, prompt) => {
|
|
const result = resolveOrchestratorExec({ command, args }, '/repo/wt', prompt);
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.reason, 'unsafe_leading_dash_prompt');
|
|
},
|
|
),
|
|
{ numRuns: 200, seed: 2627 },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('#2584 orchestratorExec — parity / divergence guard', () => {
|
|
const CAPABILITIES_DIR = path.join(REPO_ROOT, 'capabilities');
|
|
|
|
function loadCapability(id) {
|
|
return JSON.parse(fs.readFileSync(path.join(CAPABILITIES_DIR, id, 'capability.json'), 'utf8'));
|
|
}
|
|
|
|
test('every capability whose dispatch.isolation is "orchestrator-worktree" declares a resolvable orchestratorExec', () => {
|
|
const capIds = fs.readdirSync(CAPABILITIES_DIR).filter((entry) => {
|
|
const capPath = path.join(CAPABILITIES_DIR, entry, 'capability.json');
|
|
return fs.existsSync(capPath);
|
|
});
|
|
const orchestratorWorktreeHosts = [];
|
|
for (const id of capIds) {
|
|
const cap = loadCapability(id);
|
|
const iso = cap && cap.runtime && cap.runtime.hostIntegration && cap.runtime.hostIntegration.dispatch
|
|
&& cap.runtime.hostIntegration.dispatch.isolation;
|
|
if (iso !== 'orchestrator-worktree') continue;
|
|
orchestratorWorktreeHosts.push(id);
|
|
|
|
const orchestratorExec = cap.runtime.orchestratorExec;
|
|
assert.ok(
|
|
orchestratorExec,
|
|
`${id}: dispatch.isolation:"orchestrator-worktree" but no runtime.orchestratorExec declared — ` +
|
|
`a future orchestrator-worktree host MUST declare orchestratorExec (Phase 3 has nothing to spawn otherwise)`,
|
|
);
|
|
const result = resolveOrchestratorExec(orchestratorExec, '/tmp/wt');
|
|
assert.equal(result.ok, true,
|
|
`${id}: runtime.orchestratorExec must resolve cleanly; got reason="${result.ok ? '' : result.reason}"`);
|
|
}
|
|
// Sanity: the sweep actually found the 4 known hosts (guards a broken sweep
|
|
// silently matching zero capabilities and passing vacuously).
|
|
assert.deepEqual(orchestratorWorktreeHosts.sort(), ['codex', 'kimi', 'kimi-code', 'opencode']);
|
|
});
|
|
|
|
// #2627: the two guards below encode the per-host research that found two
|
|
// shipped descriptors which would have failed at spawn time — kimi resolving
|
|
// to an interactive TUI (orchestrator hangs) and kimi-code naming a binary
|
|
// that does not exist (ENOENT).
|
|
test('every orchestrator-worktree descriptor resolves to a HEADLESS invocation, never an interactive TUI', () => {
|
|
// A host that binds cwd by flag alone, with no leading subcommand or
|
|
// headless flag, launches its interactive UI. Each host must contribute at
|
|
// least one non-cwd token (a subcommand like `exec`/`run`, or an explicit
|
|
// headless flag like `--print`) BEFORE the cwd flag — or bind by process
|
|
// cwd only, which implies a prompt flag carries the instruction.
|
|
const expected = {
|
|
codex: ['exec'],
|
|
opencode: ['run'],
|
|
kimi: ['--print'],
|
|
'kimi-code': [],
|
|
};
|
|
for (const [id, leadingArgs] of Object.entries(expected)) {
|
|
const cap = loadCapability(id);
|
|
const oe = cap.runtime.orchestratorExec;
|
|
assert.deepEqual(oe.args, leadingArgs,
|
|
`${id}: orchestratorExec.args must be ${JSON.stringify(leadingArgs)} — an empty/verb-less argv for a ` +
|
|
`flag-bound host launches the interactive TUI and the orchestrator waits on it forever`);
|
|
const resolved = resolveOrchestratorExec(oe, '/tmp/wt', 'do the thing');
|
|
assert.equal(resolved.ok, true, `${id}: must resolve with a prompt`);
|
|
assert.ok(resolved.args.includes('do the thing'),
|
|
`${id}: the executor prompt must reach the argv, else the spawned process has no instruction`);
|
|
}
|
|
});
|
|
|
|
test('kimi and kimi-code both spawn the "kimi" binary — kimi-code is a package name, not a binary', () => {
|
|
// Moonshot ships both agents as a binary named `kimi`; `kimi-code` is the
|
|
// npm package / config-home name only. Declaring command:"kimi-code" is an
|
|
// immediate ENOENT at spawn.
|
|
for (const id of ['kimi', 'kimi-code']) {
|
|
assert.equal(loadCapability(id).runtime.orchestratorExec.command, 'kimi',
|
|
`${id}: orchestratorExec.command must be the real binary name "kimi"`);
|
|
}
|
|
});
|
|
|
|
test('every capability whose dispatch.isolation is "harness-worktree" declares a non-empty harnessIsolationFlag', () => {
|
|
const capIds = fs.readdirSync(CAPABILITIES_DIR).filter((entry) => (
|
|
fs.existsSync(path.join(CAPABILITIES_DIR, entry, 'capability.json'))
|
|
));
|
|
const harnessHosts = [];
|
|
for (const id of capIds) {
|
|
const cap = loadCapability(id);
|
|
const iso = cap?.runtime?.hostIntegration?.dispatch?.isolation;
|
|
if (iso !== 'harness-worktree') continue;
|
|
harnessHosts.push(id);
|
|
const flag = cap.runtime.harnessIsolationFlag;
|
|
assert.ok(
|
|
typeof flag === 'string' && flag.length > 0,
|
|
`${id}: dispatch.isolation:"harness-worktree" but no runtime.harnessIsolationFlag — the scheduler ` +
|
|
`would have nothing to pass and would dispatch UNISOLATED executors believing they are isolated`,
|
|
);
|
|
}
|
|
assert.deepEqual(harnessHosts.sort(), ['claude', 'cursor']);
|
|
});
|
|
|
|
test('no capability declares BOTH isolation mechanisms (they are mutually exclusive models)', () => {
|
|
const capIds = fs.readdirSync(CAPABILITIES_DIR).filter((entry) => (
|
|
fs.existsSync(path.join(CAPABILITIES_DIR, entry, 'capability.json'))
|
|
));
|
|
for (const id of capIds) {
|
|
const cap = loadCapability(id);
|
|
const hasHarness = typeof cap?.runtime?.harnessIsolationFlag === 'string';
|
|
const hasOrchestrator = cap?.runtime?.orchestratorExec !== undefined;
|
|
assert.ok(!(hasHarness && hasOrchestrator),
|
|
`${id}: declares both harnessIsolationFlag and orchestratorExec — a host has exactly one fan-out model`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('#2584 orchestratorExec — validator', () => {
|
|
function shippedCodexCapabilityWithoutOrchestratorExec() {
|
|
const cap = JSON.parse(
|
|
fs.readFileSync(path.join(REPO_ROOT, 'capabilities', 'codex', 'capability.json'), 'utf8'),
|
|
);
|
|
delete cap.runtime.orchestratorExec;
|
|
return cap;
|
|
}
|
|
|
|
test('a descriptor that omits orchestratorExec entirely still validates clean (optional field)', () => {
|
|
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
|
const errors = validateCapability(cap, 'codex');
|
|
assert.deepEqual(errors, [], `omitted orchestratorExec must validate clean, got: ${JSON.stringify(errors)}`);
|
|
});
|
|
|
|
test('a well-formed orchestratorExec passes with zero orchestratorExec errors', () => {
|
|
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
|
cap.runtime.orchestratorExec = { command: 'codex', args: ['exec'], cwdFlag: '--cd' };
|
|
const errors = validateCapability(cap, 'codex');
|
|
const oeErrors = errors.filter((e) => e.includes('orchestratorExec'));
|
|
assert.deepEqual(oeErrors, []);
|
|
});
|
|
|
|
test('orchestratorExec: not an object (array/null/string) → rejected', () => {
|
|
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
|
for (const bogus of [[], null, 'codex', 42]) {
|
|
cap.runtime.orchestratorExec = bogus;
|
|
const errors = validateCapability(cap, 'codex');
|
|
assert.ok(
|
|
errors.some((e) => e.includes('runtime.orchestratorExec must be an object')),
|
|
`orchestratorExec=${JSON.stringify(bogus)} must be rejected; got: ${JSON.stringify(errors)}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('command missing → rejected', () => {
|
|
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
|
cap.runtime.orchestratorExec = { args: [], cwdFlag: null };
|
|
const errors = validateCapability(cap, 'codex');
|
|
assert.ok(errors.some((e) => e.includes('runtime.orchestratorExec.command')));
|
|
});
|
|
|
|
test('command: "" (empty string) → rejected', () => {
|
|
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
|
cap.runtime.orchestratorExec = { command: '' };
|
|
const errors = validateCapability(cap, 'codex');
|
|
assert.ok(errors.some((e) => e.includes('runtime.orchestratorExec.command')));
|
|
});
|
|
|
|
test('command: "__proto__" (reserved name) → rejected', () => {
|
|
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
|
cap.runtime.orchestratorExec = { command: '__proto__' };
|
|
const errors = validateCapability(cap, 'codex');
|
|
assert.ok(
|
|
errors.some((e) => e.includes('runtime.orchestratorExec.command') && e.includes('reserved name')),
|
|
`"__proto__" must produce a reserved-name validator error; got: ${JSON.stringify(errors)}`,
|
|
);
|
|
});
|
|
|
|
test('args: non-array → rejected', () => {
|
|
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
|
cap.runtime.orchestratorExec = { command: 'codex', args: 'exec' };
|
|
const errors = validateCapability(cap, 'codex');
|
|
assert.ok(errors.some((e) => e.includes('runtime.orchestratorExec.args')));
|
|
});
|
|
|
|
test('args: array with a non-string element → rejected', () => {
|
|
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
|
cap.runtime.orchestratorExec = { command: 'codex', args: ['exec', 42] };
|
|
const errors = validateCapability(cap, 'codex');
|
|
assert.ok(errors.some((e) => e.includes('runtime.orchestratorExec.args')));
|
|
});
|
|
|
|
test('cwdFlag: a number → rejected', () => {
|
|
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
|
cap.runtime.orchestratorExec = { command: 'codex', cwdFlag: 42 };
|
|
const errors = validateCapability(cap, 'codex');
|
|
assert.ok(errors.some((e) => e.includes('runtime.orchestratorExec.cwdFlag')));
|
|
});
|
|
|
|
test('cwdFlag: null is valid (no error)', () => {
|
|
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
|
cap.runtime.orchestratorExec = { command: 'kimi-code', args: [], cwdFlag: null };
|
|
const errors = validateCapability(cap, 'codex');
|
|
const oeErrors = errors.filter((e) => e.includes('orchestratorExec'));
|
|
assert.deepEqual(oeErrors, []);
|
|
});
|
|
|
|
test('full descriptor sweep: every shipped capability.json still validates clean after the orchestratorExec addition', () => {
|
|
const CAPABILITIES_DIR = path.join(REPO_ROOT, 'capabilities');
|
|
const capIds = fs.readdirSync(CAPABILITIES_DIR).filter((entry) => {
|
|
const capPath = path.join(CAPABILITIES_DIR, entry, 'capability.json');
|
|
return fs.existsSync(capPath);
|
|
});
|
|
for (const id of capIds) {
|
|
const cap = JSON.parse(fs.readFileSync(path.join(CAPABILITIES_DIR, id, 'capability.json'), 'utf8'));
|
|
if (cap.role !== 'runtime') continue;
|
|
const errors = validateCapability(cap, id);
|
|
assert.deepEqual(errors, [], `${id}: capability.json must still validate clean; got: ${JSON.stringify(errors)}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #2627 Phase 3 — the `dispatch-isolation` CLI route.
|
|
//
|
|
// Behavioral: each case SPAWNS the real gsd-tools CLI and asserts on its actual
|
|
// stdout, rather than inspecting the route's source. This is the scheduler
|
|
// consumer's only entry point — execute-phase branches on exactly this output.
|
|
// ---------------------------------------------------------------------------
|
|
describe('#2627 dispatch-isolation CLI route', () => {
|
|
const { runNode } = require('./helpers/process-seam.cjs');
|
|
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
|
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
const GSD_TOOLS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs');
|
|
|
|
function query(runtimeId, extraArgs = []) {
|
|
const r = runNode(
|
|
[GSD_TOOLS, 'query', 'dispatch-isolation', ...extraArgs],
|
|
{ cwd: REPO_ROOT, env: { ...process.env, GSD_RUNTIME: runtimeId }, timeoutMs: PROBE_TIMEOUT_MS },
|
|
);
|
|
throwIfFailed(r, `gsd-tools query dispatch-isolation ${extraArgs.join(' ')}`);
|
|
return r.stdout;
|
|
}
|
|
const queryJson = (runtimeId, extraArgs = []) => JSON.parse(query(runtimeId, ['--json', ...extraArgs]));
|
|
|
|
test('raw output is the bare negotiated value for each isolation model', () => {
|
|
assert.equal(query('claude').trim(), 'harness-worktree');
|
|
assert.equal(query('codex').trim(), 'orchestrator-worktree');
|
|
assert.equal(query('pi').trim(), 'none');
|
|
});
|
|
|
|
test('an undocumented isolation declaration degrades to none (fail-closed)', () => {
|
|
// cline declares isolation:"undocumented" — the corpus-wide sentinel.
|
|
assert.equal(query('cline').trim(), 'none');
|
|
});
|
|
|
|
test('an unknown runtime degrades to none rather than erroring', () => {
|
|
assert.equal(query('no-such-runtime-xyz').trim(), 'none');
|
|
});
|
|
|
|
test('harness-worktree surfaces the host\'s declared flag, and no exec', () => {
|
|
const claude = queryJson('claude');
|
|
assert.equal(claude.isolation, 'harness-worktree');
|
|
assert.equal(claude.harnessFlag, 'isolation="worktree"');
|
|
assert.equal(claude.exec, null, 'GSD runs no git on the harness path — there is nothing to spawn');
|
|
|
|
assert.equal(queryJson('cursor').harnessFlag, '--worktree');
|
|
});
|
|
|
|
test('orchestrator-worktree yields exec only when a cwd target is supplied', () => {
|
|
assert.equal(queryJson('codex').exec, null, 'no --cwd-target → nothing to bind');
|
|
|
|
const withTarget = queryJson('codex', ['--cwd-target', '/tmp/wt', '--prompt', 'do the thing']);
|
|
assert.equal(withTarget.isolation, 'orchestrator-worktree');
|
|
assert.equal(withTarget.exec.command, 'codex');
|
|
assert.deepEqual(withTarget.exec.args, ['exec', '--cd', '/tmp/wt', 'do the thing']);
|
|
assert.equal(withTarget.exec.cwd, '/tmp/wt');
|
|
assert.equal(withTarget.harnessFlag, null);
|
|
});
|
|
|
|
test('each orchestrator-worktree host resolves to its own documented argv shape', () => {
|
|
const args = (id) => queryJson(id, ['--cwd-target', '/tmp/wt', '--prompt', 'P']).exec.args;
|
|
assert.deepEqual(args('opencode'), ['run', '--dir', '/tmp/wt', 'P']);
|
|
// kimi carries the prompt behind --prompt (its --print mode requires it),
|
|
// unlike codex/opencode which take it positionally.
|
|
assert.deepEqual(args('kimi'), ['--print', '--work-dir', '/tmp/wt', '--prompt', 'P']);
|
|
// kimi-code binds by process cwd — no flag in argv, but cwd still returned.
|
|
assert.deepEqual(args('kimi-code'), ['--prompt', 'P']);
|
|
assert.equal(queryJson('kimi-code', ['--cwd-target', '/tmp/wt', '--prompt', 'P']).exec.cwd, '/tmp/wt');
|
|
});
|
|
|
|
test('a cwd target with no prompt still resolves (prompt is optional at this seam)', () => {
|
|
const noPrompt = queryJson('codex', ['--cwd-target', '/tmp/wt']);
|
|
assert.equal(noPrompt.isolation, 'orchestrator-worktree');
|
|
assert.deepEqual(noPrompt.exec.args, ['exec', '--cd', '/tmp/wt']);
|
|
});
|
|
|
|
test('the route never reports an isolation model it cannot actually service', () => {
|
|
// The invariant the scheduler depends on: if isolation is non-none, the
|
|
// corresponding mechanism is present. Anything else would have the wave
|
|
// create a worktree and then discover it has nothing to spawn into it.
|
|
for (const id of ['claude', 'cursor', 'codex', 'opencode', 'kimi', 'kimi-code', 'pi', 'cline', 'zcode']) {
|
|
const r = queryJson(id, ['--cwd-target', '/tmp/wt', '--prompt', 'P']);
|
|
if (r.isolation === 'harness-worktree') {
|
|
assert.ok(r.harnessFlag && r.harnessFlag.length > 0, `${id}: harness-worktree without a flag`);
|
|
} else if (r.isolation === 'orchestrator-worktree') {
|
|
assert.ok(r.exec && r.exec.command, `${id}: orchestrator-worktree without a spawnable exec`);
|
|
} else {
|
|
assert.equal(r.isolation, 'none', `${id}: unexpected isolation value ${r.isolation}`);
|
|
assert.equal(r.exec, null);
|
|
assert.equal(r.harnessFlag, null);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('a none-isolation host never yields an exec even when a target is supplied', () => {
|
|
const pi = queryJson('pi', ['--cwd-target', '/tmp/wt', '--prompt', 'P']);
|
|
assert.equal(pi.isolation, 'none');
|
|
assert.equal(pi.exec, null);
|
|
assert.equal(pi.harnessFlag, null);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #2652 — dispatch-site parity: isolation is decided by the negotiated
|
|
// dispatch.isolation capability, never by a runtime id.
|
|
//
|
|
// #2584 migrated the phase scheduler off `RUNTIME != "claude"` but left quick.md
|
|
// and diagnose-issues.md behind, so Codex — which declares orchestrator-worktree
|
|
// — was refused isolation it had negotiated. That is this repo's
|
|
// DEFECT.GENERATIVE-FIX-DIVERGENCE shape: parallel surfaces reading one contract,
|
|
// one migrated and the others silently stale. This guard fails when any dispatch
|
|
// site reintroduces a runtime-name test around its isolation decision.
|
|
// ---------------------------------------------------------------------------
|
|
// Scan workflows AND the reference fragments they inline: scheduler branches that
|
|
// mutate USE_WORKTREES live in gsd-core/references/ too (execute-phase-wave-guard,
|
|
// execute-phase-between-wave-reset), and a workflows-only scan misses them.
|
|
// Shared by both #2652 and #2728 suites below — a second, hand-listed copy is how
|
|
// the degrade scan silently narrowed to three files (round-7 review, Major 6).
|
|
const SCAN_ROOTS = [
|
|
path.join(REPO_ROOT, 'gsd-core', 'workflows'),
|
|
path.join(REPO_ROOT, 'gsd-core', 'references'),
|
|
];
|
|
|
|
function collectMarkdown(dir) {
|
|
const out = [];
|
|
if (!fs.existsSync(dir)) return out;
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) out.push(...collectMarkdown(full));
|
|
else if (entry.name.endsWith('.md')) out.push(full);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe('#2652 dispatch-site parity — isolation gates on capability, not runtime id', () => {
|
|
const ISOLATION_TOKEN = /USE_WORKTREES|ISOLATION|isolation="worktree"|harnessFlag/;
|
|
|
|
// Any shape that reads RUNTIME as a branch condition. Line-based matching missed
|
|
// multiline `&&`, [[ ]], case, `test`, and JS-template forms, so match the RUNTIME
|
|
// test itself and then look for an isolation token within the following window.
|
|
//
|
|
// Operand ORDER is not fixed either: `[ "claude" != "$RUNTIME" ]` is the same gate
|
|
// written backwards, and hand-written left-only patterns let it through. Each
|
|
// comparison shape is therefore generated in both orders from a single template, so
|
|
// a new shape cannot be added in one order and forgotten in the other.
|
|
const RT = '"?\\$\\{?RUNTIME\\}?"?'; // $RUNTIME / "$RUNTIME" / "${RUNTIME}"
|
|
const JS_RT = 'RUNTIME'; // bare identifier inside a ${…} template
|
|
const QLIT = '["\'][a-z-]+["\']'; // "claude"
|
|
const LIT = '["\']?[a-z-]+["\']?'; // claude / "claude" ([[ ]] permits bare)
|
|
|
|
/** One comparison shape → two regexes, operands in either order. */
|
|
const bothOrders = (tpl, a, b) => [
|
|
new RegExp(tpl.replace('%L', a).replace('%R', b)),
|
|
new RegExp(tpl.replace('%L', b).replace('%R', a)),
|
|
];
|
|
|
|
const RUNTIME_TESTS = [
|
|
...bothOrders('\\[\\s*%L\\s*(?:!=|==?)\\s*%R\\s*\\]', RT, QLIT), // [ "$RUNTIME" = "x" ]
|
|
...bothOrders('\\[\\[\\s*%L\\s*(?:!=|==?)\\s*%R\\s*\\]\\]', RT, LIT), // [[ "$RUNTIME" == x ]]
|
|
...bothOrders('\\btest\\s+%L\\s*(?:!=|=)\\s*%R', RT, QLIT), // test "$RUNTIME" = "x"
|
|
...bothOrders('\\$\\{\\s*%L\\s*(?:===|!==|==|!=)\\s*%R', JS_RT, QLIT), // ${RUNTIME === "x" ? ...}
|
|
/\bcase\s+"?\$\{?RUNTIME\}?"?\s+in\b/, // case "$RUNTIME" in (no reversed form)
|
|
];
|
|
|
|
const WINDOW = 400; // chars after the RUNTIME test to look for an isolation decision
|
|
|
|
function isolationGateOffenders(text, label) {
|
|
const hits = [];
|
|
for (const re of RUNTIME_TESTS) {
|
|
const global = new RegExp(re.source, 'g');
|
|
let m;
|
|
while ((m = global.exec(text)) !== null) {
|
|
const window = text.slice(m.index, m.index + WINDOW);
|
|
if (ISOLATION_TOKEN.test(window)) {
|
|
const line = text.slice(0, m.index).split('\n').length;
|
|
hits.push(`${label}:${line}: ${m[0].trim()}`);
|
|
}
|
|
}
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
const dispatchSites = SCAN_ROOTS.flatMap(collectMarkdown).filter(f =>
|
|
ISOLATION_TOKEN.test(fs.readFileSync(f, 'utf-8')),
|
|
);
|
|
|
|
test('the scan covers the known dispatch sites (guards against a vacuous pass)', () => {
|
|
const rel = dispatchSites.map(f => path.relative(REPO_ROOT, f).replace(/\\/g, '/'));
|
|
// Assert identities, not just a count: a count survives the scan silently
|
|
// drifting off the files that actually matter.
|
|
for (const required of [
|
|
'gsd-core/workflows/quick.md',
|
|
'gsd-core/workflows/diagnose-issues.md',
|
|
'gsd-core/workflows/execute-plan.md',
|
|
'gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md',
|
|
]) {
|
|
assert.ok(rel.includes(required), `dispatch-site scan must cover ${required}; found ${rel.length} files`);
|
|
}
|
|
});
|
|
|
|
// #2652 review: executor-isolation-dispatch.md declared
|
|
// references/dispatch-isolation-gate.md canonical while keeping the OLDER
|
|
// collapsing resolver inline — `|| echo "none"`, no ISOLATION_RESOLVED — so a
|
|
// transient shim failure aborted /gsd:execute-phase telling a Claude or
|
|
// Cursor user their runtime "declares no executor-isolation primitive". Still
|
|
// fail-closed, so not an unsafe-dispatch hole, but the correction this PR is
|
|
// about was unwired at one of the five sites. Nothing caught it: the emitted
|
|
// coverage in install.test.cjs checks the REFERENCE, not each site's own copy.
|
|
test('every site that inlines the resolver uses the non-collapsing shape', () => {
|
|
// A site "inlines the resolver" only if it ASSIGNS from it. The other
|
|
// dispatch sites @-reference the gate and merely re-record a degrade
|
|
// (`--force-isolation …` with no assignment), which carries no verdict of
|
|
// its own — matching those too would flag files that have nothing to fix.
|
|
const INLINE_RESOLVE = /\w+=\$\(gsd_run query dispatch-isolation --raw/;
|
|
// Dedupe: SCAN_ROOTS already yields the gate reference, so appending it
|
|
// again made `length >= 2` satisfiable by the gate alone — the executor
|
|
// site could drop out of the predicate entirely and this would still pass.
|
|
const candidates = [...new Set(
|
|
[...dispatchSites, path.join(REPO_ROOT, 'gsd-core', 'references', 'dispatch-isolation-gate.md')]
|
|
.filter(f => fs.existsSync(f))
|
|
.map(f => path.resolve(f)),
|
|
)];
|
|
const inliners = candidates
|
|
.map(f => ({ rel: path.relative(REPO_ROOT, f).replace(/\\/g, '/'), text: fs.readFileSync(f, 'utf-8') }))
|
|
.filter(({ text }) => INLINE_RESOLVE.test(text));
|
|
|
|
// Pin identities, not a count. A count cannot tell "the executor site was
|
|
// fixed" from "the executor site stopped matching the predicate".
|
|
assert.deepEqual(
|
|
inliners.map(i => i.rel).sort(),
|
|
[
|
|
'gsd-core/references/dispatch-isolation-gate.md',
|
|
'gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md',
|
|
],
|
|
'the set of files inlining the resolver changed — a new inliner needs the same treatment, and a missing one means the predicate stopped seeing it',
|
|
);
|
|
|
|
for (const { rel, text } of inliners) {
|
|
// Any assignment target, not just ISOLATION — `_ISOLATION_RAW=$(… || echo
|
|
// "none")` restores the identical defect while leaving ISOLATION_RESOLVED
|
|
// in the file, so a name-specific pattern passes on a broken block.
|
|
assert.doesNotMatch(
|
|
text,
|
|
/\w+=\$\(gsd_run query dispatch-isolation --raw[^\n]*\|\|[^\n]*echo/,
|
|
`${rel}: collapses a resolver failure straight to none — that reports "declares no primitive" for a host that simply could not be queried`,
|
|
);
|
|
assert.match(
|
|
text,
|
|
/ISOLATION_RESOLVED=false/,
|
|
`${rel}: inlines the resolver but never records that no verdict was learned`,
|
|
);
|
|
assert.match(
|
|
text,
|
|
/could not resolve this runtime's executor-isolation capability/,
|
|
`${rel}: has no distinct message for the unresolved case, so both outcomes read as a capability verdict`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('the detector flags every known runtime-gate shape (discrimination proof)', () => {
|
|
// Each of these slipped past the original same-line, single-bracket detector.
|
|
const mutations = {
|
|
'single bracket, same line':
|
|
'if [ "$RUNTIME" != "claude" ] && [ "$USE_WORKTREES" != "false" ]; then',
|
|
'multiline &&':
|
|
'if [ "$RUNTIME" != "claude" ] && \\\n [ "$USE_WORKTREES" != "false" ]; then',
|
|
'double bracket':
|
|
'if [[ "$RUNTIME" == claude ]]; then\n USE_WORKTREES=false\nfi',
|
|
'nested, later assignment':
|
|
'if [ "$RUNTIME" = "codex" ]; then\n echo hi\n ISOLATION=none\nfi',
|
|
'case statement':
|
|
'case "$RUNTIME" in\n claude) USE_WORKTREES=true ;;\nesac',
|
|
'js template':
|
|
'${RUNTIME === "claude" ? \'isolation="worktree",\' : \'\'}',
|
|
'test builtin':
|
|
'if test "$RUNTIME" = "claude"; then\n ISOLATION=harness-worktree\nfi',
|
|
// Reversed operands — the same gate written backwards. Every one of these
|
|
// evaded the original left-only patterns (#2728 review, Minor).
|
|
'reversed single bracket':
|
|
'if [ "claude" != "$RUNTIME" ] && [ "$USE_WORKTREES" != "false" ]; then',
|
|
'reversed double bracket':
|
|
'if [[ claude == "$RUNTIME" ]]; then\n USE_WORKTREES=false\nfi',
|
|
'reversed test builtin':
|
|
'if test "claude" = "$RUNTIME"; then\n ISOLATION=harness-worktree\nfi',
|
|
'reversed js template':
|
|
'${"claude" === RUNTIME ? \'isolation="worktree",\' : \'\'}',
|
|
};
|
|
for (const [name, snippet] of Object.entries(mutations)) {
|
|
assert.equal(
|
|
isolationGateOffenders(snippet, 'mutation').length >= 1,
|
|
true,
|
|
`detector must flag the "${name}" reintroduction — otherwise the guard below proves nothing`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('the detector flags generated shell-comparison permutations (property)', () => {
|
|
// The mutation table above is 11 hand-picked cases; this generates the cross
|
|
// product of the axes an author actually varies — bracket form, operator,
|
|
// operand order, quoting, spacing, runtime id. A permutation the hand-written
|
|
// patterns miss shows up here rather than in production (#2728 review, Nit).
|
|
fc.assert(
|
|
fc.property(
|
|
fc.constantFrom('[', '[[', 'test'),
|
|
fc.constantFrom('=', '==', '!='),
|
|
fc.boolean(), // reversed operands?
|
|
fc.constantFrom('"$RUNTIME"', '$RUNTIME', '"${RUNTIME}"'),
|
|
fc.constantFrom('claude', 'codex', 'kimi-code'),
|
|
fc.boolean(), // quote the literal?
|
|
fc.constantFrom('', ' '), // extra padding
|
|
(form, op, reversed, rtTok, id, quoted, pad) => {
|
|
// `test` has no `==` form and never takes brackets; bare literals are
|
|
// only legal inside [[ ]].
|
|
if (form === 'test' && op === '==') return true;
|
|
const lit = quoted || form !== '[[' ? `"${id}"` : id;
|
|
const [l, r] = reversed ? [lit, rtTok] : [rtTok, lit];
|
|
const cond = `${l}${pad} ${op} ${pad}${r}`;
|
|
const snippet = form === 'test'
|
|
? `if test ${cond}; then\n ISOLATION=none\nfi`
|
|
: `if ${form} ${cond} ${form === '[[' ? ']]' : ']'}; then\n ISOLATION=none\nfi`;
|
|
return isolationGateOffenders(snippet, 'prop').length >= 1;
|
|
},
|
|
),
|
|
{ numRuns: 300 },
|
|
);
|
|
});
|
|
|
|
test('a RUNTIME read with no isolation decision nearby is NOT flagged (no false positive)', () => {
|
|
const benign = 'RUNTIME=$(gsd_run query config-get runtime --raw)\necho "runtime is $RUNTIME"';
|
|
assert.deepEqual(isolationGateOffenders(benign, 'benign'), []);
|
|
});
|
|
|
|
test('no dispatch site gates isolation on a runtime id', () => {
|
|
const offenders = [];
|
|
for (const file of dispatchSites) {
|
|
offenders.push(
|
|
...isolationGateOffenders(
|
|
fs.readFileSync(file, 'utf-8'),
|
|
path.relative(REPO_ROOT, file).replace(/\\/g, '/'),
|
|
),
|
|
);
|
|
}
|
|
assert.deepEqual(
|
|
offenders,
|
|
[],
|
|
'isolation must be resolved from `gsd_run query dispatch-isolation` (see ' +
|
|
'gsd-core/references/dispatch-isolation-gate.md), never from a RUNTIME comparison:\n' +
|
|
offenders.join('\n'),
|
|
);
|
|
});
|
|
|
|
// #2728 review Blocker — the ISOLATION_TOKEN regex above treats
|
|
// `isolation="worktree"` as a legitimate isolation marker, so the runtime-gate
|
|
// detector cannot catch a *conditional* keyed on that literal. But the literal
|
|
// is Claude Code's own rendering of {harnessFlag}; Cursor's rendering is
|
|
// `--worktree`, so any post-dispatch step gated on the literal is a silent
|
|
// no-op for a correctly-isolated Cursor run (quick.md's manifest append and
|
|
// worktree merge-back were exactly this — isolated work never merged, never
|
|
// cleaned up). Post-dispatch bookkeeping must key on the negotiated ISOLATION
|
|
// value instead (dispatch-isolation-gate.md's "never hardcode" rule).
|
|
const LITERAL_CONDITION = /\bIf\b[^.\n]*`isolation="worktree"`/g;
|
|
|
|
function literalConditionOffenders(text, label) {
|
|
const hits = [];
|
|
let m;
|
|
const re = new RegExp(LITERAL_CONDITION.source, 'g');
|
|
while ((m = re.exec(text)) !== null) {
|
|
const line = text.slice(0, m.index).split('\n').length;
|
|
hits.push(`${label}:${line}: ${m[0].trim()}`);
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
test('the literal-condition detector flags the pre-fix quick.md shapes (discrimination proof)', () => {
|
|
const preFix = {
|
|
'manifest append':
|
|
'If the executor ran with `isolation="worktree"`, append its returned metadata to `QUICK_WORKTREE_MANIFEST` before cleanup.',
|
|
'worktree cleanup':
|
|
'1. **Worktree cleanup:** If the executor ran with `isolation="worktree"`, merge the worktree branch back and clean up:',
|
|
};
|
|
for (const [name, snippet] of Object.entries(preFix)) {
|
|
assert.equal(
|
|
literalConditionOffenders(snippet, 'mutation').length,
|
|
1,
|
|
`detector must flag the pre-fix "${name}" conditional — otherwise the guard below proves nothing`,
|
|
);
|
|
}
|
|
// Explanatory prose that merely *names* the literal (no conditional) stays legal.
|
|
assert.deepEqual(
|
|
literalConditionOffenders(
|
|
'Claude Code\'s `isolation="worktree"` forks new worktrees from `origin/HEAD`.',
|
|
'benign',
|
|
),
|
|
[],
|
|
);
|
|
});
|
|
|
|
test('no dispatch site conditions post-dispatch behavior on the Claude-rendered literal', () => {
|
|
const offenders = [];
|
|
for (const file of dispatchSites) {
|
|
offenders.push(
|
|
...literalConditionOffenders(
|
|
fs.readFileSync(file, 'utf-8'),
|
|
path.relative(REPO_ROOT, file).replace(/\\/g, '/'),
|
|
),
|
|
);
|
|
}
|
|
assert.deepEqual(
|
|
offenders,
|
|
[],
|
|
'post-dispatch steps must key on `ISOLATION = "harness-worktree"`, never on ' +
|
|
'Claude Code\'s rendered `isolation="worktree"` literal (a Cursor dispatch renders ' +
|
|
'`--worktree` and would silently skip these steps):\n' + offenders.join('\n'),
|
|
);
|
|
});
|
|
|
|
test('quick.md post-dispatch bookkeeping keys on the negotiated ISOLATION value', () => {
|
|
const quick = fs.readFileSync(
|
|
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'quick.md'), 'utf-8',
|
|
);
|
|
assert.match(
|
|
quick,
|
|
/If the executor ran isolated \(`ISOLATION = "harness-worktree"` at dispatch\), append its returned/,
|
|
'the QUICK_WORKTREE_MANIFEST append must be gated on ISOLATION',
|
|
);
|
|
assert.match(
|
|
quick,
|
|
/\*\*Worktree cleanup:\*\* If the executor ran isolated \(`ISOLATION = "harness-worktree"` at dispatch\)/,
|
|
'the worktree merge-back/cleanup must be gated on ISOLATION',
|
|
);
|
|
assert.match(
|
|
quick,
|
|
/If `ISOLATION` was not `"harness-worktree"` at dispatch[^\n]*skip this step/,
|
|
'the cleanup skip clause must mirror the same ISOLATION gate (USE_WORKTREES stays true on an isolated Cursor run)',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #2728 review BLOCKER (B1/B2/B3) — a degrade must re-RECORD, not just reassign.
|
|
//
|
|
// Every isolation degrade in a dispatch site is decided in SHELL, where
|
|
// `routeDispatchIsolation` cannot see it. That resolver persists whatever it
|
|
// resolved to the run-scoped sentinel as an unconditional side effect (#3045
|
|
// CORE REDESIGN, hooks/lib/isolation-sentinel.js), so a degrade that only
|
|
// reassigns `$ISOLATION` leaves the sentinel asserting `harness-worktree`
|
|
// while the dispatch correctly omits the harness flag. The shipped PreToolUse
|
|
// guard reads the sentinel at the instant of the `Agent()` call and denies the
|
|
// mismatch with exit 2 — the work does not run unisolated, it does not run.
|
|
//
|
|
// WHY THIS ASSERTS THE RECORDED VALUE, NOT `$ISOLATION`: asserting the local
|
|
// variable is precisely what let this class through. `$ISOLATION` was already
|
|
// correct at all three sites — `none` — and the defect was entirely in what
|
|
// reached the sentinel. So these tests execute each workflow's own degrade
|
|
// block under a `gsd_run` stub that captures every call, and assert on the
|
|
// value the workflow PUSHED THROUGH THE WRITE PATH.
|
|
// ---------------------------------------------------------------------------
|
|
describe('#2728 B1 — isolation degrades re-record through the single write path', () => {
|
|
const { runHook } = require('./helpers/process-seam.cjs');
|
|
// Class-norm timeout, not a local literal (CONTRIBUTING: class-norm
|
|
// timeouts live in tests/helpers/timeouts.cjs). This harness is a short
|
|
// shell probe against a gsd_run stub — exactly the PROBE class.
|
|
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
const os = require('node:os');
|
|
|
|
const WORKFLOWS = path.join(REPO_ROOT, 'gsd-core', 'workflows');
|
|
|
|
/**
|
|
* Pull the fenced ```bash block containing `marker` out of a workflow.
|
|
*
|
|
* DEFECT.WINDOWS-CRLF-TEST-PORTABILITY: the captured body is handed to
|
|
* `bash` below, so it must be CRLF-free. A `\r?\n` fence regex is NOT
|
|
* sufficient — it only protects the delimiter match, leaving embedded `\r`
|
|
* on every line of the body, which bash treats as part of the token
|
|
* (helpers.cjs documents exactly this trap). Normalize at the READ
|
|
* boundary so everything downstream is LF-only by construction.
|
|
*/
|
|
function bashBlockContaining(file, marker) {
|
|
const text = readFileNormalized(file);
|
|
for (const m of text.matchAll(/```bash\r?\n([\s\S]*?)```/g)) {
|
|
if (m[1].includes(marker)) return m[1];
|
|
}
|
|
assert.fail(`no \`\`\`bash block containing ${JSON.stringify(marker)} in ${file}`);
|
|
}
|
|
|
|
/**
|
|
* Run a degrade block with the base-check forced to fire, under a `gsd_run`
|
|
* stub that logs its argv. Returns every `dispatch-isolation` call the block
|
|
* made, in order — i.e. the writes that would have hit the sentinel.
|
|
*/
|
|
function recordedWrites(block) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-degrade-'));
|
|
const log = path.join(dir, 'calls.log');
|
|
// The stub answers the base-check `true` so the degrade path is TAKEN;
|
|
// every other query returns empty. It logs the full argv of each call.
|
|
const harness = [
|
|
'set -u',
|
|
`gsd_run() { printf '%s\\n' "$*" >> ${JSON.stringify(log)};`,
|
|
' case "$*" in',
|
|
' *"worktree.base-check"*"shouldDegrade"*) printf true ;;',
|
|
' *"worktree.base-check"*"message"*) printf "base diverged" ;;',
|
|
' *) printf "" ;;',
|
|
' esac; }',
|
|
'ISOLATION=harness-worktree',
|
|
'USE_WORKTREES=true',
|
|
'RUNTIME=claude',
|
|
'PHASE_NUMBER=7',
|
|
block,
|
|
// Prove the local variable was ALSO correct, so a failure below can only
|
|
// mean the recording is missing — not that the degrade itself misfired.
|
|
'printf "FINAL_LOCAL=%s\\n" "$ISOLATION"',
|
|
].join('\n');
|
|
|
|
// Through the process seam, never a hand-rolled spawnSync (CONTRIBUTING
|
|
// "Spawning a subprocess: use the process seam"). `runHook` documents
|
|
// `interpreter: 'bash'` for running a shell script, so the harness is
|
|
// written to a file rather than passed as `-c`. Bounded by construction
|
|
// (DEFECT.UNBOUNDED-SUBPROCESS): pure shell against a `gsd_run` stub — no
|
|
// git, no network, no real CLI — so it completes in milliseconds.
|
|
const scriptPath = path.join(dir, 'degrade-harness.sh');
|
|
fs.writeFileSync(scriptPath, harness);
|
|
const res = runHook(scriptPath, [], { interpreter: 'bash', timeoutMs: PROBE_TIMEOUT_MS });
|
|
if (res.outcome !== 'exited') {
|
|
cleanup(dir);
|
|
assert.fail(`degrade block did not complete: outcome=${res.outcome} ${res.stderr || ''}`);
|
|
}
|
|
assert.equal(res.exitCode, 0, `degrade block exited ${res.exitCode}: ${res.stderr}`);
|
|
assert.match(res.stdout, /FINAL_LOCAL=none/, 'the degrade must set $ISOLATION=none locally');
|
|
|
|
const calls = fs.existsSync(log)
|
|
? fs.readFileSync(log, 'utf-8').split(/\r?\n/).filter(Boolean)
|
|
: [];
|
|
cleanup(dir);
|
|
return calls.filter(c => c.includes('dispatch-isolation'));
|
|
}
|
|
|
|
/** The isolation mode the last write pushed, or null if nothing was written. */
|
|
function recordedIsolation(block) {
|
|
const writes = recordedWrites(block);
|
|
if (writes.length === 0) return null;
|
|
const last = writes[writes.length - 1];
|
|
const m = last.match(/--force-isolation\s+(\S+)/);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
const DEGRADE_SITES = [
|
|
{
|
|
label: 'quick.md #1941 base-check degrade',
|
|
file: path.join(WORKFLOWS, 'quick.md'),
|
|
marker: '_QUICK_SHOULD_DEGRADE',
|
|
},
|
|
{
|
|
label: 'diagnose-issues.md #2649 base-check degrade',
|
|
file: path.join(WORKFLOWS, 'diagnose-issues.md'),
|
|
marker: '_DIAG_SHOULD_DEGRADE',
|
|
},
|
|
];
|
|
|
|
for (const site of DEGRADE_SITES) {
|
|
test(`${site.label} records none, not just the local variable`, () => {
|
|
const block = bashBlockContaining(site.file, site.marker);
|
|
assert.equal(
|
|
recordedIsolation(block),
|
|
'none',
|
|
`${site.label}: $ISOLATION degraded to none but the block never pushed that ` +
|
|
'through `query dispatch-isolation --force-isolation`. The sentinel still ' +
|
|
'asserts harness-worktree, so the #3045 PreToolUse guard denies the dispatch ' +
|
|
'with exit 2. Re-record immediately after the degrade.',
|
|
);
|
|
});
|
|
}
|
|
|
|
test('the harness detects a degrade that only reassigns (fail-first proof)', () => {
|
|
// Strip the re-record from the shipped block. If the assertion above can
|
|
// still pass against this, it is not testing what it claims to test.
|
|
const block = bashBlockContaining(
|
|
path.join(WORKFLOWS, 'quick.md'), '_QUICK_SHOULD_DEGRADE',
|
|
);
|
|
const preFix = block
|
|
.split('\n')
|
|
.filter(l => !l.includes('--force-isolation'))
|
|
.join('\n');
|
|
|
|
assert.notEqual(preFix, block, 'the shipped block must contain a --force-isolation re-record');
|
|
assert.equal(
|
|
recordedIsolation(preFix),
|
|
null,
|
|
'the pre-fix shape must record NOTHING — otherwise these tests prove nothing. ' +
|
|
'Note $ISOLATION is `none` in BOTH shapes: that is exactly why asserting the ' +
|
|
'local variable would have passed on the defect.',
|
|
);
|
|
});
|
|
|
|
test('every dispatch-site degrade block re-records before the block ends', () => {
|
|
// Coverage guard: a NEW degrade site added later cannot silently skip the
|
|
// re-record. Scans the shipped shell rather than a hand-listed set.
|
|
const offenders = [];
|
|
// DERIVED, not hand-listed. The previous revision named three files, so a
|
|
// degrade added anywhere else passed a check whose name claims "every"
|
|
// (#2728 round-7 review, Major 6). Scan every workflow and reference.
|
|
const scan = SCAN_ROOTS.flatMap(collectMarkdown);
|
|
assert.ok(
|
|
scan.length > 10,
|
|
`the degrade scan collected only ${scan.length} files — the walk is broken, not the tree`,
|
|
);
|
|
|
|
// Wave sites re-record PER PLAN, not inline: `execute-phase-wave-guard.md` and
|
|
// `execute-phase-between-wave-reset.md` degrade `USE_WORKTREES=false` together
|
|
// with `ISOLATION=none`, and `per-plan-worktree-gate.md` seeds
|
|
// `USE_WORKTREES_FOR_PLAN="$USE_WORKTREES"` and pushes `--force-isolation none`
|
|
// before each plan's dispatch. That is a real re-record, just delegated — so
|
|
// these two are exempt. The exemption is ASSERTED below rather than assumed,
|
|
// so deleting the delegate fails this test instead of silently widening a hole.
|
|
const DELEGATED_TO_PER_PLAN_GATE = new Set([
|
|
'gsd-core/references/execute-phase-wave-guard.md',
|
|
'gsd-core/references/execute-phase-between-wave-reset.md',
|
|
]);
|
|
const perPlanGate = readFileNormalized(
|
|
path.join(WORKFLOWS, 'execute-phase', 'steps', 'per-plan-worktree-gate.md'),
|
|
);
|
|
assert.ok(
|
|
perPlanGate.includes('USE_WORKTREES_FOR_PLAN="$USE_WORKTREES"') &&
|
|
perPlanGate.includes('--force-isolation none'),
|
|
'per-plan-worktree-gate.md no longer inherits USE_WORKTREES and re-records `none`, so the ' +
|
|
'wave degrade sites above are no longer covered by delegation — either restore the ' +
|
|
'delegate or make those sites re-record inline',
|
|
);
|
|
|
|
// #2486 note: the runtime-neutral diagnostics (health.md, settings.md)
|
|
// resolve isolation for a READ, not a dispatch, and deliberately name their
|
|
// state `INSPECTED_ISOLATION` rather than `ISOLATION`. That keeps them out
|
|
// of this scan by construction. An earlier revision exempted those two
|
|
// files instead; the exemption was file-wide, so any real dispatch block
|
|
// added to either one would have inherited it and escaped a guard whose
|
|
// name promises "every dispatch site" (#2486 review). Renaming the variable
|
|
// removes the carve-out entirely — a diagnostic that ever writes a literal
|
|
// `ISOLATION=none` is caught here like any other site.
|
|
for (const file of scan) {
|
|
const rel = path.relative(REPO_ROOT, file).replace(/\\/g, '/');
|
|
if (DELEGATED_TO_PER_PLAN_GATE.has(rel)) continue;
|
|
const text = readFileNormalized(file);
|
|
for (const m of text.matchAll(/```bash\r?\n([\s\S]*?)```/g)) {
|
|
const block = m[1];
|
|
if (!/^\s*ISOLATION=none\s*$/m.test(block)) continue;
|
|
if (!block.includes('--force-isolation')) {
|
|
const line = text.slice(0, m.index).split(/\r?\n/).length;
|
|
offenders.push(`${rel}:${line}`);
|
|
}
|
|
}
|
|
}
|
|
assert.deepEqual(
|
|
offenders,
|
|
[],
|
|
'these shell blocks degrade $ISOLATION to none without re-recording it through ' +
|
|
'`query dispatch-isolation --force-isolation` — the #3045 guard will deny the ' +
|
|
'resulting dispatch:\n' + offenders.join('\n'),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Folded from tests/issue-2939-dispatch-flatten-maxdepth.test.cjs (H3 Wave 7,
|
|
// issue #3339). Two of the original nine cases (maxDepth:1 → true and
|
|
// maxDepth:2 → false, both with the full codex-like descriptor) were exact
|
|
// duplicates of the "Phase B: shouldFlattenDispatch — contract pin" describe
|
|
// above (lines ~1014-1029) and were dropped; the remaining seven exercise
|
|
// input shapes (maxDepth:-1 unbounded, nested:false, non-full toolkit,
|
|
// background:false/backgroundDispatch:false with maxDepth:5, maxDepth:0,
|
|
// and maxDepth missing/non-number) not covered elsewhere in this file.
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe('folded:issue-2939-dispatch-flatten-maxdepth', () => {
|
|
/** The live Codex-shaped descriptor (the #2939 bug input), with per-test depth overrides. */
|
|
function codexLike(overrides = {}) {
|
|
return {
|
|
namedDispatch: true,
|
|
nested: true,
|
|
maxDepth: 1,
|
|
background: true,
|
|
subagentToolkit: 'full',
|
|
backgroundDispatch: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test('maxDepthUnboundedBackgroundsUnchanged', () => {
|
|
// Row 3: maxDepth:-1 (unbounded) → background permitted, unchanged.
|
|
assert.strictEqual(
|
|
hi.shouldFlattenDispatch(codexLike({ maxDepth: -1 })),
|
|
false,
|
|
'maxDepth:-1 (unbounded) → background permitted (unchanged)',
|
|
);
|
|
});
|
|
|
|
test('nestedFalseFlattensRegardlessOfDepth', () => {
|
|
// Row 4 / acceptance #4: nested:false cannot host a nesting orchestrator →
|
|
// flatten regardless of maxDepth.
|
|
assert.strictEqual(
|
|
hi.shouldFlattenDispatch(codexLike({ nested: false, maxDepth: 5 })),
|
|
true,
|
|
'nested:false → flatten regardless of maxDepth',
|
|
);
|
|
});
|
|
|
|
test('nonFullToolkitFlattens', () => {
|
|
// Row 5 / acceptance #4: a non-full toolkit cannot delegate → flatten
|
|
// regardless of maxDepth.
|
|
assert.strictEqual(
|
|
hi.shouldFlattenDispatch(codexLike({ subagentToolkit: 'read-only', maxDepth: 5 })),
|
|
true,
|
|
'subagentToolkit!=="full" → flatten regardless of maxDepth',
|
|
);
|
|
});
|
|
|
|
test('backgroundFalseStillFlattens', () => {
|
|
// Row 6 / negative-space: background:false → flatten (the existing
|
|
// background-boolean fail-closed path is unchanged).
|
|
assert.strictEqual(
|
|
hi.shouldFlattenDispatch(codexLike({ background: false, maxDepth: 5 })),
|
|
true,
|
|
'background:false → flatten (unchanged)',
|
|
);
|
|
});
|
|
|
|
test('backgroundDispatchFalseStillFlattens', () => {
|
|
// Row 7 / negative-space: backgroundDispatch:false → flatten (unchanged).
|
|
assert.strictEqual(
|
|
hi.shouldFlattenDispatch(codexLike({ backgroundDispatch: false, maxDepth: 5 })),
|
|
true,
|
|
'backgroundDispatch:false → flatten (unchanged)',
|
|
);
|
|
});
|
|
|
|
test('maxDepth0Flattens', () => {
|
|
// Row 9: maxDepth:0 (zero depth budget) → flatten.
|
|
assert.strictEqual(
|
|
hi.shouldFlattenDispatch(codexLike({ maxDepth: 0 })),
|
|
true,
|
|
'maxDepth:0 → flatten (zero depth budget)',
|
|
);
|
|
});
|
|
|
|
test('maxDepthMissingFlattens', () => {
|
|
// Row 10: maxDepth missing/non-number → flatten (fail-closed on absent
|
|
// budget, mirrors degradationFor treating non-finite as 0).
|
|
assert.strictEqual(
|
|
hi.shouldFlattenDispatch(codexLike({ maxDepth: undefined })),
|
|
true,
|
|
'maxDepth missing → flatten (fail-closed)',
|
|
);
|
|
assert.strictEqual(
|
|
hi.shouldFlattenDispatch(codexLike({ maxDepth: 'deep' })),
|
|
true,
|
|
'maxDepth non-number → flatten (fail-closed)',
|
|
);
|
|
});
|
|
});
|
|
}
|