@1agh/maude 0.43.0 → 0.44.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 (122) hide show
  1. package/apps/studio/acp/bridge.ts +44 -10
  2. package/apps/studio/acp/env.ts +23 -0
  3. package/apps/studio/acp/login-state.ts +263 -0
  4. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  5. package/apps/studio/acp/probe.ts +152 -1
  6. package/apps/studio/annotations-layer.tsx +396 -46
  7. package/apps/studio/api.ts +395 -2
  8. package/apps/studio/bin/_audio-search.mjs +140 -0
  9. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  10. package/apps/studio/bin/_import-asset.mjs +815 -0
  11. package/apps/studio/bin/_import-brand.mjs +635 -0
  12. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  13. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  14. package/apps/studio/bin/_transcribe.mjs +419 -0
  15. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  16. package/apps/studio/bin/audio-search.sh +28 -0
  17. package/apps/studio/bin/generate.sh +154 -0
  18. package/apps/studio/bin/import-asset.sh +34 -0
  19. package/apps/studio/bin/import-brand.sh +29 -0
  20. package/apps/studio/bin/import-tokens.sh +33 -0
  21. package/apps/studio/bin/photo-bg-remove.sh +119 -21
  22. package/apps/studio/bin/transcribe.sh +39 -0
  23. package/apps/studio/canvas-edit.ts +197 -2
  24. package/apps/studio/canvas-lib.tsx +115 -6
  25. package/apps/studio/client/app.jsx +1954 -685
  26. package/apps/studio/client/generate-dialog.jsx +352 -0
  27. package/apps/studio/client/github.js +14 -0
  28. package/apps/studio/client/inspector-controls.jsx +781 -0
  29. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  30. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  31. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  32. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  33. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  34. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  35. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  36. package/apps/studio/client/photo-knobs.jsx +167 -172
  37. package/apps/studio/client/styles/3-shell-maude.css +334 -28
  38. package/apps/studio/client/styles/4-components.css +223 -0
  39. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  40. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  41. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  42. package/apps/studio/config.schema.json +70 -0
  43. package/apps/studio/context-menu.tsx +11 -2
  44. package/apps/studio/context.ts +16 -0
  45. package/apps/studio/design-setup-readiness.ts +115 -0
  46. package/apps/studio/dist/client.bundle.js +2097 -2088
  47. package/apps/studio/dist/styles.css +1 -1
  48. package/apps/studio/draw/palette.ts +34 -0
  49. package/apps/studio/footage/schema.test.ts +92 -0
  50. package/apps/studio/footage/schema.ts +179 -1
  51. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  52. package/apps/studio/generation/adapters/gemini.ts +542 -0
  53. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  54. package/apps/studio/generation/adapters/groq.ts +247 -0
  55. package/apps/studio/generation/audio-library.test.ts +95 -0
  56. package/apps/studio/generation/audio-library.ts +156 -0
  57. package/apps/studio/generation/captions.test.ts +78 -0
  58. package/apps/studio/generation/captions.ts +132 -0
  59. package/apps/studio/generation/download.ts +76 -0
  60. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  61. package/apps/studio/generation/gemini.test.ts +350 -0
  62. package/apps/studio/generation/jobs.test.ts +128 -0
  63. package/apps/studio/generation/jobs.ts +243 -0
  64. package/apps/studio/generation/keys.test.ts +75 -0
  65. package/apps/studio/generation/keys.ts +162 -0
  66. package/apps/studio/generation/prefs.test.ts +95 -0
  67. package/apps/studio/generation/prefs.ts +82 -0
  68. package/apps/studio/generation/registry.ts +88 -0
  69. package/apps/studio/generation/types.test.ts +123 -0
  70. package/apps/studio/generation/types.ts +266 -0
  71. package/apps/studio/generation/whisper-models.test.ts +152 -0
  72. package/apps/studio/generation/whisper-models.ts +257 -0
  73. package/apps/studio/generation/whisper-spike-results.md +61 -0
  74. package/apps/studio/git/service.ts +3 -1
  75. package/apps/studio/http.ts +867 -9
  76. package/apps/studio/media/intro.mp4 +0 -0
  77. package/apps/studio/media-commit-chain.ts +119 -0
  78. package/apps/studio/paths.ts +10 -0
  79. package/apps/studio/readiness.ts +108 -20
  80. package/apps/studio/scaffold-design.ts +1145 -0
  81. package/apps/studio/server.ts +13 -1
  82. package/apps/studio/test/_helpers.ts +15 -2
  83. package/apps/studio/test/acp-bridge.test.ts +34 -1
  84. package/apps/studio/test/acp-env.test.ts +30 -0
  85. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  86. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  87. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  88. package/apps/studio/test/annotations-layer.test.ts +174 -0
  89. package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
  90. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  91. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  92. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  93. package/apps/studio/test/element-structural-edit.test.ts +108 -0
  94. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  95. package/apps/studio/test/generate-route.test.ts +106 -0
  96. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  97. package/apps/studio/test/import-asset-route.test.ts +71 -0
  98. package/apps/studio/test/import-asset.test.ts +399 -0
  99. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  100. package/apps/studio/test/import-brand-route.test.ts +120 -0
  101. package/apps/studio/test/import-brand.test.ts +206 -0
  102. package/apps/studio/test/import-tokens.test.ts +722 -0
  103. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  104. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  105. package/apps/studio/test/readiness.test.ts +13 -11
  106. package/apps/studio/test/scaffold-design.test.ts +122 -0
  107. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  108. package/apps/studio/tool-palette.tsx +11 -6
  109. package/apps/studio/ui-prefs.ts +130 -0
  110. package/apps/studio/use-canvas-media-drop.tsx +4 -2
  111. package/apps/studio/use-chrome-visibility.tsx +8 -2
  112. package/apps/studio/whats-new.json +96 -0
  113. package/apps/studio/ws.ts +5 -0
  114. package/cli/bin/maude.mjs +6 -6
  115. package/cli/commands/design.mjs +33 -0
  116. package/cli/lib/gitignore-block.mjs +1 -0
  117. package/cli/lib/pkg-root.mjs +107 -0
  118. package/cli/lib/pkg-root.test.mjs +79 -0
  119. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  120. package/cli/lib/update-check.mjs +8 -0
  121. package/package.json +12 -10
  122. package/plugins/design/dependencies.json +18 -0
