diff --git a/src/runtime-identity.cts b/src/runtime-identity.cts index 5abea8bb5..984fc470e 100644 --- a/src/runtime-identity.cts +++ b/src/runtime-identity.cts @@ -190,16 +190,22 @@ export function classifyIdentityProbe( 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 `packageName` must - // serialize first. A structural parse alone would accept - // `{"note":"x","packageName":""}` — a shape this package never emits — - // while the shell rejected it, so the two surfaces would disagree in the - // FAIL-OPEN direction. Honoring the anchor here is also what the module - // already claims to do: IDENTITY_RAW_PREFIX is documented as "ANCHORED, - // never a substring search", and buildIdentityPayload inserts packageName - // first precisely so a genuine payload always satisfies it. - if (!probe.stdout.startsWith(IDENTITY_RAW_PREFIX)) { + // 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":""}` 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, diff --git a/tests/runtime-identity.test.cjs b/tests/runtime-identity.test.cjs index d1f56452b..495b6303f 100644 --- a/tests/runtime-identity.test.cjs +++ b/tests/runtime-identity.test.cjs @@ -190,6 +190,19 @@ describe('classifyIdentityProbe', () => { assert.equal(v.reason, 'unparseable'); }); + test('the default PRETTY serialization still verifies (not just --raw)', () => { + // The classifier is handed both serializations: the shell only ever sees + // `--raw`, but `runtime-identity` with no flag emits two-space-indented + // JSON. An anchor expressed as a compact byte prefix rejected this. + const pretty = JSON.stringify({ packageName: EXPECTED_PACKAGE_NAME, version: '9.9.9' }, null, 2); + assert.equal(classifyIdentityProbe({ stdout: `${pretty}\n`, exitCode: 0 }).reason, 'ok'); + }); + + test('a pretty payload with packageName not first does not verify', () => { + const pretty = JSON.stringify({ note: 'x', packageName: EXPECTED_PACKAGE_NAME }, null, 2); + assert.equal(classifyIdentityProbe({ stdout: `${pretty}\n`, exitCode: 0 }).reason, 'unparseable'); + }); + test('a foreign packageName is a mismatch wherever it appears in the object', () => { const stdout = '{"note":"x","packageName":"get-shit-done-cc"}'; const v = classifyIdentityProbe({ stdout, exitCode: 0 });