@1agh/maude 0.58.0 → 0.58.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,8 @@
24
24
  // file outside the design root. That is not policy filtering, it is the
25
25
  // difference between writing your project and writing your filesystem.
26
26
 
27
+ import { type CanvasGroupLike, resolveCanvasBodyRel } from './canvas-path.ts';
28
+
27
29
  /** One document as the hub reports it. */
28
30
  export interface RemoteDoc {
29
31
  name: string;
@@ -147,31 +149,66 @@ export function slugFromDocName(docName: string): string | null {
147
149
  export interface PullTarget {
148
150
  slug: string;
149
151
  docName: string;
150
- /** Absolute path the body will be written to, flat under the design root. */
152
+ /** Absolute path the body will be written to. */
151
153
  bodyAbs: string;
154
+ /** True when the path came from the document's own `syncMeta.path` and was
155
+ * accepted — false when it was absent or refused and the fallback applied. */
156
+ fromPath: boolean;
157
+ }
158
+
159
+ export interface PullTargetOptions {
160
+ /** Design root, relative to the repo root. Rule 7 of the path check. */
161
+ designRel?: string;
162
+ /** Declared canvas groups — the fallback places the body inside one. */
163
+ canvasGroups?: readonly CanvasGroupLike[];
164
+ /** Fresh-link relaxation — see `validateCanvasPath`. */
165
+ allowUndeclaredGroup?: boolean;
166
+ /**
167
+ * The document's own `syncMeta.path`, if it is already known.
168
+ *
169
+ * USUALLY NULL AT THIS POINT, and that is not an oversight. The listing
170
+ * (`GET /api/documents`) carries names and byte counts only — the path lives
171
+ * INSIDE the document, so it cannot be resolved until that document has
172
+ * synced. The runtime therefore calls this to get a provisional target, then
173
+ * `resolvePulledTarget` again per document once its doc is populated (see
174
+ * sync/index.ts). Tests pass it directly, which is the whole reason it is a
175
+ * parameter rather than a fetch.
176
+ */
177
+ pathFor?: (docName: string, slug: string) => string | null | undefined;
178
+ /** See `resolvePulledTarget`. */
179
+ realpath?: (p: string) => string;
180
+ /** Called with (slug, reason) when a PRESENT path is refused. */
181
+ onRefused?: (slug: string, reason: string) => void;
152
182
  }
153
183
 
154
184
  /**
155
185
  * Where a hub-only document lands on disk.
156
186
  *
157
- * FLAT, directly under the design root — the same convention the hub applies in
158
- * the mirror direction (`workspace-files.mjs defaultBodyPath`) and for the same
159
- * reason: a slug is lossy, `ui-card` cannot be un-flattened into `ui/Card.tsx`
160
- * without guessing, and guessing wrong scatters files into directories the user
161
- * never made. A flat file is trivially moved; an invented tree is not. Moving it
162
- * later does not break sync — both paths slug to the same document.
187
+ * It used to be FLAT, directly under the design root, because a slug is lossy —
188
+ * `ui-card` cannot be un-flattened into `ui/Card.tsx` without guessing, and
189
+ * guessing wrong scatters files into directories the user never made. The
190
+ * comment here said a flat file "is trivially moved"; the hub's twin said a
191
+ * desktop peer "will move it on its next sync". Neither was a mechanism, and
192
+ * nothing moved it — and a file at the design root is inside no canvas group,
193
+ * so the tree never listed it and `scanCanvases` never synced it onward.
194
+ *
195
+ * So the path now travels with the document (`syncMeta.path`) and is checked
196
+ * rather than trusted — see `sync/canvas-path.ts` for the eight rules and for
197
+ * why rule 7 is the one that makes it safe. Absent or refused, the fallback
198
+ * still applies, but inside a canvas group.
163
199
  *
164
- * Returns only targets that resolve INSIDE the design root. A document name is
165
- * hub-controlled input, and this is the last point before a create.
200
+ * Returns only targets that resolve INSIDE the design root. A document name and
201
+ * a document's path are both hub-controlled input, and this is the last point
202
+ * before a create — so the containment check stays even for a validated path.
166
203
  */
167
204
  export function pullTargets(
168
205
  hubOnly: readonly RemoteDoc[],
169
206
  designRoot: string,
170
207
  join: (...parts: string[]) => string,
171
208
  resolve: (p: string) => string,
172
- sep: string
209
+ sep: string,
210
+ opts: PullTargetOptions = {}
173
211
  ): PullTarget[] {
174
- const rootResolved = resolve(designRoot);
175
212
  const out: PullTarget[] = [];
176
213
  // One slug, one target. `slugFromDocName` lowercases, so a hub advertising
177
214
  // `Foo`, `foo` and `ws/w/main/foo` yields three targets for ONE file — three
@@ -182,10 +219,80 @@ export function pullTargets(
182
219
  const slug = slugFromDocName(doc.name);
183
220
  if (!slug || seen.has(slug)) continue;
184
221
  seen.add(slug);
185
- const bodyAbs = join(designRoot, `${slug}.tsx`);
186
- const target = resolve(bodyAbs);
187
- if (target !== rootResolved && !target.startsWith(rootResolved + sep)) continue;
188
- out.push({ slug, docName: doc.name, bodyAbs });
222
+ const target = resolvePulledTarget({
223
+ slug,
224
+ path: opts.pathFor?.(doc.name, slug) ?? null,
225
+ designRoot,
226
+ designRel: opts.designRel,
227
+ canvasGroups: opts.canvasGroups,
228
+ allowUndeclaredGroup: opts.allowUndeclaredGroup,
229
+ join,
230
+ resolve,
231
+ sep,
232
+ realpath: opts.realpath,
233
+ onRefused: opts.onRefused ? (reason) => opts.onRefused?.(slug, reason) : undefined,
234
+ });
235
+ if (!target) continue;
236
+ out.push({ slug, docName: doc.name, ...target });
189
237
  }
190
238
  return out;
191
239
  }
240
+
241
+ /**
242
+ * One document's body path — the whole receiver-side decision, in one call.
243
+ *
244
+ * Separate from `pullTargets` because the runtime needs it TWICE: once from the
245
+ * listing (where no path is known yet, so every target is a fallback) and again
246
+ * per document once that document has synced and its `syncMeta.path` is
247
+ * readable. Both hops go through the same function, so "where does this canvas
248
+ * go" cannot have two answers.
249
+ *
250
+ * Returns null when even the containment check refuses — the only case in which
251
+ * a canvas is dropped rather than degraded.
252
+ */
253
+ export function resolvePulledTarget(args: {
254
+ slug: string;
255
+ path: unknown;
256
+ designRoot: string;
257
+ designRel?: string;
258
+ canvasGroups?: readonly CanvasGroupLike[];
259
+ allowUndeclaredGroup?: boolean;
260
+ join: (...parts: string[]) => string;
261
+ resolve: (p: string) => string;
262
+ sep: string;
263
+ /**
264
+ * Resolve symlinks on the deepest EXISTING ancestor of a path.
265
+ *
266
+ * `resolve()` is purely lexical — it never follows a symlink — and the
267
+ * receivers create parent directories with `mkdirSync(recursive: true)`, which
268
+ * happily traverses one that already exists. While the only reachable target
269
+ * was `<designRoot>/<slug>.tsx` that only mattered for a symlinked design root
270
+ * (operator-controlled); now the sender picks the directory, so any symlink
271
+ * committed anywhere under a canvas group is a write-outside primitive.
272
+ * Injected rather than imported because this module is dependency-free.
273
+ */
274
+ realpath?: (p: string) => string;
275
+ onRefused?: (reason: string) => void;
276
+ }): { bodyAbs: string; fromPath: boolean } | null {
277
+ const { rel, fromPath } = resolveCanvasBodyRel({
278
+ path: args.path,
279
+ slug: args.slug,
280
+ designRel: args.designRel,
281
+ canvasGroups: args.canvasGroups,
282
+ allowUndeclaredGroup: args.allowUndeclaredGroup,
283
+ onRefused: args.onRefused,
284
+ });
285
+ const bodyAbs = args.join(args.designRoot, rel);
286
+ // Belt and braces at a create. The validator already refuses everything that
287
+ // could escape lexically; this catches whatever a platform's own `resolve`
288
+ // makes of a string neither of us anticipated.
289
+ const rootResolved = args.resolve(args.designRoot);
290
+ const target = args.resolve(bodyAbs);
291
+ if (target !== rootResolved && !target.startsWith(rootResolved + args.sep)) return null;
292
+ if (args.realpath) {
293
+ const realRoot = args.realpath(rootResolved);
294
+ const realTarget = args.realpath(target);
295
+ if (realTarget !== realRoot && !realTarget.startsWith(realRoot + args.sep)) return null;
296
+ }
297
+ return { bodyAbs, fromPath };
298
+ }
@@ -28,6 +28,7 @@ import type { WsData } from '../ws.ts';
28
28
 
29
29
  const FIXTURE_SLOW = join(import.meta.dir, 'fixtures', 'mock-acp-agent-slow.mjs');
30
30
  const FIXTURE_PERM = join(import.meta.dir, 'fixtures', 'mock-acp-agent-permission.mjs');
31
+ const FIXTURE_WEDGED = join(import.meta.dir, 'fixtures', 'mock-acp-agent-wedged.mjs');
31
32
  const TEST_ENV_KEYS = ['MAUDE_ACP_ADAPTER_ENTRY', 'MAUDE_ACP_RUNTIME', 'MAUDE_CLAUDE_BIN'];
32
33
 
33
34
  function useMockAgent(fixture: string) {
@@ -333,6 +334,111 @@ describe('bridge lifetime — the attach seq is client-supplied and must be boun
333
334
  }, 30000);
334
335
  });
