@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.
@@ -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, so the
662
- // untrusted canvas iframe is 403'd. The disk caps live in api.saveChatAttachment
663
- // (magic-byte sniff / 10 MB / content-addressed name / session write budget).
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
- const ctx = {
77
- paths: { repoRoot: process.cwd(), designRoot: join(process.cwd(), '.design') },
78
- } as unknown as Context;
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
- // POST-only, main-origin only. A valid PNG → 201 with an absolute path under
105
- // the runtime _chat/attachments/; GET is 405; the canvas origin + a
106
- // cross-origin drive-by POST are 403.
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(405); // GET not allowed
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: false },
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.38.1",
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.38.1",
54
- "@1agh/maude-darwin-x64": "0.38.1",
55
- "@1agh/maude-linux-arm64": "0.38.1",
56
- "@1agh/maude-linux-arm64-musl": "0.38.1",
57
- "@1agh/maude-linux-x64": "0.38.1",
58
- "@1agh/maude-linux-x64-musl": "0.38.1",
59
- "@1agh/maude-win32-x64": "0.38.1"
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",