@1agh/maude 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +2 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +93 -0
  3. package/apps/studio/acp/bridge.ts +114 -1
  4. package/apps/studio/acp/index.ts +184 -21
  5. package/apps/studio/acp/plugin-bootstrap.ts +115 -0
  6. package/apps/studio/acp/transcript.ts +36 -3
  7. package/apps/studio/activity.ts +45 -0
  8. package/apps/studio/api.ts +265 -47
  9. package/apps/studio/bin/_ensure-browser.mjs +305 -0
  10. package/apps/studio/bin/ensure-browser.sh +26 -0
  11. package/apps/studio/bin/screenshot.sh +33 -8
  12. package/apps/studio/bin/smoke.sh +46 -0
  13. package/apps/studio/canvas-edit.ts +422 -6
  14. package/apps/studio/canvas-lib.tsx +48 -0
  15. package/apps/studio/canvas-shell.tsx +684 -12
  16. package/apps/studio/client/app.jsx +683 -33
  17. package/apps/studio/client/panels/ChatPanel.jsx +593 -31
  18. package/apps/studio/client/panels/acp-runtime.js +227 -70
  19. package/apps/studio/client/panels/chat-context.js +124 -0
  20. package/apps/studio/client/panels/slash-commands.js +147 -0
  21. package/apps/studio/client/styles/3-shell-maude.css +15 -0
  22. package/apps/studio/client/styles/6-acp-chat.css +244 -0
  23. package/apps/studio/commands/reorder-command.ts +77 -0
  24. package/apps/studio/config.schema.json +6 -0
  25. package/apps/studio/dist/client.bundle.js +25 -53617
  26. package/apps/studio/dist/styles.css +1 -1
  27. package/apps/studio/hmr-broadcast.ts +27 -19
  28. package/apps/studio/http.ts +168 -26
  29. package/apps/studio/inspect.ts +108 -1
  30. package/apps/studio/paths.ts +32 -0
  31. package/apps/studio/readiness.ts +79 -30
  32. package/apps/studio/server.ts +11 -2
  33. package/apps/studio/test/acp-activity.test.ts +154 -0
  34. package/apps/studio/test/acp-ai-activity.test.ts +182 -0
  35. package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
  36. package/apps/studio/test/acp-commands.test.ts +108 -0
  37. package/apps/studio/test/acp-origin-gate.test.ts +64 -1
  38. package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
  39. package/apps/studio/test/acp-session-plugins.test.ts +132 -0
  40. package/apps/studio/test/acp-transcript.test.ts +53 -0
  41. package/apps/studio/test/active-state.test.ts +41 -0
  42. package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
  43. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  44. package/apps/studio/test/canvas-reorder.test.ts +211 -0
  45. package/apps/studio/test/chat-context.test.ts +129 -0
  46. package/apps/studio/test/csrf-write-guard.test.ts +24 -0
  47. package/apps/studio/test/edit-suppress.test.ts +170 -0
  48. package/apps/studio/test/ensure-browser.test.ts +92 -0
  49. package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
  50. package/apps/studio/test/hmr-broadcast.test.ts +44 -0
  51. package/apps/studio/test/inspect-selections.test.ts +219 -0
  52. package/apps/studio/test/paths.test.ts +57 -0
  53. package/apps/studio/test/readiness.test.ts +83 -13
  54. package/apps/studio/test/reorder-api.test.ts +210 -0
  55. package/apps/studio/test/slash-commands.test.ts +117 -0
  56. package/apps/studio/undo-stack.ts +6 -0
  57. package/apps/studio/whats-new.json +45 -0
  58. package/apps/studio/ws.ts +37 -0
  59. package/cli/commands/design.mjs +1 -0
  60. package/package.json +8 -8
  61. package/plugins/design/dependencies.json +3 -3
