@1agh/maude 0.38.1 → 0.39.1
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 +144 -5
- package/apps/studio/acp/index.ts +11 -1
- package/apps/studio/api.ts +21 -0
- package/apps/studio/client/app.jsx +1 -0
- package/apps/studio/client/panels/ChatPanel.jsx +190 -4
- package/apps/studio/client/panels/acp-runtime.js +108 -0
- package/apps/studio/client/styles/6-acp-chat.css +123 -1
- package/apps/studio/dist/client.bundle.js +14 -14
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +23 -3
- package/apps/studio/test/acp-attachment-serve.test.ts +74 -0
- package/apps/studio/test/acp-bridge.test.ts +132 -0
- package/apps/studio/test/acp-commands.test.ts +8 -3
- package/apps/studio/test/acp-origin-gate.test.ts +6 -4
- package/apps/studio/test/canvas-origin-gate.test.ts +5 -0
- package/apps/studio/test/chat-attachments.test.ts +117 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +24 -1
- package/apps/studio/whats-new.json +18 -0
- package/package.json +8 -8
package/apps/studio/http.ts
CHANGED
|
@@ -658,10 +658,30 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
658
658
|
// Phase 31 follow-up — persist an image pasted straight into the ACP composer
|
|
659
659
|
// (a clipboard screenshot has no path), returning an absolute path the chip
|
|
660
660
|
// expands to so Claude can Read it. MAIN-ORIGIN ONLY: sameOriginWrite CSRF gate
|
|
661
|
-
// + deliberately absent from CANVAS_SAFE_API + startCanvasServer routes,
|
|
662
|
-
// untrusted canvas iframe is 403'd. The disk caps live in
|
|
663
|
-
// (magic-byte sniff / 10 MB / content-addressed name /
|
|
661
|
+
// on POST + deliberately absent from CANVAS_SAFE_API + startCanvasServer routes,
|
|
662
|
+
// so the untrusted canvas iframe is 403'd. The disk caps live in
|
|
663
|
+
// api.saveChatAttachment (magic-byte sniff / 10 MB / content-addressed name /
|
|
664
|
+
// session write budget).
|
|
665
|
+
//
|
|
666
|
+
// GET ?name=<sha8>.<ext> serves the pasted image back to the chat feed
|
|
667
|
+
// (thumbnail + lightbox). The name is regex-allowlisted in
|
|
668
|
+
// api.resolveChatAttachment — content-addressed, never a caller path, so
|
|
669
|
+
// traversal-proof by construction; content-addressed ⇒ immutable cache. No
|
|
670
|
+
// CSRF gate on GET (sameOriginWrite is the POST/CSRF boundary) — the
|
|
671
|
+
// main-origin posture above is what keeps the canvas origin out.
|
|
664
672
|
'/_api/acp/attachment': async (req: Request) => {
|
|
673
|
+
if (req.method === 'GET') {
|
|
674
|
+
const name = new URL(req.url).searchParams.get('name');
|
|
675
|
+
const abs = await api.resolveChatAttachment(name);
|
|
676
|
+
if (!abs) return new Response('Not found', { status: 404 });
|
|
677
|
+
return serveFile(abs, {
|
|
678
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
679
|
+
// Uploaded bytes on the privileged main origin — never let the browser
|
|
680
|
+
// MIME-sniff a polyglot into a richer type (mirrors the DDR-088 static
|
|
681
|
+
// lane; the sniff/allowlist on write is the primary gate).
|
|
682
|
+
'X-Content-Type-Options': 'nosniff',
|
|
683
|
+
});
|
|
684
|
+
}
|
|
665
685
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
666
686
|
if (!sameOriginWrite(req))
|
|
667
687
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// GET /_api/acp/attachment — the content-addressed serve route behind the ACP
|
|
2
|
+
// chat image thumbnails + lightbox. The name is the ONLY input and must match
|
|
3
|
+
// our own `<sha8>.<ext>` shape (api.resolveChatAttachment) — traversal-proof by
|
|
4
|
+
// construction, so these tests pin the allowlist: a real uploaded attachment
|
|
5
|
+
// serves 200 with an image content-type + immutable cache; every malformed /
|
|
6
|
+
// hostile name 404s; non-GET/POST methods 405. The canvas-origin 403 lives in
|
|
7
|
+
// canvas-origin-gate.test.ts (dual-allowlist invariant, DDR-054/DDR-088).
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
|
|
11
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
12
|
+
|
|
13
|
+
// Minimal valid PNG magic bytes, padded so length checks pass (asset-api idiom).
|
|
14
|
+
const pad = (header: number[], len = 64): Uint8Array => {
|
|
15
|
+
const out = new Uint8Array(len);
|
|
16
|
+
out.set(header, 0);
|
|
17
|
+
return out;
|
|
18
|
+
};
|
|
19
|
+
const PNG = pad([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
20
|
+
|
|
21
|
+
describe('acp-attachment-serve / GET /_api/acp/attachment', () => {
|
|
22
|
+
test('serves an uploaded attachment by name; rejects hostile names; gates methods', async () => {
|
|
23
|
+
const sandbox = makeSandbox();
|
|
24
|
+
const port = nextPort();
|
|
25
|
+
const proc = await bootServer(sandbox.root, port);
|
|
26
|
+
const base = `http://localhost:${port}/_api/acp/attachment`;
|
|
27
|
+
try {
|
|
28
|
+
// Write side first — the serve route only ever serves what saveChatAttachment
|
|
29
|
+
// itself named (content-addressed <sha8>.<ext>).
|
|
30
|
+
const post = await fetch(base, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { 'Content-Type': 'image/png' },
|
|
33
|
+
body: PNG,
|
|
34
|
+
});
|
|
35
|
+
expect(post.status).toBe(201);
|
|
36
|
+
const { path: absPath } = (await post.json()) as { path: string };
|
|
37
|
+
const name = absPath.split('/').pop() as string;
|
|
38
|
+
expect(name).toMatch(/^[0-9a-f]{8}\.png$/);
|
|
39
|
+
|
|
40
|
+
// Happy path — 200, image content-type, immutable cache (content-addressed).
|
|
41
|
+
const ok = await fetch(`${base}?name=${name}`);
|
|
42
|
+
expect(ok.status).toBe(200);
|
|
43
|
+
expect(ok.headers.get('content-type')).toBe('image/png');
|
|
44
|
+
expect(ok.headers.get('cache-control')).toContain('immutable');
|
|
45
|
+
expect(new Uint8Array(await ok.arrayBuffer())).toEqual(PNG);
|
|
46
|
+
|
|
47
|
+
// Name allowlist — anything that is not our own <sha8>.(png|jpe?g|gif|webp)
|
|
48
|
+
// shape must 404 (never resolved against the filesystem).
|
|
49
|
+
for (const bad of [
|
|
50
|
+
'../../etc/passwd',
|
|
51
|
+
'..%2f..%2fetc%2fpasswd',
|
|
52
|
+
`/etc/passwd`,
|
|
53
|
+
'abc',
|
|
54
|
+
'deadbeef.svg', // SVG is never written — scriptable, stays unservable
|
|
55
|
+
'deadbeef.png.svg',
|
|
56
|
+
'DEADBEEF.png', // uppercase hex — writer emits lowercase only
|
|
57
|
+
'deadbeef1.png', // 9 hex chars
|
|
58
|
+
`_chat/attachments/${name}`,
|
|
59
|
+
]) {
|
|
60
|
+
const res = await fetch(`${base}?name=${encodeURIComponent(bad)}`);
|
|
61
|
+
expect(res.status).toBe(404);
|
|
62
|
+
}
|
|
63
|
+
// Missing name / valid-shaped but nonexistent file → 404 too.
|
|
64
|
+
expect((await fetch(base)).status).toBe(404);
|
|
65
|
+
expect((await fetch(`${base}?name=00000000.png`)).status).toBe(404);
|
|
66
|
+
|
|
67
|
+
// Method gate — only GET (serve) + POST (upload) exist.
|
|
68
|
+
expect((await fetch(base, { method: 'DELETE' })).status).toBe(405);
|
|
69
|
+
expect((await fetch(base, { method: 'PUT' })).status).toBe(405);
|
|
70
|
+
} finally {
|
|
71
|
+
await killProc(proc);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// (DDR-123 guardrail #1, verified end-to-end — the mock echoes its own env).
|
|
5
5
|
|
|
6
6
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
7
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
7
9
|
import { join } from 'node:path';
|
|
8
10
|
|
|
9
11
|
import { AcpBridge } from '../acp/bridge.ts';
|
|
@@ -112,6 +114,136 @@ describe('AcpBridge — round-trip + subscription guardrail', () => {
|
|
|
112
114
|
}, 15000);
|
|
113
115
|
});
|
|
114
116
|
|
|
117
|
+
describe('AcpBridge — cross-restart session resume (DDR-125 gap)', () => {
|
|
118
|
+
let dir: string;
|
|
119
|
+
|
|
120
|
+
afterEach(async () => {
|
|
121
|
+
if (dir) await rm(dir, { recursive: true, force: true });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('a persisted sessionId resumes via loadSession instead of spawning a new session', async () => {
|
|
125
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
126
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
127
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
128
|
+
dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
|
|
129
|
+
const storePath = join(dir, 'c1.session.json');
|
|
130
|
+
await Bun.write(storePath, JSON.stringify({ sessionId: 'persisted-session-abc' }));
|
|
131
|
+
|
|
132
|
+
const updates: unknown[] = [];
|
|
133
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
|
|
134
|
+
try {
|
|
135
|
+
bridge.setSessionStorePath(storePath);
|
|
136
|
+
await bridge.prompt('pokracuj', 'c1');
|
|
137
|
+
// Resumed the persisted id verbatim — never fell through to session/new
|
|
138
|
+
// (which would have minted a fresh `mock-session-N`).
|
|
139
|
+
expect(bridge.sessionId).toBe('persisted-session-abc');
|
|
140
|
+
// The mock's replay notification must NOT reach the UI sink.
|
|
141
|
+
expect(JSON.stringify(updates)).not.toContain('REPLAYED-HISTORY-SHOULD-NOT-SURFACE');
|
|
142
|
+
} finally {
|
|
143
|
+
await bridge.stop();
|
|
144
|
+
}
|
|
145
|
+
}, 15000);
|
|
146
|
+
|
|
147
|
+
test('a resume that fails (pruned session) falls back to newSession and re-persists the new id', async () => {
|
|
148
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
149
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
150
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
151
|
+
dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
|
|
152
|
+
const storePath = join(dir, 'c1.session.json');
|
|
153
|
+
await Bun.write(storePath, JSON.stringify({ sessionId: 'not-found' }));
|
|
154
|
+
|
|
155
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
|
|
156
|
+
try {
|
|
157
|
+
bridge.setSessionStorePath(storePath);
|
|
158
|
+
await bridge.prompt('pokracuj', 'c1');
|
|
159
|
+
expect(bridge.sessionId).toBe('mock-session-1'); // fresh session, not the stale id
|
|
160
|
+
const stored = JSON.parse(await readFile(storePath, 'utf8'));
|
|
161
|
+
expect(stored.sessionId).toBe('mock-session-1'); // re-persisted so the NEXT restart resumes it
|
|
162
|
+
} finally {
|
|
163
|
+
await bridge.stop();
|
|
164
|
+
}
|
|
165
|
+
}, 15000);
|
|
166
|
+
|
|
167
|
+
test('no prior chat for this sidecar behaves exactly as before — a brand new session is created and persisted', async () => {
|
|
168
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
169
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
170
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
171
|
+
dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
|
|
172
|
+
const storePath = join(dir, 'brand-new.session.json');
|
|
173
|
+
|
|
174
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
|
|
175
|
+
try {
|
|
176
|
+
bridge.setSessionStorePath(storePath);
|
|
177
|
+
await bridge.prompt('hi', 'c1');
|
|
178
|
+
expect(bridge.sessionId).toBe('mock-session-1');
|
|
179
|
+
const stored = JSON.parse(await readFile(storePath, 'utf8'));
|
|
180
|
+
expect(stored.sessionId).toBe('mock-session-1');
|
|
181
|
+
} finally {
|
|
182
|
+
await bridge.stop();
|
|
183
|
+
}
|
|
184
|
+
}, 15000);
|
|
185
|
+
|
|
186
|
+
test('no sidecar wired at all (e.g. a warm-up before any prompt) never touches disk and behaves exactly as before', async () => {
|
|
187
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
188
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
189
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
190
|
+
|
|
191
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
|
|
192
|
+
try {
|
|
193
|
+
// setSessionStorePath is never called — sessionStorePath stays null.
|
|
194
|
+
await bridge.prompt('hi', 'c1');
|
|
195
|
+
expect(bridge.sessionId).toBe('mock-session-1');
|
|
196
|
+
} finally {
|
|
197
|
+
await bridge.stop();
|
|
198
|
+
}
|
|
199
|
+
}, 15000);
|
|
200
|
+
|
|
201
|
+
test('a malformed persisted sessionId (not a plausible shape) is rejected — falls back to newSession', async () => {
|
|
202
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
203
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
204
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
205
|
+
dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
|
|
206
|
+
const storePath = join(dir, 'c1.session.json');
|
|
207
|
+
// Not a plausible sessionId shape (embedded newline + non-UUID charset) —
|
|
208
|
+
// must never reach the wire as a `loadSession` sessionId.
|
|
209
|
+
await Bun.write(storePath, JSON.stringify({ sessionId: 'evil\nsessionId; rm -rf /' }));
|
|
210
|
+
|
|
211
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
|
|
212
|
+
try {
|
|
213
|
+
bridge.setSessionStorePath(storePath);
|
|
214
|
+
await bridge.prompt('hi', 'c1');
|
|
215
|
+
expect(bridge.sessionId).toBe('mock-session-1'); // rejected — fresh session instead
|
|
216
|
+
} finally {
|
|
217
|
+
await bridge.stop();
|
|
218
|
+
}
|
|
219
|
+
}, 15000);
|
|
220
|
+
|
|
221
|
+
test('warm and prompt racing for the same chat share one resume attempt (no replaying race)', async () => {
|
|
222
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
223
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
224
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
225
|
+
dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
|
|
226
|
+
const storePath = join(dir, 'c1.session.json');
|
|
227
|
+
await Bun.write(storePath, JSON.stringify({ sessionId: 'persisted-race-session' }));
|
|
228
|
+
|
|
229
|
+
const updates: unknown[] = [];
|
|
230
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
|
|
231
|
+
try {
|
|
232
|
+
bridge.setSessionStorePath(storePath);
|
|
233
|
+
// Fire warmUp and prompt concurrently for the same chatId — both call
|
|
234
|
+
// sessionFor('c1') before either has resolved.
|
|
235
|
+
const [, result] = await Promise.all([bridge.warmUp('c1'), bridge.prompt('pokracuj', 'c1')]);
|
|
236
|
+
expect(bridge.sessionId).toBe('persisted-race-session');
|
|
237
|
+
expect(result.stopReason).toBe('end_turn');
|
|
238
|
+
// Only ONE resume attempt happened — the mock's replay text never leaked,
|
|
239
|
+
// and the prompt's own turn still streamed normally.
|
|
240
|
+
expect(JSON.stringify(updates)).not.toContain('REPLAYED-HISTORY-SHOULD-NOT-SURFACE');
|
|
241
|
+
} finally {
|
|
242
|
+
await bridge.stop();
|
|
243
|
+
}
|
|
244
|
+
}, 15000);
|
|
245
|
+
});
|
|
246
|
+
|
|
115
247
|
describe('probeAcpAvailability — not-connected detection', () => {
|
|
116
248
|
test('reports not-available with a Claude-Code reason when the CLI is absent', () => {
|
|
117
249
|
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// the cached list to a freshly-opened socket.
|
|
7
7
|
|
|
8
8
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
9
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
9
11
|
import { join } from 'node:path';
|
|
10
12
|
|
|
11
13
|
import type { ServerWebSocket } from 'bun';
|
|
@@ -73,9 +75,11 @@ describe('AcpBridge.warmUp — publishes the command catalogue without a prompt'
|
|
|
73
75
|
describe('Acp manager — warm frame broadcasts + open replays commands', () => {
|
|
74
76
|
test('a {t:warm} frame yields a {t:commands} frame, replayed to a new socket', async () => {
|
|
75
77
|
useMockAgent();
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
// Isolated tmp designRoot — `warm` now also writes a session-store sidecar
|
|
79
|
+
// (bridge.ts sessionFor's resume path), so this must not land in a real
|
|
80
|
+
// repo path.
|
|
81
|
+
const designRoot = await mkdtemp(join(tmpdir(), 'acp-commands-test-'));
|
|
82
|
+
const ctx = { paths: { repoRoot: process.cwd(), designRoot } } as unknown as Context;
|
|
79
83
|
const acp = createAcp(ctx);
|
|
80
84
|
|
|
81
85
|
const a = fakeWs('ws-a');
|
|
@@ -103,6 +107,7 @@ describe('Acp manager — warm frame broadcasts + open replays commands', () =>
|
|
|
103
107
|
acp.onClose(a.ws);
|
|
104
108
|
// give teardown a tick to kill the subprocess
|
|
105
109
|
await new Promise((r) => setTimeout(r, 50));
|
|
110
|
+
await rm(designRoot, { recursive: true, force: true });
|
|
106
111
|
}
|
|
107
112
|
}, 20000);
|
|
108
113
|
});
|
|
@@ -101,9 +101,11 @@ describe('ACP bridge origin gate', () => {
|
|
|
101
101
|
expect(crossOrigin.status).toBe(403);
|
|
102
102
|
|
|
103
103
|
// (6) Phase 31 follow-up — /_api/acp/attachment (clipboard-image paste):
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
104
|
+
// main-origin only. A valid PNG → 201 with an absolute path under the
|
|
105
|
+
// runtime _chat/attachments/; GET (the thumbnail serve branch) 404s
|
|
106
|
+
// without a valid content-addressed name — full serve coverage lives in
|
|
107
|
+
// acp-attachment-serve.test.ts; the canvas origin + a cross-origin
|
|
108
|
+
// drive-by POST are 403.
|
|
107
109
|
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
|
|
108
110
|
const up = await fetch(`${main}/_api/acp/attachment`, {
|
|
109
111
|
method: 'POST',
|
|
@@ -115,7 +117,7 @@ describe('ACP bridge origin gate', () => {
|
|
|
115
117
|
const upJson = await up.json();
|
|
116
118
|
expect(typeof upJson.path).toBe('string');
|
|
117
119
|
expect(upJson.path).toContain('_chat/attachments/');
|
|
118
|
-
expect(await status(main, '/_api/acp/attachment')).toBe(
|
|
120
|
+
expect(await status(main, '/_api/acp/attachment')).toBe(404); // GET without a name → 404
|
|
119
121
|
expect(await status(canvas, '/_api/acp/attachment')).toBe(403); // off the canvas origin
|
|
120
122
|
const attDriveBy = await fetch(`${main}/_api/acp/attachment`, {
|
|
121
123
|
method: 'POST',
|
|
@@ -114,6 +114,11 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
114
114
|
// the untrusted canvas origin must never reach it (it writes the global
|
|
115
115
|
// ~/.config/maude/hubs.json token store).
|
|
116
116
|
'/_api/hub/link',
|
|
117
|
+
// ACP chat attachments (POST upload + GET thumbnail serve) are MAIN-ORIGIN
|
|
118
|
+
// ONLY — absent from CANVAS_SAFE_API + startCanvasServer's routes. The
|
|
119
|
+
// untrusted canvas origin must never read (or write) the user's pasted
|
|
120
|
+
// chat images; a GET here 403s at the gate, not 404 from the handler.
|
|
121
|
+
'/_api/acp/attachment',
|
|
117
122
|
'/package.json',
|
|
118
123
|
]) {
|
|
119
124
|
expect(await code(p)).toBe(403);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// ACP chat image thumbnails — the pure ref helpers behind the two render paths:
|
|
2
|
+
// a LIVE user bubble carries the collapsed chip token ([image-1]), a RELOADED
|
|
3
|
+
// bubble (transcript) carries the expanded absolute `_chat/attachments/` path.
|
|
4
|
+
// Both must resolve to the same content-addressed `<sha8>.<ext>` name the GET
|
|
5
|
+
// serve route accepts — and nothing else may match (a random project path must
|
|
6
|
+
// never render as a thumbnail).
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
attachmentName,
|
|
12
|
+
designImageRefs,
|
|
13
|
+
extractAttachmentRefs,
|
|
14
|
+
} from '../client/panels/acp-runtime.js';
|
|
15
|
+
|
|
16
|
+
const ABS = '/Users/x/proj/.design/_chat/attachments/ab12cd34.png';
|
|
17
|
+
|
|
18
|
+
describe('chat-attachments / attachmentName', () => {
|
|
19
|
+
test('extracts the content-addressed basename from attachment paths', () => {
|
|
20
|
+
expect(attachmentName(ABS)).toBe('ab12cd34.png');
|
|
21
|
+
expect(attachmentName('_chat/attachments/00ff00ff.webp')).toBe('00ff00ff.webp');
|
|
22
|
+
expect(attachmentName(` ${ABS} `)).toBe('ab12cd34.png'); // trimmed
|
|
23
|
+
expect(attachmentName('/x/_chat/attachments/deadbeef.jpeg')).toBe('deadbeef.jpeg');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('rejects everything that is not a servable attachment path', () => {
|
|
27
|
+
expect(attachmentName('/Users/x/proj/assets/ab12cd34.png')).toBeNull(); // wrong dir
|
|
28
|
+
expect(attachmentName('/x/_chat/attachments/ab12cd34.svg')).toBeNull(); // never served
|
|
29
|
+
expect(attachmentName('/x/_chat/attachments/AB12CD34.png')).toBeNull(); // uppercase hex
|
|
30
|
+
expect(attachmentName('/x/_chat/attachments/abc.png')).toBeNull(); // short hash
|
|
31
|
+
expect(attachmentName(`${ABS}/../secret.png`)).toBeNull(); // trailing junk
|
|
32
|
+
expect(attachmentName('')).toBeNull();
|
|
33
|
+
expect(attachmentName(null)).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('chat-attachments / extractAttachmentRefs', () => {
|
|
38
|
+
test('chip-only string (live bubble)', () => {
|
|
39
|
+
expect(extractAttachmentRefs('[image-1]')).toEqual([
|
|
40
|
+
{ type: 'chip', token: '[image-1]', kind: 'image' },
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('expanded absolute path (reloaded bubble) — path never leaks as text', () => {
|
|
45
|
+
expect(extractAttachmentRefs(`look at ${ABS}`)).toEqual([
|
|
46
|
+
{ type: 'text', text: 'look at ' },
|
|
47
|
+
{ type: 'attachment', name: 'ab12cd34.png', raw: ABS },
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('mixed text, multiple ref kinds, order preserved', () => {
|
|
52
|
+
const segs = extractAttachmentRefs(`fix [image-2] and [file-1] then ${ABS} done`);
|
|
53
|
+
expect(segs).toEqual([
|
|
54
|
+
{ type: 'text', text: 'fix ' },
|
|
55
|
+
{ type: 'chip', token: '[image-2]', kind: 'image' },
|
|
56
|
+
{ type: 'text', text: ' and ' },
|
|
57
|
+
{ type: 'chip', token: '[file-1]', kind: 'file' },
|
|
58
|
+
{ type: 'text', text: ' then ' },
|
|
59
|
+
{ type: 'attachment', name: 'ab12cd34.png', raw: ABS },
|
|
60
|
+
{ type: 'text', text: ' done' },
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('non-attachment paths and near-miss tokens stay plain text', () => {
|
|
65
|
+
expect(extractAttachmentRefs('/Users/x/proj/assets/ab12cd34.png')).toEqual([
|
|
66
|
+
{ type: 'text', text: '/Users/x/proj/assets/ab12cd34.png' },
|
|
67
|
+
]);
|
|
68
|
+
expect(extractAttachmentRefs('[image-x] [screenshot-1]')).toEqual([
|
|
69
|
+
{ type: 'text', text: '[image-x] [screenshot-1]' },
|
|
70
|
+
]);
|
|
71
|
+
expect(extractAttachmentRefs('')).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('chat-attachments / designImageRefs (assistant-mentioned images — DDR-145)', () => {
|
|
76
|
+
test('the /design:screenshot reply shape — backticked relative path', () => {
|
|
77
|
+
expect(
|
|
78
|
+
designImageRefs('Saved to: `.design/_history/.design-ui-smoke/screenshots/001-smoke.png`')
|
|
79
|
+
).toEqual(['/.design/_history/.design-ui-smoke/screenshots/001-smoke.png']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('absolute path is clamped to the designRel-relative URL', () => {
|
|
83
|
+
expect(designImageRefs('see /Users/x/proj/.design/exports/hero.png done')).toEqual([
|
|
84
|
+
'/.design/exports/hero.png',
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('custom designRel + trailing punctuation + dedup + cap', () => {
|
|
89
|
+
expect(designImageRefs('wrote mocks/a.png.', 'mocks')).toEqual(['/mocks/a.png']);
|
|
90
|
+
expect(designImageRefs('x .design/a.png and .design/a.png again')).toEqual(['/.design/a.png']);
|
|
91
|
+
const many = Array.from({ length: 9 }, (_, i) => `.design/s/${i}0000000.png`).join(' ');
|
|
92
|
+
expect(designImageRefs(many)).toHaveLength(6);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('never matches outside designRel, non-images, SVG, or lookalike dirs', () => {
|
|
96
|
+
expect(designImageRefs('site/public/logo.png')).toEqual([]);
|
|
97
|
+
expect(designImageRefs('.design/ui/Canvas.tsx')).toEqual([]);
|
|
98
|
+
expect(designImageRefs('.design/assets/mark.svg')).toEqual([]); // scriptable — excluded
|
|
99
|
+
expect(designImageRefs('not-.design/a.png')).toEqual([]); // prefix must be a path segment
|
|
100
|
+
expect(designImageRefs('', undefined)).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('traversal is rejected before it can escape designRel (DDR-145 A9 fix)', () => {
|
|
104
|
+
// A `..` dot-segment would collapse to /etc/x.png in the browser.
|
|
105
|
+
expect(designImageRefs('.design/../../etc/secret.png')).toEqual([]);
|
|
106
|
+
expect(designImageRefs('.design/../.git/config.png')).toEqual([]);
|
|
107
|
+
// Percent-encoding would decode past <rel>/ server-side — reject any `%`.
|
|
108
|
+
expect(designImageRefs('.design/..%2f..%2fsecrets/cam.png')).toEqual([]);
|
|
109
|
+
expect(designImageRefs('.design/%2e%2e/x.png')).toEqual([]);
|
|
110
|
+
// Backslash: WHATWG rewrites `\`→`/` for http(s), so `..\..\` would climb out
|
|
111
|
+
// AFTER a naive guard — the canonical-form allowlist catches it (attacker F).
|
|
112
|
+
expect(designImageRefs('.design/..\\..\\secret.png')).toEqual([]);
|
|
113
|
+
expect(designImageRefs('.design/a\\..\\..\\x.png')).toEqual([]);
|
|
114
|
+
// A legit path that merely CONTAINS "dotdot"-ish text (not a segment) is fine.
|
|
115
|
+
expect(designImageRefs('.design/a..b/hero.png')).toEqual(['/.design/a..b/hero.png']);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
// `agent_message_chunk` whose text echoes whether ANTHROPIC_API_KEY survived
|
|
6
6
|
// into the child env — it MUST read `<unset>`, proving scrubAgentEnv (DDR-123
|
|
7
7
|
// guardrail #1) stripped it before spawn.
|
|
8
|
+
//
|
|
9
|
+
// session/load simulates the REAL adapter's cross-restart resume (this is a
|
|
10
|
+
// fresh subprocess per test, so it has no memory of a prior session/new call —
|
|
11
|
+
// exactly like the real claude-agent-acp adapter after an app restart, whose
|
|
12
|
+
// resume is backed by the underlying `claude` CLI's own on-disk store, not its
|
|
13
|
+
// own process memory). Any sessionId resolves EXCEPT the `not-found` sentinel
|
|
14
|
+
// (simulates a pruned/unresumable session), so a test controls resume
|
|
15
|
+
// success/failure by choosing which id it persists. It also emits one replay
|
|
16
|
+
// `session/update` BEFORE resolving, mirroring claude-agent-acp's
|
|
17
|
+
// `replaySessionHistory` — bridge.ts must not forward or re-transcript it.
|
|
8
18
|
|
|
9
19
|
import { Readable, Writable } from 'node:stream';
|
|
10
20
|
|
|
@@ -16,7 +26,7 @@ acp
|
|
|
16
26
|
.agent({ name: 'mock-acp-agent' })
|
|
17
27
|
.onRequest('initialize', () => ({
|
|
18
28
|
protocolVersion: acp.PROTOCOL_VERSION,
|
|
19
|
-
agentCapabilities: { loadSession:
|
|
29
|
+
agentCapabilities: { loadSession: true },
|
|
20
30
|
}))
|
|
21
31
|
.onRequest(
|
|
22
32
|
'session/new',
|
|
@@ -25,6 +35,19 @@ acp
|
|
|
25
35
|
return () => ({ sessionId: `mock-session-${++n}` });
|
|
26
36
|
})()
|
|
27
37
|
)
|
|
38
|
+
.onRequest('session/load', async (ctx) => {
|
|
39
|
+
if (ctx.params.sessionId === 'not-found') {
|
|
40
|
+
throw new Error('session not found');
|
|
41
|
+
}
|
|
42
|
+
await ctx.client.notify('session/update', {
|
|
43
|
+
sessionId: ctx.params.sessionId,
|
|
44
|
+
update: {
|
|
45
|
+
sessionUpdate: 'agent_message_chunk',
|
|
46
|
+
content: { type: 'text', text: 'REPLAYED-HISTORY-SHOULD-NOT-SURFACE' },
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
return {};
|
|
50
|
+
})
|
|
28
51
|
.onRequest('session/prompt', async (ctx) => {
|
|
29
52
|
// The per-request handler context exposes the client connection at `.client`.
|
|
30
53
|
await ctx.client.notify('session/update', {
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "acp-chat-cross-restart-resume",
|
|
6
|
+
"version": "0.39.1",
|
|
7
|
+
"date": "2026-07-03",
|
|
8
|
+
"kind": "fix",
|
|
9
|
+
"title": "The Assistant remembers your conversation after a restart",
|
|
10
|
+
"summary": "Killed the app to grab an update, or restarted the dev server mid-chat? The Assistant now picks up the actual conversation where you left off instead of quietly starting fresh while the old messages just sat there.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "acp-chat-image-thumbnails",
|
|
15
|
+
"version": "0.39.0",
|
|
16
|
+
"date": "2026-07-03",
|
|
17
|
+
"kind": "feature",
|
|
18
|
+
"title": "See images right in the chat",
|
|
19
|
+
"summary": "Images now show up as thumbnails in the Assistant panel — paste a screenshot into the box and it appears in your message, and when Claude points to an image it made (like a /design:screenshot capture) that shows up too. Click any thumbnail to open it full-size in a lightbox.",
|
|
20
|
+
"surface": "design-ui"
|
|
21
|
+
},
|
|
4
22
|
{
|
|
5
23
|
"id": "acp-context-hardening",
|
|
6
24
|
"version": "0.38.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.1",
|
|
4
4
|
"description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -50,13 +50,13 @@
|
|
|
50
50
|
"test:e2e:desktop:onboarding": "pnpm --filter @maude/desktop-e2e e2e:onboarding"
|
|
51
51
|
},
|
|
52
52
|
"optionalDependencies": {
|
|
53
|
-
"@1agh/maude-darwin-arm64": "0.
|
|
54
|
-
"@1agh/maude-darwin-x64": "0.
|
|
55
|
-
"@1agh/maude-linux-arm64": "0.
|
|
56
|
-
"@1agh/maude-linux-arm64-musl": "0.
|
|
57
|
-
"@1agh/maude-linux-x64": "0.
|
|
58
|
-
"@1agh/maude-linux-x64-musl": "0.
|
|
59
|
-
"@1agh/maude-win32-x64": "0.
|
|
53
|
+
"@1agh/maude-darwin-arm64": "0.39.1",
|
|
54
|
+
"@1agh/maude-darwin-x64": "0.39.1",
|
|
55
|
+
"@1agh/maude-linux-arm64": "0.39.1",
|
|
56
|
+
"@1agh/maude-linux-arm64-musl": "0.39.1",
|
|
57
|
+
"@1agh/maude-linux-x64": "0.39.1",
|
|
58
|
+
"@1agh/maude-linux-x64-musl": "0.39.1",
|
|
59
|
+
"@1agh/maude-win32-x64": "0.39.1"
|
|
60
60
|
},
|
|
61
61
|
"files": [
|
|
62
62
|
"cli",
|