@1agh/maude 0.29.0 → 0.31.0

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.
Files changed (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -0,0 +1,127 @@
1
+ // End-to-end bridge test against a mock ACP agent (test/fixtures/mock-acp-agent.mjs)
2
+ // so no real `claude` install is needed. Proves: spawn + handshake + streamed
3
+ // update + turn completion, AND that ANTHROPIC_API_KEY never reaches the child
4
+ // (DDR-123 guardrail #1, verified end-to-end — the mock echoes its own env).
5
+
6
+ import { afterEach, describe, expect, test } from 'bun:test';
7
+ import { join } from 'node:path';
8
+
9
+ import { AcpBridge } from '../acp/bridge.ts';
10
+ import { probeAcpAvailability } from '../acp/probe.ts';
11
+
12
+ const FIXTURE = join(import.meta.dir, 'fixtures', 'mock-acp-agent.mjs');
13
+ const TEST_ENV_KEYS = [
14
+ 'ANTHROPIC_API_KEY',
15
+ 'MAUDE_ACP_ADAPTER_ENTRY',
16
+ 'MAUDE_ACP_RUNTIME',
17
+ 'MAUDE_CLAUDE_BIN',
18
+ ];
19
+
20
+ afterEach(() => {
21
+ for (const key of TEST_ENV_KEYS) delete process.env[key];
22
+ });
23
+
24
+ describe('AcpBridge — round-trip + subscription guardrail', () => {
25
+ test('connects, streams an update, completes a turn, and scrubs ANTHROPIC_API_KEY', async () => {
26
+ process.env.ANTHROPIC_API_KEY = 'sk-must-be-scrubbed';
27
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
28
+ process.env.MAUDE_ACP_RUNTIME = process.execPath; // run the mock under this bun
29
+ process.env.MAUDE_CLAUDE_BIN = process.execPath; // satisfy the claude-present check
30
+
31
+ const updates: unknown[] = [];
32
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
33
+ try {
34
+ const result = await bridge.prompt('hello', 'c1');
35
+ expect(bridge.connected).toBe(true);
36
+ expect(bridge.sessionId).toBe('mock-session-1');
37
+ expect(result.stopReason).toBe('end_turn');
38
+
39
+ // The mock streamed its own ANTHROPIC_API_KEY — it must read `<unset>`,
40
+ // proving scrubAgentEnv stripped it from the child env before spawn.
41
+ const streamed = JSON.stringify(updates);
42
+ expect(streamed).toContain('apiKey=<unset>');
43
+ expect(streamed).not.toContain('sk-must-be-scrubbed');
44
+ } finally {
45
+ await bridge.stop();
46
+ }
47
+ }, 15000);
48
+
49
+ test('setConfig passes model + effort to the child env (ANTHROPIC_MODEL / MAX_THINKING_TOKENS)', async () => {
50
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
51
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
52
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
53
+
54
+ const updates: unknown[] = [];
55
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
56
+ try {
57
+ bridge.setConfig('opus', 'thorough');
58
+ await bridge.prompt('hi', 'c1');
59
+ const first = JSON.stringify(updates);
60
+ expect(first).toContain('model=opus');
61
+ expect(first).toContain('thinking=31999');
62
+
63
+ // Changing the config re-spawns with the new env on the next prompt.
64
+ updates.length = 0;
65
+ bridge.setConfig(null, 'fast');
66
+ await bridge.prompt('again', 'c1');
67
+ const second = JSON.stringify(updates);
68
+ expect(second).toContain('model=<unset>'); // null model → ANTHROPIC_MODEL not set
69
+ expect(second).toContain('thinking=0'); // fast → thinking disabled
70
+ } finally {
71
+ await bridge.stop();
72
+ }
73
+ }, 15000);
74
+
75
+ test('a second prompt reuses the same live session', async () => {
76
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
77
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
78
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
79
+
80
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
81
+ try {
82
+ await bridge.prompt('first', 'c1');
83
+ const sid = bridge.sessionId;
84
+ await bridge.prompt('second', 'c1');
85
+ expect(bridge.sessionId).toBe(sid);
86
+ } finally {
87
+ await bridge.stop();
88
+ }
89
+ }, 15000);
90
+
91
+ test('different chat ids get separate claude sessions; same id reuses its own', async () => {
92
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
93
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
94
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
95
+
96
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
97
+ try {
98
+ await bridge.prompt('a', 'chatA');
99
+ const sa = bridge.sessionId;
100
+ await bridge.prompt('b', 'chatB');
101
+ const sb = bridge.sessionId;
102
+ expect(sa).not.toBe(sb); // separate per-chat contexts
103
+ await bridge.prompt('a again', 'chatA');
104
+ expect(bridge.sessionId).toBe(sa); // chatA reuses its own session
105
+ } finally {
106
+ await bridge.stop();
107
+ }
108
+ }, 15000);
109
+ });
110
+
111
+ describe('probeAcpAvailability — not-connected detection', () => {
112
+ test('reports not-available with a Claude-Code reason when the CLI is absent', () => {
113
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
114
+ process.env.MAUDE_CLAUDE_BIN = join(import.meta.dir, 'no-such-claude-bin');
115
+ const probe = probeAcpAvailability();
116
+ expect(probe.available).toBe(false);
117
+ expect(probe.reason ?? '').toMatch(/Claude Code/i);
118
+ });
119
+
120
+ test('reports available when both the adapter and a claude binary resolve', () => {
121
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
122
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
123
+ const probe = probeAcpAvailability();
124
+ expect(probe.available).toBe(true);
125
+ expect(probe.adapterEntry).toBe(FIXTURE);
126
+ });
127
+ });
@@ -0,0 +1,65 @@
1
+ // DDR-123 guardrail #1 — the single load-bearing detail that keeps the chat
2
+ // panel on the user's Pro/Max subscription instead of metered API billing.
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+
6
+ import { SUBSCRIPTION_SCRUBBED_ENV_KEYS, scrubAgentEnv } from '../acp/env.ts';
7
+
8
+ describe('scrubAgentEnv — subscription guardrail', () => {
9
+ test('strips the billing-switching keys, keeps everything else', () => {
10
+ const out = scrubAgentEnv({
11
+ PATH: '/usr/bin',
12
+ HOME: '/Users/x',
13
+ ANTHROPIC_API_KEY: 'sk-live-xxx',
14
+ ANTHROPIC_AUTH_TOKEN: 'tok-xxx',
15
+ UNDEFINED_VAR: undefined,
16
+ });
17
+ expect(out.PATH).toBe('/usr/bin');
18
+ expect(out.HOME).toBe('/Users/x');
19
+ expect('ANTHROPIC_API_KEY' in out).toBe(false);
20
+ expect('ANTHROPIC_AUTH_TOKEN' in out).toBe(false);
21
+ // undefined values are dropped (Bun.spawn env wants Record<string,string>)
22
+ expect('UNDEFINED_VAR' in out).toBe(false);
23
+ });
24
+
25
+ test('pins exactly the two precedence-relevant keys', () => {
26
+ expect([...SUBSCRIPTION_SCRUBBED_ENV_KEYS].sort()).toEqual([
27
+ 'ANTHROPIC_API_KEY',
28
+ 'ANTHROPIC_AUTH_TOKEN',
29
+ ]);
30
+ });
31
+
32
+ test('scrubs the whole provider/billing namespace, not just the two keys (F1)', () => {
33
+ const out = scrubAgentEnv({
34
+ PATH: '/usr/bin',
35
+ ANTHROPIC_BASE_URL: 'https://evil.example/v1',
36
+ ANTHROPIC_MODEL: 'attacker-pinned',
37
+ ANTHROPIC_BEDROCK_BASE_URL: 'https://evil',
38
+ CLAUDE_CODE_USE_BEDROCK: '1',
39
+ CLAUDE_CODE_USE_VERTEX: '1',
40
+ AWS_BEARER_TOKEN_BEDROCK: 'tok',
41
+ claude_session: 'keep-me', // not a provider redirect → kept
42
+ });
43
+ expect(out.PATH).toBe('/usr/bin');
44
+ expect('ANTHROPIC_BASE_URL' in out).toBe(false);
45
+ expect('ANTHROPIC_MODEL' in out).toBe(false); // re-added by the bridge from a validated value
46
+ expect('ANTHROPIC_BEDROCK_BASE_URL' in out).toBe(false);
47
+ expect('CLAUDE_CODE_USE_BEDROCK' in out).toBe(false);
48
+ expect('CLAUDE_CODE_USE_VERTEX' in out).toBe(false);
49
+ expect('AWS_BEARER_TOKEN_BEDROCK' in out).toBe(false);
50
+ expect(out.claude_session).toBe('keep-me');
51
+ });
52
+
53
+ test('never mutates the source (process.env stays intact for the parent)', () => {
54
+ const src: Record<string, string | undefined> = { ANTHROPIC_API_KEY: 'sk', PATH: '/bin' };
55
+ const out = scrubAgentEnv(src);
56
+ expect(src.ANTHROPIC_API_KEY).toBe('sk');
57
+ expect(out).not.toBe(src);
58
+ });
59
+
60
+ test('defaults to process.env when called with no argument', () => {
61
+ // Smoke: doesn't throw, returns a string map without the scrubbed keys.
62
+ const out = scrubAgentEnv();
63
+ expect('ANTHROPIC_API_KEY' in out).toBe(false);
64
+ });
65
+ });
@@ -0,0 +1,78 @@
1
+ // ACP bridge origin gate (DDR-123). The agent bridge spawns the user's `claude`
2
+ // and can drive file edits, so it lives ONLY on the main (privileged) origin and
3
+ // only over loopback. This proves:
4
+ // (1) GET /_api/acp/status serves on the main origin;
5
+ // (2) it is 403'd on the untrusted canvas origin (off CANVAS_SAFE_API +
6
+ // startCanvasServer routes — the dual-allowlist rule);
7
+ // (3) /_ws/acp is wired on the main origin (upgrade-without-headers → 400);
8
+ // (4) the loopback guard that fronts /_ws/acp rejects non-loopback hosts.
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ import { isLoopbackHost } from '../ws.ts';
15
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
16
+
17
+ async function readCanvasOrigin(designRoot: string): Promise<string> {
18
+ for (let i = 0; i < 40; i++) {
19
+ try {
20
+ const info = JSON.parse(readFileSync(join(designRoot, '_server.json'), 'utf8'));
21
+ if (info.canvasOrigin) return info.canvasOrigin as string;
22
+ } catch {
23
+ /* not written yet */
24
+ }
25
+ await Bun.sleep(50);
26
+ }
27
+ throw new Error('canvasOrigin never appeared in _server.json');
28
+ }
29
+
30
+ describe('ACP bridge origin gate', () => {
31
+ test('status is main-origin only; /_ws/acp wired on main', async () => {
32
+ const { root, designRoot } = makeSandbox();
33
+ const port = nextPort();
34
+ const proc = await bootServer(root, port, { MAUDE_CANVAS_ORIGIN_SPLIT: '1' });
35
+ try {
36
+ const main = `http://localhost:${port}`;
37
+ const canvas = await readCanvasOrigin(designRoot);
38
+ const status = async (base: string, path: string) =>
39
+ (await fetch(base + path, { signal: AbortSignal.timeout(2000) })).status;
40
+
41
+ // (1) main origin exposes the readiness probe
42
+ const probe = await fetch(`${main}/_api/acp/status`, { signal: AbortSignal.timeout(2000) });
43
+ expect(probe.status).toBe(200);
44
+ expect(typeof (await probe.json()).available).toBe('boolean');
45
+
46
+ // (2) the untrusted canvas origin must NOT reach the probe
47
+ expect(await status(canvas, '/_api/acp/status')).toBe(403);
48
+
49
+ // (3) /_ws/acp is wired on the main origin — a plain GET (no Upgrade
50
+ // headers) passes the loopback guard then fails the WS upgrade → 400,
51
+ // which proves the route branch exists (a 404 would mean it's unwired).
52
+ expect(await status(main, '/_ws/acp')).toBe(400);
53
+
54
+ // (4) /_api/acp/focus (the /design:chat hook) — POST-only on the main
55
+ // origin, 403 on the canvas origin.
56
+ const focusPost = await fetch(`${main}/_api/acp/focus`, {
57
+ method: 'POST',
58
+ signal: AbortSignal.timeout(2000),
59
+ });
60
+ expect(focusPost.status).toBe(200);
61
+ expect((await focusPost.json()).ok).toBe(true);
62
+ expect(await status(main, '/_api/acp/focus')).toBe(405); // GET not allowed
63
+ expect(await status(canvas, '/_api/acp/focus')).toBe(403); // off the canvas origin
64
+ } finally {
65
+ await killProc(proc);
66
+ }
67
+ }, 15000);
68
+
69
+ test('the loopback guard fronting /_ws/acp rejects non-loopback hosts', () => {
70
+ // Same guard server.ts applies before upgrading the acp socket.
71
+ expect(isLoopbackHost('127.0.0.1:4399')).toBe(true);
72
+ expect(isLoopbackHost('localhost:4399')).toBe(true);
73
+ expect(isLoopbackHost('[::1]:4399')).toBe(true);
74
+ expect(isLoopbackHost('evil.example.com')).toBe(false);
75
+ expect(isLoopbackHost('10.0.0.5:4399')).toBe(false);
76
+ expect(isLoopbackHost(null)).toBe(false);
77
+ });
78
+ });
@@ -0,0 +1,112 @@
1
+ // acp/transcript — repo-level chat list + raw-transcript → clean-messages.
2
+
3
+ import { afterEach, describe, expect, test } from 'bun:test';
4
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ import { deleteChat, listChats, readChatMessages } from '../acp/transcript.ts';
9
+
10
+ let root: string;
11
+ afterEach(() => {
12
+ if (root) rmSync(root, { recursive: true, force: true });
13
+ });
14
+
15
+ function seed(chatId: string, lines: object[]): string {
16
+ root = mkdtempSync(join(tmpdir(), 'acp-tx-'));
17
+ const dir = join(root, '_chat');
18
+ mkdirSync(dir, { recursive: true });
19
+ writeFileSync(join(dir, `${chatId}.jsonl`), lines.map((l) => JSON.stringify(l)).join('\n'));
20
+ return root;
21
+ }
22
+
23
+ describe('readChatMessages — raw → clean turns', () => {
24
+ test('aggregates agent updates into one assistant message; drops noise', () => {
25
+ const designRoot = seed('c1', [
26
+ { ts: 1, role: 'user', text: 'make the button red' },
27
+ {
28
+ ts: 2,
29
+ role: 'agent',
30
+ update: { sessionUpdate: 'available_commands_update', availableCommands: [] },
31
+ },
32
+ {
33
+ ts: 3,
34
+ role: 'agent',
35
+ update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Sure, ' } },
36
+ },
37
+ {
38
+ ts: 4,
39
+ role: 'agent',
40
+ update: {
41
+ sessionUpdate: 'tool_call',
42
+ toolCallId: 't1',
43
+ title: 'Edit Button.tsx',
44
+ kind: 'edit',
45
+ status: 'pending',
46
+ },
47
+ },
48
+ {
49
+ ts: 5,
50
+ role: 'agent',
51
+ update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'done.' } },
52
+ },
53
+ {
54
+ ts: 6,
55
+ role: 'agent',
56
+ update: { sessionUpdate: 'tool_call_update', toolCallId: 't1', status: 'completed' },
57
+ },
58
+ { ts: 7, role: 'agent', update: { sessionUpdate: 'usage_update', used: 10 } },
59
+ { ts: 8, role: 'stop', stopReason: 'end_turn' },
60
+ ]);
61
+
62
+ const msgs = readChatMessages(designRoot, 'c1');
63
+ expect(msgs.length).toBe(2);
64
+ expect(msgs[0]).toEqual({
65
+ role: 'user',
66
+ parts: [{ type: 'text', text: 'make the button red' }],
67
+ });
68
+ expect(msgs[1].role).toBe('assistant');
69
+ // text parts merged across the tool call boundary order-preserving
70
+ const texts = msgs[1].parts.filter((p) => p.type === 'text').map((p) => p.text);
71
+ expect(texts.join('')).toBe('Sure, done.');
72
+ const tool = msgs[1].parts.find((p) => p.type === 'tool');
73
+ expect(tool?.toolName).toBe('Edit Button.tsx');
74
+ expect(tool?.done).toBe(true);
75
+ // noise (available_commands_update / usage_update) is not rendered
76
+ expect(JSON.stringify(msgs)).not.toContain('availableCommands');
77
+ expect(JSON.stringify(msgs)).not.toContain('usage');
78
+ });
79
+
80
+ test('missing chat → empty', () => {
81
+ const designRoot = seed('c1', [{ ts: 1, role: 'user', text: 'hi' }]);
82
+ expect(readChatMessages(designRoot, 'nope')).toEqual([]);
83
+ });
84
+ });
85
+
86
+ describe('listChats', () => {
87
+ test('lists chats with a title derived from the first user line', () => {
88
+ const designRoot = seed('chat-7', [
89
+ { ts: 1, role: 'user', text: 'redesign the pricing page please' },
90
+ { ts: 2, role: 'stop', stopReason: 'end_turn' },
91
+ ]);
92
+ const chats = listChats(designRoot);
93
+ expect(chats.length).toBe(1);
94
+ expect(chats[0].id).toBe('chat-7');
95
+ expect(chats[0].title).toBe('redesign the pricing page please');
96
+ });
97
+
98
+ test('no _chat dir → empty', () => {
99
+ root = mkdtempSync(join(tmpdir(), 'acp-tx-'));
100
+ expect(listChats(root)).toEqual([]);
101
+ });
102
+ });
103
+
104
+ describe('deleteChat', () => {
105
+ test('removes the transcript; missing → false', () => {
106
+ const designRoot = seed('gone', [{ ts: 1, role: 'user', text: 'bye' }]);
107
+ expect(listChats(designRoot).length).toBe(1);
108
+ expect(deleteChat(designRoot, 'gone')).toBe(true);
109
+ expect(listChats(designRoot).length).toBe(0);
110
+ expect(deleteChat(designRoot, 'gone')).toBe(false);
111
+ });
112
+ });
@@ -2,13 +2,7 @@
2
2
 
