@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.
- package/apps/studio/.ai/cache/_stats.json +1 -1
- package/apps/studio/acp/bridge.ts +165 -15
- package/apps/studio/artboard-hug-commit.ts +30 -0
- package/apps/studio/client/app.jsx +107 -33
- package/apps/studio/client/panels/ChatPanel.jsx +25 -2
- package/apps/studio/client/panels/timeline-media-cache.js +38 -2
- package/apps/studio/client/styles/6-acp-chat.css +12 -0
- package/apps/studio/clip-ops.ts +84 -35
- package/apps/studio/dist/client.bundle.js +899 -899
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/sync/canvas-path.ts +329 -0
- package/apps/studio/sync/codec.ts +42 -0
- package/apps/studio/sync/index.ts +490 -17
- package/apps/studio/sync/projection.ts +11 -1
- package/apps/studio/sync/remote-docs.ts +122 -15
- package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
- package/apps/studio/test/artboard-hug-commit.test.ts +36 -0
- package/apps/studio/test/clip-ops.test.ts +84 -2
- package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
- package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
- package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
- package/apps/studio/test/sync-canvas-path.test.ts +200 -0
- package/apps/studio/test/sync-path-pull.test.ts +402 -0
- package/apps/studio/test/sync-remote-docs.test.ts +55 -3
- package/apps/studio/test/sync-runtime.test.ts +118 -0
- package/apps/studio/test/timeline-media-cache.test.ts +41 -0
- package/apps/studio/whats-new.json +9 -0
- package/package.json +8 -8
|
@@ -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
|
|
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
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
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
|
|
165
|
-
* hub-controlled input, and this is the last point
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
+
});
|