@@ -0,0 +1,92 @@
1
+ // Screenshot-browser resolver (bundled-screenshots feature). Covers the pure
2
+ // resolution priority; the actual download (priority 5) is exercised only when no
3
+ // browser exists on the host — untestable in CI where system Chrome / a Playwright
4
+ // headless-shell are usually present, so we assert `download:false` never fetches.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ import { CFT_VERSION_RE, isTrustedDownloadUrl, resolveBrowser } from '../bin/_ensure-browser.mjs';
12
+
13
+ function withEnv<T>(patch: Record<string, string | undefined>, fn: () => T): T {
14
+ const saved: Record<string, string | undefined> = {};
15
+ for (const k of Object.keys(patch)) {
16
+ saved[k] = process.env[k];
17
+ if (patch[k] === undefined) delete process.env[k];
18
+ else process.env[k] = patch[k];
19
+ }
20
+ try {
21
+ return fn();
22
+ } finally {
23
+ for (const k of Object.keys(patch)) {
24
+ if (saved[k] === undefined) delete process.env[k];
25
+ else process.env[k] = saved[k];
26
+ }
27
+ }
28
+ }
29
+
30
+ describe('resolveBrowser — resolution priority', () => {
31
+ test('AGENT_BROWSER_EXECUTABLE_PATH override wins', async () => {
32
+ const r = await withEnv({ AGENT_BROWSER_EXECUTABLE_PATH: import.meta.path }, () =>
33
+ resolveBrowser({ download: false })
34
+ );
35
+ expect(r.source).toBe('override');
36
+ expect(r.path).toBe(import.meta.path);
37
+ });
38
+
39
+ test('a cached chrome-headless-shell in MAUDE_BROWSERS_DIR resolves before system Chrome', async () => {
40
+ const dir = mkdtempSync(join(tmpdir(), 'maude-browsers-'));
41
+ const sub = join(dir, 'chrome-headless-shell-1.2.3-mac-arm64');
42
+ mkdirSync(sub, { recursive: true });
43
+ const exeName =
44
+ process.platform === 'win32' ? 'chrome-headless-shell.exe' : 'chrome-headless-shell';
45
+ const exe = join(sub, exeName);
46
+ writeFileSync(exe, '#!/bin/sh\n');
47
+ try {
48
+ const r = await withEnv(
49
+ {
50
+ MAUDE_BROWSERS_DIR: dir,
51
+ AGENT_BROWSER_EXECUTABLE_PATH: undefined,
52
+ MAUDE_BROWSER_EXECUTABLE: undefined,
53
+ },
54
+ () => resolveBrowser({ download: false })
55
+ );
56
+ expect(r.source).toBe('cache');
57
+ expect(r.path).toBe(exe);
58
+ } finally {
59
+ rmSync(dir, { recursive: true, force: true });
60
+ }
61
+ });
62
+
63
+ test('download:false never fetches (source is never "downloaded")', async () => {
64
+ const r = await resolveBrowser({ download: false });
65
+ expect(r.source).not.toBe('downloaded');
66
+ });
67
+ });
68
+
69
+ describe('download hardening (DDR-144 security fixes)', () => {
70
+ test('isTrustedDownloadUrl accepts only https on the allowlisted CDN hosts (F2)', () => {
71
+ expect(
72
+ isTrustedDownloadUrl('https://storage.googleapis.com/chrome-for-testing-public/x.zip')
73
+ ).toBe(true);
74
+ expect(isTrustedDownloadUrl('https://googlechromelabs.github.io/x.json')).toBe(true);
75
+ // plaintext scheme (MITM-able) rejected
76
+ expect(isTrustedDownloadUrl('http://storage.googleapis.com/x.zip')).toBe(false);
77
+ // foreign host (manifest tampering / redirect) rejected
78
+ expect(isTrustedDownloadUrl('https://evil.example.com/x.zip')).toBe(false);
79
+ // garbage rejected, no throw
80
+ expect(isTrustedDownloadUrl('not a url')).toBe(false);
81
+ expect(isTrustedDownloadUrl('')).toBe(false);
82
+ });
83
+
84
+ test('CFT_VERSION_RE accepts real versions, rejects shell-injection payloads (F3)', () => {
85
+ expect(CFT_VERSION_RE.test('150.0.7871.46')).toBe(true);
86
+ expect(CFT_VERSION_RE.test('120')).toBe(true);
87
+ // the PowerShell -Command break-out attempt
88
+ expect(CFT_VERSION_RE.test("1.0'; Remove-Item C:\\ -Recurse #")).toBe(false);
89
+ expect(CFT_VERSION_RE.test('$(rm -rf /)')).toBe(false);
90
+ expect(CFT_VERSION_RE.test('../../etc')).toBe(false);
91
+ });
92
+ });
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bun
2
+ // Mock ACP agent variant that publishes an `available_commands_update` the moment
3
+ // a session is created (before the prompt) — stands in for Claude Code's command
4
+ // catalogue so acp-commands.test.ts can exercise the warm-up + broadcast path
5
+ // WITHOUT a real `claude`. Also answers a prompt so prompt-based paths still work.
6
+
7
+ import { Readable, Writable } from 'node:stream';
8
+
9
+ import * as acp from '@agentclientprotocol/sdk';
10
+
11
+ const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
12
+
13
+ let n = 0;
14
+
15
+ acp
16
+ .agent({ name: 'mock-acp-agent-commands' })
17
+ .onRequest('initialize', () => ({
18
+ protocolVersion: acp.PROTOCOL_VERSION,
19
+ agentCapabilities: { loadSession: false },
20
+ }))
21
+ .onRequest('session/new', async (ctx) => {
22
+ const sessionId = `mock-session-${++n}`;
23
+ // Publish the command catalogue on session creation — the warm-up path relies
24
+ // on this arriving without a prompt.
25
+ await ctx.client.notify('session/update', {
26
+ sessionId,
27
+ update: {
28
+ sessionUpdate: 'available_commands_update',
29
+ availableCommands: [
30
+ { name: 'design:edit', description: 'Iterate on the active canvas' },
31
+ { name: 'flow:plan', description: 'Create a plan' },
32
+ ],
33
+ },
34
+ });
35
+ return { sessionId };
36
+ })
37
+ .onRequest('session/prompt', async (ctx) => {
38
+ await ctx.client.notify('session/update', {
39
+ sessionId: ctx.params.sessionId,
40
+ update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'ok' } },
41
+ });
42
+ return { stopReason: 'end_turn' };
43
+ })
44
+ .connect(stream);
@@ -95,6 +95,50 @@ describe('hmr-broadcast / debouncing', () => {
95
95
  });