3
3
  import { describe, expect, test } from 'bun:test';
4
4
 
5
- import {
6
- ACTIVITY_IDLE_MS,
7
- type ActivityChange,
8
- createActivity,
9
- diffArtboardIds,
10
- isCanvasFile,
11
- } from '../activity.ts';
5
+ import { type ActivityChange, createActivity, diffArtboardIds, isCanvasFile } from '../activity.ts';
12
6
  import { type Context, createBus } from '../context.ts';
13
7
 
14
8
  function mkCtx(): Context {
@@ -0,0 +1,184 @@
1
+ // annotate-write — FigJam v3 AI write verb (`maude design annotate`).
2
+ // Drives bin/annotate.mjs as a subprocess against a temp design root and
3
+ // asserts the output through the CANONICAL parser: everything the verb writes
4
+ // must be sanitize-stable, byte-identical under parse → re-serialize, and
5
+ // readable back as a graph by read-annotations --graph.
6
+
7
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
8
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
12
+
13
+ import {
14
+ type ArrowStroke,
15
+ type StickyStroke,
16
+ type Stroke,
17
+ sanitizeAnnotationSvg,
18
+ strokesToSvg,
19
+ svgToStrokes,
20
+ } from '../annotations-model.ts';
21
+
22
+ const BIN = new URL('../bin/annotate.mjs', import.meta.url).pathname;
23
+ const READER = new URL('../bin/read-annotations.mjs', import.meta.url).pathname;
24
+
25
+ let root: string;
26
+
27
+ beforeAll(() => {
28
+ GlobalRegistrator.register();
29
+ root = mkdtempSync(join(tmpdir(), 'annotate-test-'));
30
+ // A design root with no config — resolveDesignRoot defaults to .design.
31
+ writeFileSync(
32
+ join(root, 'flow.json'),
33
+ JSON.stringify({
34
+ nodes: [
35
+ { id: 'a', label: 'Start' },
36
+ { id: 'b', label: 'Middle' },
37
+ { id: 'c', label: 'End', shape: 'ellipse' },
38
+ ],
39
+ edges: [
40
+ { from: 'a', to: 'b' },
41
+ { from: 'b', to: 'c', label: 'ships' },
42
+ ],
43
+ })
44
+ );
45
+ Bun.spawnSync(['mkdir', '-p', join(root, '.design')]);
46
+ });
47
+
48
+ afterAll(async () => {
49
+ await GlobalRegistrator.unregister();
50
+ rmSync(root, { recursive: true, force: true });
51
+ });
52
+
53
+ function runAnnotate(args: string[], stdin?: string): { code: number; out: string; err: string } {
54
+ const proc = Bun.spawnSync(['bun', BIN, ...args], {
55
+ stdin: stdin ? new TextEncoder().encode(stdin) : undefined,
56
+ });
57
+ return {
58
+ code: proc.exitCode,
59
+ out: new TextDecoder().decode(proc.stdout),
60
+ err: new TextDecoder().decode(proc.stderr),
61
+ };
62
+ }
63
+
64
+ function readSvg(): string {
65
+ return readFileSync(join(root, '.design', 'ui-flow.annotations.svg'), 'utf8');
66
+ }
67
+
68
+ describe('annotate --flow', () => {
69
+ test('writes an auto-laid-out diagram of bound connectors', () => {
70
+ const res = runAnnotate(['ui/Flow.tsx', '--flow', join(root, 'flow.json'), '--root', root]);
71
+ expect(res.code).toBe(0);
72
+ const result = JSON.parse(res.out) as { ok: boolean; refs: Record<string, string> };
73
+ expect(result.ok).toBe(true);
74
+ expect(Object.keys(result.refs)).toEqual(['@a', '@b', '@c']);
75
+
76
+ const svg = readSvg();
77
+ // Canonical-parser compatible, sanitize-stable, byte-identical round-trip.
78
+ expect(sanitizeAnnotationSvg(svg)).toBe(svg);
79
+ const strokes = svgToStrokes(svg);
80
+ expect(strokesToSvg(strokes)).toBe(svg);
81
+ // 3 shapes + 3 labels (2 anchored + 1 ellipse-anchored) + 2 arrows + 1 edge label.
82
+ const arrows = strokes.filter((s): s is ArrowStroke => s.tool === 'arrow');
83
+ expect(arrows).toHaveLength(2);
84
+ for (const a of arrows) {
85
+ expect(a.startBind?.hostId).toBeDefined();
86
+ expect(a.endBind?.hostId).toBeDefined();
87
+ expect(a.author).toBe('ai');
88
+ }
89
+ // Layered layout: 'b' sits one column right of 'a'.
90
+ const aShape = strokes.find((s) => s.id === result.refs['@a']) as Stroke & { x: number };
91
+ const bShape = strokes.find((s) => s.id === result.refs['@b']) as Stroke & { x: number };
92
+ expect(bShape.x).toBeGreaterThan(aShape.x);
93
+ });
94
+
95
+ test('read-annotations --graph reads the diagram back as the same graph', () => {
96
+ const proc = Bun.spawnSync(['node', READER, 'ui/Flow.tsx', '--root', root, '--graph']);
97
+ const parsed = JSON.parse(new TextDecoder().decode(proc.stdout)) as {
98
+ graph: { nodes: Array<{ label: string | null }>; edges: unknown[] };
99
+ };
100
+ expect(parsed.graph.edges).toHaveLength(2);
101
+ expect(parsed.graph.nodes.map((n) => n.label).sort()).toEqual(['End', 'Middle', 'Start']);
102
+ });
103
+ });
104
+
105
+ describe('annotate --ops', () => {
106
+ test('create sticky + group + connect-to-existing + delete, via stdin', () => {
107
+ // Connect to a node minted by the previous --flow run.
108
+ const flowSvg = readSvg();
109
+ const existingId = (svgToStrokes(flowSvg).find((s) => s.tool === 'rect') as Stroke).id;
110
+ const ops = {
111
+ ops: [
112
+ { op: 'create', type: 'sticky', ref: '@n1', text: 'pain point', x: 100, y: 600 },
113
+ { op: 'create', type: 'sticky', ref: '@n2', text: 'idea', x: 340, y: 600 },
114
+ { op: 'group', ids: ['@n1', '@n2'] },
115
+ { op: 'connect', from: '@n1', to: existingId },
116
+ ],
117
+ };
118
+ const res = runAnnotate(['ui/Flow.tsx', '--root', root], JSON.stringify(ops));
119
+ expect(res.code).toBe(0);
120
+ const svg = readSvg();
121
+ expect(sanitizeAnnotationSvg(svg)).toBe(svg);
122
+ const strokes = svgToStrokes(svg);
123
+ expect(strokesToSvg(svg && strokes ? strokes : [])).toBe(svg);
124
+ const stickies = strokes.filter((s): s is StickyStroke => s.tool === 'sticky');
125
+ expect(stickies).toHaveLength(2);
126
+ expect(stickies[0]?.groupIds).toEqual(stickies[1]?.groupIds);
127
+ expect(stickies[0]?.groupIds?.length).toBe(1);
128
+ const bound = strokes.filter(
129
+ (s): s is ArrowStroke => s.tool === 'arrow' && s.endBind?.hostId === existingId
130
+ );
131
+ expect(bound).toHaveLength(1);
132
+
133
+ // Delete one sticky — the element disappears and the file stays canonical.
134
+ const del = runAnnotate(
135
+ ['ui/Flow.tsx', '--root', root],
136
+ JSON.stringify({ ops: [{ op: 'delete', id: stickies[1]?.id }] })
137
+ );
138
+ expect(del.code).toBe(0);
139
+ const after = svgToStrokes(readSvg());
140
+ expect(after.filter((s) => s.tool === 'sticky')).toHaveLength(1);
141
+ expect(strokesToSvg(after)).toBe(readSvg());
142
+ });
143
+
144
+ test('unknown connect target fails loud, writes nothing', () => {
145
+ const before = readSvg();
146
+ const res = runAnnotate(
147
+ ['ui/Flow.tsx', '--root', root],
148
+ JSON.stringify({ ops: [{ op: 'connect', from: '@ghost', to: 'nope' }] })
149
+ );
150
+ expect(res.code).toBe(2);
151
+ expect(readSvg()).toBe(before);
152
+ });
153
+
154
+ test('--dry-run prints the merged SVG without writing', () => {
155
+ const before = readSvg();
156
+ const res = runAnnotate(
157
+ ['ui/Flow.tsx', '--root', root, '--dry-run'],
158
+ JSON.stringify({ ops: [{ op: 'create', type: 'text', text: 'ghost', x: 0, y: 0 }] })
159
+ );
160
+ expect(res.code).toBe(0);
161
+ expect(res.out).toContain('ghost');
162
+ expect(readSvg()).toBe(before);
163
+ });
164
+
165
+ test('non-loopback _server.json.url is refused — writes to file, never PUTs off-box (F2)', () => {
166
+ // A poisoned/foreign _server.json points the egress at an external host.
167
+ // The verb must NOT PUT there (SSRF/exfil); it falls back to the file write.
168
+ const serverJson = join(root, '.design', '_server.json');
169
+ writeFileSync(serverJson, JSON.stringify({ url: 'http://attacker.example.com:4399' }));
170
+ try {
171
+ const res = runAnnotate(
172
+ ['ui/F2.tsx', '--root', root],
173
+ JSON.stringify({ ops: [{ op: 'create', type: 'text', text: 'secret', x: 0, y: 0 }] })
174
+ );
175
+ expect(res.code).toBe(0);
176
+ // via:"file" proves the off-box PUT was skipped (a successful PUT → "server").
177
+ expect(JSON.parse(res.out).via).toBe('file');
178
+ const svg = readFileSync(join(root, '.design', 'ui-f2.annotations.svg'), 'utf8');
179
+ expect(svg).toContain('secret');
180
+ } finally {
181
+ rmSync(serverJson, { force: true });
182
+ }
183
+ });
184
+ });
@@ -0,0 +1,88 @@
1
+ // annotations-align — FigJam v3 align + distribute (pure bbox math, DOM-free).
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import { alignStrokes, distributeStrokes } from '../annotations-align.ts';
6
+ import type { RectStroke, Stroke } from '../annotations-model.ts';
7
+
8
+ function rect(
9
+ id: string,
10
+ x: number,
11
+ y: number,
12
+ w = 50,
13
+ h = 50,
14
+ extra: Partial<RectStroke> = {}
15
+ ): RectStroke {
16
+ return { id, tool: 'rect', color: '#1f1f1f', width: 3, x, y, w, h, ...extra };
17
+ }
18
+
19
+ const byId = (strokes: readonly Stroke[], id: string): RectStroke =>
20
+ strokes.find((s) => s.id === id) as RectStroke;
21
+
22
+ describe('alignStrokes', () => {
23
+ test('align left snaps every unit to the selection bbox left edge', () => {
24
+ const strokes = [rect('a', 0, 0), rect('b', 100, 80), rect('c', 40, 200)];
25
+ const out = alignStrokes(strokes, ['a', 'b', 'c'], 'left');
26
+ expect(byId(out, 'a').x).toBe(0);
27
+ expect(byId(out, 'b').x).toBe(0);
28
+ expect(byId(out, 'c').x).toBe(0);
29
+ // y untouched by a horizontal align
30
+ expect(byId(out, 'b').y).toBe(80);
31
+ });
32
+
33
+ test('align h-center / right / bottom', () => {
34
+ const strokes = [rect('a', 0, 0, 100, 100), rect('b', 200, 50, 20, 20)];
35
+ const centered = alignStrokes(strokes, ['a', 'b'], 'h-center');
36
+ // Selection spans x:0..220 → center 110; b (w20) → x=100.
37
+ expect(byId(centered, 'b').x).toBe(100);
38
+ const right = alignStrokes(strokes, ['a', 'b'], 'right');
39
+ expect(byId(right, 'a').x).toBe(120);
40
+ const bottom = alignStrokes(strokes, ['a', 'b'], 'bottom');
41
+ expect(byId(bottom, 'b').y).toBe(80);
42
+ });
43
+
44
+ test('a group aligns as ONE unit — members keep their relative offsets', () => {
45
+ const strokes = [
46
+ rect('g1', 100, 0, 50, 50, { groupIds: ['g'] }),
47
+ rect('g2', 160, 30, 50, 50, { groupIds: ['g'] }),
48
+ rect('solo', 0, 200),
49
+ ];
50
+ const out = alignStrokes(strokes, ['g1', 'solo'], 'left');
51
+ // Group bbox spans x:100..210; aligning left moves the group by -100.
52
+ expect(byId(out, 'g1').x).toBe(0);
53
+ expect(byId(out, 'g2').x).toBe(60);
54
+ expect(byId(out, 'solo').x).toBe(0);
55
+ });
56
+
57
+ test('fewer than two units → referential no-op', () => {
58
+ const strokes = [
59
+ rect('a', 0, 0),
60
+ rect('b', 10, 10, 50, 50, { groupIds: ['g'] }),
61
+ rect('c', 70, 10, 50, 50, { groupIds: ['g'] }),
62
+ ];
63
+ expect(alignStrokes(strokes, ['b'], 'left')).toBe(strokes);
64
+ });
65
+ });
66
+
67
+ describe('distributeStrokes', () => {
68
+ test('equalizes gaps, pinning the first and last unit', () => {
69
+ // a: 0..50, b: 60..110, c: 200..250 → span 250, total 150, gap = 50.
70
+ const strokes = [rect('a', 0, 0), rect('b', 60, 0), rect('c', 200, 0)];
71
+ const out = distributeStrokes(strokes, ['a', 'b', 'c'], 'h');
72
+ expect(byId(out, 'a').x).toBe(0);
73
+ expect(byId(out, 'b').x).toBe(100);
74
+ expect(byId(out, 'c').x).toBe(200);
75
+ });
76
+
77
+ test('vertical distribute', () => {
78
+ // a: 0..50, b: 55..105, c: 300..350 → span 350, total 150, gap = 100.
79
+ const strokes = [rect('a', 0, 0), rect('b', 0, 55), rect('c', 0, 300)];
80
+ const out = distributeStrokes(strokes, ['a', 'b', 'c'], 'v');
81
+ expect(byId(out, 'b').y).toBe(150);
82
+ });
83
+
84
+ test('fewer than three units → referential no-op', () => {
85
+ const strokes = [rect('a', 0, 0), rect('b', 100, 0)];
86
+ expect(distributeStrokes(strokes, ['a', 'b'], 'h')).toBe(strokes);
87
+ });
88
+ });