@1agh/maude 0.56.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/build.ts +28 -1
- package/apps/studio/client/app.jsx +47 -30
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +72 -13
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- 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/6-acp-chat.css +51 -0
- package/apps/studio/dist/client.bundle.js +1278 -1278
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +51 -2
- package/apps/studio/server.ts +11 -0
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +50 -1
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- 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 +11 -4
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -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/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/whats-new.json +18 -0
- package/package.json +8 -8
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// What the PROJECT holds, versus what this machine happens to carry.
|
|
2
|
+
//
|
|
3
|
+
// The sync runtime opens one provider per canvas found on LOCAL disk, and Yjs
|
|
4
|
+
// has no enumeration — so a document that exists only on the hub is invisible
|
|
5
|
+
// to a peer by construction. A desktop with 72 of a project's 75 canvases syncs
|
|
6
|
+
// 72, reports "72/72 synced", and is accurate about the wrong universe. The
|
|
7
|
+
// user-visible form of that was "I pressed Open in Maude and nothing happened":
|
|
8
|
+
// three real canvases (welcome, how-to-use, how-to-make-video) lived only in
|
|
9
|
+
// the cloud and could never arrive.
|
|
10
|
+
//
|
|
11
|
+
// This module asks the hub what it has (`GET /api/documents`, scope-bound to
|
|
12
|
+
// the same token the sync uses) and diffs it against the local set, so the
|
|
13
|
+
// runtime can say which side is missing what.
|
|
14
|
+
//
|
|
15
|
+
// SYNC IS BIDIRECTIONAL AND COMPLETE. A project you have been granted access to
|
|
16
|
+
// is a project you get — all of it, both directions. Local canvases go up (they
|
|
17
|
+
// always did); hub-only documents now come DOWN, materialised as real files.
|
|
18
|
+
//
|
|
19
|
+
// What that trades, stated plainly: a hub can create files in your design root,
|
|
20
|
+
// where before it could only update canvases you already had (DDR-054 treats
|
|
21
|
+
// hub-pushed content as untrusted). That is accepted deliberately — the hub is
|
|
22
|
+
// the project, the caller is authenticated, and a partial project is not a
|
|
23
|
+
// project. The one guard kept is containment: a document NAME can never place a
|
|
24
|
+
// file outside the design root. That is not policy filtering, it is the
|
|
25
|
+
// difference between writing your project and writing your filesystem.
|
|
26
|
+
|
|
27
|
+
/** One document as the hub reports it. */
|
|
28
|
+
export interface RemoteDoc {
|
|
29
|
+
name: string;
|
|
30
|
+
bytes: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RemoteDocDiff {
|
|
34
|
+
/** Documents on both sides — the ones actually syncing. */
|
|
35
|
+
shared: string[];
|
|
36
|
+
/** On the hub, absent here. The project has them; this machine cannot get them. */
|
|
37
|
+
hubOnly: RemoteDoc[];
|
|
38
|
+
/** Here, not on the hub yet — normal for a canvas this peer just created. */
|
|
39
|
+
localOnly: string[];
|
|
40
|
+
/** Null when the hub could not be asked (old hub, offline, refused). */
|
|
41
|
+
reachable: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** How long to wait for the listing. Never blocks a sync — see `fetchRemoteDocs`. */
|
|
45
|
+
const LIST_TIMEOUT_MS = 6000;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Ask the hub which documents this token may open.
|
|
49
|
+
*
|
|
50
|
+
* Returns null on ANY failure — an old hub without the route, a refused token,
|
|
51
|
+
* a network blip. A peer that cannot get the listing must still sync: this is a
|
|
52
|
+
* reporting improvement, and making it load-bearing would trade a real feature
|
|
53
|
+
* for a nicer message.
|
|
54
|
+
*/
|
|
55
|
+
export async function fetchRemoteDocs(
|
|
56
|
+
hubUrl: string,
|
|
57
|
+
token: string,
|
|
58
|
+
fetchImpl: typeof fetch = fetch
|
|
59
|
+
): Promise<RemoteDoc[] | null> {
|
|
60
|
+
try {
|
|
61
|
+
const base = hubUrl.replace(/\/+$/, '');
|
|
62
|
+
const res = await fetchImpl(`${base}/api/documents`, {
|
|
63
|
+
headers: { authorization: `Bearer ${token}` },
|
|
64
|
+
signal: AbortSignal.timeout(LIST_TIMEOUT_MS),
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) return null;
|
|
67
|
+
const body = (await res.json()) as { documents?: unknown };
|
|
68
|
+
if (!Array.isArray(body?.documents)) return null;
|
|
69
|
+
return body.documents
|
|
70
|
+
.filter(
|
|
71
|
+
(d): d is RemoteDoc =>
|
|
72
|
+
!!d && typeof (d as RemoteDoc).name === 'string' && (d as RemoteDoc).name.length > 0
|
|
73
|
+
)
|
|
74
|
+
.map((d) => ({ name: d.name, bytes: Number(d.bytes) || 0 }));
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Diff the hub's documents against the ones this peer syncs.
|
|
82
|
+
*
|
|
83
|
+
* `localDocNames` must already be in WIRE form (`docNameFor(slug)`), not raw
|
|
84
|
+
* slugs — comparing a namespaced hub against flat local names would report
|
|
85
|
+
* every document as missing on both sides, which is the exact failure the
|
|
86
|
+
* namespace exists to prevent, arrived at from the reporting side.
|
|
87
|
+
*/
|
|
88
|
+
export function diffRemoteDocs(
|
|
89
|
+
localDocNames: readonly string[],
|
|
90
|
+
remote: RemoteDoc[] | null
|
|
91
|
+
): RemoteDocDiff {
|
|
92
|
+
if (remote === null) {
|
|
93
|
+
return { shared: [], hubOnly: [], localOnly: [...localDocNames], reachable: false };
|
|
94
|
+
}
|
|
95
|
+
const local = new Set(localDocNames);
|
|
96
|
+
const remoteNames = new Set(remote.map((d) => d.name));
|
|
97
|
+
return {
|
|
98
|
+
shared: [...local].filter((n) => remoteNames.has(n)).sort(),
|
|
99
|
+
hubOnly: remote.filter((d) => !local.has(d.name)).sort((a, b) => a.name.localeCompare(b.name)),
|
|
100
|
+
localOnly: [...local].filter((n) => !remoteNames.has(n)).sort(),
|
|
101
|
+
reachable: true,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One line a person can act on.
|
|
107
|
+
*
|
|
108
|
+
* Deliberately names the documents being PULLED rather than only what already
|
|
109
|
+
* matched: "72 synced" was true and useless, because the number is drawn from
|
|
110
|
+
* the local file set and can never express what the project holds beyond it.
|
|
111
|
+
*/
|
|
112
|
+
export function describeRemoteDiff(diff: RemoteDocDiff): string | null {
|
|
113
|
+
if (!diff.reachable) return null;
|
|
114
|
+
if (diff.hubOnly.length === 0) return null;
|
|
115
|
+
const n = diff.hubOnly.length;
|
|
116
|
+
const names = diff.hubOnly
|
|
117
|
+
.slice(0, 3)
|
|
118
|
+
.map((d) => d.name)
|
|
119
|
+
.join(', ');
|
|
120
|
+
return `pulling ${n} canvas${n === 1 ? '' : 'es'} down from the project (${names}${n > 3 ? ', …' : ''}).`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The local slug a hub document name maps to.
|
|
125
|
+
*
|
|
126
|
+
* Namespaced names (`ws/<workspace>/<branch>/<slug>` — DDR-192 §5) carry the
|
|
127
|
+
* slug in the last segment; a legacy flat name IS the slug. Returns null for
|
|
128
|
+
* anything that does not survive the charset the hub itself enforces on
|
|
129
|
+
* `documentName`, so a crafted name cannot become a path component.
|
|
130
|
+
*/
|
|
131
|
+
export function slugFromDocName(docName: string): string | null {
|
|
132
|
+
const raw = String(docName ?? '');
|
|
133
|
+
// Validate the WHOLE name against the two shapes a hub may legitimately use,
|
|
134
|
+
// never just its last segment. Taking the tail of an arbitrary string would
|
|
135
|
+
// accept `../../etc/passwd` as `passwd`: contained by the check below, and
|
|
136
|
+
// still a file this project never asked for, created from a name that should
|
|
137
|
+
// have been refused outright. A component is `[A-Za-z0-9_-]` — no dots (so no
|
|
138
|
+
// traversal and no extension smuggling), no spaces.
|
|
139
|
+
const COMPONENT = '[A-Za-z0-9_-]{1,120}';
|
|
140
|
+
const FLAT = new RegExp(`^${COMPONENT}$`);
|
|
141
|
+
const NAMESPACED = new RegExp(`^ws/${COMPONENT}/${COMPONENT}/(${COMPONENT})$`);
|
|
142
|
+
if (FLAT.test(raw)) return raw.toLowerCase();
|
|
143
|
+
const ns = NAMESPACED.exec(raw);
|
|
144
|
+
return ns ? ns[1].toLowerCase() : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface PullTarget {
|
|
148
|
+
slug: string;
|
|
149
|
+
docName: string;
|
|
150
|
+
/** Absolute path the body will be written to, flat under the design root. */
|
|
151
|
+
bodyAbs: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Where a hub-only document lands on disk.
|
|
156
|
+
*
|
|
157
|
+
* FLAT, directly under the design root — the same convention the hub applies in
|
|
158
|
+
* the mirror direction (`workspace-files.mjs defaultBodyPath`) and for the same
|
|
159
|
+
* reason: a slug is lossy, `ui-card` cannot be un-flattened into `ui/Card.tsx`
|
|
160
|
+
* without guessing, and guessing wrong scatters files into directories the user
|
|
161
|
+
* never made. A flat file is trivially moved; an invented tree is not. Moving it
|
|
162
|
+
* later does not break sync — both paths slug to the same document.
|
|
163
|
+
*
|
|
164
|
+
* Returns only targets that resolve INSIDE the design root. A document name is
|
|
165
|
+
* hub-controlled input, and this is the last point before a create.
|
|
166
|
+
*/
|
|
167
|
+
export function pullTargets(
|
|
168
|
+
hubOnly: readonly RemoteDoc[],
|
|
169
|
+
designRoot: string,
|
|
170
|
+
join: (...parts: string[]) => string,
|
|
171
|
+
resolve: (p: string) => string,
|
|
172
|
+
sep: string
|
|
173
|
+
): PullTarget[] {
|
|
174
|
+
const rootResolved = resolve(designRoot);
|
|
175
|
+
const out: PullTarget[] = [];
|
|
176
|
+
// One slug, one target. `slugFromDocName` lowercases, so a hub advertising
|
|
177
|
+
// `Foo`, `foo` and `ws/w/main/foo` yields three targets for ONE file — three
|
|
178
|
+
// providers on the same path, and a `pulled` count that overstates what
|
|
179
|
+
// arrived by a factor the hub chooses.
|
|
180
|
+
const seen = new Set<string>();
|
|
181
|
+
for (const doc of hubOnly) {
|
|
182
|
+
const slug = slugFromDocName(doc.name);
|
|
183
|
+
if (!slug || seen.has(slug)) continue;
|
|
184
|
+
seen.add(slug);
|
|
185
|
+
const bodyAbs = join(designRoot, `${slug}.tsx`);
|
|
186
|
+
const target = resolve(bodyAbs);
|
|
187
|
+
if (target !== rootResolved && !target.startsWith(rootResolved + sep)) continue;
|
|
188
|
+
out.push({ slug, docName: doc.name, bodyAbs });
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
@@ -79,7 +79,10 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
|
|
|
79
79
|
const conflicts: SyncConflict[] = [];
|
|
80
80
|
|
|
81
81
|
let snapshot: SyncStatusSnapshot = {
|
|
82
|
-
state:
|
|
82
|
+
// Same reason the monitor is seeded `connecting` (see connection-state.ts):
|
|
83
|
+
// this is the payload a reader gets BEFORE the first monitor snapshot lands,
|
|
84
|
+
// so seeding it `online` re-introduced the born-connected lie one layer out.
|
|
85
|
+
state: 'connecting',
|
|
83
86
|
queuedOps: 0,
|
|
84
87
|
lastSyncAt: null,
|
|
85
88
|
offlineSince: null,
|
|
@@ -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
|
+
});
|