96
96
  });
97
97
 
98
+ describe('hmr-broadcast / multi-file bursts (RC4)', () => {
99
+ test('a burst touching two canvases broadcasts one message PER file', async () => {
100
+ // The old single-slot pendingMsg kept only the LAST file of a <50ms burst —
101
+ // the other open canvas never got its module reload and sat stale until a
102
+ // manual hard refresh (rca/issue-canvas-hmr-optimistic-update-consistency).
103
+ const ctx = mkCtx();
104
+ const got: HmrMessage[] = [];
105
+ const h = createHmrBroadcaster(ctx, (m) => got.push(m));
106
+ ctx.bus.emit('fs:any', 'ui/A.tsx');
107
+ ctx.bus.emit('fs:any', 'ui/B.tsx');
108
+ await awaitNextFlush();
109
+ expect(got).toHaveLength(2);
110
+ expect(new Set(got.map((m) => m.file))).toEqual(new Set(['ui/A.tsx', 'ui/B.tsx']));
111
+ for (const m of got) expect(m.mode).toBe('module');
112
+ h.stop();
113
+ });
114
+
115
+ test('a pending hard supersedes the whole per-file queue', async () => {
116
+ const ctx = mkCtx();
117
+ const got: HmrMessage[] = [];
118
+ const h = createHmrBroadcaster(ctx, (m) => got.push(m));
119
+ ctx.bus.emit('fs:any', 'ui/A.tsx');
120
+ ctx.bus.emit('fs:any', 'ui/B.tsx');
121
+ ctx.bus.emit('fs:any', '_lib/canvas-lib.tsx');
122
+ await awaitNextFlush();
123
+ expect(got).toHaveLength(1);
124
+ expect(got[0]?.mode).toBe('hard');
125
+ h.stop();
126
+ });
127
+
128
+ test('same-file meta echo never downgrades a queued module reload', async () => {
129
+ const ctx = mkCtx();
130
+ const got: HmrMessage[] = [];
131
+ const h = createHmrBroadcaster(ctx, (m) => got.push(m));
132
+ ctx.bus.emit('fs:any', 'ui/A.tsx');
133
+ ctx.bus.emit('fs:any', 'ui/A.meta.json');
134
+ await awaitNextFlush();
135
+ const forA = got.filter((m) => m.file === 'ui/A.tsx');
136
+ expect(forA).toHaveLength(1);
137
+ expect(forA[0]?.mode).toBe('module');
138
+ h.stop();
139
+ });
140
+ });
141
+
98
142
  describe('hmr-broadcast / stop', () => {
99
143
  test('stop() prevents further broadcasts', async () => {
100
144
  const ctx = mkCtx();
@@ -0,0 +1,219 @@
1
+ // feature-acp-context-hardening — per-canvas selection memory (`selections`
2
+ // map) with the `selected` mirror invariant, restore-on-switch, the mtime
3
+ // drift gate, html size cap on parked entries, and open_tabs GC.
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { join } from 'node:path';
7
+
8
+ import { type Context, createBus } from '../context.ts';
9
+ import { createInspect, type SelectedElement } from '../inspect.ts';
10
+ import { makeSandbox } from './_helpers.ts';
11
+
12
+ function mkCtx(root: string, designRoot: string): Context {
13
+ return {
14
+ cfg: {} as Context['cfg'],
15
+ projectLabel: 'test',
16
+ bus: createBus(),
17
+ paths: {
18
+ repoRoot: root,
19
+ designRel: '.design',
20
+ designRoot,
21
+ serverInfoFile: join(designRoot, '_server.json'),
22
+ activeFile: join(designRoot, '_active.json'),
23
+ commentsDir: join(designRoot, '_comments'),
24
+ canvasStateDir: join(designRoot, '_canvas-state'),
25
+ historyDir: join(designRoot, '_history'),
26
+ tokensUrlRel: '',
27
+ systemDirRel: 'system',
28
+ },
29
+ };
30
+ }
31
+
32
+ const A = '.design/ui/fixture.html'; // exists via makeSandbox
33
+ const B = '.design/ui/second.tsx';
34
+
35
+ function selFor(file: string, text = 'hello') {
36
+ return {
37
+ file,
38
+ selector: 'h1',
39
+ tag: 'h1',
40
+ classes: '',
41
+ text,
42
+ dom_path: ['html', 'body', 'h1'],
43
+ bounds: { x: 0, y: 0, w: 100, h: 24 },
44
+ html: `<h1>${text}</h1>`,
45
+ };
46
+ }
47
+
48
+ async function mkRig() {
49
+ const { root, designRoot } = makeSandbox();
50
+ await Bun.write(join(root, B), 'export default function Second() { return <h1>B</h1>; }');
51
+ const ctx = mkCtx(root, designRoot);
52
+ const inspect = createInspect(ctx, async () => []);
53
+ return { root, designRoot, ctx, inspect };
54
+ }
55
+
56
+ const asOne = (v: unknown): SelectedElement => v as SelectedElement;
57
+
58
+ describe('per-canvas selections — restore on switch', () => {
59
+ test('selection survives a canvas switch and comes back on return', async () => {
60
+ const { inspect } = await mkRig();
61
+ inspect.setActive(A);
62
+ inspect.setSelected(selFor(A));
63
+ expect(asOne(inspect.state.selected).selector).toBe('h1');
64
+
65
+ inspect.setActive(B);
66
+ expect(inspect.state.selected).toBeNull(); // B has no memory
67
+
68
+ inspect.setActive(A);
69
+ const restored = asOne(inspect.state.selected);
70
+ expect(restored).not.toBeNull();
71
+ expect(restored.selector).toBe('h1');
72
+ expect(restored.file).toBe(A); // SEL_VALID (`selected.file === active`) holds
73
+ });
74
+
75
+ test('parked (non-active) entries carry html: "" — size cap', async () => {
76
+ const { inspect } = await mkRig();
77
+ inspect.setActive(A);
78
+ inspect.setSelected(selFor(A));
79
+ // Active canvas's write-through keeps the rich copy…
80
+ expect(asOne(inspect.state.selections['ui/fixture']).html).toContain('<h1>');
81
+ inspect.setActive(B);
82
+ // …but parking on switch-away strips it.
83
+ expect(asOne(inspect.state.selections['ui/fixture']).html).toBe('');
84
+ inspect.setActive(A);
85
+ expect(asOne(inspect.state.selected).html).toBe('');
86
+ });
87
+
88
+ test('mirror invariant: selected === selections[activeSlug] after select', async () => {
89
+ const { inspect } = await mkRig();
90
+ inspect.setActive(A);
91
+ inspect.setSelected(selFor(A));
92
+ expect(inspect.state.selections['ui/fixture']).toBe(inspect.state.selected);
93
+ });
94
+
95
+ test('a cross-canvas plant (selection.file != active) is NOT stored (attacker F2 residual)', async () => {
96
+ // An active untrusted canvas posts a selection claiming a DIFFERENT canvas's
97
+ // file — it must not land in that canvas's slot for later agent delivery.
98
+ const { inspect } = await mkRig();
99
+ inspect.setActive(A);
100
+ inspect.setSelected(selFor(B, 'planted')); // A is active, payload claims B
101
+ expect(inspect.state.selections['ui/second']).toBeUndefined();
102
+ expect(inspect.state.selections['ui/fixture']).toBeUndefined();
103
+ // Later opening B must not resurrect the plant.
104
+ inspect.setActive(B);
105
+ expect(inspect.state.selected).toBeNull();
106
+ });
107
+
108
+ test('single-entry array still collapses to a bare object (Phase 4.1 contract)', async () => {
109
+ const { inspect } = await mkRig();
110
+ inspect.setActive(A);
111
+ inspect.setSelected([selFor(A)]);
112
+ expect(Array.isArray(inspect.state.selected)).toBe(false);
113
+ expect(asOne(inspect.state.selected).tag).toBe('h1');
114
+ });
115
+ });
116
+
117
+ describe('per-canvas selections — drift gate', () => {
118
+ test('canvas edited while parked → restored selection is stale', async () => {
119
+ const { root, inspect } = await mkRig();
120
+ inspect.setActive(A);
121
+ inspect.setSelected(selFor(A));
122
+ const stamped = asOne(inspect.state.selected).canvas_mtime;
123
+ expect(stamped).toBeGreaterThan(0);
124
+
125
+ inspect.setActive(B);
126
+ // Another writer edits canvas A while it's parked (ensure mtime moves).
127
+ await Bun.sleep(5);
128
+ await Bun.write(join(root, A), '<!doctype html><html><body><h1>changed</h1></body></html>');
129
+
130
+ inspect.setActive(A);
131
+ expect(asOne(inspect.state.selected).stale).toBe(true);
132
+ });
133
+
134
+ test('untouched canvas restores without the stale flag', async () => {
135
+ const { inspect } = await mkRig();
136
+ inspect.setActive(A);
137
+ inspect.setSelected(selFor(A));
138
+ inspect.setActive(B);
139
+ inspect.setActive(A);
140
+ expect(asOne(inspect.state.selected).stale).toBeUndefined();
141
+ });
142
+ });
143
+
144
+ describe('per-canvas selections — lifecycle', () => {
145
+ test('explicit deselect clears the active canvas memory', async () => {
146
+ const { inspect } = await mkRig();
147
+ inspect.setActive(A);
148
+ inspect.setSelected(selFor(A));
149
+ inspect.setSelected(null);
150
+ expect(inspect.state.selections['ui/fixture']).toBeUndefined();
151
+ inspect.setActive(B);
152
+ inspect.setActive(A);
153
+ expect(inspect.state.selected).toBeNull();
154
+ });
155
+
156
+ test('single-canvas shell ordering (tabs BEFORE active) never GCs the outgoing canvas', async () => {
157
+ // The shell replaces the tab and sends `tabs` with ONLY the incoming canvas
158
+ // before `active` parks the outgoing one — the exact wire order of app.jsx
159
+ // openTab effects. The outgoing canvas's memory must survive the round trip.
160
+ const { inspect } = await mkRig();
161
+ inspect.setActive(A);
162
+ inspect.setOpenTabs([A]);
163
+ inspect.setSelected(selFor(A));
164
+
165
+ inspect.setOpenTabs([B]); // switch A→B: tabs first…
166
+ inspect.setActive(B); // …then active (parks A)
167
+ expect(inspect.state.selections['ui/fixture']).toBeDefined();
168
+
169
+ inspect.setOpenTabs([A]); // switch back B→A: tabs first…
170
+ inspect.setActive(A); // …then active (restores A)
171
+ expect(asOne(inspect.state.selected).selector).toBe('h1');
172
+ expect(asOne(inspect.state.selected).file).toBe(A);
173
+ });
174
+
175
+ test('closing a tab GCs its parked selection', async () => {
176
+ const { inspect } = await mkRig();
177
+ inspect.setActive(A);
178
+ inspect.setSelected(selFor(A));
179
+ inspect.setActive(B); // parks A
180
+ inspect.setOpenTabs([B]); // A closed
181
+ expect(inspect.state.selections['ui/fixture']).toBeUndefined();
182
+ expect(Object.keys(inspect.state.selections)).toHaveLength(0);
183
+ });
184
+
185
+ test('save/load round-trips selections; legacy file without them loads as {}', async () => {
186
+ const { designRoot, ctx, inspect } = await mkRig();
187
+ inspect.setActive(A);
188
+ inspect.setSelected(selFor(A));
189
+ await inspect.save();
190
+
191
+ const reloaded = createInspect(ctx, async () => []);
192
+ await reloaded.load();
193
+ expect(asOne(reloaded.state.selections['ui/fixture']).selector).toBe('h1');
194
+
195
+ // Legacy shape (pre-selections) → invariant restored on load.
196
+ await Bun.write(
197
+ join(designRoot, '_active.json'),
198
+ JSON.stringify({ active: A, open_tabs: [A], selected: null, last_change: null })
199
+ );
200
+ const legacy = createInspect(ctx, async () => []);
201
+ await legacy.load();
202
+ expect(legacy.state.selections).toEqual({});
203
+ });
204
+
205
+ test('switch emits the restored selection on the bus (clients stay in sync)', async () => {
206
+ const { ctx, inspect } = await mkRig();
207
+ const emitted: unknown[] = [];
208
+ ctx.bus.on('selected', (s: unknown) => emitted.push(s));
209
+ inspect.setActive(A);
210
+ inspect.setSelected(selFor(A));
211
+ inspect.setActive(B);
212
+ inspect.setActive(A);
213
+ // emits: initial activate (null), select(A), switch→B (null), switch→A (restored)
214
+ expect(emitted).toHaveLength(4);
215
+ expect(emitted[0]).toBeNull();
216
+ expect(emitted[2]).toBeNull();
217
+ expect(asOne(emitted[3]).selector).toBe('h1');
218
+ });
219
+ });
@@ -2,12 +2,18 @@
2
2
  // install, and compiled marketplace install. Phase 19.1 / v0.18.1.
3
3
 
4
4
  import { describe, expect, test } from 'bun:test';
5
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
5
8
 
6
9
  import {
7
10
  CLIENT_DIR,
11
+ DESIGN_PLUGIN_DIR,
8
12
  DEV_SERVER_ROOT,
9
13
  DIST_DIR,
14
+ FLOW_PLUGIN_DIR,
10
15
  IS_COMPILED_BINARY,
16
+ pluginDirFrom,
11
17
  RUNTIME_BUNDLES_DIR,
12
18
  } from '../paths.ts';
13
19
 
@@ -29,3 +35,54 @@ describe('paths.ts', () => {
29
35
  expect(IS_COMPILED_BINARY).toBe(false);
30
36
  });
31
37
  });
38
+
39
+ describe('pluginDirFrom — session-scoped plugin auto-bootstrap resolution (DDR-143)', () => {
40
+ // Both simulated layouts keep `plugins/` a sibling of `apps/studio/`, matching
41
+ // the dev tree and the desktop Resources bundle (stage-resources.mjs).
42
+ function makeLayout(shape: 'desktop-bundle' | 'npm'): { root: string; devServerRoot: string } {
43
+ const root = mkdtempSync(join(tmpdir(), 'maude-paths-'));
44
+ const devServerRoot = join(root, 'apps', 'studio');
45
+ mkdirSync(devServerRoot, { recursive: true });
46
+ for (const plugin of ['design', 'flow'] as const) {
47
+ const pluginRoot = join(root, 'plugins', plugin);
48
+ // Both layouts ship templates (DDR-044).
49
+ mkdirSync(join(pluginRoot, 'templates'), { recursive: true });
50
+ if (shape === 'desktop-bundle') {
51
+ // The desktop bundle stages the full loadable tree incl. the manifest.
52
+ mkdirSync(join(pluginRoot, '.claude-plugin'), { recursive: true });
53
+ mkdirSync(join(pluginRoot, 'commands'), { recursive: true });
54
+ writeFileSync(join(pluginRoot, '.claude-plugin', 'plugin.json'), '{"name":"x"}');
55
+ }
56
+ // npm layout: NO .claude-plugin/plugin.json (DDR-044 minimal surface).
57
+ }
58
+ return { root, devServerRoot };
59
+ }
60
+
61
+ test('desktop Resources layout → resolves both plugin dirs (manifest present)', () => {
62
+ const { root, devServerRoot } = makeLayout('desktop-bundle');
63
+ try {
64
+ expect(pluginDirFrom(devServerRoot, 'design')).toBe(join(root, 'plugins', 'design'));
65
+ expect(pluginDirFrom(devServerRoot, 'flow')).toBe(join(root, 'plugins', 'flow'));
66
+ } finally {
67
+ rmSync(root, { recursive: true, force: true });
68
+ }
69
+ });
70
+
71
+ test('npm/web layout → null (templates ship, but no plugin manifest)', () => {
72
+ const { root, devServerRoot } = makeLayout('npm');
73
+ try {
74
+ expect(pluginDirFrom(devServerRoot, 'design')).toBeNull();
75
+ expect(pluginDirFrom(devServerRoot, 'flow')).toBeNull();
76
+ } finally {
77
+ rmSync(root, { recursive: true, force: true });
78
+ }
79
+ });
80
+
81
+ test('exported constants resolve under the running dev tree (manifest present here)', () => {
82
+ // This suite runs from the repo dev tree, where plugins/{design,flow}/ carry
83
+ // their manifests — so the module-load-time constants are non-null and point
84
+ // at the real plugin roots.
85
+ expect(DESIGN_PLUGIN_DIR).toBe(join(DEV_SERVER_ROOT, '..', '..', 'plugins', 'design'));
86
+ expect(FLOW_PLUGIN_DIR).toBe(join(DEV_SERVER_ROOT, '..', '..', 'plugins', 'flow'));
87
+ });
88
+ });
@@ -22,6 +22,9 @@ const INSTALLED_BOTH = {
22
22
  },
23
23
  };
24
24
  const INSTALLED_DESIGN_ONLY = { version: 2, plugins: { 'design@maude': [{ scope: 'user' }] } };
25
+ // `/flow` is no longer part of the chat (2026-07-03) — the gate tracks `design`
26
+ // alone, so a registry with flow-but-not-design is the "design missing" case.
27
+ const INSTALLED_FLOW_ONLY = { version: 2, plugins: { 'flow@maude': [{ scope: 'user' }] } };
25
28
 
26
29
  function fixtureClaudeDir(opts: { markets?: unknown; installed?: unknown }): string {
27
30
  const dir = mkdtempSync(join(tmpdir(), 'maude-readiness-'));
@@ -45,43 +48,95 @@ async function withClaudeDir<T>(dir: string, fn: () => Promise<T> | T): Promise<
45
48
  }
46
49
  }