@@ -95,22 +95,29 @@ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT
95
95
  }
96
96
 
97
97
  /**
98
- * Build the `session/new` params, carrying TWO adapter-internal `_meta` payloads
99
- * (both spread by the installed `claude-agent-acp@0.49.x` `newSession`):
98
+ * Build the `session/new` params, carrying THREE adapter-internal `_meta`
99
+ * payloads (all spread by the installed `claude-agent-acp` `newSession`):
100
100
  *
101
101
  * • `_meta.systemPrompt.append` — the studio brief (feature-acp-context-
102
102
  * hardening); the adapter spreads its object form over the `claude_code`
103
103
  * preset (acp-agent.js:2282).
104
- * • `_meta.claudeCode.options.plugins` — session-scoped local plugins (DDR-143);
105
- * the adapter reads `_meta.claudeCode.options` (acp-agent.js:2302) and spreads
106
- * the whole object into the SDK `query()` options (`...userProvidedOptions`,
104
+ * • `_meta.claudeCode.options.plugins` — session-scoped local plugins (DDR-143,
105
+ * unconditional injection DDR-168); the adapter reads
106
+ * `_meta.claudeCode.options` (acp-agent.js:2302) and spreads the whole
107
+ * object into the SDK `query()` options (`...userProvidedOptions`,
107
108
  * :2333 → :2455), so `plugins` reaches the SDK's `plugins?: SdkPluginConfig[]`
108
109
  * (sdk.d.ts:1683) untouched — verified live (Task-1 spike).
110
+ * • `_meta.claudeCode.options.settings.enabledPlugins` — DDR-168's structural
111
+ * double-registration guard: present ONLY alongside a non-empty `plugins`,
112
+ * forcing off any natively-loaded user-level copy of the same plugin id
113
+ * via the same `...userProvidedOptions` spread (`settings` rides untouched
114
+ * exactly like `plugins` does) into the SDK's documented "flag" settings
115
+ * layer (sdk.d.ts:1831, highest priority among user-controlled settings).
109
116
  *
110
- * Both siblings coexist under one `_meta`. The SDK's `zNewSessionRequest` declares
111
- * `_meta` (zod.gen), so it rides the wire validated. These contracts are
112
- * adapter/SDK-INTERNAL and undocumented — an adapter/SDK bump that drops either
113
- * must fail the presence tests LOUDLY (acp-bootstrap-brief.test.ts +
117
+ * All three siblings coexist under one `_meta`. The SDK's `zNewSessionRequest`
118
+ * declares `_meta` (zod.gen), so it rides the wire validated. These contracts are
119
+ * adapter/SDK-INTERNAL and undocumented — an adapter/SDK bump that drops any of
120
+ * them must fail the presence tests LOUDLY (acp-bootstrap-brief.test.ts +
114
121
  * acp-session-plugins.test.ts), not silently un-brief / un-plugin every session.
115
122
  * Exported for those tests.
116
123
  *
@@ -136,7 +143,34 @@ export function newSessionParams(
136
143
  // via a separate path and is unaffected. Always injected — every Maude bridge
137
144
  // session is auto-approving. `...plugins` (DDR-143) rides the same options object.
138
145
  const options: Record<string, unknown> = { settingSources: ['user'] };
139
- if (plugins && plugins.length > 0) options.plugins = plugins;
146
+ if (plugins && plugins.length > 0) {
147
+ options.plugins = plugins;
148
+ // DDR-168 — the bundled `design` plugin is now injected UNCONDITIONALLY
149
+ // (plugin-bootstrap.ts no longer skips when it's also installed/enabled at
150
+ // the user level), so a power user with `design@maude` enabled in their OWN
151
+ // ~/.claude would otherwise get it loaded from TWO sources at once: this
152
+ // `options.plugins` entry, AND natively via `settingSources: ['user']`
153
+ // above (double-registration / duplicate MCP spawns / duplicate hooks).
154
+ // Force the natively-loaded copy off via the SDK's documented "flag"
155
+ // settings layer (sdk.d.ts:1831 — highest priority among user-controlled
156
+ // settings, precedence user < project < local < flag < policy; :5193's
157
+ // `Settings.enabledPlugins` doc comment gives this exact worked example).
158
+ // Keyed to whichever plugin ids are actually being injected — currently
159
+ // only `design@maude`. Absent (not merely false) whenever `plugins` is
160
+ // empty, so the npm/web path (where `plugins` is always empty) is
161
+ // completely unaffected.
162
+ //
163
+ // SECURITY LANDMINE (ethical-hacker finding, DDR-168 review round 2) — this
164
+ // literal is NOT derived from `plugins`/`plugin-bootstrap.ts`'s injection
165
+ // set; it's hand-maintained. `plugin-bootstrap.ts`'s own comment calls
166
+ // re-enabling `flow@maude` injection "a one-line addition" — that one line
167
+ // does NOT touch this object. If you flip that on, you MUST also add
168
+ // `'flow@maude': false` here, or a user with `flow@maude` natively enabled
169
+ // gets it double-loaded with zero suppression (the exact double-
170
+ // registration risk this override exists to close). No test currently
171
+ // catches this drift.
172
+ options.settings = { enabledPlugins: { 'design@maude': false } };
173
+ }
140
174
  meta.claudeCode = { options };
141
175
  return {
142
176
  cwd: repoRoot,
@@ -30,6 +30,28 @@ export const SUBSCRIPTION_SCRUBBED_ENV_KEYS = [
30
30
  */
