@1agh/maude 0.55.0 → 0.57.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 (85) hide show
  1. package/apps/studio/acp/bridge.ts +385 -26
  2. package/apps/studio/acp/index.ts +498 -102
  3. package/apps/studio/acp/running.ts +97 -0
  4. package/apps/studio/acp/transcript.ts +64 -0
  5. package/apps/studio/acp/write-scope.ts +459 -0
  6. package/apps/studio/bin/_smart-frames.mjs +187 -20
  7. package/apps/studio/bin/_smart-frames.test.mjs +59 -4
  8. package/apps/studio/bin/_transcribe.mjs +40 -3
  9. package/apps/studio/bin/smoke.sh +7 -1
  10. package/apps/studio/build.ts +28 -1
  11. package/apps/studio/client/app.jsx +78 -33
  12. package/apps/studio/client/panels/ChatPanel.jsx +19 -1
  13. package/apps/studio/client/panels/CloudBar.jsx +104 -6
  14. package/apps/studio/client/panels/GitPanel.jsx +38 -25
  15. package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
  16. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
  17. package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
  18. package/apps/studio/client/panels/acp-runtime.js +96 -5
  19. package/apps/studio/client/styles/3-shell-maude.css +11 -2
  20. package/apps/studio/client/styles/4-components.css +72 -0
  21. package/apps/studio/client/styles/6-acp-chat.css +51 -0
  22. package/apps/studio/cloud/endpoints.ts +56 -3
  23. package/apps/studio/collab/persistence.ts +29 -2
  24. package/apps/studio/config.schema.json +3 -3
  25. package/apps/studio/context.ts +41 -0
  26. package/apps/studio/dist/client.bundle.js +1330 -1330
  27. package/apps/studio/dist/comment-mount.js +2 -2
  28. package/apps/studio/dist/styles.css +1 -1
  29. package/apps/studio/generation/gemma-models.ts +312 -14
  30. package/apps/studio/generation/prefs.ts +7 -2
  31. package/apps/studio/generation/runtime-probe.ts +50 -0
  32. package/apps/studio/generation/whisper-models.ts +124 -0
  33. package/apps/studio/hmr-broadcast.ts +67 -0
  34. package/apps/studio/http.ts +247 -111
  35. package/apps/studio/input-router.tsx +55 -2
  36. package/apps/studio/server.ts +22 -9
  37. package/apps/studio/sync/autocommit.ts +61 -2
  38. package/apps/studio/sync/cell-pairing.ts +174 -0
  39. package/apps/studio/sync/codec.ts +11 -5
  40. package/apps/studio/sync/connection-state.ts +116 -6
  41. package/apps/studio/sync/index.ts +288 -26
  42. package/apps/studio/sync/limits.ts +49 -0
  43. package/apps/studio/sync/loopback.ts +21 -0
  44. package/apps/studio/sync/presentation.ts +273 -0
  45. package/apps/studio/sync/projection.ts +47 -12
  46. package/apps/studio/sync/remote-docs.ts +191 -0
  47. package/apps/studio/sync/status.ts +4 -1
  48. package/apps/studio/sync/supervisor.ts +178 -0
  49. package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
  50. package/apps/studio/test/acp-branch-guard.test.ts +123 -0
  51. package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
  52. package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
  53. package/apps/studio/test/acp-commands.test.ts +5 -0
  54. package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
  55. package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
  56. package/apps/studio/test/acp-permission.test.ts +18 -2
  57. package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
  58. package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
  59. package/apps/studio/test/acp-write-gate.test.ts +323 -0
  60. package/apps/studio/test/acp-write-scope.test.ts +533 -0
  61. package/apps/studio/test/bundle-smoke.test.ts +35 -18
  62. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  63. package/apps/studio/test/cloud-connect-note.test.ts +135 -0
  64. package/apps/studio/test/cloud-endpoints.test.ts +102 -0
  65. package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
  66. package/apps/studio/test/csrf-write-guard.test.ts +19 -2
  67. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
  68. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
  69. package/apps/studio/test/gemma-models.test.ts +245 -0
  70. package/apps/studio/test/hmr-broadcast.test.ts +57 -1
  71. package/apps/studio/test/input-router.test.ts +95 -0
  72. package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
  73. package/apps/studio/test/sync-autocommit.test.ts +47 -0
  74. package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
  75. package/apps/studio/test/sync-connection-state.test.ts +45 -1
  76. package/apps/studio/test/sync-presentation.test.ts +185 -0
  77. package/apps/studio/test/sync-remote-docs.test.ts +171 -0
  78. package/apps/studio/test/sync-supervisor.test.ts +212 -0
  79. package/apps/studio/test/trusted-request-host.test.ts +66 -0
  80. package/apps/studio/test/whisper-setup.test.ts +97 -0
  81. package/apps/studio/whats-new.json +45 -0
  82. package/apps/studio/ws.ts +9 -1
  83. package/cli/commands/kg.mjs +9 -2
  84. package/package.json +8 -8
  85. package/plugins/design/dependencies.json +21 -3