47
50
 
48
- const pluginsItem = async (dir: string) =>
49
- (await withClaudeDir(dir, () => probeReadiness())).items.find((i) => i.id === 'plugins')!;
51
+ /** Plugins row under the given ~/.claude fixture. `nativeBootstrap:false` sets
52
+ * MAUDE_NO_PLUGIN_BOOTSTRAP to force the web / manual (DDR-128) path needed to
53
+ * isolate the registry scan, since this suite runs in the dev tree where the
54
+ * bundled plugin dirs resolve and DDR-143 auto-load would otherwise mask it. */
55
+ const pluginsItem = async (dir: string, opts: { nativeBootstrap?: boolean } = {}) => {
56
+ const savedOptOut = process.env.MAUDE_NO_PLUGIN_BOOTSTRAP;
57
+ if (opts.nativeBootstrap === false) process.env.MAUDE_NO_PLUGIN_BOOTSTRAP = '1';
58
+ try {
59
+ return (await withClaudeDir(dir, () => probeReadiness())).items.find(
60
+ (i) => i.id === 'plugins'
61
+ )!;
62
+ } finally {
63
+ if (savedOptOut === undefined) delete process.env.MAUDE_NO_PLUGIN_BOOTSTRAP;
64
+ else process.env.MAUDE_NO_PLUGIN_BOOTSTRAP = savedOptOut;
65
+ }
66
+ };
50
67
 
