Files
msd-core/src/runtime-identity.cts
sim 67335c498c fix(#3841): express the anchor semantically so the pretty payload still verifies
The remote matrix caught a regression I introduced in the previous commit. The
anchor check was implemented as a byte-prefix match against IDENTITY_RAW_PREFIX,
which describes the `--raw` wire format -- but `cmdRuntimeIdentity` without that
flag pretty-prints at indent 2, and the classifier is handed BOTH serializations.
Only the shell is restricted to `--raw`. The pre-existing test that runs the real
verb with no flag went red: `+ 'unparseable' - 'ok'`.

No local gate caught it. build:lib, eslint and lint:ci were green throughout,
because none of them execute tests.

The anchor now reproduces its two properties semantically instead of byte-wise,
and both hold for either serialization: the payload begins at the first byte of
stdout, and `packageName` serializes first. IDENTITY_RAW_PREFIX stays exported
with its own tests -- it is the wire contract for the shell, not a general
classifier predicate, and conflating those was the error.

Adds the two rows the matrix was missing: the default pretty serialization
verifies, and a pretty payload with `packageName` not first does not. The design
and matrix now record that they enumerated only the inputs the SHELL produces
and assumed the classifier's input set was the same.

Refs #3841

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:36:59 -04:00

297 lines
13 KiB
TypeScript

/**
* Runtime identity — prove which package's `gsd-tools` actually executed (#3146).
*
* The predecessor package `get-shit-done-cc` publishes a colliding `gsd-tools`
* bin (verified 2026-08-24 against 1.42.3: `bin.gsd-tools -> bin/gsd-sdk.js`),
* and exposes verbs of the same name with different semantics. #3129 is the
* worked example: `phases.clear` archives here and **deletes** there, and the
* output is success-shaped either way, so 43 phase directories went missing
* with no warning.
*
* That collision is now prevented in the resolver rather than detected here:
* the launcher's PATH branch resolves `gsd_run`, which only this package
* publishes and which self-locates to its sibling shim, so a foreign binary is
* unreachable from PATH. The path-based branches — a project-local install, a
* runtime config directory — have no such structural guarantee: they trust
* their configured location. #3841 closes that remaining route by making the
* launcher preamble PROBE the resolved tool once, before any verb runs, and
* warn when it cannot prove it is this package. This module backs both the
* `runtime-identity` verb the preamble calls and the vocabulary that gate
* reports in (`IDENTITY_STATUS`).
*
* The rollout is warn-then-fail (the #3146 maintainer ruling): the preamble
* prints one actionable line and proceeds. It cannot hard-fail yet because
* `no_identity_verb` does not distinguish a foreign package from an
* `@opengsd/gsd-core` older than the verb — and during rollout the old-version
* case is the common one, so a hard failure would stop working installs.
*
* Parsing is deliberately STRICT. The whole value of the check is telling "us"
* from "not us"; a lenient parse that accepts the predecessor's usage text as
* close-enough would reproduce the exact silent success #3129 already produced.
* Liberality is spent on visibility instead — distinct reason codes, each
* naming what actually happened.
*
* The classifier and the shell preamble are held in agreement by a
* cross-surface parity test (#3841): both read stdout only and must reach the
* same verdict for the same payload, or the preamble's warning and this
* module's diagnostic would silently diverge on the same input.
*/
import { packageName } from './package-identity.cjs';
import { readHostVersion } from './capability-loader.cjs';
/** The package name a legitimate GSD runtime reports. Baked at build time (#498). */
export const EXPECTED_PACKAGE_NAME: string = packageName;
/**
* Why an identity probe did or did not verify.
*
* `no_identity_verb` and `unparseable` are kept apart on purpose: the first
* means "something else answered" (a foreign binary that has no such verb), the
* second means "we got an answer we cannot trust". They point at different
* remedies, and collapsing them is what makes a diagnostic useless.
*/
export type IdentityReason =
| 'ok'
| 'identity_mismatch'
| 'no_identity_verb'
| 'unparseable'
| 'probe_failed';
/**
* The two-valued status the launcher preamble exports as `GSD_IDENTITY_STATUS`
* (#3841). Frozen so it is a VALUE a test can assert on: CONTRIBUTING forbids
* proving the gate fired by matching its human-readable warning prose.
*
* Two values, not five: the shell has no use for the distinction between
* `no_identity_verb` and `unparseable` — it either holds proof or it does not.
* The five-way {@link IdentityReason} stays the diagnostic vocabulary;
* {@link statusForVerdict} is the only bridge between them.
*/
export const IDENTITY_STATUS = Object.freeze({
OK: 'ok',
UNVERIFIED: 'unverified',
} as const);
/** A value of {@link IDENTITY_STATUS}. */
export type IdentityStatus = (typeof IDENTITY_STATUS)[keyof typeof IDENTITY_STATUS];
/**
* The exact byte prefix the preamble anchors its `case` pattern to.
*
* ANCHORED, never a substring search. `JSON.stringify` emits keys in insertion
* order and `buildIdentityPayload` inserts `packageName` first, so a genuine
* `--raw` payload always STARTS with this. An unanchored match would verify the
* decoy `{"packageName":"get-shit-done-cc","note":"@opengsd/gsd-core"}`, which
* is exactly the shape a colliding package could publish.
*
* The preamble anchors at BOTH ends: its `case` pattern is this prefix, then
* anything, then a literal `}`, so a truncated payload fails too. That is safe
* for any future additive field — a JSON object's own closing brace is always
* the last character, whatever the last value's type. It also pairs the literal
* `{` for the raw-text brace guards the preamble is inlined into (#3841); the
* pairing is a consequence of the stronger check, not the reason for it.
*/
export const IDENTITY_RAW_PREFIX = `{"packageName":"${EXPECTED_PACKAGE_NAME}"`;
/** Raw result of running `<resolved gsd-tools> runtime-identity`. */
export interface IdentityProbe {
stdout: string;
exitCode: number | null;
/** The child could not be spawned at all (ENOENT, not executable). */
spawnFailed?: boolean;
/** The child exceeded its timeout and was killed. */
timedOut?: boolean;
}
export interface IdentityVerdict {
reason: IdentityReason;
expected: string;
/** The packageName the probe reported, when it reported a usable one. */
actual?: string;
version?: string;
/** Human-readable evidence for the warning text. Never a full dump. */
detail?: string;
}
/** The documented payload shape. Minimal on purpose — see Hyrum's Law note in 40-design.md. */
export interface IdentityPayload {
packageName: string;
version: string;
}
/** Cap on echoed probe output so a warning can never dump a whole usage screen. */
const EVIDENCE_MAX_CHARS = 200;
function excerpt(text: string): string {
const flat = text.replace(/\s+/g, ' ').trim();
return flat.length > EVIDENCE_MAX_CHARS ? `${flat.slice(0, EVIDENCE_MAX_CHARS)}…` : flat;
}
/**
* `stdout` as a plain JSON object, or `null` when it is not one.
*
* `JSON.parse` accepts `0`, `"str"`, `[]`, `null` and `true`; arrays and `null`
* are also `object` to `typeof`. All are rejected here so no downstream
* truthiness check can mistake `[]` for a verified identity.
*/
function parseIdentityRecord(stdout: string): Record<string, unknown> | null {
let parsed: unknown;
try {
parsed = JSON.parse(stdout) as unknown;
} catch {
return null;
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
}
/**
* Pure classifier: probe result -> verdict. Total — never throws, for any input.
*
* Non-object JSON (`0`, `"str"`, `[]`, `null`, `true`) is `unparseable`, not
* `ok` and not a crash: `JSON.parse` accepts all of them, so a naive truthiness
* check would let `[]` through as a verified identity.
*/
export function classifyIdentityProbe(
probe: IdentityProbe,
expected: string = EXPECTED_PACKAGE_NAME,
): IdentityVerdict {
if (probe.spawnFailed || probe.timedOut) {
return {
reason: 'probe_failed',
expected,
detail: probe.timedOut ? 'identity probe timed out' : 'identity probe could not be spawned',
};
}
// PARSE BEFORE CONSULTING THE EXIT CODE (#3841).
//
// The exit-code rule below is right for its stated reason -- the predecessor
// prints a usage screen and exits 1, and that is "something else answered",
// not a parse problem -- but it used to run FIRST, so it also caught a tool
// that had already proved its identity and merely exited non-zero afterwards.
// The shell preamble this module speaks for reads stdout ONLY (its command
// substitution discards the status), so the two surfaces disagreed on exactly
// that input: shell `ok`, classifier `no_identity_verb`. Measured against the
// real snippet, not inferred. Since this classifier is the engine for the
// announced hard-fail phase, such an install would have flipped from verified
// to refused at rollout, in the phase where that stops the run.
//
// The predecessor defence is unaffected: a usage screen yields no usable
// payload, so it still falls through to the exit-code branch below.
const record = parseIdentityRecord(probe.stdout);
if (record !== null) {
const actual = record.packageName;
if (typeof actual === 'string' && actual.length > 0) {
// Unknown keys are ignored so a future payload addition cannot fail an
// older check. The shell anchor is deliberately open in the middle for the
// same reason, and the parity suite pins both halves of that (case P10).
const version = typeof record.version === 'string' ? record.version : undefined;
if (actual !== expected) return { reason: 'identity_mismatch', expected, actual, version };
// ANCHOR PARITY (#3841). The shell preamble cannot parse JSON: it matches
// a `case` pattern anchored at the START of stdout, so a payload only
// verifies there when it begins at the first byte and serializes
// `packageName` first. A purely structural parse would accept
// `{"note":"x","packageName":"<us>"}` and a leading-whitespace payload
// that the shell rejects -- a FAIL-OPEN disagreement between the two
// surfaces.
//
// Reproduce those two properties SEMANTICALLY rather than byte-matching
// IDENTITY_RAW_PREFIX. The prefix describes the `--raw` wire format, but
// this classifier is also handed the DEFAULT pretty serialization
// (`runtime-identity` with no `--raw`, which is two-space indented) --
// byte-matching the compact prefix rejected that, which the remote matrix
// caught. `startsWith('{')` and a first-key check hold for both.
const firstKey = Object.keys(record)[0];
if (!probe.stdout.startsWith('{') || firstKey !== 'packageName') {
return {
reason: 'unparseable',
expected,
detail: 'payload names this package but is not in the anchored wire shape',
};
}
return { reason: 'ok', expected, actual, version };
}
}
// Nothing was proved. NOW the exit status is the discriminator: a non-zero
// exit means a binary that does not implement this verb answered.
if (probe.exitCode !== 0) {
return {
reason: 'no_identity_verb',
expected,
detail: `exit ${String(probe.exitCode)}: ${excerpt(probe.stdout)}`,
};
}
return {
reason: 'unparseable',
expected,
detail: record === null ? excerpt(probe.stdout) : 'payload has no usable packageName',
};
}
/**
* Collapse a five-way verdict onto the two-valued shell status.
*
* Total by construction: anything that is not `ok` is `unverified`. A future
* sixth reason code therefore fails CLOSED here rather than silently verifying.
*/
export function statusForVerdict(verdict: IdentityVerdict): IdentityStatus {
return verdict.reason === 'ok' ? IDENTITY_STATUS.OK : IDENTITY_STATUS.UNVERIFIED;
}
export interface PayloadDeps {
/** Injectable for tests; defaults to the shipped host-version reader. */
readVersion?: () => string;
/** Injectable for tests; defaults to the build-time baked package name. */
readPackageName?: () => string;
}
/**
* Build this runtime's identity payload.
*
* Version is REPORTED but not asserted in the warn phase, so a dev tree
* reporting readHostVersion()'s fail-closed `0.0.0` still verifies. Carrying it
* now means the hard-fail phase can gate on compatibility without a payload
* change (and therefore without a second round of Hyrum's-Law exposure).
*/
export function buildIdentityPayload(deps: PayloadDeps = {}): IdentityPayload {
const readVersion = deps.readVersion ?? ((): string => readHostVersion());
const readName = deps.readPackageName ?? ((): string => EXPECTED_PACKAGE_NAME);
return { packageName: readName(), version: readVersion() };
}
/** Render a verdict as the one-line actionable warning the preamble prints. */
export function explainVerdict(verdict: IdentityVerdict, resolvedPath: string): string {
const head = `WARNING: "${resolvedPath}" did not report a ${verdict.expected} identity.`;
const why: Record<IdentityReason, string> = {
ok: 'identity verified',
identity_mismatch: `it reported "${verdict.actual ?? '(unknown)'}" instead`,
no_identity_verb:
'it does not implement the runtime-identity verb — either a different package, or a gsd-core predating the verb',
unparseable: 'its response could not be parsed as an identity payload',
probe_failed: 'the identity probe could not be run',
};
const tail =
'A workflow shipped by this package may be running against a different tool. ' +
'See docs/how-to/diagnose-a-foreign-gsd-tools.md';
const evidence = verdict.detail ? ` (${verdict.detail})` : '';
return `${head} Reason: ${why[verdict.reason]}${evidence}. ${tail}`;
}
/**
* CLI arm for `gsd-tools runtime-identity`.
*
* Kept on the CJS fast path for the same reason as `current-timestamp`: it is a
* pure local read, and the launcher preamble spawns it once per workflow run,
* so SDK bridge startup would be a per-run tax on every workflow.
*/
export function cmdRuntimeIdentity(raw: boolean = false, deps: PayloadDeps = {}): void {
const payload = buildIdentityPayload(deps);
process.stdout.write(`${JSON.stringify(payload, null, raw ? 0 : 2)}\n`);
}