Skip to content

Commit eadea57

Browse files
fix(console): the injection assertion reads the plain runs inside long or escaped describe literals, so a .objectstack-sha pin gets a real witness (#24672)
Fixes #24663 Clause-②: no The in-build console spec-injection assertion was vacuous at `.objectui-sha` `12dc7b47` and passed the control bundle (exit 0). The cause is in `describeCandidates` (`scripts/console-spec-probes.mjs`): it took only `.describe()` literals of 32 to 160 characters with no escape. Between objectui's `.objectstack-sha` `f4755a68a797` and this tree, the one describe that changed (the flow `runAs` text) is over 160 characters, and its em dash is a backslash-u escape (a backslash, then `u2014`) in the built dist. So both candidate pools came out empty and the stamp recorded `freshWitness: null`. ## The change - **A second candidate source.** A describe literal the old rule skips is cut at every escape sequence and every quote character. Each run of 32 characters or more is now a candidate. - **Why cut at escapes.** An escape can be spelled one way in a spec's dist and another way in a bundle, but the text between escapes is plain characters in both. - **Why cut at quotes.** A bundler may re-quote a string and add a backslash before a quote. - **Why runs are not cut to 160.** That bound was about a whole literal surviving the bundler. Windows of a changed run would also still be contained in the other spec, and `pickProbe`'s substring rule would drop them. - **Old candidates are unchanged.** A literal the old rule accepted is still taken whole, so every candidate it produced is still produced. `chooseProbes`, the stamp shape and the cache-hit replay (`check-console-injection.mjs`) are unchanged. - **The fixture.** Self-test battery 16 in `scripts/check-console-injection.mjs` (16 cases) is shaped like this pin: - a `.objectstack-sha` pin, with the reference as objectui's own build of it; - each distinguishing literal over 160 characters and carrying a backslash-u escape (a backslash, then `u2014`); - a shared literal over 160 characters, and a shared run before the escape; - an injected bundle where the dash is emitted raw, and a control bundle where the escape is kept. It asserts that the stamp carries the changed run as `freshWitness`, that the assertion exits 1 on the control bundle, and that the good stamp replayed over the control exits 1. The roster floor is 13 → 14. - **No release workflow and nothing under `.github/workflows/**` changed.** The diff is 2 files under `scripts/`. ## Reverse verification The fix was committed first. Then the `literalSegments` call was replaced by a comment, and `node scripts/check-console-injection.mjs --self-test` exited 1 with 8 failures. Among them: the control bundle "expected 1, got 0", the witness "expected … got null", and the replay "expected 1, got 0". Restored with `git restore --source=HEAD`, the porcelain was clean and the self-test exited 0 with 100 assertions. ## Proof on a real build (pin `12dc7b47c340`) PR #24660 has not landed, so `.objectui-sha` on `main` is still `4016370af3c9`. The build ran in a throwaway worktree: this branch at `50f12801` merged locally with #24660's head `0b45222fef`. Nothing from that merge is pushed. I will merge `main` into this branch once #24660 lands. The build ran `turbo run build --filter=@objectstack/client... --concurrency=2 && bash scripts/build-console.sh`, which is what `pnpm objectui:build` runs, and it exited 0. It took the `.objectstack-sha` branch, built objectui's own `@objectstack/*` at `f4755a68a797` as the reference (`.stamp.json`: `source: pin`, `sha` `f4755a68a797…`), and packed this tree in through `OBJECTSTACK_ROOT`. Its log: ``` ✓ Console bundle carries THIS tree's @objectstack/spec, and only it (vendored: @objectstack/spec of objectui's own build of objectstack f4755a6). present (injected only): " system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider" — 3 of 3 injected-only descriptions are in the bundle absent (vendored only): " not yet rendered] The PARENT record this related list hangs off, bound by a query of its own: an object binding " — and all 2 pin-only descriptions are absent ``` The stamp records `skew: true`, `vendoredPin` `f4755a68a797…`, `freshWitness` the run quoted above (was `null`), and `staleDetector` the pin-only run. | reading | before (`main`'s probes) | this branch | |:--|:--|:--| | candidate pools at this pin (injected-only / pin-only) | 0 / 0 | 3 / 2 | | `pnpm check:console-injection --require-stamp` on the injected dist | 0 ("nothing assertable") | 0, the witness present and the detector absent | | `assert-console-spec-injection.mjs` on the **control** bundle | **0**, "no observable skew" | **1**, "still carries the @objectstack/spec of objectui's own build of objectstack f4755a6" | | the injected stamp replayed over the control dist (`check-console-injection.mjs --dist … --require-stamp`) | n/a (null witness) | 1 | - **The control bundle** is objectui's own build of the pin, with nothing from this tree, built in the same build tree with `OBJECTSTACK_ROOT` unset (`objectstack-pin.mjs status`: "built at the pin f4755a6"). The bundle has no stamp. - **Grep counts over each dist's `assets/*.js`** (`grep -lF`): the full witness is in 1 injected asset and 0 control assets; the full detector is in 0 injected assets and 1 control asset. ## Gates - `node scripts/pm/check-governed-merges.mjs --branch claude/issue-24663-injection-witness`: NOT governed; 134 changed lines. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived **35** commands, and **all 35 exited 0**. The `--ran` reconciliation reports "35 run, 0 NOT-MEASURED". - `pnpm exec eslint` on both files exited 0. - **No changeset:** the diff publishes nothing from any released package (repo tooling under `scripts/`), as with PR #24532. Label `skip-changeset`. - **Not measured:** - The Console Pin Gate does not run on a PR or in the merge queue. Its first CI run on this change is `main`'s battery after the merge. - The 15 pending-changeset families do not apply without a changeset. ## Acceptance notes - The cache-hit replay labels the stale line "absent (published only)" even when the stamp's reference is a pin build. That label is unchanged here. - The issue's other suggested source, the metadata-type registry `description:` text (for example the `sharing_rule` entry), is not added. The describe runs alone give a non-null witness at this pin. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_019oG4xEYrGxr5n2cdSNbWMK)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8fd3944 commit eadea57

2 files changed

Lines changed: 130 additions & 4 deletions

File tree

‎scripts/check-console-injection.mjs‎

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ import {
182182
readBundle,
183183
readSpecBlob,
184184
readStamp,
185+
uniqueCandidates,
185186
writeStamp,
186187
} from './console-spec-probes.mjs';
187188
import { isEntrypoint } from './invoked-as.mjs';
@@ -216,11 +217,12 @@ const SELF_TEST_BATTERIES = Object.freeze({
216217
'13. THE BLIND SPOT (objectstack#20646): the build-time derivation must choose': 6,
217218
"14. OBJECTUI'S OWN TEXT IS NOT EVIDENCE: a probe the console's own source carries": 23,
218219
"15. A PIN CARRYING .objectstack-sha: the reference is objectui's own build of it": 17,
220+
'16. EVERY DISTINGUISHING LITERAL IS LONG OR ESCAPED (objectstack#24663).': 16,
219221
});
220222

221223
// DELETING an entry silences that battery's floor exactly as effectively as
222224
// zeroing it, so the roster's own size is pinned too.
223-
const SELF_TEST_BATTERY_FLOOR = 13;
225+
const SELF_TEST_BATTERY_FLOOR = 14;
224226

225227
// The key an assertion is filed under when no battery is open. It is not a
226228
// declared battery, so it reds by the same set difference rather than silently
@@ -1176,6 +1178,95 @@ function selfTest() {
11761178
expect('and its replay names the commit it was judged against', sameReplay.out.join('\n').includes(`objectstack ${PIN.slice(0, 12)}`), true);
11771179
}
11781180

1181+
// 16. EVERY DISTINGUISHING LITERAL IS LONG OR ESCAPED (objectstack#24663). The
1182+
// shape of the 12dc7b47 pin: the reference and this tree differ only in a
1183+
// describe literal over 160 characters whose em dash the dist writes as a
1184+
// `—` escape. The whole-literal rule skipped it, both pools came out
1185+
// empty, the build stamped `freshWitness: null`, and the assertion passed
1186+
// the control bundle. The runs between escapes are the candidates now.
1187+
battery('16. EVERY DISTINGUISHING LITERAL IS LONG OR ESCAPED (objectstack#24663).');
1188+
{
1189+
const assert = path.join(ROOT, 'scripts', 'assert-console-spec-injection.mjs');
1190+
const PIN = 'd'.repeat(40);
1191+
const host = makeHostTree(path.join(root, 'le-host'), {
1192+
'.objectstack-sha': `${PIN}\n`,
1193+
'packages/plugin/src/index.tsx': 'export const d = "Unrelated host text";\n',
1194+
});
1195+
const runAssert = (injected, vendored, dist) =>
1196+
spawnSync(
1197+
process.execPath,
1198+
[assert, '--injected', injected, '--vendored', vendored, '--objectui', host, '--assets', path.join(dist, 'assets')],
1199+
{ encoding: 'utf8' },
1200+
);
1201+
const stampOf = (dist) => (fs.existsSync(path.join(dist, STAMP_BASENAME)) ? readStamp(dist).packages[0] : null);
1202+
// A spec dist written as a bundler writes it: each description a JS string
1203+
// literal, `—` spelled as the six-character escape.
1204+
const writeDist = (dir, literals) => {
1205+
makeSpecPkg(dir, []);
1206+
fs.writeFileSync(path.join(dir, 'dist', 'index.mjs'), `${literals.map((l) => `z.string().describe("${l}")`).join(';\n')}\n`);
1207+
return dir;
1208+
};
1209+
const ESC = '\\u2014';
1210+
const HEAD = 'Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user';
1211+
const FRESH_RUN = ' system flows keep system privileges; after a pause the run resumes as the trigger user until a human decision';
1212+
const STALE_RUN = ' the run keeps that identity across every pause and resume of a long-running flow, whatever its trigger';
1213+
const LONG_SHARED =
1214+
'A shared description over one hundred and sixty characters long that both specs carry byte for byte, so no run cut out of it can tell them apart in either direction';
1215+
const SHARED = 'Shared text in both specs for the long-literal fixtures';
1216+
const injectedLiteral = `${HEAD} ${ESC}${FRESH_RUN}`;
1217+
const pinnedLiteral = `${HEAD} ${ESC}${STALE_RUN}`;
1218+
const injected = writeDist(path.join(root, 'le-injected'), [injectedLiteral, LONG_SHARED, SHARED]);
1219+
const reference = path.join(root, 'le-cache', 'spec');
1220+
writeDist(reference, [pinnedLiteral, LONG_SHARED, SHARED]);
1221+
fs.writeFileSync(path.join(root, 'le-cache', '.stamp.json'), JSON.stringify({ source: 'pin', sha: PIN }));
1222+
1223+
// The fixture has the pin's shape: no distinguishing literal the whole-literal
1224+
// rule accepts — each is over 160 characters AND carries an escape.
1225+
expect('the injected literal is over 160 characters', injectedLiteral.length > 160, true);
1226+
expect('the pinned literal is over 160 characters', pinnedLiteral.length > 160, true);
1227+
expect('the long shared literal has no escape and is over 160 characters', LONG_SHARED.length > 160 && !LONG_SHARED.includes('\\'), true);
1228+
1229+
// The candidate source itself: runs between escapes, never the whole literal.
1230+
const injectedBlob = readSpecBlob(injected, 'injected');
1231+
const pinnedBlob = readSpecBlob(reference, 'vendored');
1232+
const candidates = describeCandidates(injectedBlob);
1233+
expect('the run after the escape is a candidate', candidates.includes(FRESH_RUN), true);
1234+
expect('the escaped literal is never a candidate whole', candidates.includes(injectedLiteral), false);
1235+
expect('a 32-to-160 plain literal is still taken whole', candidates.includes(SHARED), true);
1236+
expect('the unchanged run before the escape is not unique', uniqueCandidates(candidates, pinnedBlob).includes(HEAD), false);
1237+
expect('a long literal both specs carry yields nothing unique', uniqueCandidates(candidates, pinnedBlob).includes(LONG_SHARED), false);
1238+
expect(
1239+
'a quote cuts a run, since a bundler may re-quote it',
1240+
describeCandidates(`x.describe("${'q'.repeat(40)}\\"${'r'.repeat(40)}${ESC}")`).join('|'),
1241+
`${'q'.repeat(40)}|${'r'.repeat(40)}`,
1242+
);
1243+
1244+
// GREEN: this tree's bundle, where the bundler emitted the dash raw.
1245+
const good = makeDist(
1246+
path.join(root, 'le-good'),
1247+
`console(${JSON.stringify(`${HEAD} —${FRESH_RUN}`)});console(${JSON.stringify(SHARED)})`,
1248+
undefined,
1249+
);
1250+
const goodRun = runAssert(injected, reference, good);
1251+
expect('this tree\'s bundle against the pinned build verifies', goodRun.status, 0);
1252+
const goodStamp = stampOf(good);
1253+
expect('the stamp carries a non-null witness: the changed run', goodStamp?.freshWitness, FRESH_RUN);
1254+
expect('the stamp carries the pinned run as its detector', goodStamp?.staleDetector, STALE_RUN);
1255+
expect('and records skew', goodStamp?.skew, true);
1256+
1257+
// RED: the control bundle — objectui's own build of the pin, escape kept.
1258+
const control = makeDist(
1259+
path.join(root, 'le-control'),
1260+
`console("${pinnedLiteral}");console(${JSON.stringify(SHARED)})`,
1261+
undefined,
1262+
);
1263+
const controlRun = runAssert(injected, reference, control);
1264+
expect('the control bundle fails the build', controlRun.status, 1);
1265+
expect('the failure names the pinned run it found', controlRun.stderr.includes(STALE_RUN), true);
1266+
if (goodStamp) fs.copyFileSync(path.join(good, STAMP_BASENAME), path.join(control, STAMP_BASENAME));
1267+
expect('and the good stamp replayed over it fails too', evaluate({ distDir: control, specDir: injected, requireStamp: true }).code, 1);
1268+
}
1269+
11791270
fs.rmSync(root, { recursive: true, force: true });
11801271

11811272
// ── The floor: every declared battery RAN, and ran its cases (#13489) ───

‎scripts/console-spec-probes.mjs‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,48 @@ export function describeCandidates(blob) {
104104
const text = match[2];
105105
// Long enough to be unique, short enough to survive intact, and free of
106106
// escapes and line breaks so a literal search means what it says.
107-
if (text.length < 32 || text.length > 160) continue;
108-
if (/[\\\r\n]/.test(text)) continue;
109-
found.add(text);
107+
if (text.length >= 32 && text.length <= 160 && !/[\\\r\n]/.test(text)) {
108+
found.add(text);
109+
continue;
110+
}
111+
for (const segment of literalSegments(text)) found.add(segment);
110112
}
111113
return [...found].sort();
112114
}
113115