31
31
  const PROVIDER_REDIRECT_RE = /^(ANTHROPIC_|CLAUDE_CODE_USE_|AWS_BEARER_TOKEN_BEDROCK)/i;
32
32
 
33
+ /**
34
+ * feature-ai-media-generation (DDR-164) — the BYOK generation key-custody env
35
+ * vars. The ACP `claude` subprocess NEVER needs these: the dev-server (a separate
36
+ * process, with its own unscrubbed env) resolves provider keys; the agent only
37
+ * TRIGGERS generation through `maude design generate`. So scrub them from the
38
+ * child:
39
+ * • `MAUDE_GEN_KEY_ENDPOINT` / `MAUDE_GEN_KEY_KEY` — the Phase-5.1 native
40
+ * keychain-bridge loopback endpoint + its access key. Inheriting these would
41
+ * let a prompt-injected agent query the bridge for every provider key. This
42
+ * is DDR-164 **F3**, brought forward from Phase 5.1 so the tripwire is armed
43
+ * BEFORE the bridge is wired, never after.
44
+ * • `MAUDE_GEN_KEYS_PATH` — a custom key-file location. Scrubbing it removes the
45
+ * signpost to a non-default `keys.json` from the child (defense-in-depth).
46
+ * NOTE this does NOT make the key unreachable to a compromised same-UID agent —
47
+ * it can still read the DEFAULT `~/.config/maude/keys.json` (0600, owner = the
48
+ * agent's own user) off disk. That residual is the pre-existing full-tool-agent
49
+ * trifecta (documented in the `ai-generation` skill + DDR-164), not something an
50
+ * env-scrub can close. This pattern's job is narrower: never HAND the child an
51
+ * env pointer/credential it doesn't need.
52
+ */
53
+ const GENERATION_KEY_CUSTODY_RE = /^MAUDE_GEN_KEY/i;
54
+
33
55
  /**
34
56
  * Return a copy of `source` with the billing/provider-redirect keys removed and
35
57
  * any `undefined` values dropped (Bun.spawn's `env` wants Record<string,string>).
@@ -42,6 +64,7 @@ export function scrubAgentEnv(
42
64
  for (const [key, value] of Object.entries(source)) {
43
65
  if (value === undefined) continue;
44
66
  if (PROVIDER_REDIRECT_RE.test(key)) continue;
67
+ if (GENERATION_KEY_CUSTODY_RE.test(key)) continue;
45
68
  out[key] = value;
46
69
  }
47
70
  return out;
@@ -0,0 +1,263 @@
1
+ // Claude auth sign-in from inside Maude (DDR-166, T0d). Drives the user's OWN
2
+ // `claude` CLI through its own documented `auth login`/`auth status` subcommands
3
+ // — Maude never renders a login form, never sees a token, never touches
4
+ // ~/.claude. Deliberately in the SAME Bun process as bridge.ts/env.ts (DDR-166
5
+ // Decision 0), so this reuses the literal `scrubAgentEnv`/`resolveClaudePath`
6
+ // the chat spawn already uses — not a re-derived twin that could drift from it.
7
+
8
+ import { createHash } from 'node:crypto';
9
+ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { dirname, join } from 'node:path';
12
+ import { scrubAgentEnv } from './env.ts';
13
+ import { resolveClaudePath, setTrustedClaudeBin } from './probe.ts';
14
+
15
+ // getClaudeAuthStatus lives in probe.ts now (probeAcpAvailabilityAuthed needs
16
+ // it too, and probe.ts is the lower-level module — login-state.ts building on
17
+ // probe.ts, not the other way around, avoids a circular import). Re-exported
18
+ // here so existing `from './login-state.ts'` imports keep working.
19
+ export { type ClaudeAuthStatus, getClaudeAuthStatus } from './probe.ts';
20
+
21
+ // Single-flight guard (security review finding: concurrent sign-in attempts
22
+ // must be rejected, not queued or raced — and the child must not outlive the
23
+ // UI's own lifecycle).
24
+ let signinChild: ReturnType<typeof Bun.spawn> | null = null;
25
+
26
+ export function isSigninInFlight(): boolean {
27
+ return signinChild !== null;
28
+ }
29
+
30
+ /**
31
+ * Spawn `claude auth login` (the user's own CLI's own browser-based OAuth —
32
+ * never Maude's). Fire-and-forget from the caller's perspective; completion is
33
+ * detected by polling {@link getClaudeAuthStatus}, not by this function's
34
+ * return. stdout/stderr are discarded, not drained-and-logged — `claude auth
35
+ * login`'s own progress text isn't something Maude needs to see or persist.
36
+ */
37
+ /** DDR-166 Decision 5 — the settings-UI opt-out (prefs.rs `claude_auto_setup`). */
38
+ function autoSetupDisabled(): boolean {
39
+ return process.env.MAUDE_CLAUDE_AUTOSETUP_ENABLED === '0';
40
+ }
41
+
42
+ export function startSignin(): { ok: boolean; reason?: string } {
43
+ if (autoSetupDisabled())
44
+ return { ok: false, reason: 'Automatic setup is turned off in Settings.' };
45
+ if (signinChild) return { ok: false, reason: 'A sign-in is already in progress.' };
46
+ const bin = resolveClaudePath();
47
+ if (!bin) return { ok: false, reason: 'Claude Code is not installed.' };
48
+ const child = Bun.spawn([bin, 'auth', 'login', '--claudeai'], {
49
+ env: scrubAgentEnv(),
50
+ stdout: 'ignore',
51
+ stderr: 'ignore',
52
+ });
53
+ signinChild = child;
54
+ child.exited.finally(() => {
55
+ if (signinChild === child) signinChild = null;
56
+ });
57
+ return { ok: true };
58
+ }
59
+
60
+ /** Kill the in-flight sign-in child, if any — cancel button + app-quit path. */
61
+ export function cancelSignin(): void {
62
+ if (signinChild) {
63
+ try {
64
+ signinChild.kill();
65
+ } catch {
66
+ /* already exited */
67
+ }
68
+ signinChild = null;
69
+ }
70
+ }
71
+
72
+ // DDR-166 T0c, Addendum 2 — install `claude` via the exact official one-liner,
73
+ // once, ephemerally: no persistent Maude-managed cache of a downloaded
74
+ // artifact (that design was rejected on security review — a durable,
75
+ // unverified cache is exactly the "plant a bad file while idle, get a silent
76
+ // standing MITM over every future chat session" exploit chain). Anthropic's
77
+ // own installer decides where the binary lands and how it's trusted, same as
78
+ // when a user runs this command themselves.
79
+ const NATIVE_INSTALL_CMD = 'curl -fsSL https://claude.ai/install.sh | bash';
80
+ // Empirically confirmed 2026-07-13: the native installer places the binary
81
+ // here but does NOT add it to PATH itself — it only prints a suggestion for
82
+ // the user to edit their shell config by hand.
83
+ //
84
+ // `MAUDE_E2E_CLAUDE_INSTALL_PATH` override — desktop-e2e (T0f, `acp-cold-start`
85
+ // scenario) needs to drive this EXACT install flow deterministically, but must
86
+ // never write into a real developer's actual `~/.local/bin/claude` (would
87
+ // clobber a genuine install). The e2e config points this at a disposable temp
88
+ // path instead; unset in every normal launch, so real users always get the
89
+ // real well-known path.
90
+ const WELL_KNOWN_INSTALL_PATH =
91
+ process.env.MAUDE_E2E_CLAUDE_INSTALL_PATH || join(homedir(), '.local', 'bin', 'claude');
92
+
93
+ // Desktop-e2e stub (mirrors MAUDE_E2E_FAKE_GITHUB_LOGIN in oauth.rs) — running
94
+ // the REAL `curl | bash` installer in a test harness is both slow/network-
95
+ // dependent and not what's under test (the guided-UI wiring is). When set,
96
+ // `startInstall()` skips the real installer and instead writes this tiny,
97
+ // offline, deterministic stand-in to `WELL_KNOWN_INSTALL_PATH` (itself
98
+ // e2e-overridden — see above) — through the EXACT SAME freshness + content-pin
99
+ // verification path a real install goes through below, so that security-
100
+ // critical code stays exercised, not bypassed. Never set in a normal launch.
101
+ const E2E_FAKE_INSTALL = process.env.MAUDE_E2E_FAKE_CLAUDE_INSTALL === '1';
102
+ const FAKE_CLAUDE_SCRIPT = `#!/bin/sh
103
+ # Maude desktop-e2e fake claude binary (DDR-166) — only ever written when
104
+ # MAUDE_E2E_FAKE_CLAUDE_INSTALL=1. Implements just enough of \`auth status\`/
105
+ # \`auth login\` for the acp-cold-start scenario to drive a real sign-in poll.
106
+ STATE_FILE="$(dirname "$0")/.e2e-signed-in"
107
+ if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
108
+ if [ -f "$STATE_FILE" ]; then
109
+ echo '{"loggedIn":true,"apiProvider":"firstParty","subscriptionType":"max"}'
110
+ else
111
+ echo '{"loggedIn":false}'
112
+ fi
113
+ exit 0
114
+ elif [ "$1" = "auth" ] && [ "$2" = "login" ]; then
115
+ sleep 1
116
+ touch "$STATE_FILE"
117
+ exit 0
118
+ fi
119
+ exit 1
120
+ `;
121
+
122
+ type InstallState =
123
+ | { phase: 'idle' }
124
+ | { phase: 'running' }
125
+ | { phase: 'done'; ok: boolean; reason?: string };
126
+
127
+ let installState: InstallState = { phase: 'idle' };
128
+
129
+ export function isInstallInFlight(): boolean {
130
+ return installState.phase === 'running';
131
+ }
132
+
133
+ /** Poll target — the panel calls this while an install is in flight (mirrors `getClaudeAuthStatus`'s role for sign-in). */
134
+ export function getInstallState(): InstallState {
135
+ return installState;
136
+ }
137
+
138
+ /**
139
+ * Spawn the official installer and return immediately (fire-and-forget) — a
140
+ * request handler that instead `await`s the full install can exceed Bun's
141
+ * default 10s idle timeout on a slow network, killing the connection with no
142
+ * response. Completion is detected by polling {@link getInstallState}, the
143
+ * same shape {@link startSignin}/{@link getClaudeAuthStatus} already use.
144
+ *
145
+ * ONLY on verified-fresh success does this make the result visible to
146
+ * {@link resolveClaudePath} for the rest of this process's life. "Verified
147
+ * fresh" = the binary at the well-known path has an mtime AFTER this call
148
+ * started. This is the fix for a security-review finding against an earlier
149
+ * draft: an unconditional "does a file exist at `~/.local/bin/claude`" check
150
+ * is a standing, unverified trust point — a same-user attacker could plant a
151
+ * substitute there at any idle time, and it would get silently adopted on
152
+ * some later, unrelated install click, with the exact same blast radius the
153
+ * original cached-artifact design was rejected for (every future chat spawn
154
+ * AND every `auth status` check trusts whatever's there). Requiring the
155
+ * mtime to postdate this call's own start means only a file *this specific
156
+ * install action* just wrote gets trusted — a pre-existing or planted file
157
+ * does not, and is reported as an honest "couldn't verify" failure.
158
+ *
159
+ * The resulting trust is session-scoped ONLY: `MAUDE_CLAUDE_BIN` is set via
160
+ * `process.env`, in-memory, never written to disk. A future cold start where
161
+ * the user didn't just click install gets no special treatment — same as a
162
+ * manual curl|bash user, who has to add `~/.local/bin` to PATH themselves.
163
+ */
164
+ // Tracked at module level (not just the async IIFE's own closure) so
165
+ // cancelInstall()/app-shutdown can reach it — a security-review finding
166
+ // against the first cut of this function: the DDR itself names "an explicit
167
+ // answer for how a Tauri app-quit event reaches and kills the login-
168
+ // subprocess's grandchild" as an open question, and the install child had no
169
+ // answer at all (unreachable once spawned).
170
+ let installChild: ReturnType<typeof Bun.spawn> | null = null;
171
+
172
+ export function startInstall(): { ok: boolean; reason?: string } {
173
+ if (autoSetupDisabled())
174
+ return { ok: false, reason: 'Automatic setup is turned off in Settings.' };
175
+ if (installState.phase === 'running')
176
+ return { ok: false, reason: 'An install is already in progress.' };
177
+ installState = { phase: 'running' };
178
+ const startedAt = Date.now();
179
+ (async () => {
180
+ try {
181
+ let code: number;
182
+ if (E2E_FAKE_INSTALL) {
183
+ // Keep the "Installing…" polling UI genuinely exercised instead of
184
+ // resolving on the same tick.
185
+ await new Promise((resolve) => setTimeout(resolve, 1200));
186
+ mkdirSync(dirname(WELL_KNOWN_INSTALL_PATH), { recursive: true });
187
+ writeFileSync(WELL_KNOWN_INSTALL_PATH, FAKE_CLAUDE_SCRIPT, { mode: 0o755 });
188
+ chmodSync(WELL_KNOWN_INSTALL_PATH, 0o755);
189
+ code = 0;
190
+ } else {
191
+ const proc = Bun.spawn(['bash', '-c', NATIVE_INSTALL_CMD], {
192
+ env: scrubAgentEnv(),
193
+ stdin: 'ignore',
194
+ // Install progress text carries no PII (unlike auth status), but
195
+ // there's no product need to surface it either — discard, don't
196
+ // drain-and-log.
197
+ stdout: 'ignore',
198
+ stderr: 'ignore',
199
+ });
200
+ installChild = proc;
201
+ code = await proc.exited;
202
+ installChild = null;
203
+ }
204
+ if (code !== 0) {
205
+ installState = {
206
+ phase: 'done',
207
+ ok: false,
208
+ reason: `The installer exited with an error (code ${code}).`,
209
+ };
210
+ return;
211
+ }
212
+ if (!existsSync(WELL_KNOWN_INSTALL_PATH)) {
213
+ installState = {
214
+ phase: 'done',
215
+ ok: false,
216
+ reason:
217
+ 'The installer reported success, but no binary was found at the expected location.',
218
+ };
219
+ return;
220
+ }
221
+ const st = statSync(WELL_KNOWN_INSTALL_PATH);
222
+ if (st.mtimeMs < startedAt) {
223
+ installState = {
224
+ phase: 'done',
225
+ ok: false,
226
+ reason:
227
+ 'A claude binary already exists but could not be verified as freshly installed. Add ~/.local/bin to your PATH, then try Re-check.',
228
+ };
229
+ return;
230
+ }
231
+ // DDR-166 — content-pin, not just path-pin (a bare MAUDE_CLAUDE_BIN
232
+ // existence check trusts the path forever after this one-time
233
+ // freshness check). setTrustedClaudeBin makes resolveClaudePath()
234
+ // re-verify this exact content UNCONDITIONALLY on every future
235
+ // resolution (probe.ts's own doc comment covers why there's
236
+ // deliberately no mtime-caching shortcut — an earlier draft had one,
237
+ // and it was itself trivially bypassable by the same attacker).
238
+ const sha256 = createHash('sha256')
239
+ .update(readFileSync(WELL_KNOWN_INSTALL_PATH))
240
+ .digest('hex');
241
+ setTrustedClaudeBin(WELL_KNOWN_INSTALL_PATH, sha256);
242
+ process.env.MAUDE_CLAUDE_BIN = WELL_KNOWN_INSTALL_PATH;
243
+ installState = { phase: 'done', ok: true };
244
+ } catch {
245
+ installChild = null;
246
+ installState = { phase: 'done', ok: false, reason: 'Could not run the installer.' };
247
+ }
248
+ })();
249
+ return { ok: true };
250
+ }
251
+
252
+ /** Kill the in-flight install child, if any — cancel path + app-quit/shutdown path. */
253
+ export function cancelInstall(): void {
254
+ if (installChild) {
255
+ try {
256
+ installChild.kill();
257
+ } catch {
258
+ /* already exited */
259
+ }
260
+ installChild = null;
261
+ installState = { phase: 'idle' };
262
+ }
263
+ }
@@ -1,4 +1,4 @@
1
- // ACP session-scoped plugin auto-bootstrap (DDR-143).
1
+ // ACP session-scoped plugin auto-bootstrap (DDR-143; unconditional injection DDR-168).
2
2
  //
