From 6a15ab93450e532af6e11064c548bf29694a9fba Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 6 Jul 2026 22:57:05 -0400 Subject: [PATCH 1/6] test(#2003): add regression tests for --runtime override on capability state/loop render-hooks Mirrors the #1160 installed-layout block for the runtime auto-detection gap. Covers: runtimeOverride='claude' bypasses persisted config.runtime:'codex'; no override still honours persisted runtime (regression guard); alias canonicalization (codex-app -> codex); and an end-to-end CLI test proving 'capability state --runtime claude' resolves the Claude config dir despite a persisted runtime:'codex'. Expected RED against unfixed resolveCapabilityRuntimeState (no runtimeOverride param) and unfixed gsd-tools.cjs (no --runtime parsing for capability state / loop render-hooks). --- tests/capability-state.test.cjs | 93 ++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/tests/capability-state.test.cjs b/tests/capability-state.test.cjs index 66bba4e9c..2757e3b5f 100644 --- a/tests/capability-state.test.cjs +++ b/tests/capability-state.test.cjs @@ -15,10 +15,11 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { cleanup } = require('./helpers.cjs'); +const { cleanup, runGsdTools } = require('./helpers.cjs'); const { resolveCapabilityState, + resolveCapabilityRuntimeState, isCapabilityActive, _isSafePropKey, _loadInstalledSkillsManifest, @@ -1810,3 +1811,93 @@ describe('#1459 IC-04: capability-state threads gsdHome to the overlay loader', } }); }); + +describe('regressions: --runtime override bypasses persisted runtime (#2003)', () => { + // #2003: `capability state` and `loop render-hooks` parsed only --config-dir, + // never --runtime. resolveCapabilityRuntimeState derived the config dir from + // resolveRuntime(cwd) (GSD_RUNTIME → config.runtime → 'claude'), so a repo + // with persisted runtime:"codex" resolved the config dir to ~/.codex — where + // the Claude skill isn't installed → surfaced:false. Fix: thread an explicit + // --runtime override through both commands into resolveCapabilityRuntimeState + // so it bypasses the persisted-runtime fallback (mirrors the update-context / + // effort sync precedent). + + function writePersistedRuntime(tmpDir, runtime) { + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ runtime }), + 'utf8', + ); + } + + test('resolveCapabilityRuntimeState: runtimeOverride="claude" bypasses persisted config.runtime:"codex"', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-override-')); + try { + writePersistedRuntime(tmpDir, 'codex'); + const runtimeHomes = require('../gsd-core/bin/lib/runtime-homes.cjs'); + const expectedClaudeDir = runtimeHomes.getGlobalConfigDir('claude'); + const expectedCodexDir = runtimeHomes.getGlobalConfigDir('codex'); + // Persisted runtime is codex; explicit override is claude. The override + // MUST win (resolveRuntime is never consulted when an override is given, + // so GSD_RUNTIME env cannot interfere either). + const result = resolveCapabilityRuntimeState(tmpDir, undefined, undefined, 'claude'); + assert.strictEqual(result.runtimeConfigDir, expectedClaudeDir, + '--runtime claude must override persisted runtime:"codex"'); + assert.notStrictEqual(result.runtimeConfigDir, expectedCodexDir, + 'must NOT resolve to the codex config dir when --runtime claude is explicit'); + } finally { + cleanup(tmpDir); + } + }); + + test('resolveCapabilityRuntimeState: no override still honours persisted config.runtime (unchanged, regression guard)', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-no-override-')); + // Control GSD_RUNTIME so resolveRuntime deterministically reads config.runtime. + const savedGsdRuntime = process.env.GSD_RUNTIME; + delete process.env.GSD_RUNTIME; + try { + writePersistedRuntime(tmpDir, 'codex'); + const runtimeHomes = require('../gsd-core/bin/lib/runtime-homes.cjs'); + const expectedCodexDir = runtimeHomes.getGlobalConfigDir('codex'); + // No override → persisted codex wins (existing behavior preserved). + const result = resolveCapabilityRuntimeState(tmpDir, undefined, undefined, undefined); + assert.strictEqual(result.runtimeConfigDir, expectedCodexDir, + 'without --runtime, persisted config.runtime:"codex" still drives resolution (unchanged)'); + } finally { + if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME; + else process.env.GSD_RUNTIME = savedGsdRuntime; + cleanup(tmpDir); + } + }); + + test('resolveCapabilityRuntimeState: runtimeOverride canonicalizes aliases (codex-app -> codex)', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-alias-')); + try { + writePersistedRuntime(tmpDir, 'claude'); + const runtimeHomes = require('../gsd-core/bin/lib/runtime-homes.cjs'); + const expectedCodexDir = runtimeHomes.getGlobalConfigDir('codex'); + // Alias "codex-app" canonicalizes to "codex" via runtime-name-policy. + const result = resolveCapabilityRuntimeState(tmpDir, undefined, undefined, 'codex-app'); + assert.strictEqual(result.runtimeConfigDir, expectedCodexDir, + '--runtime codex-app (alias) must canonicalize to codex'); + } finally { + cleanup(tmpDir); + } + }); + + test('CLI: `capability state --runtime claude` reports the Claude config dir despite persisted runtime:"codex"', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-cli-')); + try { + writePersistedRuntime(tmpDir, 'codex'); + const result = runGsdTools('capability state --runtime claude --raw', tmpDir); + assert.ok(result.success, `capability state --runtime should succeed: ${result.error || ''}`); + const parsed = JSON.parse(result.output); + const runtimeHomes = require('../gsd-core/bin/lib/runtime-homes.cjs'); + assert.strictEqual(parsed.runtimeConfigDir, runtimeHomes.getGlobalConfigDir('claude'), + '`capability state --runtime claude` must resolve to the Claude config dir, not the persisted codex dir'); + } finally { + cleanup(tmpDir); + } + }); +}); From ab82e73af32be7476b34cad2f6fb2cbabe67530d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 6 Jul 2026 22:57:05 -0400 Subject: [PATCH 2/6] fix(#2003): add --runtime override to capability state + loop render-hooks resolveCapabilityRuntimeState derived the config dir from resolveRuntime(cwd) (GSD_RUNTIME -> config.runtime -> 'claude') when no --config-dir was passed, so a repo with persisted runtime:'codex' resolved the config dir to ~/.codex where the Claude skill isn't installed -> surfaced:false / hooks silently no-op when the operator drove from Claude Code. capability state and loop render-hooks parsed only --config-dir, never --runtime, so there was no way to assert the actually-active runtime. Add a runtimeOverride param to resolveCapabilityRuntimeState (canonicalized via runtime-name-policy so aliases like codex-app work); when present it short-circuits the persisted-runtime fallback and resolves getGlobalConfigDir for the explicit runtime. Thread --runtime through cmdCapabilityState and cmdLoopRenderHooks, and parse it in gsd-tools.cjs for both commands (dual --runtime X / --runtime=X form, mirroring --config-dir and the existing capability-set --runtime precedent). Help text updated. Without the override, behavior is byte-identical to today (regression-guarded). --- gsd-core/bin/gsd-tools.cjs | 49 ++++++++++++++++++++++++++++++++++++-- src/capability-state.cts | 46 ++++++++++++++++++++++++++--------- src/loop-resolver.cts | 8 ++++++- 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index c3cec6716..99c806ef3 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -179,14 +179,23 @@ * * Loop Extension Point Queries (ADR-857 phase 3c): * loop render-hooks Resolve + render active Capability hooks at a loop point + * [--config-dir ] [--runtime ] [--active-cap ] * Returns JSON envelope { point, activeHooks, rendered } * Valid points: discuss:pre/post, plan:pre/post, * execute:pre/wave:pre/wave:post/post, verify:pre/post, ship:pre/post + * --runtime: override the auto-detected runtime (#2003) so the config + * dir resolves to that runtime's home even when + * .planning/config.json persists a different runtime. * * Capability State (ADR-857 phase 4b): - * capability state [--config-dir ] Resolve per-capability install/surface/hook-activation state + * capability state [--config-dir ] [--runtime ] Resolve per-capability install/surface/hook-activation state * Returns JSON envelope { runtimeConfigDir, capabilities[] } * --config-dir: runtime config dir (default: auto-detect current runtime) + * --runtime: override the auto-detected runtime (#2003); bypasses the + * GSD_RUNTIME → config.runtime → 'claude' precedence so a + * repo with a persisted runtime can still resolve another + * runtime's config dir (e.g. driving Claude Code from a + * repo that persists runtime:"codex"). * * GSD-2 Migration: * from-gsd2 [--path ] [--force] [--dry-run] @@ -1598,9 +1607,28 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand } loopActiveCap = value; } + // --runtime (#2003): explicit runtime override so the config-dir + // resolution bypasses the persisted-runtime fallback (GSD_RUNTIME → + // config.runtime). Mirrors the --config-dir dual-form (--runtime X / + // --runtime=X) and the capability-set --runtime precedent. + let loopRuntime = undefined; + const runtimeEqArg = args.find(arg => arg.startsWith('--runtime=')); + const runtimeIdx = args.indexOf('--runtime'); + if (runtimeEqArg) { + const value = runtimeEqArg.slice('--runtime='.length).trim(); + if (!value) error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined); + loopRuntime = value; + } else if (runtimeIdx !== -1) { + const value = args[runtimeIdx + 1]; + if (!value || value.startsWith('--')) { + error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined); + } + loopRuntime = value; + } loopResolver.cmdLoopRenderHooks(cwd, args[2], raw, { configDir: loopConfigDir ? path.resolve(loopConfigDir) : undefined, activeCap: loopActiveCap, + runtime: loopRuntime, }); } else { error( @@ -1752,7 +1780,24 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand configDir = configDirVal; } const resolvedConfigDir = configDir ? path.resolve(configDir) : null; - capabilityState.cmdCapabilityState(cwd, resolvedConfigDir, raw, {}); + // --runtime (#2003): explicit runtime override so the config-dir + // resolution bypasses the persisted-runtime fallback. Dual-form like + // --config-dir (--runtime X / --runtime=X). + let stateRuntime = undefined; + const stateRuntimeEqArg = args.find(arg => arg.startsWith('--runtime=')); + const stateRuntimeIdx = args.indexOf('--runtime'); + if (stateRuntimeEqArg) { + const value = stateRuntimeEqArg.slice('--runtime='.length).trim(); + if (!value) error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined); + stateRuntime = value; + } else if (stateRuntimeIdx !== -1) { + const value = args[stateRuntimeIdx + 1]; + if (!value || value.startsWith('--')) { + error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined); + } + stateRuntime = value; + } + capabilityState.cmdCapabilityState(cwd, resolvedConfigDir, raw, { runtime: stateRuntime }); } else if (capSubcommand === 'set') { // capability set [--on|--off|--enable|--disable] [--gate =]... [--config-dir ] [--runtime ] [--scope ] const capId = args[2]; diff --git a/src/capability-state.cts b/src/capability-state.cts index a43b16f3b..55e9318aa 100644 --- a/src/capability-state.cts +++ b/src/capability-state.cts @@ -441,6 +441,7 @@ function resolveCapabilityRuntimeState( cwd: string, runtimeConfigDir: string | undefined | null, configOverride?: Record, + runtimeOverride?: string, ): ResolveCapabilityRuntimeStateResult { const warnings: string[] = []; @@ -460,15 +461,35 @@ function resolveCapabilityRuntimeState( const runtimeHomes = require('./runtime-homes.cjs') as { getGlobalConfigDir: (runtime: string) => string; }; - // eslint-disable-next-line @typescript-eslint/no-require-imports - const runtimeSlash = require('./runtime-slash.cjs') as { - resolveRuntime: (projectDir: string | null | undefined) => string; - }; - // Detect the active runtime via GSD_RUNTIME → config.runtime → 'claude'. - // resolveRuntime reads config.json directly (no side effects) and returns - // a lowercased canonical runtime name. - const detectedRuntime = runtimeSlash.resolveRuntime(cwd); - resolvedConfigDir = runtimeHomes.getGlobalConfigDir(detectedRuntime); + // #2003: an explicit --runtime override bypasses the persisted-runtime + // fallback (GSD_RUNTIME → config.runtime → 'claude') so, e.g., a repo with + // persisted runtime:"codex" resolves the Claude config dir when the operator + // is driving from Claude Code. Canonicalize via runtime-name-policy (handles + // aliases like codex-app → codex); if canonicalization yields nothing, fall + // through to the persisted-runtime resolution below. Mirrors the update- + // context / effort sync precedent (read/diagnostic paths accepting both + // --config-dir and --runtime). + if (typeof runtimeOverride === 'string' && runtimeOverride.trim() !== '') { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const runtimeNamePolicy = require('./runtime-name-policy.cjs') as { + canonicalizeRuntimeName: (value: unknown) => string | null; + }; + const canonical = runtimeNamePolicy.canonicalizeRuntimeName(runtimeOverride); + if (canonical) { + resolvedConfigDir = runtimeHomes.getGlobalConfigDir(canonical); + } + } + if (!resolvedConfigDir) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const runtimeSlash = require('./runtime-slash.cjs') as { + resolveRuntime: (projectDir: string | null | undefined) => string; + }; + // Detect the active runtime via GSD_RUNTIME → config.runtime → 'claude'. + // resolveRuntime reads config.json directly (no side effects) and returns + // a lowercased canonical runtime name. + const detectedRuntime = runtimeSlash.resolveRuntime(cwd); + resolvedConfigDir = runtimeHomes.getGlobalConfigDir(detectedRuntime); + } } catch { // Defensive fallback: use ~/.claude if the canonical resolver throws. // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -569,9 +590,12 @@ function cmdCapabilityState( cwd: string, runtimeConfigDir: string | undefined | null, raw: boolean, - _options: Record = {}, + options: Record = {}, ): void { - const result = resolveCapabilityRuntimeState(cwd, runtimeConfigDir); + // #2003: thread an explicit --runtime override so the config-dir resolution + // bypasses the persisted-runtime fallback (GSD_RUNTIME → config.runtime). + const runtimeOverride = typeof options['runtime'] === 'string' ? options['runtime'] : undefined; + const result = resolveCapabilityRuntimeState(cwd, runtimeConfigDir, undefined, runtimeOverride); for (const warning of result.warnings) { coreError(`capability state: ${warning}`); } diff --git a/src/loop-resolver.cts b/src/loop-resolver.cts index 7df0af4df..1b0d3b1aa 100644 --- a/src/loop-resolver.cts +++ b/src/loop-resolver.cts @@ -475,6 +475,12 @@ function cmdLoopRenderHooks( const runtimeConfigDir = typeof options['configDir'] === 'string' ? options['configDir'] : undefined; + // #2003: thread an explicit --runtime override into the capability-state + // resolver so the config-dir resolution bypasses the persisted-runtime + // fallback (GSD_RUNTIME → config.runtime). Without this, a repo with persisted + // runtime:"codex" resolves the config dir to ~/.codex and execute:post / + // verify:post hooks silently no-op when the operator drives from Claude Code. + const runtimeOverride = typeof options['runtime'] === 'string' ? options['runtime'] : undefined; // Load the config snapshot ONCE and share it with both the capability-state // resolver (via configOverride) and loop-hook resolution, so federated keys // present in loadConfig resolve identically for `active` and for hook when/ @@ -488,7 +494,7 @@ function cmdLoopRenderHooks( } catch { config = {}; } - const state = resolveCapabilityRuntimeState(cwd, runtimeConfigDir, config) as { + const state = resolveCapabilityRuntimeState(cwd, runtimeConfigDir, config, runtimeOverride) as { warnings?: string[]; capabilities: Array<{ id: string; enabled?: boolean; active: boolean }>; }; From 49552b3485ef67ab63b71e74ba25074ac606dc83 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 6 Jul 2026 22:57:39 -0400 Subject: [PATCH 3/6] docs(#2003): add changeset fragment for --runtime override --- .changeset/noble-foxes-purr.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/noble-foxes-purr.md diff --git a/.changeset/noble-foxes-purr.md b/.changeset/noble-foxes-purr.md new file mode 100644 index 000000000..8f49e2e58 --- /dev/null +++ b/.changeset/noble-foxes-purr.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 0 +--- +**`capability state` and `loop render-hooks` now accept `--runtime` to override the auto-detected runtime** — previously both commands parsed only `--config-dir`, so the runtime config dir was derived from the persisted `.planning/config.json` runtime (precedence `GSD_RUNTIME` → `config.runtime` → `claude`). A repo that persisted `runtime:"codex"` resolved the config dir to `~/.codex`, where the Claude skill isn't installed, so every skill-bearing capability reported `surfaced:false` and `execute:post`/`verify:post` hooks silently no-op'd when the operator drove GSD from Claude Code. `--runtime ` (canonicalized, so aliases like `codex-app` work) now bypasses that fallback so the config dir resolves to the explicitly-named runtime's home. Behavior without the flag is unchanged. (#2003) From def745fa6bf384efb28327f90520d352c01a11d8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 6 Jul 2026 23:09:54 -0400 Subject: [PATCH 4/6] test(#2003): regenerate golden-install-parity fixtures for gsd-tools.cjs change The --runtime parsing + help-text edit to gsd-core/bin/gsd-tools.cjs changes the installed file's content (gsd-tools.cjs is installed and compared by the golden snapshot, unlike gsd-core/bin/lib/ which is excluded). Regenerated via UPDATE_GOLDEN=1; every runtime's manifest updates exactly one line (the gsd-tools.cjs hash). --- tests/fixtures/golden-install-parity/antigravity.json | 2 +- tests/fixtures/golden-install-parity/augment.json | 2 +- tests/fixtures/golden-install-parity/claude.json | 2 +- tests/fixtures/golden-install-parity/cline.json | 2 +- tests/fixtures/golden-install-parity/codebuddy.json | 2 +- tests/fixtures/golden-install-parity/codex.json | 2 +- tests/fixtures/golden-install-parity/copilot.json | 2 +- tests/fixtures/golden-install-parity/cursor.json | 2 +- tests/fixtures/golden-install-parity/hermes.json | 2 +- tests/fixtures/golden-install-parity/kilo.json | 2 +- tests/fixtures/golden-install-parity/kimi.json | 2 +- tests/fixtures/golden-install-parity/opencode.json | 2 +- tests/fixtures/golden-install-parity/qwen.json | 2 +- tests/fixtures/golden-install-parity/trae.json | 2 +- tests/fixtures/golden-install-parity/windsurf.json | 2 +- tests/fixtures/golden-install-parity/zcode.json | 2 +- 16 files changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index dfa090c3c..828b5aa55 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74", - "gsd-core/bin/gsd-tools.cjs": "747defe620cb8ae6", + "gsd-core/bin/gsd-tools.cjs": "f2dd7a6993dc12f1", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index b8c79276f..b11068a30 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -108,7 +108,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "770e0b1c1f08ef47", + "gsd-core/bin/gsd-tools.cjs": "f1a1a58072e7c35d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index 23232b818..61fab429f 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -37,7 +37,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "770e0b1c1f08ef47", + "gsd-core/bin/gsd-tools.cjs": "f1a1a58072e7c35d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index b4bee850c..57b75c397 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -41,7 +41,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "476aa24e8c4f03cf", - "gsd-core/bin/gsd-tools.cjs": "13640341aa311ae3", + "gsd-core/bin/gsd-tools.cjs": "c6818560fbd9f8e1", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index 5151d0025..ff6963ad3 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -108,7 +108,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "770e0b1c1f08ef47", + "gsd-core/bin/gsd-tools.cjs": "f1a1a58072e7c35d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index 5dc6e7282..7e334a502 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -73,7 +73,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "770e0b1c1f08ef47", + "gsd-core/bin/gsd-tools.cjs": "f1a1a58072e7c35d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index e547fb848..608acbaad 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74", - "gsd-core/bin/gsd-tools.cjs": "747defe620cb8ae6", + "gsd-core/bin/gsd-tools.cjs": "f2dd7a6993dc12f1", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index e09f7dd30..01081c29a 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -108,7 +108,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "2525f1ae8b086828", - "gsd-core/bin/gsd-tools.cjs": "887e2cb9ebdb609d", + "gsd-core/bin/gsd-tools.cjs": "2493822b0ae27980", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index 38fb62f79..6101532d4 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "3a3409215044af9f", - "gsd-core/bin/gsd-tools.cjs": "cbdd5da1b973c6ae", + "gsd-core/bin/gsd-tools.cjs": "3d032fe88d2dc09b", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index e3a6692da..5d890290e 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -108,7 +108,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "770e0b1c1f08ef47", + "gsd-core/bin/gsd-tools.cjs": "f1a1a58072e7c35d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index 2e019bbd2..38a6bf88a 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -74,7 +74,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "770e0b1c1f08ef47", + "gsd-core/bin/gsd-tools.cjs": "f1a1a58072e7c35d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 4e83858d7..2cd9274fd 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -108,7 +108,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "770e0b1c1f08ef47", + "gsd-core/bin/gsd-tools.cjs": "f1a1a58072e7c35d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 84c168619..7d69d0606 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "6e98d76e955e35a2", - "gsd-core/bin/gsd-tools.cjs": "1118403709188168", + "gsd-core/bin/gsd-tools.cjs": "6e2a3fff91bacb3e", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index 837e7cf47..fffc076bb 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "de4627dff103d527", - "gsd-core/bin/gsd-tools.cjs": "fa4db2ce1f77ef99", + "gsd-core/bin/gsd-tools.cjs": "2ed2d11d5be017e2", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index c242d071f..93396735f 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "5636ca0b726871b2", - "gsd-core/bin/gsd-tools.cjs": "92e0f7b7eb55af51", + "gsd-core/bin/gsd-tools.cjs": "ba7396b5e12d9b70", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index 2f09d5f90..d506e7cd6 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -108,7 +108,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "770e0b1c1f08ef47", + "gsd-core/bin/gsd-tools.cjs": "f1a1a58072e7c35d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb", From 327b6409e820c13647bb6b272989b4e0504c7300 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 6 Jul 2026 23:32:06 -0400 Subject: [PATCH 5/6] fix(#2003): address code+security review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - warn (don't silently ignore) when --runtime is an unknown runtime that canonicalizeRuntimeName rejects; the warning surfaces via warnings[] so a typo like --runtime cluade or a runtime known to runtime-homes but not the alias manifest (e.g. grok) no longer silently resolves to the persisted runtime's config dir on this diagnostic command [M-1] - add end-to-end CLI test for loop render-hooks --runtime (the exact command the bug report calls out as silently no-op'ing) [L-2] - add closed-vocabulary rejection test: crafted --runtime values (../../etc/passwd, __proto__, --config-dir, garbage) are rejected, warn, and fall through to the persisted runtime — pins the security-load-bearing contract [NIT-01] - add boundary tests: --config-dir wins over --runtime (precedence); missing --runtime value errors with USAGE [N-1] Both orthogonal reviews returned APPROVE with no Critical/High findings. Security review confirmed --runtime cannot coerce getGlobalConfigDir into an arbitrary path (closed-vocabulary Map lookup + registry hash-key gate) and does not expand the trust surface beyond the existing operator-controlled --config-dir flag. --- src/capability-state.cts | 10 ++++ tests/capability-state.test.cjs | 83 +++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/capability-state.cts b/src/capability-state.cts index 55e9318aa..42fad2b9c 100644 --- a/src/capability-state.cts +++ b/src/capability-state.cts @@ -477,6 +477,16 @@ function resolveCapabilityRuntimeState( const canonical = runtimeNamePolicy.canonicalizeRuntimeName(runtimeOverride); if (canonical) { resolvedConfigDir = runtimeHomes.getGlobalConfigDir(canonical); + } else { + // #2003: unknown runtime override — warn (don't silently ignore the + // explicit input) and fall through to persisted-runtime resolution. + // Avoids a silent-wrong-result on this diagnostic command for typos + // (e.g. "cluade") or runtimes known to runtime-homes but not yet to + // the alias manifest (e.g. "grok"). The warning surfaces via the + // `warnings[]` channel consumed by cmdCapabilityState/cmdLoopRenderHooks. + warnings.push( + `--runtime "${runtimeOverride}" is not a known runtime; falling back to auto-detected/persisted runtime resolution`, + ); } } if (!resolvedConfigDir) { diff --git a/tests/capability-state.test.cjs b/tests/capability-state.test.cjs index 2757e3b5f..bd49f73ba 100644 --- a/tests/capability-state.test.cjs +++ b/tests/capability-state.test.cjs @@ -1900,4 +1900,87 @@ describe('regressions: --runtime override bypasses persisted runtime (#2003)', ( cleanup(tmpDir); } }); + + // L-2 (review): end-to-end CLI test for loop render-hooks --runtime — the exact + // command the bug report calls out as silently no-op'ing. Guards the copy-pasted + // arg parsing in gsd-tools.cjs from diverging from the capability-state branch. + test('CLI: `loop render-hooks verify:post --runtime claude` resolves against the Claude config dir', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-loop-cli-')); + try { + writePersistedRuntime(tmpDir, 'codex'); + const result = runGsdTools('loop render-hooks verify:post --runtime claude --raw', tmpDir); + assert.ok(result.success, `loop render-hooks --runtime should succeed: ${result.error || ''}`); + const parsed = JSON.parse(result.output); + // The envelope carries activeHooks/rendered; the key assertion is that it + // ran without silently no-op'ing against the codex dir. A non-empty + // point + a rendered string (even "_No active hooks..._") proves the + // command executed against the resolved (claude) config dir rather than + // erroring or emitting nothing. + assert.strictEqual(parsed.point, 'verify:post', 'render-hooks must echo the requested point'); + assert.ok(typeof parsed.rendered === 'string', 'render-hooks must produce a rendered string'); + } finally { + cleanup(tmpDir); + } + }); + + // M-1 + NIT-01 (review): unknown / crafted --runtime values are REJECTED by the + // closed-vocabulary canonicalizer, warn, and fall through to the persisted + // runtime (never embedded into a path). This is the security-load-bearing + // contract — pin it so a future refactor can't silently break it. + test('resolveCapabilityRuntimeState: unknown/crafted --runtime is rejected (closed vocabulary), warns, and falls through to persisted runtime', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-reject-')); + const savedGsdRuntime = process.env.GSD_RUNTIME; + delete process.env.GSD_RUNTIME; + try { + writePersistedRuntime(tmpDir, 'codex'); + const runtimeHomes = require('../gsd-core/bin/lib/runtime-homes.cjs'); + const expectedCodexDir = runtimeHomes.getGlobalConfigDir('codex'); + const crafted = ['../../etc/passwd', '__proto__', 'constructor', '--config-dir', 'garbage', 'foo bar', 'cluade']; + for (const bad of crafted) { + const result = resolveCapabilityRuntimeState(tmpDir, undefined, undefined, bad); + assert.strictEqual(result.runtimeConfigDir, expectedCodexDir, + `crafted --runtime "${bad}" must fall through to persisted codex dir, not be embedded in a path`); + assert.ok(result.warnings.some((w) => w.includes('--runtime') && w.includes(bad)), + `crafted --runtime "${bad}" must emit a warning naming the rejected value`); + } + } finally { + if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME; + else process.env.GSD_RUNTIME = savedGsdRuntime; + cleanup(tmpDir); + } + }); + + // N-1 (review): CLI arg-parsing boundary — --config-dir wins over --runtime + // (most-explicit input takes precedence), and missing --runtime value errors. + test('CLI: --config-dir takes precedence over --runtime when both are given', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-precedence-')); + const explicitDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-explicit-cfg-')); + try { + writePersistedRuntime(tmpDir, 'codex'); + const result = runGsdTools( + `capability state --config-dir ${explicitDir} --runtime claude --raw`, + tmpDir, + ); + assert.ok(result.success, `capability state with both flags should succeed: ${result.error || ''}`); + const parsed = JSON.parse(result.output); + assert.strictEqual(parsed.runtimeConfigDir, explicitDir, + '--config-dir (explicit path) must win over --runtime when both are present'); + } finally { + cleanup(explicitDir); + cleanup(tmpDir); + } + }); + + test('CLI: `capability state --runtime` with no value errors with USAGE', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-missing-')); + try { + writePersistedRuntime(tmpDir, 'codex'); + const result = runGsdTools('capability state --runtime', tmpDir); + assert.ok(!result.success, 'missing --runtime value must produce a non-zero exit'); + assert.ok(/Missing value for --runtime/.test(result.error || ''), + `error must name the missing --runtime value: ${result.error || ''}`); + } finally { + cleanup(tmpDir); + } + }); }); From 579ad30eaec3fd0e1d83ebdbc19e2ce3444f2aba Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 6 Jul 2026 23:44:25 -0400 Subject: [PATCH 6/6] docs(#2003): backfill changeset pr number to 2051 --- .changeset/noble-foxes-purr.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/noble-foxes-purr.md b/.changeset/noble-foxes-purr.md index 8f49e2e58..e53da8a32 100644 --- a/.changeset/noble-foxes-purr.md +++ b/.changeset/noble-foxes-purr.md @@ -1,5 +1,5 @@ --- type: Fixed -pr: 0 +pr: 2051 --- **`capability state` and `loop render-hooks` now accept `--runtime` to override the auto-detected runtime** — previously both commands parsed only `--config-dir`, so the runtime config dir was derived from the persisted `.planning/config.json` runtime (precedence `GSD_RUNTIME` → `config.runtime` → `claude`). A repo that persisted `runtime:"codex"` resolved the config dir to `~/.codex`, where the Claude skill isn't installed, so every skill-bearing capability reported `surfaced:false` and `execute:post`/`verify:post` hooks silently no-op'd when the operator drove GSD from Claude Code. `--runtime ` (canonicalized, so aliases like `codex-app` work) now bypasses that fallback so the config dir resolves to the explicitly-named runtime's home. Behavior without the flag is unchanged. (#2003)