* feat(#3146): resolve gsd_run so workflows cannot reach a foreign gsd-tools The predecessor package get-shit-done-cc publishes a colliding gsd-tools bin whose phases.clear DELETES where this package's ARCHIVES, and both print success-shaped output against a gitignored .planning/ -- which is how #3129 cost a user 43 phase directories with no error and nothing recoverable from git. The launcher's PATH branch now resolves gsd_run, published only by this package and self-locating via its own symlink chain to the sibling shim, instead of the colliding gsd-tools. A foreign handler becomes unreachable from PATH, and when no gsd_run is reachable the resolver fails closed rather than falling back -- that fallback was the vulnerability. This is smaller than the branch it replaces, which matters: the preamble is inlined into 113 shipped files and agents/gsd-verifier.md sits 2 bytes under a red-line size cap. unset -f gsd_run leads the preamble so a re-source is idempotent. Without it, command -v finds the shell function, returns a bare name, and the resolver falls through to an exit 1 that kills a sourced caller's shell. Adds gsd-tools runtime-identity, a manual diagnostic reporting this runtime's package coordinates over the baked package-identity (#498) and readHostVersion, with a strict total classifier: only a JSON object with an exact packageName verifies, since JSON.parse admits 0/"str"/[]/null/true. An inlined identity assertion was built and reviewed first, then withdrawn -- it breaks five frozen size ceilings and no assertion fits in 2 bytes. Closes #3146 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3146): stop sync:launcher relocating a deliberate preamble placement Pre-existing defect, surfaced by this PR because sync is a no-op unless the snippet content actually changes. transformFile inserts the preamble into the first block that CALLS gsd_run, but gsd-core/workflows/explore.md deliberately places it in a bootstrap-only block that DEFINES gsd_run without calling it -- its own comment explains why: declining the research offer must not leave Step 5's commit call unbootstrapped. Stripping empties that block of calls, so the preamble migrated forward and broke the define-before-use invariant tests/explore-command.test.cjs pins. Reproduced on a pristine origin/next checkout with the base snippet and base file, so this was not introduced here. The insertion target now honours a block that already carried the preamble, falling back to the first calling block for files that have none yet. Adds a behavioral regression test over a two-block fixture. Also updates three runtime-launcher-parity tests that pinned the removed PATH fallback to gsd-tools. Their intent is preserved -- the PATH stub is renamed gsd_run so it is reachable by the new resolver, and the RUNTIME_DIR-wins test still asserts the stub is never invoked. Fixture shebangs move to an absolute /bin/sh, because the fixture PATH is deliberately restricted and #!/usr/bin/env sh could not resolve. Refs #3146 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3146): backfill changeset PR number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3146): document the FEATURES.md section-numbering practice The monotonically increasing section number in docs/FEATURES.md is the most frequent merge-conflict source in this repo, and it has TWO conflict cells, not one: the ### N. heading and the hand-maintained table of contents. Two PRs adding differently numbered features still collide on the TOC, so renumbering alone does not make a branch safe. This branch alone was renumbered 165 -> 166 -> 167 -> 168 across successive rebases. Adds a CONTRIBUTING section stating the practice: allocate the number last, never pre-emptively renumber, take max+1 after a rebase and update the TOC in the same commit, and never renumber someone else's section. Fork contributors are told explicitly they may leave the number to a maintainer at merge rather than chasing the counter. Agents are told to lease the allocation and to include the file in their published touched set. Records the durable fix as planned rather than pretending it exists: FEATURES.md should be generated from per-feature fragments the way CHANGELOG.md is generated from .changeset/, and the way tests/emitted-drift-acks/ works (#2914). Also renumbers this branch's own section to 168, leaving 167 to the PR already in flight. Refs #3146 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
186
src/runtime-identity.cts
Normal file
186
src/runtime-identity.cts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 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. This module backs the `runtime-identity` verb, a
|
||||
* MANUAL diagnostic for answering "which tool am I actually running?" — it is
|
||||
* not an automatic gate, and nothing calls it on the hot path.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
};
|
||||
}
|
||||
|
||||
// A non-zero exit is what a binary without this verb does. The predecessor
|
||||
// prints its usage screen and exits 1; treat that as "something else
|
||||
// answered", never as a parse problem.
|
||||
if (probe.exitCode !== 0) {
|
||||
return {
|
||||
reason: 'no_identity_verb',
|
||||
expected,
|
||||
detail: `exit ${String(probe.exitCode)}: ${excerpt(probe.stdout)}`,
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(probe.stdout) as unknown;
|
||||
} catch {
|
||||
return { reason: 'unparseable', expected, detail: excerpt(probe.stdout) };
|
||||
}
|
||||
|
||||
// Arrays are objects to typeof; null is too. Both must be rejected.
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return { reason: 'unparseable', expected, detail: excerpt(probe.stdout) };
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const actual = record.packageName;
|
||||
if (typeof actual !== 'string' || actual.length === 0) {
|
||||
return { reason: 'unparseable', expected, detail: 'payload has no usable packageName' };
|
||||
}
|
||||
|
||||
const version = typeof record.version === 'string' ? record.version : undefined;
|
||||
if (actual !== expected) {
|
||||
return { reason: 'identity_mismatch', expected, actual, version };
|
||||
}
|
||||
|
||||
// Unknown keys are ignored so a future payload addition cannot fail an older check.
|
||||
return { reason: 'ok', expected, actual, version };
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
Reference in New Issue
Block a user