@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.
- package/apps/studio/acp/bridge.ts +385 -26
- package/apps/studio/acp/index.ts +498 -102
- package/apps/studio/acp/running.ts +97 -0
- package/apps/studio/acp/transcript.ts +64 -0
- package/apps/studio/acp/write-scope.ts +459 -0
- package/apps/studio/bin/_smart-frames.mjs +187 -20
- package/apps/studio/bin/_smart-frames.test.mjs +59 -4
- package/apps/studio/bin/_transcribe.mjs +40 -3
- package/apps/studio/bin/smoke.sh +7 -1
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +78 -33
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +104 -6
- package/apps/studio/client/panels/GitPanel.jsx +38 -25
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
- package/apps/studio/client/panels/acp-runtime.js +96 -5
- package/apps/studio/client/styles/3-shell-maude.css +11 -2
- package/apps/studio/client/styles/4-components.css +72 -0
- package/apps/studio/client/styles/6-acp-chat.css +51 -0
- package/apps/studio/cloud/endpoints.ts +56 -3
- package/apps/studio/collab/persistence.ts +29 -2
- package/apps/studio/config.schema.json +3 -3
- package/apps/studio/context.ts +41 -0
- package/apps/studio/dist/client.bundle.js +1330 -1330
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/gemma-models.ts +312 -14
- package/apps/studio/generation/prefs.ts +7 -2
- package/apps/studio/generation/runtime-probe.ts +50 -0
- package/apps/studio/generation/whisper-models.ts +124 -0
- package/apps/studio/hmr-broadcast.ts +67 -0
- package/apps/studio/http.ts +247 -111
- package/apps/studio/input-router.tsx +55 -2
- package/apps/studio/server.ts +22 -9
- package/apps/studio/sync/autocommit.ts +61 -2
- package/apps/studio/sync/cell-pairing.ts +174 -0
- package/apps/studio/sync/codec.ts +11 -5
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +288 -26
- package/apps/studio/sync/limits.ts +49 -0
- package/apps/studio/sync/loopback.ts +21 -0
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/projection.ts +47 -12
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/sync/supervisor.ts +178 -0
- package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
- package/apps/studio/test/acp-branch-guard.test.ts +123 -0
- package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
- package/apps/studio/test/acp-commands.test.ts +5 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
- package/apps/studio/test/acp-permission.test.ts +18 -2
- package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
- package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
- package/apps/studio/test/acp-write-gate.test.ts +323 -0
- package/apps/studio/test/acp-write-scope.test.ts +533 -0
- package/apps/studio/test/bundle-smoke.test.ts +35 -18
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/cloud-connect-note.test.ts +135 -0
- package/apps/studio/test/cloud-endpoints.test.ts +102 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/csrf-write-guard.test.ts +19 -2
- package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
- package/apps/studio/test/gemma-models.test.ts +245 -0
- package/apps/studio/test/hmr-broadcast.test.ts +57 -1
- package/apps/studio/test/input-router.test.ts +95 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
- package/apps/studio/test/sync-autocommit.test.ts +47 -0
- package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
- package/apps/studio/test/sync-connection-state.test.ts +45 -1
- package/apps/studio/test/sync-presentation.test.ts +185 -0
- package/apps/studio/test/sync-remote-docs.test.ts +171 -0
- package/apps/studio/test/sync-supervisor.test.ts +212 -0
- package/apps/studio/test/trusted-request-host.test.ts +66 -0
- package/apps/studio/test/whisper-setup.test.ts +97 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +9 -1
- package/cli/commands/kg.mjs +9 -2
- package/package.json +8 -8
- 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
|
+
});
|