51
- describe('readiness plugin registry scan', () => {
52
- test('both plugins + marketplace present → plugins item is present, no remediation', async () => {
68
+ // Registry-scan branches under the WEB / manual path (auto-bootstrap opted out)
69
+ // the pure DDR-128 detect-and-guide behavior, still what a `maude design serve`
70
+ // user sees.
71
+ describe('readiness — plugin registry scan (web / manual path)', () => {
72
+ test('design@maude + marketplace present → plugins item is present, no remediation', async () => {
53
73
  const item = await pluginsItem(
54
- fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_BOTH })
74
+ fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_DESIGN_ONLY }),
75
+ { nativeBootstrap: false }
55
76
  );
56
77
  expect(item.status).toBe('present');
57
78
  expect(item.remediation).toBeUndefined();
58
79
  });
59
80
 
60
- test('only design@maude installed → missing, names the absent flow@maude + offers the fix', async () => {
81
+ test('design@maude NOT installed → missing, names the absent design@maude + offers the fix', async () => {
82
+ // flow-only registry: `/flow` is irrelevant to the chat now, so this reads as
83
+ // "design missing" — the gate tracks design alone.
61
84
  const item = await pluginsItem(
62
- fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_DESIGN_ONLY })
85
+ fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_FLOW_ONLY }),
86
+ { nativeBootstrap: false }
63
87
  );
64
88
  expect(item.status).toBe('missing');
65
- expect(item.detail).toContain('flow@maude');
89
+ expect(item.detail).toContain('design@maude');
66
90
  expect(item.remediation).toContain('/plugin install');
67
91
  });
