@1agh/maude 0.45.0 → 0.45.2

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 (47) hide show
  1. package/apps/studio/acp/bridge.ts +585 -73
  2. package/apps/studio/acp/index.ts +172 -23
  3. package/apps/studio/acp/probe.ts +31 -7
  4. package/apps/studio/acp/transcript.ts +91 -5
  5. package/apps/studio/bin/_import-asset.mjs +35 -5
  6. package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
  7. package/apps/studio/client/panels/ChatPanel.jsx +802 -133
  8. package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
  9. package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
  10. package/apps/studio/client/panels/ReadinessList.jsx +17 -3
  11. package/apps/studio/client/panels/ToolGroup.jsx +79 -0
  12. package/apps/studio/client/panels/acp-capabilities.js +71 -0
  13. package/apps/studio/client/panels/acp-elicitation.js +194 -0
  14. package/apps/studio/client/panels/acp-runtime.js +248 -9
  15. package/apps/studio/client/panels/acp-usage.js +65 -0
  16. package/apps/studio/client/panels/transcript-view.js +36 -0
  17. package/apps/studio/client/styles/6-acp-chat.css +648 -11
  18. package/apps/studio/dist/client.bundle.js +1596 -1594
  19. package/apps/studio/dist/styles.css +1 -1
  20. package/apps/studio/generation/whisper-models.test.ts +28 -1
  21. package/apps/studio/generation/whisper-models.ts +22 -7
  22. package/apps/studio/http.ts +33 -2
  23. package/apps/studio/test/acp-bridge.test.ts +11 -6
  24. package/apps/studio/test/acp-capabilities.test.ts +123 -0
  25. package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
  26. package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
  27. package/apps/studio/test/acp-elicitation.test.ts +251 -0
  28. package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
  29. package/apps/studio/test/acp-permission.test.ts +262 -0
  30. package/apps/studio/test/acp-toolgroup.test.ts +76 -0
  31. package/apps/studio/test/acp-transcript-view.test.ts +71 -0
  32. package/apps/studio/test/acp-transcript.test.ts +75 -2
  33. package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
  34. package/apps/studio/test/acp-usage.test.ts +143 -0
  35. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
  36. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
  37. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
  38. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
  39. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
  40. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
  41. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
  42. package/apps/studio/test/import-asset.test.ts +31 -3
  43. package/apps/studio/whats-new.json +34 -0
  44. package/cli/commands/design.mjs +107 -2
  45. package/cli/lib/pkg-root.mjs +42 -10
  46. package/cli/lib/pkg-root.test.mjs +33 -1
  47. package/package.json +11 -9
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bun
2
+ // Mock ACP agent that sends a `mode:'url'` elicitation request — proves
3
+ // AcpBridge.unstable_createElicitation rejects it structurally (declines
4
+ // without ever calling onElicitationRequest) even though the adapter/agent
5
+ // side ignored the client's declared `elicitation:{form:{}}`-only capability
6
+ // (ethical-hacker finding — capability negotiation is advisory, not
7
+ // enforced; the client must reject what it never advertised, not just hope
8
+ // the other side honors it).
9
+
10
+ import { Readable, Writable } from 'node:stream';
11
+
12
+ import * as acp from '@agentclientprotocol/sdk';
13
+
14
+ const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
15
+
16
+ let n = 0;
17
+
18
+ acp
19
+ .agent({ name: 'mock-acp-agent-elicit-url' })
20
+ .onRequest('initialize', () => ({
21
+ protocolVersion: acp.PROTOCOL_VERSION,
22
+ agentCapabilities: { loadSession: false },
23
+ }))
24
+ .onRequest('session/new', () => ({ sessionId: `mock-elicit-url-session-${++n}` }))
25
+ .onRequest('session/prompt', async (ctx) => {
26
+ const response = await ctx.client.request(acp.methods.client.elicitation.create, {
27
+ mode: 'url',
28
+ sessionId: ctx.params.sessionId,
29
+ message: 'Confirm you have completed verification',
30
+ url: 'https://example.com/verify',
31
+ elicitationId: 'elicit-url-1',
32
+ });
33
+ await ctx.client.notify('session/update', {
34
+ sessionId: ctx.params.sessionId,
35
+ update: {
36
+ sessionUpdate: 'agent_message_chunk',
37
+ content: { type: 'text', text: `response=${JSON.stringify(response)}` },
38
+ },
39
+ });
40
+ return { stopReason: 'end_turn' };
41
+ })
42
+ .connect(stream);
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bun
2
+ // Mock ACP agent exercising the elicitation-form gate (feature-acp-ask-user-
3
+ // question) — session/prompt calls back INTO the client via a REAL
4
+ // `elicitation/create` request, shaped exactly like
5
+ // `askUserQuestionsToCreateRequest`'s single-question output (confirmed on
6
+ // disk against the installed `@agentclientprotocol/claude-agent-acp` — a
7
+ // titled `oneOf` enum plus its sibling `_custom` free-text field), before
8
+ // streaming its final reply. Mirrors mock-acp-agent-permission.mjs's shape.
9
+ // No real `claude` needed.
10
+
11
+ import { Readable, Writable } from 'node:stream';
12
+
13
+ import * as acp from '@agentclientprotocol/sdk';
14
+
15
+ const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
16
+
17
+ let n = 0;
18
+
19
+ acp
20
+ .agent({ name: 'mock-acp-agent-elicit' })
21
+ .onRequest('initialize', () => ({
22
+ protocolVersion: acp.PROTOCOL_VERSION,
23
+ agentCapabilities: { loadSession: false },
24
+ }))
25
+ .onRequest('session/new', () => ({ sessionId: `mock-elicit-session-${++n}` }))
26
+ .onRequest('session/prompt', async (ctx) => {
27
+ // `ctx.client` (AgentContext) only exposes the generic request()/notify() —
28
+ // no typed convenience method — so call the raw ACP method via
29
+ // `acp.methods.client.elicitation.create` (confirmed on disk: acp.js's
30
+ // `methods.client.elicitation.create === schema.CLIENT_METHODS.elicitation_create`).
31
+ const response = await ctx.client.request(acp.methods.client.elicitation.create, {
32
+ mode: 'form',
33
+ sessionId: ctx.params.sessionId,
34
+ toolCallId: 'tc-elicit-1',
35
+ message: 'Which color do you like?',
36
+ requestedSchema: {
37
+ type: 'object',
38
+ properties: {
39
+ question_0: {
40
+ type: 'string',
41
+ oneOf: [
42
+ { const: 'Red', title: 'Red' },
43
+ { const: 'Blue', title: 'Blue' },
44
+ ],
45
+ },
46
+ question_0_custom: {
47
+ type: 'string',
48
+ title: 'Other',
49
+ description: 'Type your own answer instead of choosing an option above (optional).',
50
+ },
51
+ },
52
+ },
53
+ });
54
+ await ctx.client.notify('session/update', {
55
+ sessionId: ctx.params.sessionId,
56
+ update: {
57
+ sessionUpdate: 'agent_message_chunk',
58
+ content: { type: 'text', text: `response=${JSON.stringify(response)}` },
59
+ },
60
+ });
61
+ return { stopReason: 'end_turn' };
62
+ })
63
+ .connect(stream);
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bun
2
+ // Mock ACP agent proving the MAX_PENDING_PERMISSIONS cap (SECURITY /
3
+ // ethical-hacker finding, retroactive review of Milestone B — a single agent
4
+ // turn can legitimately issue several tool calls back to back, so "one per
5
+ // tool call" is not a natural rate limit). Fires MORE than the cap
6
+ // concurrently, without waiting for each one, and reports which ones actually
7
+ // round-tripped to the client vs. were denied immediately by the bridge's
8
+ // own cap.
9
+
10
+ import { Readable, Writable } from 'node:stream';
11
+
12
+ import * as acp from '@agentclientprotocol/sdk';
13
+
14
+ const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
15
+
16
+ const FLOOD_COUNT = 13; // > MAX_PENDING_PERMISSIONS (10) in acp/bridge.ts
17
+
18
+ let n = 0;
19
+
20
+ acp
21
+ .agent({ name: 'mock-acp-agent-permission-flood' })
22
+ .onRequest('initialize', () => ({
23
+ protocolVersion: acp.PROTOCOL_VERSION,
24
+ agentCapabilities: { loadSession: false },
25
+ }))
26
+ .onRequest('session/new', () => ({ sessionId: `mock-perm-flood-session-${++n}` }))
27
+ .onRequest('session/prompt', async (ctx) => {
28
+ const requests = Array.from({ length: FLOOD_COUNT }, (_, i) =>
29
+ ctx.client.request(acp.methods.client.session.requestPermission, {
30
+ sessionId: ctx.params.sessionId,
31
+ toolCall: { toolCallId: `tc${i}`, title: `Flood tool call ${i}`, kind: 'edit' },
32
+ options: [
33
+ { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
34
+ { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
35
+ ],
36
+ })
37
+ );
38
+ const outcomes = await Promise.all(requests);
39
+ await ctx.client.notify('session/update', {
40
+ sessionId: ctx.params.sessionId,
41
+ update: {
42
+ sessionUpdate: 'agent_message_chunk',
43
+ content: {
44
+ type: 'text',
45
+ text: `outcomes=${JSON.stringify(outcomes.map((o) => o.outcome))}`,
46
+ },
47
+ },
48
+ });
49
+ return { stopReason: 'end_turn' };
50
+ })
51
+ .connect(stream);
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bun
2
+ // Mock ACP agent exercising the permission approve/deny gate (Milestone B,
3
+ // retires DDR-125 F2) — session/prompt calls back INTO the client via
4
+ // `ctx.client.requestPermission(...)` (a real request, not a notification)
5
+ // before streaming its final reply, mirroring how claude-agent-acp asks for
6
+ // tool authorization mid-turn. No real `claude` needed.
7
+
8
+ import { Readable, Writable } from 'node:stream';
9
+
10
+ import * as acp from '@agentclientprotocol/sdk';
11
+
12
+ const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
13
+
14
+ let n = 0;
15
+
16
+ acp
17
+ .agent({ name: 'mock-acp-agent-permission' })
18
+ .onRequest('initialize', () => ({
19
+ protocolVersion: acp.PROTOCOL_VERSION,
20
+ agentCapabilities: { loadSession: false },
21
+ }))
22
+ .onRequest('session/new', () => ({ sessionId: `mock-perm-session-${++n}` }))
23
+ .onRequest('session/prompt', async (ctx) => {
24
+ // `ctx.client` (AgentContext) only exposes the generic request()/notify() —
25
+ // no typed `.requestPermission()` convenience method — so call the raw ACP
26
+ // method via `acp.methods.client.session.requestPermission`.
27
+ const outcome = await ctx.client.request(acp.methods.client.session.requestPermission, {
28
+ sessionId: ctx.params.sessionId,
29
+ toolCall: {
30
+ toolCallId: 'tc1',
31
+ title: 'Write file',
32
+ kind: 'edit',
33
+ rawInput: { path: '/tmp/x' },
34
+ },
35
+ options: [
36
+ { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
37
+ { optionId: 'allow-always', name: 'Allow always', kind: 'allow_always' },
38
+ { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
39
+ ],
40
+ });
41
+ await ctx.client.notify('session/update', {
42
+ sessionId: ctx.params.sessionId,
43
+ update: {
44
+ sessionUpdate: 'agent_message_chunk',
45
+ content: { type: 'text', text: `outcome=${JSON.stringify(outcome.outcome)}` },
46
+ },
47
+ });
48
+ return { stopReason: 'end_turn' };
49
+ })
50
+ .connect(stream);
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bun
2
+ // Mock ACP agent emitting `usage_update` notifications (Milestone D) — one
3
+ // after the prompt result (context/cost), and one carrying the thin
4
+ // `_meta["_claude/rateLimit"]` rate-limit signal (mirrors the real adapter's
5
+ // `rate_limit_event` forwarding, acp-agent.js:1768-1776). No real `claude` needed.
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-usage' })
17
+ .onRequest('initialize', () => ({
18
+ protocolVersion: acp.PROTOCOL_VERSION,
19
+ agentCapabilities: { loadSession: false },
20
+ }))
21
+ .onRequest('session/new', () => ({ sessionId: `mock-usage-session-${++n}` }))
22
+ .onRequest('session/prompt', async (ctx) => {
23
+ await ctx.client.notify('session/update', {
24
+ sessionId: ctx.params.sessionId,
25
+ update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'ok' } },
26
+ });
27
+ await ctx.client.notify('session/update', {
28
+ sessionId: ctx.params.sessionId,
29
+ update: {
30
+ sessionUpdate: 'usage_update',
31
+ used: 4200,
32
+ size: 200000,
33
+ cost: { amount: 0.0123, currency: 'USD' },
34
+ },
35
+ });
36
+ if (ctx.params.prompt?.[0]?.text === 'trigger-rate-limit') {
37
+ await ctx.client.notify('session/update', {
38
+ sessionId: ctx.params.sessionId,
39
+ update: {
40
+ sessionUpdate: 'usage_update',
41
+ used: 4200,
42
+ size: 200000,
43
+ _meta: {
44
+ '_claude/rateLimit': {
45
+ status: 'allowed_warning',
46
+ rateLimitType: 'five_hour',
47
+ utilization: 82,
48
+ resetsAt: 1234567890,
49
+ },
50
+ },
51
+ },
52
+ });
53
+ }
54
+ return { stopReason: 'end_turn' };
55
+ })
56
+ .connect(stream);
@@ -42,16 +42,44 @@ describe('svgPreParseReject', () => {
42
42
  expect(() => svgPreParseReject(huge)).toThrow(/cap/);
43
43
  });
44
44
 
45
- test('rejects DOCTYPE (XXE class)', () => {
45
+ test('rejects a DOCTYPE with an internal subset carrying an ENTITY (XXE class)', () => {
46
46
  const xxe =
47
47
  '<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY x "y">]><svg xmlns="http://www.w3.org/2000/svg"/>';
48
- expect(() => svgPreParseReject(xxe)).toThrow(/DOCTYPE/);
48
+ expect(() => svgPreParseReject(xxe)).toThrow(/entity-expansion/i);
49
49
  });
50
50
 
51
51
  test('rejects a bare ENTITY declaration even without DOCTYPE wrapping', () => {
52
52
  expect(() =>
53
53
  svgPreParseReject('<!ENTITY x "y"><svg xmlns="http://www.w3.org/2000/svg"/>')
54
- ).toThrow(/DOCTYPE\/ENTITY/);
54
+ ).toThrow(/ENTITY/);
55
+ });
56
+
57
+ test('rejects an internal DTD subset even with no ENTITY declared', () => {
58
+ expect(() =>
59
+ svgPreParseReject('<!DOCTYPE svg [ <!-- x --> ]><svg xmlns="http://www.w3.org/2000/svg"/>')
60
+ ).toThrow(/internal subset/i);
61
+ });
62
+
63
+ // ethical-hacker review: a `>` inside a quoted external-id literal must NOT let
64
+ // an internal subset slip past the detector (a naive non-greedy regex truncated
65
+ // there and missed the `[`).
66
+ test('rejects an internal subset hidden behind a `>` in a quoted SYSTEM literal', () => {
67
+ expect(() =>
68
+ svgPreParseReject(
69
+ '<!DOCTYPE svg SYSTEM "http://attacker/x>y" [ <!ELEMENT foo ANY> ]><svg xmlns="http://www.w3.org/2000/svg"/>'
70
+ )
71
+ ).toThrow(/internal subset/i);
72
+ });
73
+
74
+ // RCA G8 — a plain external-identifier DOCTYPE (no internal subset, no ENTITY)
75
+ // is what Illustrator/Serif/Inkscape emit; rejecting the whole class turned away
76
+ // real clean logos. It must be accepted (the sanitizer drops it from the output).
77
+ test('accepts a plain external-identifier DOCTYPE (Illustrator/Serif export)', () => {
78
+ const illustrator =
79
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
80
+ '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n' +
81
+ '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="#3B5BDB"/></svg>';
82
+ expect(() => svgPreParseReject(illustrator)).not.toThrow();
55
83
  });
56
84
 
57
85
  test('rejects a non-declaration processing instruction (xml-stylesheet)', () => {
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "acp-ask-user-question",
6
+ "version": "0.45.2",
7
+ "date": "2026-07-15",
8
+ "kind": "feature",
9
+ "title": "Claude can ask you real questions in chat",
10
+ "summary": "When Claude needs a decision from you mid-task, it can now ask directly — a multi-choice or free-text form appears right in the chat panel, the same AskUserQuestion picker you'd see in the Claude Code CLI, instead of falling back to plain text.",
11
+ "surface": "design-ui",
12
+ "tour": [
13
+ {
14
+ "target": "[data-testid=\"chat-elicit-prompt\"]",
15
+ "title": "Claude asking you something",
16
+ "body": "Pick an option, type your own answer, or Skip — Cancel aborts whatever Claude was about to do with your answer.",
17
+ "placement": "top"
18
+ }
19
+ ]
20
+ },
21
+ {
22
+ "id": "acp-live-capabilities",
23
+ "version": "0.45.2",
24
+ "date": "2026-07-15",
25
+ "kind": "feature",
26
+ "title": "Model, mode & permission controls in chat",
27
+ "summary": "Switch models, effort, and permission mode right from the chat panel — pulled live from your own Claude Code install, never a fixed list. Pick Manual to approve each tool action yourself, or Bypass for hands-off editing; plus grouped tool calls, message Copy/Retry, and a context-usage meter.",
28
+ "surface": "design-ui",
29
+ "tour": [
30
+ {
31
+ "target": "[data-testid=\"chat-mode-picker\"]",
32
+ "title": "Live from your Claude Code",
33
+ "body": "Model, effort, and permission mode are pulled straight from your own Claude Code install — pick Manual to approve each action, or Bypass for hands-off editing.",
34
+ "placement": "top"
35
+ }
36
+ ]
37
+ },
4
38
  {
5
39
  "id": "bring-your-brand",
6
40
  "version": "0.44.0",
@@ -1,11 +1,21 @@
1
1
  import { execSync, spawn, spawnSync } from 'node:child_process';
2
- import { chmodSync, existsSync, readFileSync } from 'node:fs';
2
+ import {
3
+ chmodSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
3
11
  import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
4
12
  import { createRequire } from 'node:module';
13
+ import { homedir } from 'node:os';
5
14
  import { basename, dirname, join, resolve } from 'node:path';
6
15
  import { parseArgs } from '../lib/argv.mjs';
7
16
  import { runAdopt, runLink, runStatus, runUnlink } from '../lib/design-link.mjs';
8
17
  import { writeGitignoreBlock } from '../lib/gitignore-block.mjs';
18
+ import { isCompiledBinary } from '../lib/pkg-root.mjs';
9
19
 
10
20
  const SUBCOMMANDS = new Set([
11
21
  'serve',
@@ -133,6 +143,89 @@ export async function run({ args, pkgRoot }) {
133
143
  // pass straight through — preserves `$(maude design slug …)` capture,
134
144
  // `eval "$(maude design prep --shell-export …)"`, and non-zero gating idioms.
135
145
  // maude writes nothing of its own on this path (no banner on stdout).
146
+ /** True if a `bun` executable is reachable on the given PATH string (POSIX). */
147
+ function bunOnPath(pathStr) {
148
+ for (const dir of (pathStr || '').split(':')) {
149
+ if (dir && existsSync(join(dir, 'bun'))) return true;
150
+ }
151
+ return false;
152
+ }
153
+
154
+ /** A bun-capable compiled binary to borrow as `bun` (RCA G3), or null.
155
+ * Prefer the dev-server binary the sidecar hands us, else our own compiled
156
+ * self (the `maude` CLI is `bun --compile`d too), else a resolvable server bin. */
157
+ function resolveBunnable(pkgRoot) {
158
+ const dev = process.env.MAUDE_DEV_SERVER_BIN;
159
+ if (dev && existsSync(dev)) return dev;
160
+ if (isCompiledBinary()) return process.execPath;
161
+ const server = resolveServerBinary({ pkgRoot });
162
+ if (server && existsSync(server)) return server;
163
+ return null;
164
+ }
165
+
166
+ /**
167
+ * Ensure the design helpers' `command -v bun` / `exec bun run` resolve, even on a
168
+ * machine with NO standalone `bun` (RCA G3 — the desktop `.app` and the npm
169
+ * package ship compiled Bun BINARIES, never a `bun` CLI). The compiled binaries
170
+ * ARE Bun: run with `BUN_BE_BUN=1` a `bun --compile` executable behaves as the
171
+ * `bun` CLI. So if no real `bun` is on PATH, stage a tiny `bun` shim
172
+ * (→ `BUN_BE_BUN=1 <compiled-binary> "$@"`) in a per-user cache dir (0700) and
173
+ * PREPEND it to the child's PATH. No-op when a real `bun` is already present (dev)
174
+ * or no compiled binary exists to borrow (pure-source dev without bun — where a
175
+ * real `bun` is expected anyway). Returns the (possibly PATH-augmented) env.
176
+ */
177
+ /** POSIX single-quote escape: wrap in '…' and encode embedded quotes as '\''.
178
+ * Neutralizes $, backticks, ${}, and every other sh metachar — unlike
179
+ * JSON.stringify, which leaves them live inside sh double-quotes. */
180
+ function shSingleQuote(s) {
181
+ return `'${String(s).replace(/'/g, "'\\''")}'`;
182
+ }
183
+
184
+ /**
185
+ * Provide a `bun` to a design helper that has none (RCA G3), then have the caller
186
+ * REMOVE it after the spawn. Returns `{ env, shimDir }` — `shimDir` is a fresh
187
+ * dir the caller must `rmSync` once the helper exits (null when no shim was made).
188
+ *
189
+ * Security (ethical-hacker review, DDR-177 addendum): the shim goes in a fresh,
190
+ * unpredictable, owner-only `mkdtemp` dir created PER SPAWN and torn down right
191
+ * after — NEVER a stable, guessable `~/.cache/maude/bun-shim/bun`. A stable path
192
+ * on a child's PATH with an unqualified `bun` lookup is the exact PATH-shadow
193
+ * primitive `sidecar.rs` deliberately refuses (Attacker-F4/DDR-168): an untrusted
194
+ * SAME-UID actor (in scope per DDR-054 — a poisoned dependency in an opened
195
+ * project, a prompt-injected agent's Bash tool) could pre-plant/symlink/race a
196
+ * stable shim and hijack the interpreter of every future helper. A per-spawn
197
+ * mkdtemp dir has no pre-existing entry to symlink-follow and no guessable name to
198
+ * race, and is gone before the next spawn. Fails CLOSED with no HOME (never plants
199
+ * an executable in world-writable /tmp).
200
+ */
201
+ function ensureBunOnPath(childEnv, pkgRoot) {
202
+ if (bunOnPath(childEnv.PATH)) return { env: childEnv, shimDir: null };
203
+ const bunnable = resolveBunnable(pkgRoot);
204
+ if (!bunnable) return { env: childEnv, shimDir: null }; // helper's own guard reports the miss
205
+ const home = homedir();
206
+ if (!home) return { env: childEnv, shimDir: null }; // fail closed — no /tmp fallback for an executable
207
+ try {
208
+ const cacheParent = join(home, '.cache', 'maude');
209
+ mkdirSync(cacheParent, { recursive: true, mode: 0o700 });
210
+ // Fresh + unpredictable + 0700, one per spawn. mkdtemp guarantees a brand-new
211
+ // dir (fails if it exists), so there's nothing to symlink-follow on the write.
212
+ const shimDir = mkdtempSync(join(cacheParent, 'bun-shim-'));
213
+ const shim = join(shimDir, 'bun');
214
+ // Single-quote the binary path (NOT JSON.stringify — that leaves $/backticks
215
+ // live inside sh double-quotes, so a binary path with shell metacharacters
216
+ // would run command substitution when the shim executes).
217
+ writeFileSync(
218
+ shim,
219
+ `#!/bin/sh\n# maude bun shim (RCA G3) — the bundled compiled binary IS Bun;\n# BUN_BE_BUN=1 makes it act as the \`bun\` CLI so design helpers run with no\n# user-installed bun. Ephemeral: written per-spawn, removed after (design.mjs).\nBUN_BE_BUN=1 exec ${shSingleQuote(bunnable)} "$@"\n`,
220
+ { mode: 0o755 }
221
+ );
222
+ chmodSync(shim, 0o755);
223
+ return { env: { ...childEnv, PATH: `${shimDir}:${childEnv.PATH || ''}` }, shimDir };
224
+ } catch {
225
+ return { env: childEnv, shimDir: null }; // best-effort; the helper reports the miss
226
+ }
227
+ }
228
+
136
229
  function runBinDispatch(verb, { args, pkgRoot }) {
137
230
  if (!BIN_VERBS.has(verb)) {
138
231
  process.stderr.write(`maude design: "${verb}" is not a dev-tooling verb.\n${usage()}`);
@@ -164,10 +257,22 @@ function runBinDispatch(verb, { args, pkgRoot }) {
164
257
  const bin = resolveServerBinary({ pkgRoot });
165
258
  if (bin) childEnv.MAUDE_DEV_SERVER_BIN = bin;
166
259
  }
260
+ // RCA G3 — make `bun` resolvable to the helper from the bundled compiled binary
261
+ // when no standalone `bun` is installed (desktop / npm). No-op in dev. The shim
262
+ // dir is ephemeral (per-spawn mkdtemp) — remove it as soon as the helper exits,
263
+ // so nothing bun-named lingers on a guessable path (ethical-hacker review).
264
+ const { env: finalEnv, shimDir } = ensureBunOnPath(childEnv, pkgRoot);
167
265
  const child = spawnSync('bash', [script, ...rest], {
168
266
  stdio: 'inherit',
169
- env: childEnv,
267
+ env: finalEnv,
170
268
  });
269
+ if (shimDir) {
270
+ try {
271
+ rmSync(shimDir, { recursive: true, force: true });
272
+ } catch {
273
+ /* best-effort cleanup */
274
+ }
275
+ }
171
276
  if (child.error) {
172
277
  process.stderr.write(`maude design ${verb}: ${child.error.message}\n`);
173
278
  process.exit(1);
@@ -59,14 +59,41 @@ export function isPkgRoot(dir) {
59
59
 
60
60
  /**
61
61
  * Resolve maude's own package root — real disk path, safe inside a compiled
62
- * binary. Priority: (0) explicit override, (1) dev-mode `import.meta.url`
63
- * (plain `node`/`bun run cli/bin/maude.mjs`), (2) compiled-binary walk-up
64
- * from `process.execPath` (matches the npm-install layout: binary at
65
- * `@1agh/maude-<platform>/maude` walks up to `@1agh/maude/`, which ships
66
- * `apps/studio/` via `package.json` `files`; matches the Tauri desktop layout
67
- * too, where the bundled `maude` binary and the `Resources/apps/studio`
68
- * bundle share a common ancestor within the walk-up depth).
62
+ * binary. Priority: (0) explicit `MAUDE_PKG_ROOT` override (sidecar.rs sets this
63
+ * on the desktop), (1) dev-mode `import.meta.url` (plain `node`/`bun run
64
+ * cli/bin/maude.mjs`), (2) compiled-binary walk-up from `process.execPath`
65
+ * (matches the npm-install layout: binary at `@1agh/maude-<platform>/maude`
66
+ * walks up to `@1agh/maude/`, which ships `apps/studio/` via `package.json`
67
+ * `files`), (3) the Tauri **`.app` sibling probe** below.
68
+ *
69
+ * Why (3) exists: in a packaged `Maude.app` the bundled `maude` binary lives in
70
+ * `Contents/MacOS/` but the staged package tree (`apps/studio/`, `cli/`,
71
+ * `package.json`) lives in the SIBLING `Contents/Resources/` — NOT an ancestor
72
+ * of the binary, so the walk-up in (2) never reaches it and used to fall through
73
+ * to `dirname(execPath)` = `Contents/MacOS/`, breaking every `maude design
74
+ * <verb>` with "helper not found" (RCA issue-acp-panel-hangs-on-nodeless-machine
75
+ * G2, reproduced on the shipped v0.45.1 `.app`). The earlier claim that the
76
+ * binary + `Resources/apps/studio` "share a common ancestor within the walk-up
77
+ * depth" was WRONG — `Contents/` is the common ancestor but `apps/studio` sits
78
+ * under `Contents/Resources/`, not `Contents/`, and `cli/` isn't in the ancestry
79
+ * at all. We probe the known Tauri resource-dir siblings explicitly instead.
69
80
  */
81
+ /**
82
+ * Probe the Tauri packaged-app resource dir relative to the binary's own dir.
83
+ * From `<app>/Contents/MacOS/maude`, the staged package tree is at the SIBLING
84
+ * `<app>/Contents/Resources`. Covers macOS (`Resources`), the lower-cased
85
+ * resource dir other Tauri targets use, and a binary co-located with a
86
+ * `resources/` dir (dev staging). Returns the first valid pkgRoot, or null.
87
+ * Exported so the RCA-G2 fix is unit-testable without spawning a real `.app`.
88
+ */
89
+ export function resolveTauriResourcePkgRoot(execDir) {
90
+ for (const rel of [['..', 'Resources'], ['..', 'resources'], ['resources']]) {
91
+ const cand = join(execDir, ...rel);
92
+ if (isPkgRoot(cand)) return cand;
93
+ }
94
+ return null;
95
+ }
96
+
70
97
  export function resolvePkgRoot() {
71
98
  const override = process.env.MAUDE_PKG_ROOT;
72
99
  if (override && !isVirtualBunfsPath(override) && isPkgRoot(override)) return override;
@@ -84,12 +111,13 @@ export function resolvePkgRoot() {
84
111
  // from there (e.g. ~/Library/Caches/…/bin-link/) never reaches the repo
85
112
  // root. Dereference first. Falls back to the raw path if the file somehow
86
113
  // doesn't exist (shouldn't happen — we ARE that running process).
87
- let cur;
114
+ let execDir;
88
115
  try {
89
- cur = dirname(realpathSync(process.execPath));
116
+ execDir = dirname(realpathSync(process.execPath));
90
117
  } catch {
91
- cur = dirname(process.execPath);
118
+ execDir = dirname(process.execPath);
92
119
  }
120
+ let cur = execDir;
93
121
  for (let i = 0; i < 10; i++) {
94
122
  if (isPkgRoot(cur)) return cur;
95
123
  const parent = dirname(cur);
@@ -97,6 +125,10 @@ export function resolvePkgRoot() {
97
125
  cur = parent;
98
126
  }
99
127
 
128
+ // (3) Tauri `.app` sibling probe — the packaged layout the walk-up can't reach.
129
+ const sibling = resolveTauriResourcePkgRoot(execDir);
130
+ if (sibling) return sibling;
131
+
100
132
  // Final fallback for unanchored test contexts — callers should expect
101
133
  // existsSync-based lookups to fail and surface a clear error, same
102
134
  // contract as paths.ts's own resolver. Never join a VIRTUAL importDir here
@@ -16,7 +16,7 @@ import { tmpdir } from 'node:os';
16
16
  import { join } from 'node:path';
17
17
  import { test } from 'node:test';
18
18
 
19
- import { isPkgRoot, resolvePkgRoot } from './pkg-root.mjs';
19
+ import { isPkgRoot, resolvePkgRoot, resolveTauriResourcePkgRoot } from './pkg-root.mjs';
20
20
 
21
21
  function makeRealRoot(base) {
22
22
  mkdirSync(join(base, 'apps', 'studio', 'bin'), { recursive: true });
@@ -52,6 +52,38 @@ test('isPkgRoot requires BOTH anchors — the staged-resources false-positive is
52
52
  }
53
53
  });
54
54
 
55
+ test('resolveTauriResourcePkgRoot finds the .app sibling Resources tree (RCA G2)', () => {
56
+ // Mirror the packaged macOS layout: binary in Contents/MacOS, staged tree in
57
+ // the SIBLING Contents/Resources — NOT an ancestor of the binary, which is why
58
+ // the plain walk-up misses it and every `maude design <verb>` broke.
59
+ const app = mkdtempSync(join(tmpdir(), 'maude-app-'));
60
+ try {
61
+ const macosDir = join(app, 'Contents', 'MacOS');
62
+ const resources = join(app, 'Contents', 'Resources');
63
+ mkdirSync(macosDir, { recursive: true });
64
+ makeRealRoot(resources); // stages apps/studio/bin/screenshot.sh + cli/commands/design.mjs
65
+
66
+ // From the binary's own dir, the sibling probe must find Resources.
67
+ assert.equal(resolveTauriResourcePkgRoot(macosDir), resources);
68
+
69
+ // Regression guard: Resources WITHOUT cli/ (the shipped-broken v0.45.1 shape)
70
+ // must NOT resolve — that's exactly the false-positive isPkgRoot rejects.
71
+ const broken = mkdtempSync(join(tmpdir(), 'maude-app-broken-'));
72
+ try {
73
+ const bMacos = join(broken, 'Contents', 'MacOS');
74
+ const bRes = join(broken, 'Contents', 'Resources', 'apps', 'studio', 'bin');
75
+ mkdirSync(bMacos, { recursive: true });
76
+ mkdirSync(bRes, { recursive: true });
77
+ writeFileSync(join(bRes, 'screenshot.sh'), '#!/bin/sh\n'); // no cli/ staged
78
+ assert.equal(resolveTauriResourcePkgRoot(bMacos), null);
79
+ } finally {
80
+ rmSync(broken, { recursive: true, force: true });
81
+ }
82
+ } finally {
83
+ rmSync(app, { recursive: true, force: true });
84
+ }
85
+ });
86
+
55
87
  test('resolvePkgRoot honors MAUDE_PKG_ROOT only when it actually satisfies both anchors', () => {
56
88
  const real = mkdtempSync(join(tmpdir(), 'maude-pkgroot-real-'));
57
89
  const fake = mkdtempSync(join(tmpdir(), 'maude-pkgroot-fake-'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.45.0",
3
+ "version": "0.45.2",
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": {
@@ -51,13 +51,13 @@
51
51
  "test:e2e:desktop:acp-cold-start": "pnpm --filter @maude/desktop-e2e e2e:acp-cold-start"
52
52
  },
53
53
  "optionalDependencies": {
54
- "@1agh/maude-darwin-arm64": "0.45.0",
55
- "@1agh/maude-darwin-x64": "0.45.0",
56
- "@1agh/maude-linux-arm64": "0.45.0",
57
- "@1agh/maude-linux-arm64-musl": "0.45.0",
58
- "@1agh/maude-linux-x64": "0.45.0",
59
- "@1agh/maude-linux-x64-musl": "0.45.0",
60
- "@1agh/maude-win32-x64": "0.45.0"
54
+ "@1agh/maude-darwin-arm64": "0.45.2",
55
+ "@1agh/maude-darwin-x64": "0.45.2",
56
+ "@1agh/maude-linux-arm64": "0.45.2",
57
+ "@1agh/maude-linux-arm64-musl": "0.45.2",
58
+ "@1agh/maude-linux-x64": "0.45.2",
59
+ "@1agh/maude-linux-x64-musl": "0.45.2",
60
+ "@1agh/maude-win32-x64": "0.45.2"
61
61
  },
62
62
  "files": [
63
63
  "cli",
@@ -102,6 +102,8 @@
102
102
  },
103
103
  "dependencies": {
104
104
  "ajv": "^8.20.0",
105
- "ajv-formats": "^3.0.1"
105
+ "ajv-formats": "^3.0.1",
106
+ "happy-dom": "20.10.6",
107
+ "svgo": "^4.0.1"
106
108
  }
107
109
  }