335
336
 
337
+ describe('cancel() must not hang forever on a non-cooperating agent (issue-82)', () => {
338
+ // The reported bug: "I clicked STOP and nothing happened. Probably stuck."
339
+ // The old cancel() just `await`ed the cooperative ACP `session/cancel`
340
+ // request with no bound — a wedged subprocess (or one that never
341
+ // implemented session/cancel at all, as here) left it, and the pending
342
+ // prompt() call, hanging forever. `bridge.cancel()` below must always
343
+ // return, and it must force the stuck turn to actually end.
344
+ test('cancel() escalates and force-ends the turn when the agent never responds', async () => {
345
+ useMockAgent(FIXTURE_WEDGED);
346
+ const ctx = freshCtx();
347
+ const bridge = new AcpBridge({
348
+ repoRoot: ctx.paths.repoRoot,
349
+ cancelEscalationMs: 200, // real default is 5s; keep the test fast
350
+ onUpdate: () => {},
351
+ });
352
+ try {
353
+ const promptPromise = bridge.prompt('go', 'c1');
354
+ // Let the turn actually reach the wedged conn.prompt() call — the same
355
+ // race a real Stop click lands in — before cancelling.
356
+ await until(() => (bridge.sessionId ? true : undefined));
357
+
358
+ // This is the assertion that matters: without the fix, this call never
359
+ // resolves and the test times out.
360
+ await bridge.cancel();
361
+
362
+ await expect(promptPromise).rejects.toThrow(/did not respond/);
363
+ // The subprocess was actually killed, not just the promise resolved —
364
+ // otherwise a "cancelled" chat could keep silently running/burning
365
+ // tokens in the background.
366
+ expect(bridge.connected).toBe(false);
367
+ } finally {
368
+ await bridge.stop();
369
+ }
370
+ }, 15000);
371
+
372
+ // Ethical-hacker finding on the first cut of this fix: `handlePrompt` has
373
+ // no reentrancy guard, so a duplicate/replayed `prompt` frame for the same
374
+ // chat can start a SECOND, overlapping turn on one bridge. A single shared
375
+ // "pending turn" field would be overwritten by that second turn and then
376
+ // nulled by whichever turn settles FIRST — even an ordinary, uncancelled
377
+ // completion — silently leaving an earlier, genuinely wedged turn
378
+ // permanently unescalatable and reopening this exact bug from a different
379
+ // angle. Pin that a normal turn finishing does not blind cancel() to a
380
+ // still-stuck one that started before it.
381
+ test('an overlapping turn finishing normally does not blind cancel() to an earlier wedged one', async () => {
382
+ useMockAgent(FIXTURE_WEDGED);
383
+ const ctx = freshCtx();
384
+ const bridge = new AcpBridge({
385
+ repoRoot: ctx.paths.repoRoot,
386
+ cancelEscalationMs: 200,
387
+ onUpdate: () => {},
388
+ });
389
+ try {
390
+ // Turn A — the fixture's FIRST session/prompt call, which wedges forever.
391
+ const turnA = bridge.prompt('go', 'c1');
392
+ await until(() => (bridge.sessionId ? true : undefined));
393
+
394
+ // Turn B — a second, overlapping call for the SAME chat while A is
395
+ // still in flight. The fixture resolves every call after the first
396
+ // quickly, so this settles normally with no cancellation involved.
397
+ const turnB = bridge.prompt('go again', 'c1');
398
+ await expect(turnB).resolves.toMatchObject({ stopReason: 'end_turn' });
399
+
400
+ // Turn B's ordinary completion must not have cleared turn A's
401
+ // escalation registration — cancel() must still find and force-end A.
402
+ await bridge.cancel();
403
+ await expect(turnA).rejects.toThrow(/did not respond/);
404
+ expect(bridge.connected).toBe(false);
405
+ } finally {
406
+ await bridge.stop();
407
+ }
408
+ }, 15000);
409
+
410
+ // A second-round ethical-hacker finding on THIS fix: cancel()'s escalation
411
+ // calls this.stop() directly, which does NOT go through index.ts's reap()
412
+ // (the only place that deletes a bridge from the `bridges` map). Without a
413
+ // guard, the SAME now-`stopped` instance would stay live and silently
414
+ // respawn a working-looking subprocess on the next prompt — except its
415
+ // requestPermission/unstable_createElicitation handlers auto-deny forever
416
+ // (the `stopped` check never resets), a silent permission-gate regression
417
+ // with no visible error. `ensureStarted()` must refuse to resurrect it.
418
+ test('after cancel() escalates and self-stops, the bridge refuses to silently respawn', async () => {
419
+ useMockAgent(FIXTURE_WEDGED);
420
+ const ctx = freshCtx();
421
+ const bridge = new AcpBridge({
422
+ repoRoot: ctx.paths.repoRoot,
423
+ cancelEscalationMs: 200,
424
+ onUpdate: () => {},
425
+ });
426
+ try {
427
+ const promptPromise = bridge.prompt('go', 'c1');
428
+ await until(() => (bridge.sessionId ? true : undefined));
429
+ await bridge.cancel(); // escalates → self-stop()
430
+ await expect(promptPromise).rejects.toThrow(/did not respond/);
431
+ expect(bridge.connected).toBe(false);
432
+
433
+ // The assertion that matters: a LOUD, immediate error — never a silent
434
+ // respawn into a permanently permission-denying session.
435
+ await expect(bridge.prompt('again', 'c1')).rejects.toThrow(/stopped and cannot be reused/);
436
+ } finally {
437
+ await bridge.stop();
438
+ }
439
+ }, 15000);
440
+ });
441
+
336
442
  describe('set-mode is attachment-scoped too (ethical-hacker A2)', () => {
337
443
  test('a socket CANNOT flip another chat into bypassPermissions', async () => {
338
444
  // A2 was the one control frame that escaped the attachment-scoping sweep,
@@ -0,0 +1,36 @@
1
+ // artboard-hug-commit.ts — issue #79. The Artboard Inspector's Height
2
+ // NumberField silently dropped a typed value whenever the artboard was in
3
+ // Hug mode (fixed=false, the default per DDR-027 Design Decision 1) — Width
4
+ // always worked because it has no such mode. Typing a height should instead
5
+ // promote the artboard to Fixed at that height, same as the Hug/Fixed
6
+ // toggle's own freeze-current-height write.
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import { resolveHeightCommit } from '../artboard-hug-commit.ts';
11
+
12
+ describe('artboard-hug-commit / resolveHeightCommit', () => {
13
+ test('fixed=true → plain resize (Width-field parity)', () => {
14
+ expect(resolveHeightCommit(true, 480)).toEqual({ kind: 'resize', height: 480 });
15
+ });
16
+
17
+ test('fixed=false (Hug, the default) → promotes to Fixed at the typed height', () => {
18
+ // Before the fix this typed value was dropped entirely (no action) —
19
+ // reproducing "when I want to set height... manually from inspector
20
+ // panel, nothing happened. Width works."
21
+ expect(resolveHeightCommit(false, 480)).toEqual({ kind: 'promote-to-fixed', height: 480 });
22
+ });
23
+
24
+ test('rounds a fractional typed value', () => {
25
+ expect(resolveHeightCommit(true, 480.6)).toEqual({ kind: 'resize', height: 481 });
26
+ expect(resolveHeightCommit(false, 480.6)).toEqual({ kind: 'promote-to-fixed', height: 481 });
27
+ });
28
+
29
+ test('non-finite or non-positive input → noop, in either mode', () => {
30
+ expect(resolveHeightCommit(true, Number.NaN)).toEqual({ kind: 'noop' });
31
+ expect(resolveHeightCommit(true, 0)).toEqual({ kind: 'noop' });
32
+ expect(resolveHeightCommit(true, -10)).toEqual({ kind: 'noop' });
33
+ expect(resolveHeightCommit(false, Number.NaN)).toEqual({ kind: 'noop' });
34
+ expect(resolveHeightCommit(false, 0)).toEqual({ kind: 'noop' });
35
+ });
36
+ });
@@ -31,6 +31,11 @@ import {
31
31
 
32
32
  const CANVAS = '/abs/Canvas.tsx';
33
33
 
34
+ // Adversarial fixture (security review 2026-07-30) — a prompt embedding a
35
+ // fake </AIPlaceholder> close tag + template-literal/env-var syntax, proving
36
+ // the JSON.stringify escaping discipline (DDR-150 P1) holds under attack.
37
+ const EVIL = '*/ `backtick` ${process.env.HOME} </AIPlaceholder> "q" \\';
38
+
34
39
  const SERIES = [
35
40
  "import { TransitionSeries } from '@remotion/transitions';",
36
41
  'const S1 = 90;',
@@ -568,8 +573,6 @@ describe('greenfield first drop (Task 20)', () => {
568
573
  });
569
574
 
570
575
  describe('AIPlaceholder insert + resolve (Tasks 21–22)', () => {
571
- const EVIL = '*/ `backtick` ${process.env.HOME} </AIPlaceholder> "q" \\';
572
-
573
576
  test('prompt is JSON.stringified into the JSX (injection fixtures) and enumerates back', () => {
574
577
  const r = applyInsertClipAt(CANVAS, SERIES, 'a', {
575
578
  lane: 'storyline',
@@ -833,4 +836,83 @@ describe('layers model — set-text + move between layers', () => {
833
836
  expect(cc.clips.filter((c) => c.tag === 'Sequence').length).toBe(0); // overlay row gone
834
837
  expect(back.source).toContain('const TOTAL = 270;'); // exact content end again
835
838
  });
839
+
840
+ // Bug: issue #81 — an AI placeholder slate (no media src yet) was refused by
841
+ // both move verbs, so it could never leave the storyline it was dropped
842
+ // into. A placeholder has no <src> to graft, but it has a prompt + kind
843
+ // that can ride over unchanged.
844
+ test('AI placeholder beat → overlay layer keeps the prompt (no media src required)', () => {
845
+ const withPh = applyInsertClipAt(CANVAS, HARDCUT, 'a', {
846
+ lane: 'storyline',
847
+ index: 3,
848
+ durationInFrames: 60,
849
+ placeholder: { prompt: 'drone shot over Brno', kind: 'veo' },
850
+ });
851
+ const r = applyMoveClipToOverlay(
852
+ CANVAS,
853
+ withPh.source,
854
+ 'a',
855
+ withPh.stableId as string,
856
+ undefined
857
+ );
858
+ const cc = enumerateClips(CANVAS, r.source, 'a');
859
+ const overlays = cc.clips.filter((c) => c.tag === 'Sequence');
860
+ expect(overlays.length).toBe(1);
861
+ expect(overlays[0]?.placeholder?.prompt).toBe('drone shot over Brno');
862
+ expect(overlays[0]?.mediaSrc).toBe(null);
863
+ const beats = cc.clips.filter((c) => c.kind === 'sequence' && c.tag !== 'Sequence');
864
+ expect(beats.length).toBe(3); // the placeholder beat left the storyline
865
+ });
866
+
867
+ test('AI placeholder overlay → storyline round-trips the prompt back into a beat', () => {
868
+ const withPh = applyInsertClipAt(CANVAS, HARDCUT, 'a', {
869
+ lane: 'overlay',
870
+ from: 0,
871
+ durationInFrames: 45,
872
+ placeholder: { prompt: 'sunset time-lapse', kind: 'image' },
873
+ });
874
+ const back = applyMoveClipToStoryline(
875
+ CANVAS,
876
+ withPh.source,
877
+ 'a',
878
+ withPh.stableId as string,
879
+ undefined
880
+ );
881
+ const cc = enumerateClips(CANVAS, back.source, 'a');
882
+ const beats = cc.clips.filter((c) => c.kind === 'sequence' && c.tag !== 'Sequence');
883
+ expect(beats.some((c) => c.placeholder?.prompt === 'sunset time-lapse')).toBe(true);
884
+ expect(cc.clips.filter((c) => c.tag === 'Sequence').length).toBe(0); // overlay row gone
885
+ });
886
+
887
+ // Adversarial (security review 2026-08-07): the move path must go through
888
+ // the same JSON.stringify escaping as a fresh insert (DDR-150 P1) — prove
889
+ // it with the same EVIL fixture the insert/resolve suite uses above, not
890
+ // just infer it from code-path sharing.
891
+ test('AI placeholder move preserves EVIL-prompt escaping (F-A3 companion)', () => {
892
+ const withPh = applyInsertClipAt(CANVAS, HARDCUT, 'a', {
893
+ lane: 'storyline',
894
+ index: 3,
895
+ durationInFrames: 60,
896
+ placeholder: { prompt: EVIL, kind: 'veo' },
897
+ });
898
+ const moved = applyMoveClipToOverlay(
899
+ CANVAS,
900
+ withPh.source,
901
+ 'a',
902
+ withPh.stableId as string,
903
+ undefined
904
+ );
905
+ expect(moved.source).toContain(`<span>{${JSON.stringify(EVIL)}}</span>`);
906
+ const back = applyMoveClipToStoryline(
907
+ CANVAS,
908
+ moved.source,
909
+ 'a',
910
+ moved.stableId as string,
911
+ undefined
912
+ );
913
+ expect(back.source).toContain(`<span>{${JSON.stringify(EVIL)}}</span>`);
914
+ const cc = enumerateClips(CANVAS, back.source, 'a');
915
+ const beats = cc.clips.filter((c) => c.kind === 'sequence' && c.tag !== 'Sequence');
916
+ expect(beats.some((c) => c.placeholder?.prompt === EVIL)).toBe(true);
917
+ });
836
918
  });
@@ -0,0 +1,117 @@
1
+ // The shell's inbound-postMessage comment relays must stay scoped to the
2
+ // ACTIVE canvas.
3
+ //
4
+ // Threat: the origin check at the top of app.jsx's `onMessage` compares against
5
+ // the single shared `canvasOrigin`, so EVERY open canvas iframe passes it —
6
+ // including ones in background tabs. `comment-submit` / `comment-patch` /
7
+ // `comment-delete` relay straight onto the privileged WS, so without a
8
+ // per-message `e.source === activeWin` gate a background canvas can write
9
+ // comments onto a file it names itself, with no user gesture. That mattered
10
+ // little while comments were an inert store; the ACP panel's one-click
11
+ // "Implement N comments" action turns open comments into a feed the agent acts
12
+ // on, which is what promoted the seam to worth-closing.
13
+ //
14
+ // This is a SOURCE-STRUCTURE test, not a behavioural one: `App()` is a
15
+ // 14k-line component that cannot be mounted in isolation, and the twenty other
16
+ // `e.source === activeWin` gates in the same handler have no coverage at all.
17
+ // Asserting on the source keeps the guard cheap and honest about its reach —
18
+ // it catches a gate being deleted, a `file:` pin regressing to the
19
+ // caller-supplied value, and (the last test) a gate that is written but cannot
20
+ // execute. It claims nothing beyond that: it does not prove the relay behaves
21
+ // correctly, only that the control is present and reachable.
22
+
23
+ import { describe, expect, test } from 'bun:test';
24
+ import { readFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+
27
+ const SRC = readFileSync(join(import.meta.dir, '..', 'client', 'app.jsx'), 'utf8');
28
+
29
+ /** The body of one `else if (m.dgn === '<name>' …)` branch, up to the next one. */
30
+ function branch(name: string): string {
31
+ const start = SRC.indexOf(`m.dgn === '${name}'`);
32
+ expect(start, `branch for ${name} not found in app.jsx`).toBeGreaterThan(-1);
33
+ const rest = SRC.slice(start + name.length);
34
+ const end = rest.indexOf('} else if (m.dgn ===');
35
+ return rest.slice(0, end === -1 ? 2000 : end);
36
+ }
37
+
38
+ describe('canvas → shell comment relays are scoped to the active canvas', () => {
39
+ // The comment relays, plus the three shell-control branches that were
40
+ // ungated alongside them: `shell-shortcut` (reload / panel toggles /
41
+ // Export + Handoff dialogs), and `open-export` / `open-timeline-request`,
42
+ // which reach the same dialogs from the in-canvas toolbar and context menu.
43
+ // All five are actions a user takes INSIDE the canvas in view; none has a
44
+ // reason to be honoured from a background frame.
45
+ for (const name of [
46
+ 'comment-submit',
47
+ 'comment-patch',
48
+ 'comment-delete',
49
+ 'shell-shortcut',
50
+ 'open-export',
51
+ 'open-timeline-request',
52
+ ]) {
53
+ test(`${name} is gated on e.source === activeWin`, () => {
54
+ expect(branch(name)).toContain('e.source === activeWin');
55
+ });
56
+ }
57
+
58
+ test('comment-submit pins the target file to activePath, never the payload', () => {
59
+ const body = branch('comment-submit');
60
+ // The file the comment lands on is the one the user is looking at.
61
+ expect(body).toContain('file: activePath');
62
+ // A canvas-supplied target would let a background iframe pick the victim.
63
+ expect(body).not.toContain('file: p.file');
64
+ });
65
+
66
+ test('comment-patch / comment-delete only accept ids owned by the active canvas', () => {
67
+ // A bare id would otherwise reach any comment in the project — and these
68
+ // two verbs change or remove somebody else's note (ws.ts refuses both for a
69
+ // viewer session for exactly that reason).
70
+ expect(branch('comment-patch')).toContain('ownsActiveComment(m.id)');
71
+ expect(branch('comment-delete')).toContain('ownsActiveComment(m.id)');
72
+ });
73
+
74
+ // The above only prove the gate is WRITTEN. This proves it can RUN.
75
+ //
76
+ // House style declares `const activeWin` block-locally inside each `else if`
77
+ // branch, so a branch that compares against `activeWin` without declaring it
78
+ // reads exactly like a working gate while throwing `ReferenceError` at
79
+ // runtime — fail-closed, but it takes the legitimate path down with it and
80
+ // silently reopens the moment anything introduces an outer binding. That is
81
+ // not hypothetical: the first cut of this fix shipped it, and every
82
+ // string-matching assertion above stayed green.
83
+ test('every activeWin comparison has a declaration, derived from activePath', () => {
84
+ // Line comments are blanked first — the prose in this handler quotes the
85
+ // gate expression, and a comment is not a use.
86
+ const CODE = SRC.split('\n')
87
+ .map((l) => l.replace(/^\s*\/\/.*$/, ''))
88
+ .join('\n');
89
+ const BRANCH_START = /\b(?:else )?if \(m\.dgn ===/g;
90
+ const starts = [...CODE.matchAll(BRANCH_START)].map((m) => m.index ?? 0);
91
+ const undeclared: string[] = [];
92
+
93
+ // Both idioms count: the `===` gate used by the comment relays and the
94
+ // `!== … return` early-exit form five other branches use. Checking only
95
+ // one lets a branch convert to the other and slip the check.
96
+ for (const use of [...CODE.matchAll(/e\.source [!=]== activeWin/g)].map((m) => m.index ?? 0)) {
97
+ // Nearest branch boundary at or before this usage.
98
+ const open = starts.filter((s) => s < use).pop() ?? 0;
99
+ // `= activePath` is the load-bearing half: a declaration that derives the
100
+ // window from the MESSAGE (`iframesRef.current.get(m.file)`) instead of
101
+ // from app state satisfies a bare `const activeWin` check while handing
102
+ // the attacker the very comparison the gate exists to make.
103
+ if (!/const activeWin =\s*\n?\s*activePath/.test(CODE.slice(open, use))) {
104
+ undeclared.push(
105
+ CODE.slice(open, open + 60)
106
+ .split('\n')[0]!
107
+ .trim()
108
+ );
109
+ }
110
+ }
111
+
112
+ expect(
113
+ undeclared,
114
+ `activeWin used without a block-local declaration in: ${undeclared.join(' | ')}`
115
+ ).toEqual([]);
116
+ });
117
+ });