3
3
  // A Maude Desktop user with only Claude Code installed should get `/design:*`
4
4
  // working in the chat panel with ZERO manual install — no `npm i`, no `/plugin
@@ -15,7 +15,7 @@
15
15
  // no package manager runs, no network is touched (DDR-126/128) — it's reversible
16
16
  // and per-session by construction.
17
17
  //
18
- // Three gates, ALL must hold for a plugin to be injected:
18
+ // Two gates must hold for a plugin to be injected:
19
19
  // 1. NATIVE / BUNDLE context (guard #4, DDR-119/123). The web `maude design
20
20
  // serve` (npm) path ships no plugin manifest (DDR-044) and its users have a
21
21
  // terminal — never inject there. The signal is the manifest's presence on
@@ -24,15 +24,21 @@
24
24
  // MAUDE_DEV_SERVER_ROOT as an explicit stricter marker.
25
25
  // 2. BUNDLED FILES present — the per-plugin dir resolved (Task 2). Null on the
26
26
  // npm/web layout ⇒ skip that plugin.
27
- // 3. NO-OP for power users (guard #3, hard). If the plugin is already installed
28
- // via the marketplace OR enabled in the user's settings.json, it loads
29
- // NATIVELY under the adapter's `settingSources:user` injecting the bundled
30
- // copy on top would double-register commands + risk version skew. So a
31
- // `scanPlugins()` hit skip that plugin. A pristine ~/.claude (the target
32
- // non-power-user) reads as not-installed inject.
27
+ //
28
+ // DDR-168 — the bundled copy is now injected UNCONDITIONALLY on every native
29
+ // session, regardless of whether the plugin is ALSO installed/enabled on disk
30
+ // (`~/.claude`). Every release ships an internally-consistent, release-matched
31
+ // CLI + plugin set — that's the copy this session should run, not whatever a
32
+ // power user happens to have lying around. This reverses this file's original
33
+ // DDR-143 Decision 3 ("no-op for power users, hard") on purpose. The double-
34
+ // registration risk that gate existed to prevent is now closed structurally,
35
+ // one level up: `bridge.ts`'s `newSessionParams()` sets `options.settings
36
+ // .enabledPlugins['design@maude'] = false` whenever it's carrying a non-empty
37
+ // `plugins` array, forcing off any natively-loaded user-level copy of the same
38
+ // id via the SDK's documented `flag > user` settings precedence — so the
39
+ // bundled copy injected here is the ONLY one that ever loads.
33
40
 
