@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.
- package/apps/studio/acp/bridge.ts +585 -73
- package/apps/studio/acp/index.ts +172 -23
- package/apps/studio/acp/probe.ts +31 -7
- package/apps/studio/acp/transcript.ts +91 -5
- package/apps/studio/bin/_import-asset.mjs +35 -5
- package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
- package/apps/studio/client/panels/ChatPanel.jsx +802 -133
- package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
- package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
- package/apps/studio/client/panels/ReadinessList.jsx +17 -3
- package/apps/studio/client/panels/ToolGroup.jsx +79 -0
- package/apps/studio/client/panels/acp-capabilities.js +71 -0
- package/apps/studio/client/panels/acp-elicitation.js +194 -0
- package/apps/studio/client/panels/acp-runtime.js +248 -9
- package/apps/studio/client/panels/acp-usage.js +65 -0
- package/apps/studio/client/panels/transcript-view.js +36 -0
- package/apps/studio/client/styles/6-acp-chat.css +648 -11
- package/apps/studio/dist/client.bundle.js +1596 -1594
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/whisper-models.test.ts +28 -1
- package/apps/studio/generation/whisper-models.ts +22 -7
- package/apps/studio/http.ts +33 -2
- package/apps/studio/test/acp-bridge.test.ts +11 -6
- package/apps/studio/test/acp-capabilities.test.ts +123 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
- package/apps/studio/test/acp-elicitation.test.ts +251 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
- package/apps/studio/test/acp-permission.test.ts +262 -0
- package/apps/studio/test/acp-toolgroup.test.ts +76 -0
- package/apps/studio/test/acp-transcript-view.test.ts +71 -0
- package/apps/studio/test/acp-transcript.test.ts +75 -2
- package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
- package/apps/studio/test/acp-usage.test.ts +143 -0
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
- package/apps/studio/test/import-asset.test.ts +31 -3
- package/apps/studio/whats-new.json +34 -0
- package/cli/commands/design.mjs +107 -2
- package/cli/lib/pkg-root.mjs +42 -10
- package/cli/lib/pkg-root.test.mjs +33 -1
- 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(/
|
|
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(/
|
|
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",
|
package/cli/commands/design.mjs
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { execSync, spawn, spawnSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
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:
|
|
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);
|
package/cli/lib/pkg-root.mjs
CHANGED
|
@@ -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
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* `@1agh/maude-<platform>/maude`
|
|
66
|
-
* `apps/studio/` via `package.json`
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
|
114
|
+
let execDir;
|
|
88
115
|
try {
|
|
89
|
-
|
|
116
|
+
execDir = dirname(realpathSync(process.execPath));
|
|
90
117
|
} catch {
|
|
91
|
-
|
|
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.
|
|
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.
|
|
55
|
-
"@1agh/maude-darwin-x64": "0.45.
|
|
56
|
-
"@1agh/maude-linux-arm64": "0.45.
|
|
57
|
-
"@1agh/maude-linux-arm64-musl": "0.45.
|
|
58
|
-
"@1agh/maude-linux-x64": "0.45.
|
|
59
|
-
"@1agh/maude-linux-x64-musl": "0.45.
|
|
60
|
-
"@1agh/maude-win32-x64": "0.45.
|
|
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
|
}
|