fix(#4709): a retired runtime id must not resolve to Claude Code (#4756)

* fix(#4709): a retired runtime id must not resolve to Claude Code

AC#1 of epic #4709 — the last unmet acceptance criterion. Every other phase
(#4711, #4716, #4732, #4743, #4753) is merged; the epic does not close until
this lands.

THE DEFECT, MEASURED

Five runtime-resolution accessors resolved a RETIRED id to a plausible-looking
value, indistinguishable from the same call with a canonical id. Measured on
5d4c98cde7 by executing the built modules:

  getRuntimeLabel('gemini')              -> 'Claude Code'
  getProjectInstructionFile('gemini')    -> 'AGENTS.md'
  getGlobalConfigHomeFragment('gemini')  -> "'.claude'"
  getGlobalConfigDir('gemini')           -> ~/.claude   (byte-identical to 'claude')
  getDirName('gemini')                   -> '.claude'

So asking for a runtime Google sunset on 2026-06-18 wrote into Claude Code's
global config home and labelled the install "Claude Code". Nothing errored and
nothing warned.

AC#1 names four accessors. getDirName is the fifth, found by a reviewer: same
module, same silent-wrong-answer class, and it feeds capability-state's
runtimeConfigDir. Fixing only the four the criterion happened to list would
have left the defect reachable, so it is guarded too.

WHY THE CHECK CANNOT LIVE IN CANONICALIZATION

canonicalizeRuntimeName returns null for 'gemini', 'gemini-cli', 'Gemini',
'GEMINI' AND for ''. After canonicalization a retired id, an unknown id and an
empty string are the same value, so anything keyed off the canonical form
cannot tell them apart — it would have to treat all three alike, which is the
behaviour being fixed. The check therefore runs on the RAW input.

WHAT THIS DELIBERATELY DOES NOT DO

The criterion reads "reject a non-canonical runtime id". Taken literally that
overturns three recorded decisions, so the narrower reading was put to the
maintainer as a blocking question and this implements the answer: RETIRED ids
throw, unknown and future ids keep falling back.

Preserved:

  - The #1529 contract, written into getProjectInstructionFile's own docblock
    as a mapping table ending "unknown / future runtimes -> AGENTS.md (safe
    cross-agent default)". That default exists so a runtime GSD has never heard
    of still gets a working instruction file.
  - ADR-1239 Phase B / #1679, which preserved GLOBAL_CONFIG_HOME_FRAGMENTS
    BYTE-FOR-BYTE when it collapsed a 14-branch chain, with golden install
    parity asserting generated hook output is unchanged across every runtime.
  - The explicit `if (!runtime) return <default>` branch. Empty string is a
    supported input, not a non-canonical id.

The distinction the code encodes: ABSENCE OF KNOWLEDGE IS NOT THE SAME AS
RECORDED RETIREMENT. Unknown means "no information, degrade safely". Retired
means "we know it is gone and we know what replaced it" — and silently
substituting a different product for it is the defect.

ONE INACCURACY IN THE CRITERION, RECORDED RATHER THAN REPEATED

AC#1 says the accessors return "a Claude Code value". True for getRuntimeLabel,
getGlobalConfigHomeFragment, getGlobalConfigDir and getDirName — but
getProjectInstructionFile returns 'AGENTS.md', which is not a Claude value at
all. The defect it points at is real for all of them, so the fix covers all of
them, but the wording is wrong for one.

MATCHING

RETIRED_RUNTIME_DETAILS is a Map keyed by canonical retired id, and
RETIRED_RUNTIME_SPELLINGS maps every spelling to that id. Both are Maps, not
object literals: a literal indexed by a computed key resolves INHERITED
properties, so '__proto__' and 'constructor' were truthy and threw with every
field `undefined`, while isRetiredRuntimeId — which already went through a Set
— correctly answered false for the same input. Two guards disagreeing about one
id is worse than either answer. A Map has no prototype keys, so that hazard is
structural rather than patched. The predicate and the assertion now share one
normaliser and one table and cannot diverge.

Candidates are normalised NFKC + lowercase + strip non-alphanumerics. Folding
the separators makes 'gemini-cli', 'gemini_cli', 'gemini.cli' and 'geminicli'
one key instead of four near-misses found one at a time, and NFKC folds the
full-width 'gemini' a CJK keyboard produces. It stays MEMBERSHIP matching,
never prefix or substring: 'gemini-2.5-pro' folds to 'gemini25pro' and
'gemini-3.1-pro-preview' to 'gemini31propreview', neither a member, so Google's
live model ids — part of Antigravity's real on-disk contract — are untouched.

Homoglyph folding is deliberately not attempted, and a Cyrillic 'і' would slip
through. These values arrive from argv and env, trusted inputs here, and a
mapping broad enough to catch deliberate homoglyphs would start catching
legitimate ids. Stated rather than left for the next reader to discover.

This over-broad-match trap is the recurring shape of the whole epic: an
exclusion or match written wider than its subject. Four occurrences, each
cited: #4716's `gemini-[0-9]` sweep exclusion hid a stale review.models.gemini
row whose value was "gemini-2.5-pro" on the same line; #4753's first
model-display escape was a blanket /^ \d/ that laundered "Gemini 2.5 CLI as a
supported runtime."; its dialect rule then used a +/-24-character window that
let one legitimate reference license a live claim 21 characters away; and its
model rule treated the ABSENCE of a runtime word as a grant, passing five
unqualified live-runtime claims. Earlier drafts of this message and its
artifacts said "five" in one place and "three" in another with nothing cited;
it is four, listed here, and the artifacts now agree.

THE THROW

RetiredRuntimeError carries `code: 'GSD_RETIRED_RUNTIME'` so a caller can
handle this case without string-matching a message that may be reworded, and
the message names the id, the successor and the retiring issue.
assertNotRetiredRuntime runs as the FIRST statement of each accessor, including
before getGlobalConfigDir's explicitDir branch, so an explicit directory cannot
mask a runtime that is gone.

`gsd-tools query project-instruction-file --runtime gemini` answered the new
throw with a raw stack trace — a user-facing regression this change introduced.
Its sibling routeSkillsRoot already emitted a clean single-line error for an
unknown runtime, so that route now maps GSD_RETIRED_RUNTIME through the same
`error()` helper, and a test asserts the contract directly: non-zero exit,
stderr naming Antigravity and #1928, and no stack frame. It was the only
unwrapped call site in that CLI; I checked the rest rather than assuming.

getRuntimeNewProjectCommand is deliberately NOT guarded: its value does not
vary by runtime in a way that makes a retired id a wrong answer, so throwing
would cost callers a crash without correcting anything. Verified by observing
it return the same value across claude, codex, opencode, kimi, antigravity,
copilot and an unknown id.

RECONCILING THE TESTS THAT PINNED THE DEFECT

The full remote matrix went red with 14 failures, and every one was a
pre-existing test asserting the fallback this criterion calls a defect. One had
already been caught locally by review; the matrix found the other thirteen
across four files. They were reconciled by intent, not blanket-inverted:

  - Tests whose SUBJECT is the retired runtime — "gemini falls back on label /
    config-fragment / new-project surfaces", "gemini no longer maps to
    GEMINI.md (defaults to AGENTS.md)", "gemini is no longer a known runtime —
    falls back to AGENTS.md" — had pinned the defect, titles and all. Their
    assertions are INVERTED rather than deleted, so the history of what the
    behaviour used to be stays attached to the test that pinned it.
  - Tests whose SUBJECT is "an unregistered id falls back generically", with
    gemini merely the SAMPLE, still assert a TRUE property that this change
    deliberately preserved. Those keep their assertion and switch the sample to
    a genuinely unknown id, with a retired-id refusal pinned alongside so both
    halves of the distinction sit together.
  - The project-instruction-file parity loop dropped gemini from its
    parametrised runtimes — both sides now refuse, so there is no value to
    agree on — and gained a dedicated refusal-parity test.

A FIFTEENTH was then found by executing the touched suites locally, in process,
one file at a time — `tests/runtime-name-policy.test.cjs:135` asserted
`getProjectInstructionFile('gemini-cli') === 'AGENTS.md'`, and its own comment
read "gemini-cli was an alias for gemini", which is exactly why that spelling
is now a retired one rather than a merely-unrecognised one. Inverted like the
rest.

Two remote runs on this change were avoidable: the first by reconciling the
tests that pinned the old behaviour before shipping, the second by executing
the touched suites locally first. The matrix is the authority; it is not the
discovery mechanism. Local per-file execution is bounded and cheap and is not
the banned `node --test` fan-out.

All five touched suites now pass in process: runtime-name-policy 47/47,
gemini-runtime-removed 32/32, project-instruction-file-parity 12/12,
runtime-homes-legacy-ids-drift-guard 2/2, install 452/452.

COVERAGE

Failing-first, one per accessor as the criterion demands, each proven RED
against 5d4c98cde7 before the fix existed — the table at the top of this
message IS that baseline, and the exports the tests import did not exist yet
either.

Asserting only the throw would pass if every id threw, which would break every
install, so each property is paired with its opposite: every canonical id still
resolves on all five accessors with byte-identical values; '' keeps its
documented branch; a genuinely unknown id keeps 'Claude Code' / 'AGENTS.md' /
'.claude' / ~/.claude. That last one is the load-bearing negative — it is the
decision the maintainer chose to preserve, so a later patch that "tightens" the
guard to reject all non-canonical ids turns it red with the reason attached.

Boundary coverage maps limit-1/limit/limit+1 onto set membership: 'gemin',
'geminix', 'gemini-2.5-pro' and 'gemini-3.1-pro-preview' must NOT throw, the
retired id and its folded spellings must. '__proto__', 'constructor' and
'  CONSTRUCTOR  ' are pinned as must-not-throw, and predicate/assertion
agreement is asserted directly. Several assert.throws calls initially passed a
string as the second argument, which node treats as the MESSAGE rather than a
matcher, so they asserted nothing about the error; they now use a real
predicate checking the code.

The tests live in the owning modules' suites rather than a new issue-named
file: lint-regression-test-names rejects new bug-NNNN/fix-NNNN/issue-NNNN test
files outright and directs the regression to the owning module's suite.
scripts/lib/macos-conformance-tier.generated.cjs regenerated through its own
--write path, since the tracked test-file count moved.

Fixes #4709

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4709): backfill changeset PR number (#4756)

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-09-14 22:22:16 -04:00
committed by GitHub
parent 85026f6a05
commit e2bfc06558
11 changed files with 482 additions and 26 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 4756
---
**Asking for a retired runtime no longer silently installs Claude Code** — passing a sunset runtime id resolved to Claude Code's label and config home, so `getGlobalConfigDir('gemini')` and `getGlobalConfigDir('claude')` returned byte-identical paths and the install reported itself as Claude Code. The four runtime-resolution accessors now fail with a message naming the successor and the retiring issue. Genuinely unknown and future runtime ids keep their existing safe defaults, which is a deliberate distinction: absence of knowledge is not the same as recorded retirement. (#4709)

View File

@@ -1183,7 +1183,18 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
// First positional that isn't a flag also works (lenient); otherwise ignore unknown flags.
if (!a.startsWith('-') && !pifRuntime) { pifRuntime = a; }
}
const filename = getProjectInstructionFile(pifRuntime);
// A retired runtime id now THROWS rather than resolving (#4709 AC#1).
// Map it to the same clean single-line error routeSkillsRoot emits for
// an unknown runtime — a CLI must not answer a bad flag value with a
// stack trace. The thrown message already names the successor and the
// retiring issue, so it is surfaced verbatim.
let filename;
try {
filename = getProjectInstructionFile(pifRuntime);
} catch (err) {
if (err && err.code === 'GSD_RETIRED_RUNTIME') error(err.message);
throw err;
}
process.stdout.write(filename + '\n');
}

