@1agh/maude 0.58.1 → 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/client/app.jsx +85 -22
- package/apps/studio/client/panels/ChatPanel.jsx +25 -2
- package/apps/studio/client/styles/6-acp-chat.css +12 -0
- package/apps/studio/dist/client.bundle.js +1146 -1146
- 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/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/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,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
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Issue #74 — `_comments/<slug>.json` written DIRECTLY on disk must reach
|
|
2
|
+
// already-open shells over WS.
|
|
3
|
+
//
|
|
4
|
+
// This pins EXISTING behaviour rather than covering a fix. `/design:edit`
|
|
5
|
+
// resolves comments with a plain jq-rewrite of the comments file (edit.md §3's
|
|
6
|
+
// documented recipe), which bypasses api.commentsPatch and so never fires the
|
|
7
|
+
// `onCommentsChanged` re-broadcast in server.ts. The broadcast happens anyway,
|
|
8
|
+
// from collab/index.ts's `fs:any` re-seed ("the sidebar still needs the
|
|
9
|
+
// 'comments' bus emit, so keep that unconditionally") — a non-obvious owner
|
|
10
|
+
// that a reader of server.ts alone would conclude was missing.
|
|
11
|
+
//
|
|
12
|
+
// It is worth locking down because the chat panel's "Implement N comments"
|
|
13
|
+
// quick action (issue #74) is built on it: the agent resolves comments by
|
|
14
|
+
// direct write, and if that stopped reaching open shells the button would keep
|
|
15
|
+
// counting comments that are already done, with nothing else failing.
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
22
|
+
|
|
23
|
+
const FILE = '.design/ui/Foo.tsx';
|
|
24
|
+
const SLUG = 'ui-foo';
|
|
25
|
+
|
|
26
|
+
async function openWs(port: number): Promise<WebSocket> {
|
|
27
|
+
const ws = new WebSocket(`ws://localhost:${port}/_ws`);
|
|
28
|
+
await new Promise<void>((resolve, reject) => {
|
|
29
|
+
const t = setTimeout(() => reject(new Error('ws open timeout')), 2000);
|
|
30
|
+
ws.addEventListener('open', () => {
|
|
31
|
+
clearTimeout(t);
|
|
32
|
+
resolve();
|
|
33
|
+
});
|
|
34
|
+
ws.addEventListener('error', (e) => {
|
|
35
|
+
clearTimeout(t);
|
|
36
|
+
reject(e);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
return ws;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Resolve on the first `comments` frame for `file`, or null after `ms`. */
|
|
43
|
+
function nextCommentsFrame(
|
|
44
|
+
ws: WebSocket,
|
|
45
|
+
file: string,
|
|
46
|
+
ms = 5000
|
|
47
|
+
): Promise<{ file: string; comments: Array<{ id: string; status: string }> } | null> {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
const done = (v: never | null) => {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
ws.removeEventListener('message', onMsg);
|
|
52
|
+
resolve(v);
|
|
53
|
+
};
|
|
54
|
+
const timer = setTimeout(() => done(null), ms);
|
|
55
|
+
const onMsg = (ev: MessageEvent) => {
|
|
56
|
+
try {
|
|
57
|
+
const m = JSON.parse(String(ev.data));
|
|
58
|
+
if (m && m.type === 'comments' && m.file === file) done(m);
|
|
59
|
+
} catch {
|
|
60
|
+
/* non-JSON frame — ignore */
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
ws.addEventListener('message', onMsg);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function seedCanvas(designRoot: string) {
|
|
68
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
69
|
+
writeFileSync(join(designRoot, 'ui', 'Foo.tsx'), 'export default function P(){return <main/>}\n');
|
|
70
|
+
mkdirSync(join(designRoot, '_comments'), { recursive: true });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function commentsJson(status: 'open' | 'resolved') {
|
|
74
|
+
return JSON.stringify(
|
|
75
|
+
[
|
|
76
|
+
{
|
|
77
|
+
id: 'c_one',
|
|
78
|
+
file: FILE,
|
|
79
|
+
selector: 'main',
|
|
80
|
+
dom_path: ['main'],
|
|
81
|
+
tag: 'main',
|
|
82
|
+
classes: '',
|
|
83
|
+
bounds: null,
|
|
84
|
+
html_excerpt: '',
|
|
85
|
+
text: 'tighten the padding',
|
|
86
|
+
status,
|
|
87
|
+
created: '2026-01-01T00:00:00.000Z',
|
|
88
|
+
resolved_at: status === 'resolved' ? '2026-01-02T00:00:00.000Z' : null,
|
|
89
|
+
author: 'Test User',
|
|
90
|
+
thread: [],
|
|
91
|
+
mentions: [],
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
null,
|
|
95
|
+
2
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe('issue #74 — comments file-watch re-broadcast', () => {
|
|
100
|
+
test('a direct resolve-write broadcasts the updated list to open shells', async () => {
|
|
101
|
+
const { root, designRoot } = makeSandbox();
|
|
102
|
+
const port = nextPort();
|
|
103
|
+
const proc = await bootServer(root, port);
|
|
104
|
+
try {
|
|
105
|
+
seedCanvas(designRoot);
|
|
106
|
+
const commentsFile = join(designRoot, '_comments', `${SLUG}.json`);
|
|
107
|
+
writeFileSync(commentsFile, commentsJson('open'));
|
|
108
|
+
|
|
109
|
+
const ws = await openWs(port);
|
|
110
|
+
// Clear the fs-watch per-path debounce window (50 ms) so the resolve
|
|
111
|
+
// write below is seen as its own event rather than folded into the seed.
|
|
112
|
+
await Bun.sleep(150);
|
|
113
|
+
|
|
114
|
+
const frame = nextCommentsFrame(ws, FILE);
|
|
115
|
+
// Exactly what edit.md §3's resolve recipe does: rewrite the file in
|
|
116
|
+
// place, never touching the API.
|
|
117
|
+
writeFileSync(commentsFile, commentsJson('resolved'));
|
|
118
|
+
|
|
119
|
+
const got = await frame;
|
|
120
|
+
ws.close();
|
|
121
|
+
|
|
122
|
+
expect(got).not.toBeNull();
|
|
123
|
+
expect(got!.comments).toHaveLength(1);
|
|
124
|
+
expect(got!.comments[0]!.id).toBe('c_one');
|
|
125
|
+
expect(got!.comments[0]!.status).toBe('resolved');
|
|
126
|
+
} finally {
|
|
127
|
+
await killProc(proc);
|
|
128
|
+
}
|
|
129
|
+
}, 20000);
|
|
130
|
+
|
|
131
|
+
test('a comments file for an unknown slug is ignored, not fatal', async () => {
|
|
132
|
+
const { root, designRoot } = makeSandbox();
|
|
133
|
+
const port = nextPort();
|
|
134
|
+
const proc = await bootServer(root, port);
|
|
135
|
+
try {
|
|
136
|
+
seedCanvas(designRoot);
|
|
137
|
+
const ws = await openWs(port);
|
|
138
|
+
await Bun.sleep(150);
|
|
139
|
+
|
|
140
|
+
// No canvas resolves to this slug — the handler must bail quietly and
|
|
141
|
+
// leave the server healthy.
|
|
142
|
+
writeFileSync(
|
|
143
|
+
join(designRoot, '_comments', 'ui-does-not-exist.json'),
|
|
144
|
+
JSON.stringify([], null, 2)
|
|
145
|
+
);
|
|
146
|
+
await Bun.sleep(400);
|
|
147
|
+
ws.close();
|
|
148
|
+
|
|
149
|
+
const health = await fetch(`http://localhost:${port}/_health`);
|
|
150
|
+
expect(health.ok).toBe(true);
|
|
151
|
+
} finally {
|
|
152
|
+
await killProc(proc);
|
|
153
|
+
}
|
|
154
|
+
}, 20000);
|
|
155
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// A mock ACP agent that WEDGES on the FIRST turn only (issue-82): its
|
|
3
|
+
// `session/prompt` handler never resolves for call #1 — simulating a
|
|
4
|
+
// `claude` subprocess that has hung — and it never implements
|
|
5
|
+
// `session/cancel` at all, so a bridge that only sends the cooperative ACP
|
|
6
|
+
// `session/cancel` request and trusts it will hang forever, exactly like the
|
|
7
|
+
// reported "I clicked STOP and nothing happened" bug.
|
|
8
|
+
//
|
|
9
|
+
// Every call AFTER the first resolves normally and quickly — this lets a
|
|
10
|
+
// test start a second, overlapping `prompt()` on the same bridge/chat (the
|
|
11
|
+
// ethical-hacker's "no reentrancy guard" finding) and confirm that turn's
|
|
12
|
+
// ordinary completion doesn't blind `cancel()` to the still-wedged first one.
|
|
13
|
+
|
|
14
|
+
import { Readable, Writable } from 'node:stream';
|
|
15
|
+
|
|
16
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
17
|
+
|
|
18
|
+
const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
|
|
19
|
+
|
|
20
|
+
let promptCalls = 0;
|
|
21
|
+
|
|
22
|
+
acp
|
|
23
|
+
.agent({ name: 'mock-acp-agent-wedged' })
|
|
24
|
+
.onRequest('initialize', () => ({
|
|
25
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
26
|
+
agentCapabilities: { loadSession: false },
|
|
27
|
+
}))
|
|
28
|
+
.onRequest('session/new', () => ({ sessionId: 'mock-wedged-session' }))
|
|
29
|
+
.onRequest('session/prompt', () => {
|
|
30
|
+
promptCalls += 1;
|
|
31
|
+
if (promptCalls === 1) return new Promise(() => {}); // never resolves, never rejects
|
|
32
|
+
return Promise.resolve({ stopReason: 'end_turn' });
|
|
33
|
+
})
|
|
34
|
+
// Deliberately NO `session/cancel` handler — an agent that never
|
|
35
|
+
// implemented turn-cancellation at all, the other real-world shape of
|
|
36
|
+
// "Stop does nothing".
|
|
37
|
+
.connect(stream);
|