@@ -0,0 +1,178 @@
1
+ // Sync supervisor — one owner of the live sync runtime, so linking a project
2
+ // can START SYNCING instead of asking a person to restart the app.
3
+ //
4
+ // Why this exists: `createSyncRuntime` captures `cfg.linkedHub` once, and
5
+ // `reloadConfig` deliberately refuses to hot-swap it (a config.json is
6
+ // committed and travels with the repo — a poisoned one must never re-point the
7
+ // sync socket, DDR-149). That rule is about the FILE WATCHER, and it was being
8
+ // paid for by the person: after pressing Connect in the cloud panel the whole
9
+ // product did nothing visible and the UI said "restart the studio server to
10
+ // start syncing" — a sentence with no button behind it, and no meaning at all
11
+ // inside the desktop app, where there is no server the person can see.
12
+ //
13
+ // The supervisor separates the two paths that were conflated:
14
+ //
15
+ // • a linkedHub arriving from DISK → still boot-pinned, still refused;
16
+ // • a linkedHub the person JUST AUTHORIZED in trusted app chrome, whose hub
17
+ // credential this process itself stored one line earlier → adopted in
18
+ // memory and applied by a real stop() + start() cycle.
19
+ //
20
+ // The second path never reads the value back off disk: `restart()` takes the
21
+ // LinkedHub object the attach lane just wrote, so nothing that happens to
22
+ // `.design/config.json` in between can steer the socket.
23
+
24
+ import { adoptLinkedHub, type Context, type LinkedHub } from '../context.ts';
25
+ import { resolveCellPairing } from './cell-pairing.ts';
26
+ import { getHubToken } from './hubs-config.ts';
27
+ import {
28
+ type CreateSyncRuntimeOptions,
29
+ checkUrlScheme,
30
+ createSyncRuntime,
31
+ type SyncRuntime,
32
+ } from './index.ts';
33
+
34
+ /**
35
+ * What happened when the runtime was (re)started — the honest answer the cloud
36
+ * panel repeats back to the person. `syncing:false` is never silent: `reason`
37
+ * is what to say, and the caller renders it verbatim rather than guessing.
38
+ */
39
+ export interface SyncStartOutcome {
40
+ syncing: boolean;
41
+ /** Canvases the runtime actually took ownership of. */
42
+ canvases: number;
43
+ /** Machine-readable cause when `syncing` is false. */
44
+ reason?: 'unlinked' | 'no-credential' | 'insecure-url' | 'refused' | 'nothing-syncable' | 'error';
45
+ /** One sentence a person can act on. Present whenever `syncing` is false. */
46
+ detail?: string;
47
+ }
48
+
49
+ export interface SyncSupervisor {
50
+ /** Boot the runtime from the config as loaded. Safe when unlinked. */
51
+ start(): Promise<SyncStartOutcome>;
52
+ /**
53
+ * Adopt a just-authorized link and cycle the runtime. `linkedHub` is passed
54
+ * BY VALUE from the attach lane on purpose — never re-read from disk.
55
+ */
56
+ restart(linkedHub?: LinkedHub): Promise<SyncStartOutcome>;
57
+ stop(): Promise<void>;
58
+ /** The live runtime, or null in solo mode. Test/inspection surface. */
59
+ current(): SyncRuntime | null;
60
+ }
61
+
62
+ /**
63
+ * Why did `createSyncRuntime` decline? It logs its refusals and returns null,
64
+ * which is right for a boot path and useless for a dialog. Re-derive the cause
65
+ * from the same inputs so the panel can say something true. Kept in this order
66
+ * deliberately — it mirrors the guard order in createSyncRuntime.
67
+ */
68
+ function diagnose(ctx: Context): { reason: SyncStartOutcome['reason']; detail: string } {
69
+ // Cell pairing supplies its own hub URL + credential from the environment, so
70
+ // "no linkedHub on disk" is not the diagnosis there — reporting "not linked to
71
+ // a workspace" for a cell that IS paired would send an operator looking for a
72
+ // config field that has nothing to do with it. Both pairing outcomes (refused
73
+ // to resolve, or resolved but the runtime still declined) return here — a
74
+ // successfully-resolved pairing always carries a non-empty `url`
75
+ // (`resolveCellPairing` refuses an empty one), so there is no "unlinked"
76
+ // state to fall through to on this path, unlike the plain linkedHub case below.
77
+ const { pairing, detail: pairingRefusal } = resolveCellPairing();
78
+ if (pairingRefusal) {
79
+ return { reason: 'refused', detail: pairingRefusal };
80
+ }
81
+ if (pairing) {
82
+ return {
83
+ reason: 'refused',
84
+ detail: 'Cell pairing is on but syncing did not start — see the server log for why.',
85
+ };
86
+ }
87
+ const linked = ctx.cfg.linkedHub;
88
+ if (!linked) {
89
+ return { reason: 'unlinked', detail: 'This project is not linked to a workspace.' };
90
+ }
91
+ const scheme = checkUrlScheme(linked.url);
92
+ if (scheme) {
93
+ return { reason: 'insecure-url', detail: scheme };
94
+ }
95
+ if (!getHubToken(linked.url)) {
96
+ return {
97
+ reason: 'no-credential',
98
+ detail: 'No sign-in for this workspace is stored on this machine yet.',
99
+ };
100
+ }
101
+ return {
102
+ reason: 'refused',
103
+ detail: 'Syncing is switched off in this environment — see the server log for why.',
104
+ };
105
+ }
106
+
107
+ export function createSyncSupervisor(
108
+ ctx: Context,
109
+ opts: CreateSyncRuntimeOptions = {},
110
+ factory: (c: Context, o: CreateSyncRuntimeOptions) => SyncRuntime | null = createSyncRuntime
111
+ ): SyncSupervisor {
112
+ let runtime: SyncRuntime | null = null;
113
+ // Two Connects pressed in quick succession must not interleave a stop() with
114
+ // another cycle's start() — the second would attach providers the first is
115
+ // still tearing down. One chain, in order.
116
+ let chain: Promise<unknown> = Promise.resolve();
117
+
118
+ async function boot(): Promise<SyncStartOutcome> {
119
+ runtime = factory(ctx, opts);
120
+ if (!runtime) return { syncing: false, canvases: 0, ...diagnose(ctx) };
121
+ try {
122
+ await runtime.start();
123
+ } catch (err) {
124
+ // Same posture as the boot path: a failed sync start never takes the
125
+ // server (or the attach response) down with it — solo mode continues.
126
+ console.error('[sync] start failed — continuing in solo mode:', err);
127
+ return {
128
+ syncing: false,
129
+ canvases: 0,
130
+ reason: 'error',
131
+ detail: `Syncing could not start: ${(err as Error).message}`,
132
+ };
133
+ }
134
+ const canvases = runtime.size();
135
+ if (canvases === 0) {
136
+ // Linked and authenticated, but nothing on disk qualifies. The runtime
137
+ // already wrote the full reason into `_sync.json` (9.1-D); reuse it
138
+ // rather than inventing a second explanation that can drift from it.
139
+ const status = runtime.status() as { reason?: string } | null;
140
+ return {
141
+ syncing: false,
142
+ canvases: 0,
143
+ reason: 'nothing-syncable',
144
+ detail: status?.reason ?? 'No canvases in this project are syncable yet.',
145
+ };
146
+ }
147
+ return { syncing: true, canvases };
148
+ }
149
+
150
+ function serialize<T>(work: () => Promise<T>): Promise<T> {
151
+ const next = chain.then(work, work);
152
+ // Keep the chain alive even when a link fails — the NEXT attach must still
153
+ // be able to run. (A rejected chain would poison every later restart.)
154
+ chain = next.catch(() => {});
155
+ return next;
156
+ }
157
+
158
+ return {
159
+ start: () => serialize(boot),
160
+ restart: (linkedHub) =>
161
+ serialize(async () => {
162
+ try {
163
+ await runtime?.stop();
164
+ } catch (err) {
165
+ console.error('[sync] stop before restart failed:', err);
166
+ }
167
+ runtime = null;
168
+ if (linkedHub) adoptLinkedHub(ctx, linkedHub);
169
+ return boot();
170
+ }),
171
+ stop: () =>
172
+ serialize(async () => {
173
+ await runtime?.stop();
174
+ runtime = null;
175
+ }),
176
+ current: () => runtime,
177
+ };
178
+ }
@@ -0,0 +1,183 @@
1
+ // feature-acp-turn-notifications Task 2/3 — the activity snapshot the native
2
+ // shell's poller reads (notify.rs Task 5) to decide whether to fire an OS
3
+ // notification for a project that isn't the visible one. Covers:
4
+ // • activitySnapshot() shape + monotonic seq (bumps only on real change).
5
+ // • AcpBridge.awaitingInputCount reflects pending permission requests.
6
+ // • The Acp manager's registered probe reports 'awaiting-input', not
7
+ // 'running', for a chat blocked on a permission prompt — the ordering
8
+ // Task 2's gotcha calls out explicitly.
9
+ // • /_api/acp/activity is absent from both canvas-origin allowlists
10
+ // (Decision: NEITHER, like /_api/acp/running today).
11
+
12
+ import { afterEach, describe, expect, test } from 'bun:test';
13
+ import { readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ import type { ServerWebSocket } from 'bun';
17
+
18
+ import { AcpBridge } from '../acp/bridge.ts';
19
+ import { createAcp } from '../acp/index.ts';
20
+ import { activitySnapshot, registerActivityProbe } from '../acp/running.ts';
21
+ import type { Context } from '../context.ts';
22
+ import type { WsData } from '../ws.ts';
23
+
24
+ const FIXTURE = join(import.meta.dir, 'fixtures', 'mock-acp-agent-permission.mjs');
25
+ const TEST_ENV_KEYS = ['MAUDE_ACP_ADAPTER_ENTRY', 'MAUDE_ACP_RUNTIME', 'MAUDE_CLAUDE_BIN'];
26
+
27
+ function useMockAgent() {
28
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
29
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
30
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
31
+ }
32
+
33
+ afterEach(() => {
34
+ for (const key of TEST_ENV_KEYS) delete process.env[key];
35
+ // Restore a no-op probe so a later file's `registerRunningChatsProbe`-style
36
+ // module-level state doesn't leak a stale closure across test files.
37
+ registerActivityProbe(() => []);
38
+ });
39
+
40
+ async function until<T>(fn: () => T | undefined, timeoutMs = 12000): Promise<T> {
41
+ const start = performance.now();
42
+ for (;;) {
43
+ const v = fn();
44
+ if (v !== undefined) return v;
45
+ if (performance.now() - start > timeoutMs) throw new Error('timeout waiting for condition');
46
+ await new Promise((r) => setTimeout(r, 25));
47
+ }
48
+ }
49
+
50
+ function fakeWs(id: string) {
51
+ const frames: Array<Record<string, unknown>> = [];
52
+ const ws = {
53
+ data: { id } as WsData,
54
+ send: (raw: string) => {
55
+ frames.push(JSON.parse(raw));
56
+ },
57
+ } as unknown as ServerWebSocket<WsData>;
58
+ return { ws, frames };
59
+ }
60
+
61
+ describe('activitySnapshot (running.ts)', () => {
62
+ test('reflects the registered probe and never throws on a bad one', () => {
63
+ registerActivityProbe(() => [{ chatId: 'a', state: 'running' }]);
64
+ expect(activitySnapshot().chats).toEqual([{ chatId: 'a', state: 'running' }]);
65
+
66
+ registerActivityProbe(() => {
67
+ throw new Error('boom');
68
+ });
69
+ expect(activitySnapshot().chats).toEqual([]);
70
+ });
71
+
72
+ test('seq bumps only when the (chatId, state) set actually changes', () => {
73
+ registerActivityProbe(() => [{ chatId: 'a', state: 'idle' }]);
74
+ const first = activitySnapshot().seq;
75
+ const second = activitySnapshot().seq; // same state — no bump
76
+ expect(second).toBe(first);
77
+
78
+ registerActivityProbe(() => [{ chatId: 'a', state: 'running' }]);
79
+ const third = activitySnapshot().seq; // real transition — bumps
80
+ expect(third).toBe(first + 1);
81
+
82
+ // Enumeration-order shuffle with the SAME states must NOT bump.
83
+ registerActivityProbe(() => [
84
+ { chatId: 'b', state: 'idle' },
85
+ { chatId: 'a', state: 'running' },
86
+ ]);
87
+ activitySnapshot();
88
+ registerActivityProbe(() => [
89
+ { chatId: 'a', state: 'running' },
90
+ { chatId: 'b', state: 'idle' },
91
+ ]);
92
+ const fourth = activitySnapshot().seq;
93
+ const fifth = activitySnapshot().seq;
94
+ expect(fifth).toBe(fourth);
95
+ });
96
+ });
97
+
98
+ describe('AcpBridge.awaitingInputCount + the manager probe (Task 2 gotcha: awaiting-input wins over running)', () => {
99
+ test('a pending permission request reports awaiting-input, not running', async () => {
100
+ useMockAgent();
101
+ const ctx = {
102
+ paths: { repoRoot: process.cwd(), designRoot: '/tmp/does-not-matter-activity' },
103
+ } as unknown as Context;
104
+ const acp = createAcp(ctx);
105
+
106
+ const a = fakeWs('activity-ws-a');
107
+ try {
108
+ acp.onOpen(a.ws);
109
+ acp.onMessage(a.ws, JSON.stringify({ t: 'prompt', text: 'hi', chat: 'activity-chat' }));
110
+ const reqFrame = await until(() => a.frames.find((f) => f.t === 'permission-request'));
111
+
112
+ const snap = activitySnapshot();
113
+ expect(snap.chats).toContainEqual({ chatId: 'activity-chat', state: 'awaiting-input' });
114
+
115
+ acp.onMessage(
116
+ a.ws,
117
+ JSON.stringify({ t: 'permission-response', id: reqFrame.id, decision: 'allow-once' })
118
+ );
119
+ await until(() => a.frames.find((f) => f.t === 'turn-end'));
120
+
121
+ // Turn settled — no longer awaiting-input (running or idle, never stuck).
122
+ const after = activitySnapshot().chats.find((c) => c.chatId === 'activity-chat');
123
+ expect(after?.state).not.toBe('awaiting-input');
124
+ } finally {
125
+ acp.onClose(a.ws);
126
+ acp.stopAll();
127
+ await new Promise((r) => setTimeout(r, 50));
128
+ }
129
+ }, 20000);
130
+ });
131
+
132
+ describe('AcpBridge.awaitingInputCount — direct unit', () => {
133
+ test('starts at 0 and counts a pending permission before resolution', async () => {
134
+ useMockAgent();
135
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
136
+ expect(bridge.awaitingInputCount).toBe(0);
137
+ try {
138
+ const promptPromise = bridge.prompt('hi', 'c1');
139
+ await until(() => (bridge.awaitingInputCount > 0 ? true : undefined));
140
+ expect(bridge.awaitingInputCount).toBe(1);
141
+ await bridge.cancel(); // denies the pending request rather than hanging
142
+ await promptPromise;
143
+ expect(bridge.awaitingInputCount).toBe(0);
144
+ } finally {
145
+ await bridge.stop();
146
+ }
147
+ }, 15000);
148
+ });
149
+
150
+ describe('/_api/acp/activity — NEITHER canvas-origin allowlist (Decision D + dual-allowlist rule)', () => {
151
+ const httpSrc = readFileSync(join(import.meta.dir, '..', 'http.ts'), 'utf8');
152
+ const serverSrc = readFileSync(join(import.meta.dir, '..', 'server.ts'), 'utf8');
153
+
154
+ test('the route exists on the main-origin route table', () => {
155
+ expect(httpSrc).toContain("'/_api/acp/activity'");
156
+ });
157
+
158
+ test('is absent from CANVAS_SAFE_API', () => {
159
+ const start = httpSrc.indexOf('CANVAS_SAFE_API = new Set([');
160
+ const end = httpSrc.indexOf(']);', start);
161
+ expect(start).toBeGreaterThan(-1);
162
+ const block = httpSrc.slice(start, end);
163
+ expect(block).not.toContain('/_api/acp/activity');
164
+ });
165
+
166
+ test("is absent from startCanvasServer's routes map", () => {
167
+ const start = serverSrc.indexOf('function startCanvasServer');
168
+ const routesEnd = serverSrc.indexOf('async fetch(req, srv)', start);
169
+ expect(start).toBeGreaterThan(-1);
170
+ const block = serverSrc.slice(start, routesEnd);
171
+ expect(block).not.toContain('/_api/acp/activity');
172
+ });
173
+
174
+ test('the payload never carries a chat title, message text, or transcript field name', () => {
175
+ // Grep the route handler body itself (not the whole file) for the fields
176
+ // Decision D forbids — a title/text field appearing HERE would mean
177
+ // someone widened the payload without reading the comment.
178
+ const start = httpSrc.indexOf("'/_api/acp/activity':");
179
+ const end = httpSrc.indexOf('},', start);
180
+ const block = httpSrc.slice(start, end);
181
+ expect(block).not.toMatch(/title|message|transcript|content/i);
182
+ });
183
+ });
@@ -0,0 +1,123 @@
1
+ // feature-acp-write-path-scope Addendum, Task 9 — the branch-switch warning.
2
+ //
3
+ // `RepoBranchSwitcher.jsx` has THREE `window.location.reload()` call sites
4
+ // (`switchDraft`, `createDraft`, and the local-merge fold in `foldDraft`). All
5
+ // three move the worktree, so all three need the gate; covering one and missing
6
+ // two is the easy mistake, which is why the source-shape assertion below exists
7
+ // alongside the copy tests.
8
+ //
9
+ // Why the wording matters enough to test: this is a safety prompt, and Task 8
10
+ // changed what is TRUE about it. Before, the reload killed the running turn, so
11
+ // "this will stop it" was accurate. Now the bridge outlives its socket, so the
12
+ // turn keeps going — into a worktree that has moved under it. "Will stop it"
13
+ // would now be false in the reassuring direction.
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+ import { readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+
19
+ import { chatGuardCopy } from '../client/panels/RepoBranchSwitcher.jsx';
20
+
21
+ describe('chatGuardCopy', () => {
22
+ const one = chatGuardCopy({ count: 1, branch: 'draft-a', verb: 'switch to “main”' });
23
+
24
+ test('names the branch, the action, and the real consequence', () => {
25
+ expect(one.title).toBe('A chat is working right now');
26
+ expect(one.body).toContain('draft-a');
27
+ expect(one.body).toContain('switch to “main”');
28
+ expect(one.body).toContain('wrong branch');
29
+ });
30
+
31
+ test('does NOT claim the switch stops the chat (it no longer does — Task 8)', () => {
32
+ const all = `${one.title} ${one.body} ${one.meta}`.toLowerCase();
33
+ expect(all).not.toContain('will stop it');
34
+ // …and it does say the thing that IS true: the agent is actively editing.
35
+ expect(one.body).toContain('editing files on');
36
+ });
37
+
38
+ test('is honest that History cannot undo this', () => {
39
+ // The `_history/` snapshot stack is per-canvas-slug with NO branch
40
+ // awareness, so the "it's reversible" reassurance this whole feature leans
41
+ // on genuinely does not hold across a checkout. Promising rollback here
42
+ // would be the one lie that makes the confirm worse than no confirm.
43
+ expect(one.body).toContain("History can't undo");
44
+ });
45
+
46
+ test('pluralizes when several chats are running', () => {
47
+ expect(chatGuardCopy({ count: 3, branch: 'x', verb: 'y' }).title).toBe(
48
+ '3 chats are working right now'
49
+ );
50
+ });
51
+
52
+ test('the confirm is not the safe default', () => {
53
+ // "Wait" is the cancel; "Do it anyway" is the deliberate, phrased-as-a-risk
54
+ // confirm. A neutral "OK/Cancel" pair would read as routine.
55
+ expect(one.cancel).toBe('Wait');
56
+ expect(one.confirm).toBe('Do it anyway');
57
+ });
58
+ });
59
+
60
+ describe('all THREE reload call sites are gated', () => {
61
+ const src = readFileSync(
62
+ join(import.meta.dir, '..', 'client', 'panels', 'RepoBranchSwitcher.jsx'),
63
+ 'utf8'
64
+ );
65
+
66
+ test('every reload lives in a `do*` action reached only through guardedByChat', () => {
67
+ // Source-shape guard rather than a render test: the failure mode is a
68
+ // FOURTH reload being added later without a gate, which no render test of
69
+ // the existing three would catch.
70
+ const guarded = src.match(/guardedByChat\(/g) ?? [];
71
+ // One definition + three call sites.
72
+ expect(guarded.length).toBeGreaterThanOrEqual(4);
73
+ for (const fn of ['doSwitchDraft', 'doCreateDraft', 'doFoldDraft']) {
74
+ expect(src).toContain(`function ${fn}`);
75
+ }
76
+ });
77
+
78
+ test('the gate asks the SERVER, not local chat state', () => {
79
+ // A bridge can be running DETACHED (post-reload, pre-re-attach) with no
80
+ // client that knows about it — precisely the case the warning is for. Local
81
+ // busy state would answer "nothing is running" exactly when it matters.
82
+ expect(src).toContain('/_api/acp/running');
83
+ });
84
+
85
+ test('a failed probe never BLOCKS a switch', () => {
86
+ // A switch the user asked for must not be hostage to an unrelated fetch
87
+ // error. The catch returns 0 (⇒ proceed), not a thrown error.
88
+ expect(src).toMatch(/catch\s*{[\s\S]*?return 0;/);
89
+ });
90
+ });
91
+
92
+ // SECURITY (security-auditor A6) — cross-PROJECT loopback adjacency.
93
+ //
94
+ // Before the sidecar pool (Addendum Task 10) at most one dev-server ran at a
95
+ // time, so "another Maude instance on another loopback port" was not a state the
96
+ // product produced. The pool makes concurrency DESIGNED-IN: projects A and B now
97
+ // routinely run side by side on different ports, and A's canvas iframe is
98
+ // untrusted content (DDR-054). So the question "can A's canvas reach B's
99
+ // privileged main origin?" went from hypothetical to worth an explicit test.
100
+ //
101
+ // The defence is `sameOriginWrite`'s host+port compare — different instance,
102
+ // different port, different `host`, rejected. Asserting it here so the property
103
+ // is pinned to the pool's introduction rather than left as an inference.
104
+ describe('cross-project origin adjacency (A6)', () => {
105
+ test('a request from ANOTHER instance’s origin is not same-origin', async () => {
106
+ const { sameOriginWrite } = await import('../http.ts');
107
+ const projectB = new Request('http://localhost:4400/_api/acp/running', {
108
+ method: 'POST',
109
+ // Project A's canvas/main origin, aimed at project B's server.
110
+ headers: { origin: 'http://localhost:4399' },
111
+ });
112
+ expect(sameOriginWrite(projectB)).toBe(false);
113
+ });
114
+
115
+ test('a request from the SAME instance is allowed (the pool must not break normal use)', async () => {
116
+ const { sameOriginWrite } = await import('../http.ts');
117
+ const own = new Request('http://localhost:4400/_api/acp/running', {
118
+ method: 'POST',
119
+ headers: { origin: 'http://localhost:4400' },
120
+ });
121
+ expect(sameOriginWrite(own)).toBe(true);
122
+ });
123
+ });