68
92
 
69
93
  test('registry absent → unknown (Claude Code internal contract; never throws)', async () => {
70
- const item = await pluginsItem(fixtureClaudeDir({}));
94
+ const item = await pluginsItem(fixtureClaudeDir({}), { nativeBootstrap: false });
71
95
  expect(item.status).toBe('unknown');
72
96
  });
73
97
 
74
- test('plugin presence is the gate — a foreign-repo marketplace does not change a both-installed verdict', async () => {
98
+ test('plugin presence is the gate — a foreign-repo marketplace does not change a design-installed verdict', async () => {
75
99
  const item = await pluginsItem(
76
100
  fixtureClaudeDir({
77
101
  markets: { other: { source: { repo: 'someone/else' } } },
78
102
  installed: INSTALLED_BOTH,
79
- })
103
+ }),
104
+ { nativeBootstrap: false }
80
105
  );
81
106
  expect(item.status).toBe('present');
82
107
  });
83
108
  });
84
109
 
110
+ // DDR-143 — on the native/desktop path the ACP session auto-loads the bundled
111
+ // plugins, so the row is satisfied even against a pristine registry. This suite
112
+ // runs in the dev tree, so the bundled plugin dirs resolve → native context is
113
+ // on by default (no opt-out).
114
+ describe('readiness — plugin auto-bootstrap (native, DDR-143)', () => {
115
+ test('pristine registry → present + "Auto-loaded" detail, no manual remediation', async () => {
116
+ const item = await pluginsItem(fixtureClaudeDir({}));
117
+ expect(item.status).toBe('present');
118
+ expect(item.detail).toContain('Auto-loaded');
119
+ expect(item.remediation).toBeUndefined();
120
+ });
121
+
122
+ test('design NOT installed (flow-only registry) → still present (design is auto-loaded, no red wall)', async () => {
123
+ const item = await pluginsItem(
124
+ fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_FLOW_ONLY })
125
+ );
126
+ expect(item.status).toBe('present');
127
+ expect(item.detail).toContain('Auto-loaded');
128
+ expect(item.remediation).toBeUndefined();
129
+ });
130
+
131
+ test('design already installed → present, keeps the "is installed" detail (no auto-load framing)', async () => {
132
+ const item = await pluginsItem(
133
+ fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_DESIGN_ONLY })
134
+ );
135
+ expect(item.status).toBe('present');
136
+ expect(item.detail).toContain('installed');
137
+ });
138
+ });
139
+
85
140
  describe('readiness — resolveOnPath', () => {
86
141
  test('finds a binary that is on PATH', async () => {
87
142
  const hit = await resolveOnPath('sh');
@@ -126,9 +181,24 @@ describe('readiness — report shape', () => {
126
181
  expect(typeof report.ready).toBe('boolean');
127
182
  });
128
183
 
129
- test('agent-browser is optional and never gates ready', async () => {
184
+ test('screenshot engine: required + present (bundled) on native → never blocks ready', async () => {
185
+ // Native (this dev tree): agent-browser is bundled, so the row is first-class
186
+ // (required) but PRESENT — it can't turn `ready` false.
130
187
  const ab = (await probeReadiness()).items.find((i) => i.id === 'agent-browser')!;
131
- expect(ab.required).toBe(false);
188
+ expect(ab.required).toBe(true);
189
+ expect(ab.status).toBe('present');
190
+ });
191
+
192
+ test('screenshot engine: optional on the web/opt-out path', async () => {
193
+ const saved = process.env.MAUDE_NO_PLUGIN_BOOTSTRAP;
194
+ process.env.MAUDE_NO_PLUGIN_BOOTSTRAP = '1'; // force native=false
195
+ try {
196
+ const ab = (await probeReadiness()).items.find((i) => i.id === 'agent-browser')!;
197
+ expect(ab.required).toBe(false);
198
+ } finally {
199
+ if (saved === undefined) delete process.env.MAUDE_NO_PLUGIN_BOOTSTRAP;
200
+ else process.env.MAUDE_NO_PLUGIN_BOOTSTRAP = saved;
201
+ }
132
202
  });
133
203
 
134
204
  test('adapter row tracks the real chat gate (resolveAdapterEntry) and is required', async () => {