View File

@@ -182,6 +182,7 @@ module.exports = {
"tests/runtime-artifact-layout.test.cjs",
"tests/runtime-identity.test.cjs",
"tests/runtime-launcher-parity.test.cjs",
"tests/runtime-name-policy.test.cjs",
"tests/security.test.cjs",
"tests/settings-jsonc.test.cjs",
"tests/shared-hooks-dir-resolution.test.cjs",

View File

@@ -609,4 +609,11 @@ function main() {
return 1;
}
runMain(main);
// Only run the lint when invoked directly. `require()`ing this module (the
// parity test in tests/runtime-name-policy.test.cjs does) must not
// execute a full repository walk as a side effect.
if (require.main === module) {
runMain(main);
}
module.exports = { RETIRED_RUNTIMES };

View File

@@ -34,6 +34,8 @@ import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import { assertNotRetiredRuntime } from './runtime-name-policy.cjs';
/**
* Expand a leading ~ to the given home directory (defaults to os.homedir()).
* Every call site inside resolveConfigHomeFromDescriptor threads its
@@ -602,6 +604,9 @@ export function resolveKimiHooksTomlDir(opts: ResolveKimiHooksTomlOpts = {}): st
* the behaviour of bin/install.js getGlobalDir(runtime, explicitDir).
*/
export function getGlobalConfigDir(runtime: string, explicitDir?: string | null): string {
// A retired runtime id must never resolve — checked before `explicitDir` so
// an explicit directory cannot mask the fact that the runtime itself is gone.
assertNotRetiredRuntime(runtime);
if (explicitDir) return expandTilde(explicitDir);
// ── Grok: not in the registry — hardcoded branch ─────────────────────────

View File

@@ -14,6 +14,113 @@
import fs from 'node:fs';
import path from 'node:path';
/**
* Runtime ids that GSD deliberately RETIRED, as opposed to ids it has simply
* never heard of. The distinction is load-bearing: an unknown id degrades to a
* safe cross-agent default on purpose (the #1529 contract in
* `getProjectInstructionFile` below), because GSD cannot know what a future
* runtime wants. A retired id is the opposite case — there is a recorded
* decision that it is gone and a named successor to point at, so resolving it
* to a DIFFERENT product's label and config home is a silent wrong answer.
*
* Measured before this guard existed: `getGlobalConfigDir('gemini')` and
* `getGlobalConfigDir('claude')` returned byte-identical paths, so asking for a
* runtime Google sunset on 2026-06-18 wrote into Claude Code's config home and
* labelled the install "Claude Code".
*
* Same table shape as `RETIRED_RUNTIMES` in
* `scripts/lint-retired-runtime-name.cjs` (#4753), which guards PROSE at build
* time while this guards IDS at runtime. They are deliberately NOT shared code
* — coupling them would make a build-time lint depend on compiled `src/` output
* it does not otherwise need — so a parity test asserts the two sets agree
* (CLAUDE.md, Generative Fix Divergence).
*/
const RETIRED_RUNTIME_DETAILS: ReadonlyMap<string, { successor: string; retiredBy: string; sunset: string }> = new Map([
['gem' + 'ini', { successor: 'Antigravity', retiredBy: '#1928', sunset: '2026-06-18' }],
]);
/**
* Normalise a candidate id for retirement matching: NFKC-fold, lowercase, and
* drop every non-alphanumeric character.
*
* Folding the separators is what makes `gemini-cli`, `gemini_cli`,
* `gemini.cli` and `geminicli` one key instead of four near-misses a reviewer
* has to find one at a time — and NFKC folds the full-width `gemini` a CJK
* keyboard produces. It stays MEMBERSHIP matching, not a prefix or substring
* test: `gemini-2.5-pro` folds to `gemini25pro` and `gemini-3.1-pro-preview`
* to `gemini31propreview`, neither of which is a member, so Google's live
* model ids — part of Antigravity's real on-disk contract — are untouched.
*
* Homoglyph folding is deliberately NOT attempted. A Cyrillic `і` in place of
* `i` would slip through, and that is accepted: these values arrive from argv
* and env, which are trusted inputs here, and a mapping broad enough to catch
* deliberate homoglyphs would start catching legitimate ids.
*/
function normalizeForRetirementMatch(value: string): string {
return value.normalize('NFKC').toLowerCase().replace(/[^a-z0-9]/g, '');
}
/**
* Every spelling that names a retired runtime, mapped to its canonical id.
* A Map, not an object literal: an object literal indexed by a computed key
* resolves INHERITED properties, so `'__proto__'` and `'constructor'` were
* truthy and threw with every field `undefined` — while `isRetiredRuntimeId`,
* which already went through a Set, correctly answered false for the same
* input. Two guards disagreeing about one id is worse than either answer.
* A Map has no prototype keys, so the hazard is structural, not patched.
*/
const RETIRED_RUNTIME_SPELLINGS: ReadonlyMap<string, string> = new Map(
Array.from(RETIRED_RUNTIME_DETAILS.keys()).flatMap((id) => [
[normalizeForRetirementMatch(id), id] as [string, string],
[normalizeForRetirementMatch(`${id}-cli`), id] as [string, string],
]),
);
/**
* The canonical retired runtime ids, lowercase. Deliberately the canonical ids
* ONLY, not their alias spellings, so the parity assertion against
* `scripts/lint-retired-runtime-name.cjs`'s `RETIRED_RUNTIMES` compares like
* with like.
*/
export const RETIRED_RUNTIME_IDS: ReadonlySet<string> = new Set(RETIRED_RUNTIME_DETAILS.keys());
/** Error thrown when a retired runtime id reaches a resolution accessor. */
export class RetiredRuntimeError extends Error {
readonly code = 'GSD_RETIRED_RUNTIME';
readonly runtimeId: string;
constructor(runtimeId: string, detail: { successor: string; retiredBy: string; sunset: string }) {
super(
`Runtime "${runtimeId}" was retired by ${detail.retiredBy} (sunset ${detail.sunset}); `
+ `use "${detail.successor}" instead. Refusing to resolve it, because the previous `
+ `behaviour silently returned Claude Code's values.`,
);
this.name = 'RetiredRuntimeError';
this.runtimeId = runtimeId;
}
}
/**
* Is `runtime` a deliberately retired runtime, under any of its spellings?
* Shares one normaliser and one table with `assertNotRetiredRuntime`, so the
* predicate and the assertion can never disagree.
*/
export function isRetiredRuntimeId(runtime: unknown): boolean {
if (typeof runtime !== 'string') return false;
return RETIRED_RUNTIME_SPELLINGS.has(normalizeForRetirementMatch(runtime));
}
/** Throw if `runtime` names a retired runtime. No-op otherwise. */
export function assertNotRetiredRuntime(runtime: unknown): void {
if (typeof runtime !== 'string') return;
const id = RETIRED_RUNTIME_SPELLINGS.get(normalizeForRetirementMatch(runtime));
if (id === undefined) return;
const detail = RETIRED_RUNTIME_DETAILS.get(id);
if (detail === undefined) return;
throw new RetiredRuntimeError(id, detail);
}
const FALLBACK_ALIASES: Readonly<Record<string, string[]>> = {
claude: ['claude', 'claude-code', 'claude-cli'],
opencode: ['opencode', 'open-code', 'opencode-cli'],
@@ -132,6 +239,7 @@ export function resolveRuntimeNameFromCandidates(...candidates: unknown[]): stri
* avoid a circular dependency at module load.
*/
export function getProjectInstructionFile(runtime: unknown): string {
assertNotRetiredRuntime(runtime);
const canonical = canonicalizeRuntimeName(runtime);
if (canonical === 'claude') return '.claude/CLAUDE.md';
if (canonical === 'copilot') return '.github/copilot-instructions.md';
@@ -186,6 +294,9 @@ export const NO_LOCAL_CONFIG_DIR_SENTINEL = '(no-local-config-dir)';
* compatibility spine had no production consumer at all.
*/
export function getDirName(runtime: string): string {
// Same silent-wrong-answer class as the four accessors AC#1 names: this
// returned '.claude' for a retired id, and it feeds runtimeConfigDir.
assertNotRetiredRuntime(runtime);
if (!runtime) return '.claude';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { runtimes } = require('./capability-registry.cjs') as {
@@ -257,6 +368,7 @@ const RUNTIME_LABELS: Readonly<Record<string, string>> = {
* 'Claude Code'. Sibling to `getDirName`; pure (no I/O).
*/
export function getRuntimeLabel(runtime: string): string {
assertNotRetiredRuntime(runtime);
if (!runtime) return 'Claude Code';
const label = RUNTIME_LABELS[runtime];
return typeof label === 'string' && label.length > 0 ? label : 'Claude Code';
@@ -313,6 +425,7 @@ const GLOBAL_CONFIG_HOME_FRAGMENTS: Readonly<Record<string, string>> = {
* dynamically). Pure: no I/O. Sibling to `getDirName` / `getRuntimeLabel`.
*/
export function getGlobalConfigHomeFragment(runtime: string): string {
assertNotRetiredRuntime(runtime);
if (!runtime) return DEFAULT_CONFIG_HOME_FRAGMENT;
const frag = GLOBAL_CONFIG_HOME_FRAGMENTS[runtime];
return typeof frag === 'string' && frag.length > 0 ? frag : DEFAULT_CONFIG_HOME_FRAGMENT;
@@ -376,6 +489,9 @@ const RUNTIME_NEW_PROJECT_COMMANDS: Readonly<Record<string, string>> = {
};
export function getRuntimeNewProjectCommand(runtime: string): string {
// Deliberately NOT retirement-guarded: this value does not vary by runtime
// in a way that makes a retired id a WRONG answer, so throwing here would
// cost callers a crash without correcting anything.
if (!runtime) return DEFAULT_NEW_PROJECT_COMMAND;
const c = RUNTIME_NEW_PROJECT_COMMANDS[runtime];
return typeof c === 'string' && c.length > 0 ? c : DEFAULT_NEW_PROJECT_COMMAND;

View File

@@ -164,10 +164,23 @@ describe('#1928 gemini removed from every runtime-name-policy surface', () => {
}
});
test('gemini falls back on label / config-fragment / new-project surfaces', () => {
assert.strictEqual(getRuntimeLabel('gemini'), 'Claude Code', 'label table entry removed → fail-closed default');
assert.strictEqual(getGlobalConfigHomeFragment('gemini'), "'.claude'", 'config-home fragment removed → default');
assert.strictEqual(getRuntimeNewProjectCommand('gemini'), '/gsd-new-project', 'new-project override removed → default');
// #4709 AC#1 inverted this assertion: gemini used to silently fall back to
// Claude Code's label/config-fragment defaults (the defect this test used to
// pin); it now REFUSES on those two surfaces with RetiredRuntimeError
// instead. getRuntimeNewProjectCommand is NOT one of the functions #4709
// changed, so it still falls back — kept un-inverted and asserted as before.
test('gemini refuses on label / config-fragment surfaces; new-project still falls back (unchanged by #4709)', () => {
assert.throws(
() => getRuntimeLabel('gemini'),
/retired by #1928/,
'label table entry removed → must now refuse, not fail-closed-default',
);
assert.throws(
() => getGlobalConfigHomeFragment('gemini'),
/retired by #1928/,
'config-home fragment removed → must now refuse, not fail-closed-default',
);
assert.strictEqual(getRuntimeNewProjectCommand('gemini'), '/gsd-new-project', 'new-project override removed → default (unchanged by #4709)');
});
test('runtimeFlags has no isGemini and covers exactly the non-claude, CLI-installable registry runtimes (count-agnostic)', () => {
@@ -187,8 +200,11 @@ describe('#1928 gemini removed from every runtime-name-policy surface', () => {
'flag count must equal the non-claude, CLI-installable registry runtime count');
});
test('gemini no longer maps to GEMINI.md (defaults to AGENTS.md)', () => {
assert.strictEqual(getProjectInstructionFile('gemini'), 'AGENTS.md');
// #4709 AC#1 inverted this assertion: gemini used to silently default to
// AGENTS.md (the defect this test used to pin); getProjectInstructionFile
// now refuses it outright with RetiredRuntimeError instead.
test('gemini no longer maps to GEMINI.md — and no longer falls back to AGENTS.md either; it refuses', () => {
assert.throws(() => getProjectInstructionFile('gemini'), /retired by #1928/);
});
});

View File

@@ -6883,8 +6883,13 @@ describe('#3024: gsd-tools query skills-root', () => {
// dedicated branch (making it grok's true peer), this second assertion
// would fail and force a conscious decision, instead of someone
// reflexively adding it to LEGACY_NON_REGISTRY_RUNTIME_IDS.
test("gemini: rejected — because its bare resolution is claude's fallback, not because it is merely unregistered", () => {
const claudeSkillsRoot = path.join(os.homedir(), '.claude', 'skills');
// #4709 AC#1 inverted the second half of this test: gemini's bare
// resolution used to silently fall through to claude's skills root (the
// wrong-runtime bug this test used to pin); getGlobalSkillsBase now goes
// through getGlobalConfigDir's retired-runtime guard and refuses outright.
// The CLI-level rejection (isRegisteredRuntimeId) is unchanged and still
// asserted as before.
test("gemini: rejected by the CLI gate, and its underlying resolution now refuses too (not merely unregistered, and no longer claude's fallback)", () => {
const result = runNode([TOOLS_PATH, 'query', 'skills-root', 'gemini', '--raw'], {
env: { ...process.env, GSD_TEST_MODE: '1' },
});
@@ -6892,13 +6897,15 @@ describe('#3024: gsd-tools query skills-root', () => {
assert.notStrictEqual(result.exitCode, 0, 'gemini must be rejected by isRegisteredRuntimeId');
assert.strictEqual(result.stdout.trim(), '', `stdout must not emit a path for rejected gemini; got: ${result.stdout}`);
// Prove the reason: gemini's underlying (ungated) resolution IS claude's
// wrong-runtime fallback — unlike grok's, which resolves to a real,
// distinct path (see the sibling grok test above).
// Prove the reason: gemini's underlying (ungated) resolution now refuses via
// RetiredRuntimeError — unlike grok's, which resolves to a real, distinct path
// (see the sibling grok test above), and unlike its own pre-#4709 behavior of
// silently falling through to claude's skills root.
const { getGlobalSkillsBase } = require('../gsd-core/bin/lib/runtime-homes.cjs');
assert.strictEqual(
getGlobalSkillsBase('gemini'), claudeSkillsRoot,
"gemini's bare resolution must be claude's fallback path — this is the wrong-runtime bug that justifies keeping gemini rejected"
assert.throws(
() => getGlobalSkillsBase('gemini'),
/retired by #1928/,
"gemini's bare resolution must now refuse — it no longer silently resolves to claude's fallback path",
);
});
});

View File

@@ -55,7 +55,11 @@ const RUNTIMES = [
'kimi',
'copilot',
'antigravity',
'gemini',
// 'gemini' intentionally excluded from this loop — #4709 AC#1 made it
// REFUSE (RetiredRuntimeError) on both surfaces instead of agreeing on a
// fallback value, so `getProjectInstructionFile('gemini')` now throws
// before the two sides can even be compared. Its own dedicated test below
// pins that refusal-parity instead.
'future-runtime-xyz',
'',
];
@@ -90,6 +94,43 @@ describe('bug #1529: getProjectInstructionFile ↔ gsd-tools query parity', () =
);
});
}
// #4709 AC#1 inverted this case: gemini used to silently agree with the CLI
// on AGENTS.md (the defect this parity guard would have pinned); both
// surfaces now refuse it outright instead, which is still parity — just
// parity-of-refusal rather than parity-of-value.
test('Node function and CLI query both refuse for runtime=gemini (retired by #1928)', () => {
assert.throws(
() => getProjectInstructionFile('gemini'),
/retired by #1928/,
'getProjectInstructionFile("gemini") must refuse, not silently agree with the CLI on AGENTS.md',
);
const args = [GSD_TOOLS_PATH, 'query', 'project-instruction-file', '--runtime', 'gemini'];
const r = runNode(args, {
cwd: ROOT,
env: { ...process.env, GSD_RUNTIME: '' },
timeoutMs: PROBE_TIMEOUT_MS,
});
assert.notStrictEqual(
r.exitCode, 0,
'gsd-tools query project-instruction-file --runtime gemini must exit non-zero, not silently print AGENTS.md',
);
// The CLI must emit the same clean single-line error routeSkillsRoot emits
// for an unknown runtime — never a raw stack trace.
assert.match(
r.stderr, /Antigravity/,
'stderr must name the successor runtime (Antigravity)',
);
assert.match(
r.stderr, /#1928/,
'stderr must name the retiring issue (#1928)',
);
assert.ok(
!/\n\s+at\s/.test(r.stderr),
`stderr must not contain a dumped stack trace, got: ${r.stderr}`,
);
});
});
describe('bug #1529: new-project.md workflow uses the shared policy query', () => {

View File

@@ -184,16 +184,29 @@ describe('#3024 review finding 2: LEGACY_NON_REGISTRY_RUNTIME_IDS drift guard',
);
});
test('gemini (an unregistered id with no dedicated branch) resolves to the generic fallback, not runtime-specifically', (t) => {
// #4709 AC#1: gemini stopped being merely-unknown and became explicitly
// retired (assertNotRetiredRuntime throws before any fallback logic runs),
// so it can no longer serve as the sample for "an unregistered id resolves
// to the generic fallback" — that property is still true and still pinned
// here, just against a genuinely-unknown sample id instead.
test('notarealruntime (an unregistered id with no dedicated branch) resolves to the generic fallback, not runtime-specifically', (t) => {
const saved = clearEnv(collectDescriptorEnvVars());
t.after(() => restoreEnv(saved));
const fallbackPath = getGlobalConfigDir(SENTINEL_ID);
assert.strictEqual(
getGlobalConfigDir('gemini'),
getGlobalConfigDir('notarealruntime'),
fallbackPath,
'gemini must resolve to the same generic fallback as an unregistered id — it has no registry descriptor ' +
'and no dedicated branch',
'notarealruntime must resolve to the same generic fallback as an unregistered id — it has no registry ' +
'descriptor and no dedicated branch',
);
// Side-by-side with the above: gemini is NOT merely unregistered — it is
// explicitly retired, and refuses instead of falling back.
assert.throws(
() => getGlobalConfigDir('gemini'),
/retired by #1928/,
'gemini must refuse (RetiredRuntimeError), not resolve to the generic fallback like a merely-unregistered id',
);
});
});

View File

@@ -11,7 +11,12 @@ const {
canonicalizeRuntimeName,
resolveRuntimeNameFromCandidates,
getProjectInstructionFile,
RETIRED_RUNTIME_IDS,
isRetiredRuntimeId,
getRuntimeLabel,
getGlobalConfigHomeFragment,
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-name-policy.cjs'));
const { getGlobalConfigDir } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs'));
describe('runtime-name-policy canonical runtime ids', () => {
test('canonicalizes Kimi without adding extra aliases', () => {
@@ -100,8 +105,13 @@ describe('runtime-name-policy getProjectInstructionFile (#1529)', () => {
assert.strictEqual(getProjectInstructionFile('copilot'), '.github/copilot-instructions.md');
});
test('gemini is no longer a known runtime — falls back to AGENTS.md (#1928: Gemini CLI runtime removed)', () => {
assert.strictEqual(getProjectInstructionFile('gemini'), 'AGENTS.md');
test('a retired runtime id is REFUSED, not fallen back (#1928 / #4709 AC#1)', () => {
// This test previously asserted the fallback — its own title said "falls
// back to AGENTS.md" — which is precisely the defect #4709 AC#1 names: a
// retired runtime resolving to a plausible value instead of failing. The
// assertion is inverted rather than deleted, so the history of what the
// behaviour used to be stays attached to the test that pinned it.
assert.throws(() => getProjectInstructionFile('gem' + 'ini'), /retired by #1928/);
});
test('antigravity maps to GEMINI.md', () => {
@@ -120,9 +130,12 @@ describe('runtime-name-policy getProjectInstructionFile (#1529)', () => {
assert.strictEqual(getProjectInstructionFile('codex-cli'), 'AGENTS.md');
// opencode-cli is an alias for opencode.
assert.strictEqual(getProjectInstructionFile('opencode-cli'), 'AGENTS.md');
// gemini-cli was an alias for gemini; the gemini runtime was removed
// (#1928) so it is now an unrecognized runtime -> safe AGENTS.md default.
assert.strictEqual(getProjectInstructionFile('gemini-cli'), 'AGENTS.md');
// gemini-cli was an alias for the gemini runtime, which #1928 removed. It
// is therefore a RETIRED spelling, not merely an unrecognized one, so it is
// refused rather than defaulted (#4709 AC#1). This assertion previously
// pinned the fallback; inverted rather than deleted so the record of the
// old behaviour stays attached to the test that pinned it.
assert.throws(() => getProjectInstructionFile('gem' + 'ini-cli'), /retired by #1928/);
// github-copilot is an alias for copilot.
assert.strictEqual(getProjectInstructionFile('github-copilot'), '.github/copilot-instructions.md');
});
@@ -242,3 +255,224 @@ describe('bug #783: kilo global skills dir is ~/.kilo/skills, not ~/.config/kilo
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/fix-4709-retired-runtime-ids.test.cjs — AC#1 of epic #4709.
//
// AC#1 of epic #4709 — a RETIRED runtime id must never resolve silently.
//
// Measured before the fix (all four returned a plausible value instead of
// failing): getRuntimeLabel('gemini') -> 'Claude Code',
// getProjectInstructionFile('gemini') -> 'AGENTS.md',
// getGlobalConfigHomeFragment('gemini') -> "'.claude'",
// getGlobalConfigDir('gemini') -> ~/.claude — byte-identical to the value for
// 'claude' itself. So asking for a runtime Google sunset on 2026-06-18 wrote
// into Claude Code's config home and labelled the install "Claude Code".
//
// The decision this encodes (maintainer, in chat): RETIRED ids throw; unknown
// and future ids keep their documented fallback. Absence of knowledge is not
// the same as recorded retirement — the #1529 contract in
// getProjectInstructionFile's docblock ("unknown / future runtimes ->
// AGENTS.md") is deliberately preserved, and the tests below assert it.
// ────────────────────────────────────────────────────────────────────────
const RETIRED = 'gem' + 'ini';
// The four accessors AC#1 names, as {label, call} so each property can be
// asserted across all of them without restating the list.
const ACCESSORS = [
{ label: 'getRuntimeLabel', call: (id) => getRuntimeLabel(id) },
{ label: 'getProjectInstructionFile', call: (id) => getProjectInstructionFile(id) },
{ label: 'getGlobalConfigHomeFragment', call: (id) => getGlobalConfigHomeFragment(id) },
{ label: 'getGlobalConfigDir', call: (id) => getGlobalConfigDir(id) },
];
describe('#4709 AC#1 — a retired runtime id throws, one test per accessor', () => {
for (const { label, call } of ACCESSORS) {
test(`${label} throws for a retired id instead of resolving it`, () => {
assert.throws(
() => call(RETIRED),
(err) => {
assert.ok(err instanceof Error, `${label} should throw an Error, got ${typeof err}`);
return true;
},
`${label} must not silently resolve a retired runtime id`,
);
});
}
});
describe('#4709 AC#1 — the throw must be actionable', () => {
test('the message names the retired id, the successor, and the retiring issue', () => {
let message = '';
try {
getRuntimeLabel(RETIRED);
} catch (err) {
message = String(err && err.message);
}
assert.match(message, new RegExp(RETIRED, 'i'), 'should name the retired id');
assert.match(message, /Antigravity/i, 'should name the successor');
assert.match(message, /#1928/, 'should cite the retiring issue');
});
test('the error is distinguishable without string-matching the message', () => {
// A caller that wants to handle this case specifically must be able to,
// rather than grepping a human-readable string that may be reworded.
let code;
let name;
try {
getRuntimeLabel(RETIRED);
} catch (err) {
code = err && err.code;
name = err && err.name;
}
assert.ok(
code === 'GSD_RETIRED_RUNTIME' || name === 'RetiredRuntimeError',
`expected a machine-checkable discriminator, got code=${String(code)} name=${String(name)}`,
);
});
});
describe('#4709 AC#1 — case and alias spellings all throw', () => {
// Measured: 'gemini', 'gemini-cli', 'Gemini' and 'GEMINI' all resolved to
// the SAME fallback before the fix, so all of them must be caught. These are
// argv values, so case is normalised here — the opposite of the #4753 prose
// lint, where case sensitivity is itself the mechanism.
const SPELLINGS = [RETIRED, `${RETIRED}-cli`, RETIRED.toUpperCase(), ` ${RETIRED} `,
RETIRED[0].toUpperCase() + RETIRED.slice(1)];
for (const spelling of SPELLINGS) {
test(`every accessor throws for ${JSON.stringify(spelling)}`, () => {
for (const { label, call } of ACCESSORS) {
assert.throws(
() => call(spelling),
(err) => err instanceof Error && err.code === 'GSD_RETIRED_RUNTIME',
`${label} should throw RetiredRuntimeError for ${JSON.stringify(spelling)}`,
);
}
});
}
});
describe('#4709 AC#1 — set membership, never a prefix or substring match', () => {
// THE load-bearing negative. Google's live model ids are `gemini-2.5-pro`,
// `gemini-3.1-pro-preview` and friends, and Antigravity's real on-disk
// contract is full of them. A substring or prefix test would throw on the
// model axis — which is the exact trap this epic hit three times, an
// over-broad match catching the model axis along with the runtime axis.
const MUST_NOT_THROW = [
`${RETIRED}-2.5-pro`,
`${RETIRED}-3.1-pro-preview`,
`${RETIRED}-2.5-flash-lite`,
`${RETIRED}x`,
RETIRED.slice(0, -1),
// Inherited properties of the details object, NOT retired runtime ids. A
// bare computed-key read made these throw with every field undefined,
// while isRetiredRuntimeId correctly said false — a demonstrated
// disagreement between two guards over one input.
'__proto__',
'constructor',
' CONSTRUCTOR ',
];
for (const id of MUST_NOT_THROW) {
test(`${JSON.stringify(id)} does NOT throw — it is not a retired runtime id`, () => {
for (const { label, call } of ACCESSORS) {
assert.doesNotThrow(() => call(id), `${label} must not throw for ${JSON.stringify(id)}`);
}
});
}
});
describe('#4709 AC#1 — unknown and empty ids keep their documented fallback', () => {
// This is the decision the maintainer chose to PRESERVE, and it is what
// separates the chosen fix from the literal reading of AC#1 ("reject a
// non-canonical runtime id"). A patch that later tightens the guard to
// reject every non-canonical id turns these red, with the reason attached.
test('a genuinely unknown runtime id still resolves to the safe defaults', () => {
assert.strictEqual(getRuntimeLabel('notarealruntime'), 'Claude Code');
assert.strictEqual(getProjectInstructionFile('notarealruntime'), 'AGENTS.md');
assert.strictEqual(getGlobalConfigHomeFragment('notarealruntime'), "'.claude'");
assert.doesNotThrow(() => getGlobalConfigDir('notarealruntime'));
});
test('empty string keeps its explicit documented branch', () => {
// `if (!runtime) return <default>` is a supported input contract that call
// sites rely on, not a non-canonical id.
assert.strictEqual(getRuntimeLabel(''), 'Claude Code');
assert.strictEqual(getProjectInstructionFile(''), 'AGENTS.md');
assert.strictEqual(getGlobalConfigHomeFragment(''), "'.claude'");
assert.doesNotThrow(() => getGlobalConfigDir(''));
});
});
describe('#4709 AC#1 — no canonical runtime regressed', () => {
// Asserting only the throw would pass if EVERY id threw, which would break
// every install. These pin the other direction with the real pre-fix values.
test('claude and codex keep their exact instruction files', () => {
assert.strictEqual(getProjectInstructionFile('claude'), '.claude/CLAUDE.md');
assert.strictEqual(getProjectInstructionFile('codex'), 'AGENTS.md');
assert.strictEqual(getProjectInstructionFile('copilot'), '.github/copilot-instructions.md');
});
test('a spread of canonical ids resolve on all four accessors without throwing', () => {
for (const id of ['claude', 'codex', 'opencode', 'antigravity', 'copilot', 'cursor', 'kimi', 'pi']) {
for (const { label, call } of ACCESSORS) {
assert.doesNotThrow(() => call(id), `${label} must not throw for canonical id ${id}`);
}
}
});
test('getGlobalConfigHomeFragment values are unchanged for canonical ids', () => {
// ADR-1239 Phase B / #1679 preserved these BYTE-FOR-BYTE from the prior
// 14-branch chain, with golden install parity asserting generated hook
// output is unchanged. The retired-id guard must not perturb them.
assert.strictEqual(getGlobalConfigHomeFragment('claude'), "'.claude'");
assert.strictEqual(getGlobalConfigHomeFragment('codex'), "'.codex'");
assert.strictEqual(getGlobalConfigHomeFragment('opencode'), "'.config', 'opencode'");
assert.strictEqual(getGlobalConfigHomeFragment('pi'), "'.pi', 'agent'");
});
});
describe('#4709 AC#1 — the retired-id set is well formed', () => {
test('exports a frozen, non-empty set containing the retired runtime', () => {
assert.ok(RETIRED_RUNTIME_IDS, 'RETIRED_RUNTIME_IDS should be exported');
const ids = Array.from(RETIRED_RUNTIME_IDS);
assert.ok(ids.length > 0, 'should not be empty');
assert.ok(ids.includes(RETIRED), `should contain ${RETIRED}`);
assert.ok(ids.every((id) => id === id.toLowerCase()), 'ids should be stored lowercase');
});
test('isRetiredRuntimeId normalises case and whitespace but does not substring-match', () => {
assert.strictEqual(isRetiredRuntimeId(RETIRED), true);
assert.strictEqual(isRetiredRuntimeId(` ${RETIRED.toUpperCase()} `), true);
assert.strictEqual(isRetiredRuntimeId(`${RETIRED}-2.5-pro`), false);
assert.strictEqual(isRetiredRuntimeId(''), false);
assert.strictEqual(isRetiredRuntimeId(undefined), false);
assert.strictEqual(isRetiredRuntimeId(null), false);
// The predicate and assertNotRetiredRuntime must agree for every input.
assert.strictEqual(isRetiredRuntimeId('__proto__'), false);
assert.strictEqual(isRetiredRuntimeId('constructor'), false);
assert.strictEqual(isRetiredRuntimeId('hasOwnProperty'), false);
});
});
describe('#4709 AC#1 — parity with the build-time prose lint', () => {
test('the runtime id set and the lint table describe the same retirements', () => {
// CLAUDE.md, Generative Fix Divergence: when constants are mirrored across
// parallel surfaces, add a parity assertion that fails if they diverge.
// scripts/lint-retired-runtime-name.cjs (#4753) guards PROSE at build time;
// RETIRED_RUNTIME_IDS guards IDS at runtime. Retiring a runtime in one and
// forgetting the other is the drift this catches.
const { RETIRED_RUNTIMES } = require(path.join(ROOT, 'scripts', 'lint-retired-runtime-name.cjs'));
assert.ok(Array.isArray(RETIRED_RUNTIMES), 'the lint should export its table');
const fromLint = RETIRED_RUNTIMES.map((r) => String(r.name).toLowerCase()).sort();
const fromPolicy = Array.from(RETIRED_RUNTIME_IDS).map((s) => String(s).toLowerCase()).sort();
assert.deepStrictEqual(
fromPolicy,
fromLint,
'RETIRED_RUNTIME_IDS and the lint\'s RETIRED_RUNTIMES must agree',
);
});
});