34
41
  import { DESIGN_PLUGIN_DIR, FLOW_PLUGIN_DIR } from '../paths.ts';
35
- import { type PluginScan, scanPlugins } from '../readiness.ts';
36
42
 
37
43
  /**
38
44
  * SDK plugin-load config (`@anthropic-ai/claude-agent-sdk` `SdkPluginConfig`).
@@ -55,8 +61,6 @@ export interface SessionPluginDeps {
55
61
  designDir: string | null;
56
62
  /** Bundled `flow` plugin dir, or null (npm/web layout). */
57
63
  flowDir: string | null;
58
- /** ~/.claude registry+settings scan — a present plugin loads natively (no-op). */
59
- scan: Pick<PluginScan, 'design' | 'flow'>;
60
64
  }
61
65
 
62
66
  /**
@@ -66,15 +70,17 @@ export interface SessionPluginDeps {
66
70
  export function computeSessionPlugins(deps: SessionPluginDeps): SdkPluginConfig[] {
67
71
  if (!deps.native) return [];
68
72
  const out: SdkPluginConfig[] = [];
69
- const add = (dir: string | null, alreadyPresent: boolean): void => {
70
- // dir === null → not bundled in this layout (web/npm) → skip.
71
- // alreadyPresent loads natively skip (no double-registration / skew).
72
- if (dir && !alreadyPresent) out.push({ type: 'local', path: dir, skipMcpDiscovery: true });
73
+ const add = (dir: string | null): void => {
74
+ // dir === null → not bundled in this layout (web/npm) → skip. Otherwise
75
+ // inject unconditionally (DDR-168) the double-registration risk of also
76
+ // having a disk-installed copy is closed structurally in bridge.ts, not
77
+ // by skipping injection here.
78
+ if (dir) out.push({ type: 'local', path: dir, skipMcpDiscovery: true });
73
79
  };
74
- add(deps.designDir, deps.scan.design);
80
+ add(deps.designDir);
75
81
  // `/flow` auto-load is intentionally OFF for now (2026-07-03) — the chat ships
76
- // design-only. `deps.flowDir` / `deps.scan.flow` stay resolved (harmless) so
77
- // restoring it is a one-liner: re-add `add(deps.flowDir, deps.scan.flow)`.
82
+ // design-only. `deps.flowDir` stays resolved (harmless) so restoring it is a
83
+ // one-liner: re-add `add(deps.flowDir)`.
78
84
  return out;
79
85
  }
80
86
 
@@ -98,18 +104,18 @@ export function isNativePluginContext(): boolean {
98
104
  }
99
105
 
100
106
  /**
101
- * Resolve the session-scoped plugins to inject for a fresh ACP session. Wires the
102
- * real deps (native context, bundled dirs, live ~/.claude scan) into
103
- * {@link computeSessionPlugins}. Empty array ⇒ inject nothing (the power-user /
104
- * web no-op). Recomputed per bridge construction; stable across an adapter
105
- * re-spawn because it's carried on the bridge's readonly options.
107
+ * Resolve the session-scoped plugins to inject for a fresh ACP session. Wires
108
+ * the real deps (native context, bundled dirs) into {@link computeSessionPlugins}.
109
+ * Empty array ⇒ inject nothing (the web no-op). Recomputed per bridge
110
+ * construction; stable across an adapter re-spawn because it's carried on the
111
+ * bridge's readonly options. Does NOT consult `~/.claude`'s registry (DDR-168 —
112
+ * the bundled copy always wins regardless of disk state); `readiness.ts` still
113
+ * calls `scanPlugins()` independently for its own UI row.
106
114
  */
