@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.
- package/apps/studio/acp/bridge.ts +44 -10
- package/apps/studio/acp/env.ts +23 -0
- package/apps/studio/acp/login-state.ts +263 -0
- package/apps/studio/acp/plugin-bootstrap.ts +31 -25
- package/apps/studio/acp/probe.ts +152 -1
- package/apps/studio/annotations-layer.tsx +396 -46
- package/apps/studio/api.ts +395 -2
- package/apps/studio/bin/_audio-search.mjs +140 -0
- package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
- package/apps/studio/bin/_import-asset.mjs +815 -0
- package/apps/studio/bin/_import-brand.mjs +635 -0
- package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
- package/apps/studio/bin/_import-tokens.mjs +1201 -0
- package/apps/studio/bin/_transcribe.mjs +419 -0
- package/apps/studio/bin/_transcribe.test.mjs +80 -0
- package/apps/studio/bin/audio-search.sh +28 -0
- package/apps/studio/bin/generate.sh +154 -0
- package/apps/studio/bin/import-asset.sh +34 -0
- package/apps/studio/bin/import-brand.sh +29 -0
- package/apps/studio/bin/import-tokens.sh +33 -0
- package/apps/studio/bin/photo-bg-remove.sh +119 -21
- package/apps/studio/bin/transcribe.sh +39 -0
- package/apps/studio/canvas-edit.ts +197 -2
- package/apps/studio/canvas-lib.tsx +115 -6
- package/apps/studio/client/app.jsx +1954 -685
- package/apps/studio/client/generate-dialog.jsx +352 -0
- package/apps/studio/client/github.js +14 -0
- package/apps/studio/client/inspector-controls.jsx +781 -0
- package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
- package/apps/studio/client/panels/ChatPanel.jsx +15 -33
- package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
- package/apps/studio/client/panels/ReadinessList.jsx +227 -7
- package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
- package/apps/studio/client/photo-knobs.jsx +167 -172
- package/apps/studio/client/styles/3-shell-maude.css +334 -28
- package/apps/studio/client/styles/4-components.css +223 -0
- package/apps/studio/client/styles/6-acp-chat.css +86 -0
- package/apps/studio/client/tour/quick-setup-tour.js +39 -0
- package/apps/studio/commands/annotation-strokes-command.ts +13 -3
- package/apps/studio/config.schema.json +70 -0
- package/apps/studio/context-menu.tsx +11 -2
- package/apps/studio/context.ts +16 -0
- package/apps/studio/design-setup-readiness.ts +115 -0
- package/apps/studio/dist/client.bundle.js +2097 -2088
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/draw/palette.ts +34 -0
- package/apps/studio/footage/schema.test.ts +92 -0
- package/apps/studio/footage/schema.ts +179 -1
- package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
- package/apps/studio/generation/adapters/gemini.ts +542 -0
- package/apps/studio/generation/adapters/groq.test.ts +80 -0
- package/apps/studio/generation/adapters/groq.ts +247 -0
- package/apps/studio/generation/audio-library.test.ts +95 -0
- package/apps/studio/generation/audio-library.ts +156 -0
- package/apps/studio/generation/captions.test.ts +78 -0
- package/apps/studio/generation/captions.ts +132 -0
- package/apps/studio/generation/download.ts +76 -0
- package/apps/studio/generation/elevenlabs.test.ts +223 -0
- package/apps/studio/generation/gemini.test.ts +350 -0
- package/apps/studio/generation/jobs.test.ts +128 -0
- package/apps/studio/generation/jobs.ts +243 -0
- package/apps/studio/generation/keys.test.ts +75 -0
- package/apps/studio/generation/keys.ts +162 -0
- package/apps/studio/generation/prefs.test.ts +95 -0
- package/apps/studio/generation/prefs.ts +82 -0
- package/apps/studio/generation/registry.ts +88 -0
- package/apps/studio/generation/types.test.ts +123 -0
- package/apps/studio/generation/types.ts +266 -0
- package/apps/studio/generation/whisper-models.test.ts +152 -0
- package/apps/studio/generation/whisper-models.ts +257 -0
- package/apps/studio/generation/whisper-spike-results.md +61 -0
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/http.ts +867 -9
- package/apps/studio/media/intro.mp4 +0 -0
- package/apps/studio/media-commit-chain.ts +119 -0
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/readiness.ts +108 -20
- package/apps/studio/scaffold-design.ts +1145 -0
- package/apps/studio/server.ts +13 -1
- package/apps/studio/test/_helpers.ts +15 -2
- package/apps/studio/test/acp-bridge.test.ts +34 -1
- package/apps/studio/test/acp-env.test.ts +30 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
- package/apps/studio/test/acp-session-plugins.test.ts +72 -1
- package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
- package/apps/studio/test/annotations-layer.test.ts +174 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
- package/apps/studio/test/csrf-write-guard.test.ts +23 -1
- package/apps/studio/test/design-setup-readiness.test.ts +155 -0
- package/apps/studio/test/element-structural-edit.test.ts +108 -0
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
- package/apps/studio/test/generate-route.test.ts +106 -0
- package/apps/studio/test/import-asset-browser.test.ts +64 -0
- package/apps/studio/test/import-asset-route.test.ts +71 -0
- package/apps/studio/test/import-asset.test.ts +399 -0
- package/apps/studio/test/import-brand-browser.test.ts +88 -0
- package/apps/studio/test/import-brand-route.test.ts +120 -0
- package/apps/studio/test/import-brand.test.ts +206 -0
- package/apps/studio/test/import-tokens.test.ts +722 -0
- package/apps/studio/test/media-commit-chain.test.ts +210 -0
- package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
- package/apps/studio/test/readiness.test.ts +13 -11
- package/apps/studio/test/scaffold-design.test.ts +122 -0
- package/apps/studio/test/tour-overlay.test.tsx +16 -0
- package/apps/studio/tool-palette.tsx +11 -6
- package/apps/studio/ui-prefs.ts +130 -0
- package/apps/studio/use-canvas-media-drop.tsx +4 -2
- package/apps/studio/use-chrome-visibility.tsx +8 -2
- package/apps/studio/whats-new.json +96 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/bin/maude.mjs +6 -6
- package/cli/commands/design.mjs +33 -0
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/pkg-root.mjs +107 -0
- package/cli/lib/pkg-root.test.mjs +79 -0
- package/cli/lib/reconstruct-toolset.test.mjs +194 -0
- package/cli/lib/update-check.mjs +8 -0
- package/package.json +12 -10
- 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
|
|
99
|
-
* (
|
|
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
|
-
*
|
|
106
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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)
|
|
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,
|
package/apps/studio/acp/env.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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
|
|
70
|
-
// dir === null → not bundled in this layout (web/npm) → skip.
|
|
71
|
-
//
|
|
72
|
-
|
|
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
|
|
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`
|
|
77
|
-
//
|
|
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
|
|
102
|
-
* real deps (native context, bundled dirs
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
}
|
package/apps/studio/acp/probe.ts
CHANGED
|
@@ -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)
|
|
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
|
+
}
|