116+
/**
117+
* The second candidate source: the plain runs inside a describe literal the
118+
* whole-literal rule above skips — one longer than 160 characters, or one
119+
* carrying an escape.
120+
*
121+
* That rule is right about the WHOLE literal: an escape is spelled one way in a
122+
* spec's dist and may be spelled another in a bundle (`—` emitted as the
123+
* raw dash, or the reverse), so the literal as a whole is no probe. The text
124+
* BETWEEN two escapes is plain characters in both spellings. Skipping it cost
125+
* real coverage (objectstack#24663): on a pin whose reference differs from this
126+
* tree only in such literals both pools were empty, the build stamped
127+
* `freshWitness: null`, and the assertion passed a console carrying the
128+
* reference's spec — at objectui 12dc7b47 against objectstack f4755a68a797 the
129+
* one changed describe is the flow `runAs` text, over 160 characters with its em
130+
* dash escaped.
131+
*
132+
* So a skipped literal is cut at every escape sequence and at every quote
133+
* character — a bundler re-quotes a string to whichever quote needs fewer
134+
* escapes, which can put a backslash before a quote that had none — and each run
135+
* of 32 characters or more is a candidate. A run is NOT cut to 160: that bound
136+
* was about a whole literal surviving, and a run is plain text the bundler copies
137+
* byte for byte. Cutting it into windows would also turn one changed run into
138+
* windows the other spec still contains, which pickProbe's substring rule drops.
139+
*
140+
* A literal the rule above accepts is still taken whole, so every candidate it
141+
* produced before is still produced; this only adds candidates it skipped.
142+
*/
143+
function literalSegments(text) {
144+
return text
145+
.split(/\\(?:u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])|["'`\r\n]/)
146+
.filter((segment) => segment.length >= 32);
147+
}
148+
114149
/**
115150
* First candidate present in `mine` and absent from `theirs`, as raw text.
116151
*

0 commit comments

Comments
 (0)