107
115
  export function resolveSessionPlugins(): SdkPluginConfig[] {
108
- const scan = scanPlugins();
109
116
  return computeSessionPlugins({
110
117
  native: isNativePluginContext(),
111
118
  designDir: DESIGN_PLUGIN_DIR,
112
119
  flowDir: FLOW_PLUGIN_DIR,
113
- scan: { design: scan.design, flow: scan.flow },
114
120
  });
115
121
  }
@@ -6,10 +6,12 @@
6
6
  // native shell ships the apps/studio source tree (with node_modules), so the
7
7
  // adapter resolves on disk there.
8
8
 
9
+ import { createHash } from 'node:crypto';
9
10
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
10
11
  import { dirname, join } from 'node:path';
11
12
 
12
13
  import { DEV_SERVER_ROOT } from '../paths.ts';
14
+ import { scrubAgentEnv } from './env.ts';
13
15
 
14
16
  /** Adapter npm package — the renamed continuation of `@zed-industries/claude-code-acp`. */
15
17
  const ADAPTER_PKG = '@agentclientprotocol/claude-agent-acp';
@@ -64,13 +66,91 @@ export function resolveAdapterEntry(): string | null {
64
66
  return null;
65
67
  }
66
68
 
69
+ /**
70
+ * Content pin for a Maude-auto-provisioned `MAUDE_CLAUDE_BIN` override
71
+ * (DDR-166 T0c, `installClaudeCli()` in `login-state.ts` is the only writer).
72
+ *
73
+ * Second-round security-review finding against the first cut of this file:
74
+ * checking only `existsSync(override)` on every call meant that once a
75
+ * freshly-installed binary passed its one-time freshness check, the PATH
76
+ * override was trusted **forever** for the rest of this long-lived process —
77
+ * a same-UID attacker who swapped the file's *content* at any later point in
78
+ * the session (the install-flow one-time check does not repeat) got the
79
+ * exact "durable, silent MITM over every future chat spawn" blast radius the
80
+ * original cached-artifact design was rejected for, just reached through a
81
+ * different door. Path existence is not authenticity.
82
+ *
83
+ * The fix: pin the SHA-256 captured at verified-install time, and re-hash
84
+ * UNCONDITIONALLY on every resolution — no mtime-based caching shortcut.
85
+ *
86
+ * A third-round review finding against the first cut of this fix: an
87
+ * earlier draft cached the hash result keyed on mtime ("skip the re-hash if
88
+ * mtime is unchanged since the last verified read") as a performance
89
+ * optimization. That cache is trivially forgeable by the EXACT SAME attacker
90
+ * the fix exists to stop: swapping the file's content costs them nothing
91
+ * beyond a `touch -d <original-mtime>` immediately after, at zero additional
92
+ * privilege — same-UID write access to the file is already required for the
93
+ * swap itself, and mtime is neither secret nor tamper-evident (`utimensat`
94
+ * sets it with nanosecond precision, not a race against clock granularity).
95
+ * The cache silently reopened the exact "durable, silent MITM over every
96
+ * future chat spawn" blast radius the whole fix exists to close.
97
+ *
98
+ * The "don't re-hash on every 1.5s readiness-poll tick" concern that
99
+ * motivated the cache doesn't actually hold: readiness polling
100
+ * (`useReadiness` in `ReadinessList.jsx`) only runs while the panel is
101
+ * DISCONNECTED — i.e., only during the bounded, actively-supervised
102
+ * install/sign-in flow itself, not for the lifetime of a long session. Once
103
+ * connected, nothing polls `/_api/preflight` on an interval. A live-measured
104
+ * SHA-256 pass over the actual ~241 MB installed `claude` binary took
105
+ * ~116 ms — cheap enough to simply always pay, especially since the other
106
+ * caller that matters (a fresh chat-session spawn, once per session) has no
107
+ * latency budget concern at all.
108
+ */
109
+ let trustedBin: { path: string; sha256: string } | null = null;
110
+
111
+ /** Called only by `installClaudeCli()` after its own freshness check passes. */
112
+ export function setTrustedClaudeBin(path: string, sha256: string): void {
113
+ trustedBin = { path, sha256 };
114
+ }
115
+
116
+ function hashFile(path: string): string | null {
117
+ try {
118
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /** True iff `path` is either not a Maude-provisioned override (nothing to pin) or its content still matches the pinned hash — re-hashed every call, deliberately no caching shortcut (see doc comment above). */
125
+ function contentStillTrusted(path: string): boolean {
126
+ if (!trustedBin || trustedBin.path !== path) return true; // not our concern — e.g. a user-set override
127
+ return hashFile(path) === trustedBin.sha256;
128
+ }
129
+
67
130
  /**
68
131
  * Absolute path to the user's installed `claude` CLI, or null. Honors a
69
132
  * `MAUDE_CLAUDE_BIN` override; otherwise looks it up on PATH.
70
133
  */
71
134
  export function resolveClaudePath(): string | null {
135
+ // An explicit, content-pinned `MAUDE_CLAUDE_BIN` override is checked FIRST
136
+ // and always wins — including over the E2E force-missing stub below. This
137
+ // matters for the desktop-e2e `acp-cold-start` scenario (T0f): it starts
138
+ // with MAUDE_E2E_FORCE_CLAUDE_STATUS=missing to simulate a machine with no
139
+ // `claude` on PATH, then drives the real T0c install flow, which sets THIS
140
+ // override once its own freshness+hash verification passes
141
+ // (login-state.ts's setTrustedClaudeBin). A coarse "simulate absent" test
142
+ // stub must not out-rank a just-verified, explicitly-provisioned install —
143
+ // that would make the guided install flow untestable end-to-end.
72
144
  const override = process.env.MAUDE_CLAUDE_BIN;
73
- if (override) return existsSync(override) ? override : null;
145
+ if (override) {
146
+ if (!existsSync(override)) return null;
147
+ if (!contentStillTrusted(override)) return null;
148
+ return override;
149
+ }
150
+ // DDR-166 — deterministic E2E stub (mirrors MAUDE_E2E_FAKE_GITHUB_LOGIN in
151
+ // oauth.rs), simulating a machine where `claude` is absent from PATH.
152
+ // Never set in a normal launch.
153
+ if (process.env.MAUDE_E2E_FORCE_CLAUDE_STATUS === 'missing') return null;
74
154
  return Bun.which('claude');
75
155
  }
76
156
 
@@ -110,3 +190,74 @@ export function probeAcpAvailability(): AcpAvailability {
110
190
  }
111
191
  return { available: true, adapterEntry, claudePath };
112
192
  }
193
+
194
+ export interface ClaudeAuthStatus {
195
+ loggedIn: boolean;
196
+ /** 'firstParty' = claude.ai subscription. Anything else may mean API billing. */
197
+ apiProvider?: string;
198
+ subscriptionType?: string;
199
+ }
200
+
201
+ /**
202
+ * Shells `claude auth status --json` and narrows the result to only the fields
203
+ * ever trusted/exposed here. `email`/`orgId`/`orgName` are deliberately dropped
204
+ * on read — never forwarded to a caller, never logged (security review finding:
205
+ * raw-stdout-to-log is a real habit elsewhere in this codebase; the fix is to
206
+ * never let the raw payload exist past this function).
207
+ */
208
+ export async function getClaudeAuthStatus(): Promise<ClaudeAuthStatus | null> {
209
+ const bin = resolveClaudePath();
210
+ if (!bin) return null;
211
+ try {
212
+ const proc = Bun.spawn([bin, 'auth', 'status', '--json'], {
213
+ env: scrubAgentEnv(),
214
+ stdout: 'pipe',
215
+ stderr: 'ignore',
216
+ });
217
+ const timeout = setTimeout(() => proc.kill(), 5000);
218
+ const out = await new Response(proc.stdout).text();
219
+ await proc.exited;
220
+ clearTimeout(timeout);
221
+ const parsed: unknown = JSON.parse(out);
222
+ if (typeof parsed !== 'object' || parsed === null) return null;
223
+ const p = parsed as Record<string, unknown>;
224
+ return {
225
+ loggedIn: !!p.loggedIn,
226
+ apiProvider: typeof p.apiProvider === 'string' ? p.apiProvider : undefined,
227
+ subscriptionType: typeof p.subscriptionType === 'string' ? p.subscriptionType : undefined,
228
+ };
229
+ } catch {
230
+ return null;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * The fuller availability check backing `GET /_api/acp/status` — the ONE
236
+ * signal the ChatPanel UI's connected/not-connected gate actually watches
237
+ * (`probeStatus()` in ChatPanel.jsx). Adds a real sign-in check on top of
238
+ * {@link probeAcpAvailability}'s cheap adapter+path probe.
239
+ *
240
+ * Security-review-grade bug found while building the T0f `acp-cold-start`
241
+ * desktop-e2e scenario: `probeAcpAvailability()` alone reports `available:
242
+ * true` the moment `claude` is INSTALLED, before the user has signed in —
243
+ * exactly the "logged-out-but-installed reads as available" failure mode
244
+ * T0d's own validation criteria calls out by name. Deliberately a SEPARATE
245
+ * function rather than changing `probeAcpAvailability()` itself: that one
246
+ * also backs the WS `onOpen` ready frame (cheap, no subprocess spawn on every
247
+ * socket open) and has an existing test contract (`acp-bridge.test.ts`)
248
+ * treating "a resolvable binary" as sufficient — this is the one call site
249
+ * that needs the fuller, auth-checked answer.
250
+ */
251
+ export async function probeAcpAvailabilityAuthed(): Promise<AcpAvailability> {
252
+ const base = probeAcpAvailability();
253
+ if (!base.available) return base;
254
+ const authStatus = await getClaudeAuthStatus();
255
+ if (!authStatus?.loggedIn) {
256
+ return {
257
+ ...base,
258
+ available: false,
259
+ reason: 'Claude Code is installed but not signed in — sign in to connect.',
260
+ };
261
+ }
262
+ return base;
263
+ }