@clearlysh/beehaven 0.8.16
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/LICENSE +21 -0
- package/README.md +201 -0
- package/dist/__tests__/_cli-switch.js +105 -0
- package/dist/__tests__/agent-wrap-probe.js +136 -0
- package/dist/agent-attribution.js +192 -0
- package/dist/agent-identity-daemon.js +135 -0
- package/dist/agent-onboard.js +156 -0
- package/dist/agent-scope.js +79 -0
- package/dist/agent-sessions.js +370 -0
- package/dist/agent-state.js +482 -0
- package/dist/app-manifest.js +221 -0
- package/dist/app-signer.js +54 -0
- package/dist/app-signing.js +127 -0
- package/dist/audio-play.js +98 -0
- package/dist/avatar-protocol.js +25 -0
- package/dist/avatar-route.js +79 -0
- package/dist/bee/cloud-proxy.js +130 -0
- package/dist/blocked-actions.js +120 -0
- package/dist/build-channel.js +101 -0
- package/dist/buildings/daemon-workspace.js +475 -0
- package/dist/claude-bridge.js +310 -0
- package/dist/claude-credential-store.js +633 -0
- package/dist/clearly-sync.js +518 -0
- package/dist/clearly.js +616 -0
- package/dist/cli-state.js +334 -0
- package/dist/cli.js +6973 -0
- package/dist/commands/agent-skills.js +302 -0
- package/dist/commands/agent.js +1310 -0
- package/dist/commands/app.js +1123 -0
- package/dist/commands/auth.js +171 -0
- package/dist/commands/building-shell.js +392 -0
- package/dist/commands/canvas.js +932 -0
- package/dist/commands/claude.js +218 -0
- package/dist/commands/compliance.js +387 -0
- package/dist/commands/compose.js +507 -0
- package/dist/commands/composition.js +95 -0
- package/dist/commands/connect-session.js +409 -0
- package/dist/commands/conversations.js +160 -0
- package/dist/commands/costs.js +443 -0
- package/dist/commands/d1.js +253 -0
- package/dist/commands/daemon-cmd.js +162 -0
- package/dist/commands/deploy.js +164 -0
- package/dist/commands/design.js +617 -0
- package/dist/commands/dev-generate.js +125 -0
- package/dist/commands/dev.js +109 -0
- package/dist/commands/devices.js +135 -0
- package/dist/commands/explain-router.js +24 -0
- package/dist/commands/explain.js +882 -0
- package/dist/commands/explore.js +264 -0
- package/dist/commands/help.js +2418 -0
- package/dist/commands/init.js +408 -0
- package/dist/commands/install.js +129 -0
- package/dist/commands/library.js +229 -0
- package/dist/commands/marketplace.js +138 -0
- package/dist/commands/mcp-install.js +476 -0
- package/dist/commands/mention.js +257 -0
- package/dist/commands/omni.js +529 -0
- package/dist/commands/secrets.js +323 -0
- package/dist/commands/self-update.js +206 -0
- package/dist/commands/sheet.js +264 -0
- package/dist/commands/shell-output.js +309 -0
- package/dist/commands/shell.js +1048 -0
- package/dist/commands/shop.js +368 -0
- package/dist/commands/skills.js +311 -0
- package/dist/commands/slides.js +418 -0
- package/dist/commands/swarm.js +341 -0
- package/dist/commands/ticket.js +1301 -0
- package/dist/commands/tool.js +312 -0
- package/dist/commands/trash.js +106 -0
- package/dist/commands/treehouse.js +120 -0
- package/dist/commands/user.js +240 -0
- package/dist/commands/voice-tts.js +319 -0
- package/dist/commands/voice.js +166 -0
- package/dist/commands/watch-git.js +401 -0
- package/dist/commands/webhook.js +159 -0
- package/dist/commands/workspace.js +3354 -0
- package/dist/composition-repos.js +91 -0
- package/dist/daemon-avatar.js +130 -0
- package/dist/daemon-claude-code-auto-install.js +52 -0
- package/dist/daemon-client.js +363 -0
- package/dist/daemon-codex.js +349 -0
- package/dist/daemon-entry.js +101 -0
- package/dist/daemon-fetch-allowlist.js +194 -0
- package/dist/daemon-gemini.js +224 -0
- package/dist/daemon-mcp-clearly.js +353 -0
- package/dist/daemon-mcp-sandbox.js +127 -0
- package/dist/daemon-omni-company.js +338 -0
- package/dist/daemon-omni-hooks.js +375 -0
- package/dist/daemon-omni-identity.js +229 -0
- package/dist/daemon-omni-pipeline.js +251 -0
- package/dist/daemon-omni-policies.js +346 -0
- package/dist/daemon-omni-principal.js +580 -0
- package/dist/daemon-omni-tool-gate.js +116 -0
- package/dist/daemon-omni.js +1442 -0
- package/dist/daemon-path-transform.js +120 -0
- package/dist/daemon-write-sandbox.js +191 -0
- package/dist/daemon.js +4471 -0
- package/dist/dashboard.js +846 -0
- package/dist/dep-installer.js +286 -0
- package/dist/dev-mode.js +67 -0
- package/dist/doc-indexer.js +169 -0
- package/dist/e2e-crypto.js +94 -0
- package/dist/feature-flags.js +154 -0
- package/dist/file-browser.js +88 -0
- package/dist/first-party-hives.js +211 -0
- package/dist/folder-sync.js +226 -0
- package/dist/health.js +56 -0
- package/dist/ipc-auth.js +299 -0
- package/dist/ipc-client.js +495 -0
- package/dist/ipc-server.js +684 -0
- package/dist/keychain.js +224 -0
- package/dist/lib/admin-client.js +73 -0
- package/dist/lib/mobile-build-parser.js +156 -0
- package/dist/local-app-manager.js +660 -0
- package/dist/log-redaction.js +89 -0
- package/dist/logger.js +108 -0
- package/dist/manifest.js +143 -0
- package/dist/marketplace.js +793 -0
- package/dist/min-version-copy.js +51 -0
- package/dist/min-version.js +214 -0
- package/dist/public-surface.js +23 -0
- package/dist/relay-presets.js +89 -0
- package/dist/relay.js +2723 -0
- package/dist/repl.js +619 -0
- package/dist/setup-relay.js +363 -0
- package/dist/setup-service.js +536 -0
- package/dist/shared/bee-memory/index.js +6 -0
- package/dist/shared/bee-memory/types.js +682 -0
- package/dist/shared/claude-code/daemon-types.js +112 -0
- package/dist/shared/claude-code/index.js +2 -0
- package/dist/shared/claude-code/types.js +1 -0
- package/dist/shared/status/schema.js +133 -0
- package/dist/shell-session.js +198 -0
- package/dist/speak-lock.js +97 -0
- package/dist/spinner.js +57 -0
- package/dist/telemetry.js +166 -0
- package/dist/terminal/block-parser.js +207 -0
- package/dist/terminal/shell-integration.js +108 -0
- package/dist/transcript-tailer.js +215 -0
- package/dist/tts-qwen3.js +547 -0
- package/dist/types.js +4 -0
- package/dist/utm.js +38 -0
- package/dist/verb-surface.js +331 -0
- package/dist/version-utils.js +85 -0
- package/dist/watcher.js +207 -0
- package/package.json +88 -0
- package/python/qwen3_tts.py +230 -0
- package/scripts/stamp-build-channel.mjs +115 -0
- package/scripts/sync-installed.mjs +159 -0
- package/scripts/sync-skills.mjs +112 -0
- package/skills/README.md +24 -0
- package/skills/agent-doodle-space/SKILL.md +118 -0
- package/skills/agent-home/SKILL.md +98 -0
- package/skills/beehaven-cli/SKILL.md +144 -0
- package/skills/claude-shopper/README.md +90 -0
- package/skills/claude-shopper/SKILL.md +151 -0
- package/skills/clearly-agent/SKILL.md +183 -0
- package/skills/clearly-canvas/SKILL.md +222 -0
- package/skills/clearly-canvas/authoring.md +160 -0
- package/skills/clearly-canvas/communications.md +32 -0
- package/skills/clearly-canvas/composition-patterns.md +84 -0
- package/skills/clearly-canvas/example.mjs +78 -0
- package/skills/clearly-canvas/project-plans.md +156 -0
- package/skills/clearly-canvas/review.mjs +68 -0
- package/skills/clearly-workspace/SKILL.md +79 -0
- package/skills/clearly-workspace/survey.mjs +101 -0
- package/skills/codebase-map/SKILL.md +142 -0
- package/skills/design-craft/SKILL.md +300 -0
- package/skills/index.json +79 -0
- package/skills/layout-systems/SKILL.md +184 -0
- package/skills/pair-on-canvas/SKILL.md +92 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-credential-store — daemon-side persistent storage for the
|
|
3
|
+
* user's Claude API key / plan token, written by the
|
|
4
|
+
* `canvas-credential-store` relay handler.
|
|
5
|
+
*
|
|
6
|
+
* Storage priority:
|
|
7
|
+
* 1. macOS Keychain via `security(1)` CLI — preferred. Stored under
|
|
8
|
+
* service "clearly-claude-credential" + account "default", which
|
|
9
|
+
* keeps it discoverable via Keychain Access.app and survives
|
|
10
|
+
* OS reinstalls / disk migrations cleanly.
|
|
11
|
+
* 2. ~/.beehaven/claude-credentials JSON file with mode 600 —
|
|
12
|
+
* fallback for non-macOS hosts OR when security(1) is unavailable
|
|
13
|
+
* (e.g. inside containerised dev environments).
|
|
14
|
+
*
|
|
15
|
+
* The Agent SDK reads from one of these on each query() — see
|
|
16
|
+
* daemon-omni.ts's setup of `buildSdkOptions` (a future change will
|
|
17
|
+
* teach it to consult this store before falling back to
|
|
18
|
+
* ANTHROPIC_API_KEY env). For now, this module writes the value to BOTH
|
|
19
|
+
* the keychain AND the process env (`ANTHROPIC_API_KEY`) so the SDK
|
|
20
|
+
* picks it up on the very next turn without further changes.
|
|
21
|
+
*
|
|
22
|
+
* ── WHAT THIS STORE DOES NOT PERSIST ────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* **Masked display strings.** A mask is a pure function of the secret,
|
|
25
|
+
* so it is DERIVED on every read, never written. Persisting it bought
|
|
26
|
+
* nothing and cost twice:
|
|
27
|
+
*
|
|
28
|
+
* 1. Drift. The keychain path always recomputed the mask and ignored
|
|
29
|
+
* whatever was stored, while the file fallback trusted the stored
|
|
30
|
+
* copy — two sources of truth for one string, guaranteed to
|
|
31
|
+
* disagree the first time the format changed.
|
|
32
|
+
* 2. A real bug. The `plan` record was ONLY a mask, and that mask
|
|
33
|
+
* carried a typographic ellipsis (U+2026). `security(1)` hex-encodes
|
|
34
|
+
* any non-ASCII password on read, so `JSON.parse` threw and
|
|
35
|
+
* readClaudeCredential() returned null for every plan credential
|
|
36
|
+
* ever written on macOS — the lane looked disconnected after any
|
|
37
|
+
* daemon restart. A presentation concern reached into the storage
|
|
38
|
+
* layer and broke it.
|
|
39
|
+
*
|
|
40
|
+
* **Plan connection state.** `credType:'plan'` now stores NOTHING at
|
|
41
|
+
* all. We do not own that fact — `~/.claude/.credentials.json` does,
|
|
42
|
+
* written by Anthropic's own CLI. A stored marker was a cache of
|
|
43
|
+
* someone else's state with no invalidation: it survived `claude
|
|
44
|
+
* logout` and token expiry and kept reporting "connected". Plan status
|
|
45
|
+
* is now read live from that file on every call, so it cannot go stale.
|
|
46
|
+
*
|
|
47
|
+
* ⚠ Consequence worth knowing: "Disconnect" cannot revoke plan auth,
|
|
48
|
+
* because there is nothing of ours to delete. It clears any stored key
|
|
49
|
+
* and the env; revoking the subscription itself is `claude logout`.
|
|
50
|
+
*/
|
|
51
|
+
import { spawn } from 'node:child_process';
|
|
52
|
+
import { promises as fs, watch as fsWatch, existsSync } from 'node:fs';
|
|
53
|
+
import { homedir, platform, arch } from 'node:os';
|
|
54
|
+
import { join, dirname } from 'node:path';
|
|
55
|
+
import { fileURLToPath } from 'node:url';
|
|
56
|
+
const SERVICE = 'clearly-claude-credential';
|
|
57
|
+
const ACCOUNT = 'default';
|
|
58
|
+
const FALLBACK_DIR = join(homedir(), '.beehaven');
|
|
59
|
+
const FALLBACK_FILE = join(FALLBACK_DIR, 'claude-credentials');
|
|
60
|
+
/** Mask an API key for display. Derived, never stored. ASCII only —
|
|
61
|
+
* a non-ASCII mask is what made `security(1)` hex-encode the record. */
|
|
62
|
+
function maskKey(credType, key) {
|
|
63
|
+
// OpenRouter keeps more prefix ("sk-or-v1…") since users often have
|
|
64
|
+
// multiple OR keys and the prefix helps disambiguate.
|
|
65
|
+
const head = credType === 'openrouter-key' ? 10 : 8;
|
|
66
|
+
return `${key.slice(0, head)}...${key.slice(-4)}`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Read the LIVE plan auth from Claude Code's own credentials file.
|
|
70
|
+
*
|
|
71
|
+
* This is the source of truth for "is a Claude subscription authorized
|
|
72
|
+
* on this machine" — we never copy it, cache it, or write it anywhere.
|
|
73
|
+
* Probes the canonical `.credentials.json` then the legacy
|
|
74
|
+
* `credentials.json` (no dot) that older Claude Code releases wrote.
|
|
75
|
+
*
|
|
76
|
+
* Returns null when no subscription auth is present.
|
|
77
|
+
*/
|
|
78
|
+
async function readLivePlanAuth() {
|
|
79
|
+
for (const path of [CLAUDE_CREDENTIALS_FILE, CLAUDE_CREDENTIALS_LEGACY_FILE]) {
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(await fs.readFile(path, 'utf8'));
|
|
82
|
+
const token = parsed?.claudeAiOauth?.accessToken;
|
|
83
|
+
if (typeof token === 'string' && token) {
|
|
84
|
+
return {
|
|
85
|
+
maskedKey: `Pro/Max plan (...${token.slice(-4)})`,
|
|
86
|
+
expiresAt: typeof parsed.claudeAiOauth.expiresAt === 'number'
|
|
87
|
+
? parsed.claudeAiOauth.expiresAt
|
|
88
|
+
: undefined,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Missing / mid-atomic-write / malformed — try the next location.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
/** OpenRouter's Anthropic-compatible endpoint. The Anthropic Agent SDK
|
|
99
|
+
* reads `ANTHROPIC_BASE_URL` to override its default api.anthropic.com
|
|
100
|
+
* target — pointing it here routes the (otherwise identical) Anthropic-
|
|
101
|
+
* protocol traffic through OpenRouter, which forwards to the underlying
|
|
102
|
+
* Anthropic model (or any compatible Claude SKU) and bills against the
|
|
103
|
+
* caller's OpenRouter account instead of an Anthropic account. */
|
|
104
|
+
const OPENROUTER_ANTHROPIC_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
105
|
+
export async function storeClaudeCredential(input) {
|
|
106
|
+
const { credType, apiKey, planTokenHint } = input;
|
|
107
|
+
// ── plan: persist NOTHING ─────────────────────────────────────────
|
|
108
|
+
// Selecting the plan lane is not a write, it's a switch. Anthropic's
|
|
109
|
+
// CLI owns the token; all we do is get our key-based env out of the
|
|
110
|
+
// way so the `claude` auth chain falls through to subscription OAuth
|
|
111
|
+
// (ANTHROPIC_API_KEY outranks it), and drop any stored key so the old
|
|
112
|
+
// one can't win again on the next daemon restart.
|
|
113
|
+
//
|
|
114
|
+
// The mask we return is read LIVE from the credentials file, so the
|
|
115
|
+
// UI shows the token actually in force rather than a remembered one.
|
|
116
|
+
if (credType === 'plan') {
|
|
117
|
+
await deleteClaudeCredential(); // removes stored key + clears both env vars
|
|
118
|
+
const live = await readLivePlanAuth();
|
|
119
|
+
return {
|
|
120
|
+
storage: 'none',
|
|
121
|
+
maskedKey: live?.maskedKey ??
|
|
122
|
+
(planTokenHint ? `Pro/Max plan (...${planTokenHint})` : 'Pro/Max plan'),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (!apiKey)
|
|
126
|
+
throw new Error(`apiKey required for ${credType} credType`);
|
|
127
|
+
const secretValue = apiKey;
|
|
128
|
+
// Derived for the return value only — never written. See the header.
|
|
129
|
+
const masked = maskKey(credType, apiKey);
|
|
130
|
+
// ── Try macOS Keychain first ──────────────────────────────────────
|
|
131
|
+
if (platform() === 'darwin') {
|
|
132
|
+
try {
|
|
133
|
+
// Delete any prior entry — security add-generic-password errors
|
|
134
|
+
// out if a duplicate (service, account) pair already exists. Idempotency
|
|
135
|
+
// via delete-then-add is simpler than detecting the prior state.
|
|
136
|
+
await runSecurity(['delete-generic-password', '-s', SERVICE, '-a', ACCOUNT]).catch(() => { });
|
|
137
|
+
await runSecurity([
|
|
138
|
+
'add-generic-password',
|
|
139
|
+
'-s', SERVICE,
|
|
140
|
+
'-a', ACCOUNT,
|
|
141
|
+
'-w', secretValue,
|
|
142
|
+
'-U', // update if exists (belt + suspenders alongside the delete above)
|
|
143
|
+
// Description visible in Keychain Access — helps users understand
|
|
144
|
+
// what the entry is when they audit their keychain.
|
|
145
|
+
'-D', 'Clearly — Claude API key / plan auth',
|
|
146
|
+
'-j', 'Stored by the Hive Mac daemon for use by the Claude Agent SDK.',
|
|
147
|
+
]);
|
|
148
|
+
// Also surface the secret to the daemon's process env so the
|
|
149
|
+
// CURRENT-PROCESS Agent SDK picks it up immediately. The next
|
|
150
|
+
// daemon restart will re-read from keychain (a future
|
|
151
|
+
// daemon-startup hook).
|
|
152
|
+
//
|
|
153
|
+
// For openrouter-key we ALSO set ANTHROPIC_BASE_URL so the
|
|
154
|
+
// Anthropic Agent SDK routes through OpenRouter's compat
|
|
155
|
+
// endpoint instead of api.anthropic.com; for a direct Anthropic
|
|
156
|
+
// key we clear it, so switching lanes can't strand a stale
|
|
157
|
+
// OpenRouter base URL that silently bills the wrong account.
|
|
158
|
+
process.env.ANTHROPIC_API_KEY = secretValue;
|
|
159
|
+
if (credType === 'openrouter-key') {
|
|
160
|
+
process.env.ANTHROPIC_BASE_URL = OPENROUTER_ANTHROPIC_BASE_URL;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
delete process.env.ANTHROPIC_BASE_URL;
|
|
164
|
+
}
|
|
165
|
+
return { storage: 'keychain', maskedKey: masked };
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
// Fall through to filesystem fallback. Don't bubble — keychain
|
|
169
|
+
// unavailability is recoverable; we just lose the lifecycle bind.
|
|
170
|
+
// eslint-disable-next-line no-console
|
|
171
|
+
console.warn(`[claude-credential-store] keychain write failed, falling back to file: ${err?.message ?? err}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// ── Filesystem fallback ───────────────────────────────────────────
|
|
175
|
+
// Mode 600 — owner-only read/write. Survives daemon restarts; a
|
|
176
|
+
// future enhancement could encrypt with a machine-id-derived key.
|
|
177
|
+
await fs.mkdir(FALLBACK_DIR, { recursive: true });
|
|
178
|
+
// No maskedKey field — it is derived on read, same as the keychain
|
|
179
|
+
// path. Storing it here is what let the two paths drift.
|
|
180
|
+
const payload = {
|
|
181
|
+
credType,
|
|
182
|
+
secret: secretValue,
|
|
183
|
+
storedAt: Date.now(),
|
|
184
|
+
};
|
|
185
|
+
await fs.writeFile(FALLBACK_FILE, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
186
|
+
// Same env handling as the keychain path above.
|
|
187
|
+
process.env.ANTHROPIC_API_KEY = secretValue;
|
|
188
|
+
if (credType === 'openrouter-key') {
|
|
189
|
+
process.env.ANTHROPIC_BASE_URL = OPENROUTER_ANTHROPIC_BASE_URL;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
delete process.env.ANTHROPIC_BASE_URL;
|
|
193
|
+
}
|
|
194
|
+
return { storage: 'file', maskedKey: masked };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* readClaudeCredential — fetch the stored value back. Used by the
|
|
198
|
+
* daemon's startup hook to re-hydrate process.env on relaunch, and
|
|
199
|
+
* by an introspection RPC the settings panel uses to verify the
|
|
200
|
+
* keychain still has the entry.
|
|
201
|
+
*
|
|
202
|
+
* Returns null when nothing is stored.
|
|
203
|
+
*/
|
|
204
|
+
export async function readClaudeCredential() {
|
|
205
|
+
// macOS keychain first.
|
|
206
|
+
if (platform() === 'darwin') {
|
|
207
|
+
try {
|
|
208
|
+
const stdout = await runSecurity(['find-generic-password', '-s', SERVICE, '-a', ACCOUNT, '-w']);
|
|
209
|
+
const secret = stdout.trim();
|
|
210
|
+
if (!secret)
|
|
211
|
+
return null;
|
|
212
|
+
if (secret.startsWith('sk-or-')) {
|
|
213
|
+
return { credType: 'openrouter-key', secret, maskedKey: maskKey('openrouter-key', secret) };
|
|
214
|
+
}
|
|
215
|
+
if (secret.startsWith('sk-ant-')) {
|
|
216
|
+
return { credType: 'api-key', secret, maskedKey: maskKey('api-key', secret) };
|
|
217
|
+
}
|
|
218
|
+
// Anything else is a LEGACY plan marker — either readable JSON or,
|
|
219
|
+
// far more often, the hex `security(1)` emits for the non-ASCII
|
|
220
|
+
// ellipsis it contained (which is why these never read back). It
|
|
221
|
+
// holds no secret and no fact we can't get live, so drop it and
|
|
222
|
+
// let the live check below answer.
|
|
223
|
+
await deleteClaudeCredential();
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// keychain miss — fall through to file fallback
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Filesystem fallback. Mask is DERIVED here too — reading a stored
|
|
230
|
+
// one is what let this path disagree with the keychain path.
|
|
231
|
+
try {
|
|
232
|
+
const raw = await fs.readFile(FALLBACK_FILE, 'utf8');
|
|
233
|
+
const parsed = JSON.parse(raw);
|
|
234
|
+
const t = parsed?.credType;
|
|
235
|
+
if (parsed?.secret && (t === 'api-key' || t === 'openrouter-key')) {
|
|
236
|
+
return { credType: t, secret: parsed.secret, maskedKey: maskKey(t, parsed.secret) };
|
|
237
|
+
}
|
|
238
|
+
// Legacy plan marker in the file fallback — same treatment as the
|
|
239
|
+
// keychain one above.
|
|
240
|
+
if (t === 'plan')
|
|
241
|
+
await deleteClaudeCredential();
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// file not present — no credential stored
|
|
245
|
+
}
|
|
246
|
+
// ── Nothing of ours is stored. Is a Claude subscription authorized
|
|
247
|
+
// on this machine? Read it live; never cached, so it cannot be
|
|
248
|
+
// stale after `claude logout` or a token expiry.
|
|
249
|
+
const live = await readLivePlanAuth();
|
|
250
|
+
if (live)
|
|
251
|
+
return { credType: 'plan', secret: '', maskedKey: live.maskedKey };
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* deleteClaudeCredential — remove from both keychain + fallback. Used
|
|
256
|
+
* by the `canvas-credential-disconnect` IPC path (future) so a
|
|
257
|
+
* worker-side disconnect propagates to the daemon's stored credential.
|
|
258
|
+
* Today the worker just clears its row; calling this from the worker
|
|
259
|
+
* side is a follow-up.
|
|
260
|
+
*/
|
|
261
|
+
export async function deleteClaudeCredential() {
|
|
262
|
+
if (platform() === 'darwin') {
|
|
263
|
+
await runSecurity(['delete-generic-password', '-s', SERVICE, '-a', ACCOUNT]).catch(() => { });
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
await fs.unlink(FALLBACK_FILE);
|
|
267
|
+
}
|
|
268
|
+
catch { /* idempotent */ }
|
|
269
|
+
// Clear ALL env vars the SDK could pick up — both the Anthropic
|
|
270
|
+
// key and any OpenRouter base-URL override. Leaving BASE_URL set
|
|
271
|
+
// after a disconnect would route a subsequent `claude login` Keychain
|
|
272
|
+
// token through OpenRouter (still valid, but bills against the wrong
|
|
273
|
+
// account silently).
|
|
274
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
275
|
+
delete process.env.ANTHROPIC_BASE_URL;
|
|
276
|
+
}
|
|
277
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
278
|
+
// Plan-SDK OAuth — `claude setup-token` driver + credentials watcher
|
|
279
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
280
|
+
//
|
|
281
|
+
// The Anthropic Agent SDK plus the Claude Code CLI handle OAuth against
|
|
282
|
+
// claude.ai natively. From our daemon we just need to:
|
|
283
|
+
// 1. Find the `claude` binary (bundled with the Agent SDK as a
|
|
284
|
+
// platform-native optional dep, or installed at ~/.local/bin)
|
|
285
|
+
// 2. Spawn `claude setup-token` — it opens the user's browser, runs
|
|
286
|
+
// the OAuth flow, and writes ~/.claude/.credentials.json on success
|
|
287
|
+
// 3. Watch ~/.claude/.credentials.json for the post-auth state and
|
|
288
|
+
// notify the worker via canvas-credential-detected
|
|
289
|
+
//
|
|
290
|
+
// Per the May 13, 2026 Anthropic announcement + June 15, 2026 policy
|
|
291
|
+
// change, third-party Agent SDK apps may now use this flow — usage
|
|
292
|
+
// draws against the user's monthly Agent SDK credit pool ($20 Pro /
|
|
293
|
+
// $100 Max 5x / $200 Max 20x) which is separate from interactive
|
|
294
|
+
// limits. The OAuth flow itself is handled by Anthropic's CLI; we
|
|
295
|
+
// just kick it off + listen for completion.
|
|
296
|
+
const CLAUDE_CREDENTIALS_FILE = join(homedir(), '.claude', '.credentials.json');
|
|
297
|
+
// Older Claude Code releases (and some `claude login` flows) wrote the
|
|
298
|
+
// auth blob to ~/.claude/credentials.json — no dot prefix. The shape is
|
|
299
|
+
// identical (`{ claudeAiOauth: { accessToken, refreshToken, expiresAt,
|
|
300
|
+
// tokenType } }`); only the filename differs. We probe both locations so
|
|
301
|
+
// users coming from the older path don't need to re-run `claude setup-
|
|
302
|
+
// token` for the daemon to recognize their existing auth.
|
|
303
|
+
const CLAUDE_CREDENTIALS_LEGACY_FILE = join(homedir(), '.claude', 'credentials.json');
|
|
304
|
+
/**
|
|
305
|
+
* Locate the `claude` CLI binary the daemon can spawn. Resolution order:
|
|
306
|
+
* 1. Platform-native bundled binary from
|
|
307
|
+
* `@anthropic-ai/claude-agent-sdk-{platform}-{arch}` (the SDK's optional native package)
|
|
308
|
+
* 2. `node_modules/.bin/claude` — if a sibling package installed one
|
|
309
|
+
* 3. System install at `~/.local/bin/claude` (Anthropic's installer default)
|
|
310
|
+
* 4. PATH lookup via `which claude`
|
|
311
|
+
*
|
|
312
|
+
* ⚠⚠ CANDIDATE 1 CANNOT RESOLVE TODAY, AND THIS DOCSTRING USED TO CLAIM THE OPPOSITE. It ended
|
|
313
|
+
* *"which will rarely happen since we bundle the SDK platform packages"* — we do not. The SDK
|
|
314
|
+
* declares eight optional platform packages and **none is installed anywhere in this monorepo**,
|
|
315
|
+
* so nothing is bundled and no electron-builder config copies one. The measured reason is size:
|
|
316
|
+
* the Claude Code runtime is ~200 MB per version against a 561 MB app, ~36% for one architecture.
|
|
317
|
+
*
|
|
318
|
+
* ⚠⚠ SO IN PRACTICE THIS RESOLVES AT STEP 3 OR 4 — the user's OWN Claude Code install. That is the
|
|
319
|
+
* BYOK model working as intended, but it means the capability is absent on a machine that has
|
|
320
|
+
* never installed Claude Code, and **the caller must say so plainly** rather than reporting a
|
|
321
|
+
* generic failure. A false claim in a docstring is worse than no docstring: it is why nobody
|
|
322
|
+
* checked.
|
|
323
|
+
*
|
|
324
|
+
* Returns null when nothing is found.
|
|
325
|
+
*/
|
|
326
|
+
export async function findClaudeBinary() {
|
|
327
|
+
const candidates = [];
|
|
328
|
+
// 1. SDK bundled platform-native binary. Resolve relative to this
|
|
329
|
+
// module's location so it works regardless of cwd.
|
|
330
|
+
try {
|
|
331
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
332
|
+
const sdkRoot = join(moduleDir, '..', 'node_modules', '@anthropic-ai');
|
|
333
|
+
const nativePkg = `claude-agent-sdk-${platform()}-${arch()}`;
|
|
334
|
+
candidates.push(join(sdkRoot, nativePkg, 'claude'));
|
|
335
|
+
candidates.push(join(sdkRoot, nativePkg, 'bin', 'claude'));
|
|
336
|
+
// Also search up the tree — the Mac app's electron-builder unpacks
|
|
337
|
+
// node_modules into Resources/app.asar.unpacked
|
|
338
|
+
for (let depth = 2; depth <= 5; depth++) {
|
|
339
|
+
candidates.push(join(moduleDir, ...Array(depth).fill('..'), 'node_modules', '@anthropic-ai', nativePkg, 'claude'));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch { /* file URL conversion failure — skip */ }
|
|
343
|
+
/**
|
|
344
|
+
* 2. A sibling `node_modules/.bin/claude`, resolved RELATIVE TO THIS MODULE.
|
|
345
|
+
*
|
|
346
|
+
* ⚠⚠ THIS WAS A HARDCODED ABSOLUTE PATH INTO ONE DEVELOPER'S HOME DIRECTORY —
|
|
347
|
+
* `~/projects/clearly/beehaven_sdk/packages/cli/node_modules/.bin/claude` — shipped inside a
|
|
348
|
+
* signed app. On that one machine it made the agent surface work for a reason no customer
|
|
349
|
+
* shares; on every other machine it is a guaranteed miss that costs a stat() and hides the real
|
|
350
|
+
* answer ("Claude Code is not installed") behind a path nobody recognises.
|
|
351
|
+
*
|
|
352
|
+
* Resolved from `import.meta.url` it means what it was trying to mean, in a checkout AND in a
|
|
353
|
+
* bundle, with nobody's username in it.
|
|
354
|
+
*/
|
|
355
|
+
try {
|
|
356
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
357
|
+
for (let depth = 1; depth <= 4; depth++) {
|
|
358
|
+
candidates.push(join(moduleDir, ...Array(depth).fill('..'), 'node_modules', '.bin', 'claude'));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
catch { /* file URL conversion failure — skip */ }
|
|
362
|
+
// 3. User-level install (Anthropic's installer default)
|
|
363
|
+
candidates.push(join(homedir(), '.local', 'bin', 'claude'));
|
|
364
|
+
candidates.push(join(homedir(), '.local', 'share', 'claude', 'bin', 'claude'));
|
|
365
|
+
for (const p of candidates) {
|
|
366
|
+
if (existsSync(p))
|
|
367
|
+
return p;
|
|
368
|
+
}
|
|
369
|
+
// 4. Last-resort PATH lookup
|
|
370
|
+
return new Promise((resolve) => {
|
|
371
|
+
const child = spawn('which', ['claude'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
372
|
+
let out = '';
|
|
373
|
+
child.stdout.on('data', (d) => { out += d.toString('utf8'); });
|
|
374
|
+
child.on('close', (code) => {
|
|
375
|
+
const path = out.trim();
|
|
376
|
+
resolve(code === 0 && path ? path : null);
|
|
377
|
+
});
|
|
378
|
+
child.on('error', () => resolve(null));
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Spawn `claude setup-token` to kick off the plan-SDK OAuth flow.
|
|
383
|
+
*
|
|
384
|
+
* The CLI opens the user's default browser to claude.com OAuth, waits
|
|
385
|
+
* for the user to authorize, and writes ~/.claude/.credentials.json
|
|
386
|
+
* with the `claudeAiOauth` block on success. Our daemon doesn't block
|
|
387
|
+
* on subprocess completion — we let it run in the background. The
|
|
388
|
+
* companion file watcher detects the credentials file mutation and
|
|
389
|
+
* notifies the worker.
|
|
390
|
+
*
|
|
391
|
+
* Returns the subprocess PID so callers can ack the launch back to
|
|
392
|
+
* the worker (the UI shows "browser tab opening / authorize there").
|
|
393
|
+
* If the subprocess exits with an error code, we surface stderr so
|
|
394
|
+
* users get diagnostic information.
|
|
395
|
+
*/
|
|
396
|
+
export async function startPlanOauth(opts) {
|
|
397
|
+
const binary = await findClaudeBinary();
|
|
398
|
+
if (!binary) {
|
|
399
|
+
return {
|
|
400
|
+
ok: false,
|
|
401
|
+
error: 'Could not find the Claude Code binary. Install at https://claude.com/download or via the Mac app reinstall.',
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
let child;
|
|
405
|
+
try {
|
|
406
|
+
child = spawn(binary, ['setup-token'], {
|
|
407
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
408
|
+
// Inherit env so the subprocess can read the user's existing
|
|
409
|
+
// ~/.claude/ config + open their browser via xdg-open / open.
|
|
410
|
+
env: { ...process.env },
|
|
411
|
+
detached: false,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
return { ok: false, error: `Could not spawn claude setup-token: ${err?.message ?? err}` };
|
|
416
|
+
}
|
|
417
|
+
// Accumulate stderr for diagnostic surfacing if the subprocess fails.
|
|
418
|
+
let stderrTail = '';
|
|
419
|
+
child.stdout?.on('data', (d) => {
|
|
420
|
+
const chunk = d.toString('utf8');
|
|
421
|
+
if (opts?.onOutput)
|
|
422
|
+
opts.onOutput(chunk);
|
|
423
|
+
});
|
|
424
|
+
child.stderr?.on('data', (d) => {
|
|
425
|
+
const chunk = d.toString('utf8');
|
|
426
|
+
stderrTail = (stderrTail + chunk).slice(-2000); // bounded buffer
|
|
427
|
+
if (opts?.onOutput)
|
|
428
|
+
opts.onOutput(chunk);
|
|
429
|
+
});
|
|
430
|
+
child.on('exit', (code) => {
|
|
431
|
+
if (opts?.onExit)
|
|
432
|
+
opts.onExit(code, stderrTail);
|
|
433
|
+
});
|
|
434
|
+
return { ok: true, pid: child.pid ?? -1, binary };
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Read Claude Code's auth file from whichever location currently holds it.
|
|
438
|
+
*
|
|
439
|
+
* Tries the canonical `.credentials.json` first; falls back to the legacy
|
|
440
|
+
* `credentials.json` (no dot) that older Claude Code releases used. When
|
|
441
|
+
* only the legacy path has a credential, best-effort symlinks it to the
|
|
442
|
+
* canonical path so the Anthropic Agent SDK — which hard-codes
|
|
443
|
+
* `~/.claude/.credentials.json` — picks up the same token without the
|
|
444
|
+
* user re-running `claude setup-token`. Symlink failure is non-fatal:
|
|
445
|
+
* we still return the parsed credential so the worker-side gate
|
|
446
|
+
* (workspace.ts `agent:claude-prompt` cred check) gets notified; only
|
|
447
|
+
* the SDK side stays broken until the symlink can be created.
|
|
448
|
+
*
|
|
449
|
+
* Returns { parsed, sourcePath } on hit, null on miss.
|
|
450
|
+
*/
|
|
451
|
+
async function readClaudeCodeAuthFile() {
|
|
452
|
+
// Canonical first.
|
|
453
|
+
try {
|
|
454
|
+
const raw = await fs.readFile(CLAUDE_CREDENTIALS_FILE, 'utf8');
|
|
455
|
+
return { parsed: JSON.parse(raw), sourcePath: CLAUDE_CREDENTIALS_FILE };
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
if (err?.code !== 'ENOENT')
|
|
459
|
+
throw err;
|
|
460
|
+
}
|
|
461
|
+
// Legacy fallback — bridge to canonical via symlink so the SDK sees it.
|
|
462
|
+
try {
|
|
463
|
+
const raw = await fs.readFile(CLAUDE_CREDENTIALS_LEGACY_FILE, 'utf8');
|
|
464
|
+
const parsed = JSON.parse(raw);
|
|
465
|
+
try {
|
|
466
|
+
await fs.symlink(CLAUDE_CREDENTIALS_LEGACY_FILE, CLAUDE_CREDENTIALS_FILE);
|
|
467
|
+
// eslint-disable-next-line no-console
|
|
468
|
+
console.log(`[claude-credential-store] bridged legacy auth: symlinked ${CLAUDE_CREDENTIALS_LEGACY_FILE} → ${CLAUDE_CREDENTIALS_FILE}`);
|
|
469
|
+
}
|
|
470
|
+
catch (linkErr) {
|
|
471
|
+
// EEXIST means the canonical file was created in the gap between
|
|
472
|
+
// our canonical-read above (which returned ENOENT) and the symlink
|
|
473
|
+
// attempt — race with `claude setup-token` running concurrently.
|
|
474
|
+
// Harmless: re-read on the next watcher tick. Anything else (EACCES,
|
|
475
|
+
// EROFS, etc.) we surface so the operator knows the SDK side won't
|
|
476
|
+
// pick up the token even though the worker side will.
|
|
477
|
+
if (linkErr?.code !== 'EEXIST') {
|
|
478
|
+
// eslint-disable-next-line no-console
|
|
479
|
+
console.warn(`[claude-credential-store] could not symlink ${CLAUDE_CREDENTIALS_LEGACY_FILE} → ${CLAUDE_CREDENTIALS_FILE}: ${linkErr?.message ?? linkErr}`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return { parsed, sourcePath: CLAUDE_CREDENTIALS_LEGACY_FILE };
|
|
483
|
+
}
|
|
484
|
+
catch (err) {
|
|
485
|
+
if (err?.code === 'ENOENT')
|
|
486
|
+
return null;
|
|
487
|
+
throw err;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Watch ~/.claude/.credentials.json (and the legacy `credentials.json`)
|
|
492
|
+
* and fire onDetected whenever a `claudeAiOauth.accessToken` appears
|
|
493
|
+
* (or changes).
|
|
494
|
+
*
|
|
495
|
+
* Implementation notes:
|
|
496
|
+
* - fs.watch on the parent dir, filtered to either credentials
|
|
497
|
+
* filename. Watching files directly is fragile because the CLI
|
|
498
|
+
* uses atomic-write (write to a temp file, rename over) which fires
|
|
499
|
+
* 'rename' events the file watcher can't track.
|
|
500
|
+
* - Coalesce events with a small debounce — atomic writes typically
|
|
501
|
+
* produce 2-3 events back-to-back.
|
|
502
|
+
* - We don't track per-token changes; the worker only cares about
|
|
503
|
+
* "from no token → token present" transitions (treats subsequent
|
|
504
|
+
* refreshes as no-ops).
|
|
505
|
+
*
|
|
506
|
+
* Returns an unsubscribe function the caller invokes to stop the watch.
|
|
507
|
+
*/
|
|
508
|
+
export function watchClaudeCredentialsFile(onDetected) {
|
|
509
|
+
const dir = dirname(CLAUDE_CREDENTIALS_FILE);
|
|
510
|
+
const canonicalFilename = '.credentials.json';
|
|
511
|
+
const legacyFilename = 'credentials.json';
|
|
512
|
+
let lastSeenKey = null;
|
|
513
|
+
let debounceTimer = null;
|
|
514
|
+
const evaluate = async () => {
|
|
515
|
+
try {
|
|
516
|
+
const result = await readClaudeCodeAuthFile();
|
|
517
|
+
if (!result) {
|
|
518
|
+
// No credential in either location. Reset state so a subsequent
|
|
519
|
+
// appearance is treated as a new event.
|
|
520
|
+
lastSeenKey = null;
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const parsed = result.parsed;
|
|
524
|
+
// Plan-SDK OAuth shape: { claudeAiOauth: { accessToken, refreshToken, expiresAt, scopes } }
|
|
525
|
+
if (parsed?.claudeAiOauth?.accessToken) {
|
|
526
|
+
const token = String(parsed.claudeAiOauth.accessToken);
|
|
527
|
+
if (token === lastSeenKey)
|
|
528
|
+
return; // no change
|
|
529
|
+
lastSeenKey = token;
|
|
530
|
+
const masked = `Pro/Max plan (…${token.slice(-4)})`;
|
|
531
|
+
onDetected({
|
|
532
|
+
type: 'plan',
|
|
533
|
+
maskedKey: masked,
|
|
534
|
+
expiresAt: typeof parsed.claudeAiOauth.expiresAt === 'number' ? parsed.claudeAiOauth.expiresAt : undefined,
|
|
535
|
+
scopes: Array.isArray(parsed.claudeAiOauth.scopes) ? parsed.claudeAiOauth.scopes : undefined,
|
|
536
|
+
});
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
// API-key shape: { primaryApiKey: 'sk-ant-...' } or similar — included for
|
|
540
|
+
// completeness if a user runs `claude --api-key`. Treated as 'api-key' type.
|
|
541
|
+
if (typeof parsed?.primaryApiKey === 'string' && parsed.primaryApiKey.startsWith('sk-ant-')) {
|
|
542
|
+
const key = parsed.primaryApiKey;
|
|
543
|
+
if (key === lastSeenKey)
|
|
544
|
+
return;
|
|
545
|
+
lastSeenKey = key;
|
|
546
|
+
onDetected({
|
|
547
|
+
type: 'api-key',
|
|
548
|
+
maskedKey: `${key.slice(0, 8)}...${key.slice(-4)}`,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
// Parse errors are normal during atomic-write windows; ignore.
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
// Initial poll in case the file already exists.
|
|
557
|
+
evaluate();
|
|
558
|
+
let watcher;
|
|
559
|
+
try {
|
|
560
|
+
watcher = fsWatch(dir, { persistent: false }, (_event, name) => {
|
|
561
|
+
if (name !== canonicalFilename && name !== legacyFilename)
|
|
562
|
+
return;
|
|
563
|
+
if (debounceTimer)
|
|
564
|
+
clearTimeout(debounceTimer);
|
|
565
|
+
debounceTimer = setTimeout(evaluate, 200);
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
catch (err) {
|
|
569
|
+
// ~/.claude/ may not exist yet (fresh install). Create it then watch.
|
|
570
|
+
// If creation fails too, we silently no-op — the OAuth flow will
|
|
571
|
+
// create the dir on first run, and a future call to this function
|
|
572
|
+
// can succeed.
|
|
573
|
+
try {
|
|
574
|
+
const fsSync = require('node:fs');
|
|
575
|
+
fsSync.mkdirSync(dir, { recursive: true });
|
|
576
|
+
watcher = fsWatch(dir, { persistent: false }, (_event, name) => {
|
|
577
|
+
if (name !== canonicalFilename && name !== legacyFilename)
|
|
578
|
+
return;
|
|
579
|
+
if (debounceTimer)
|
|
580
|
+
clearTimeout(debounceTimer);
|
|
581
|
+
debounceTimer = setTimeout(evaluate, 200);
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
return () => { };
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return () => {
|
|
589
|
+
if (debounceTimer)
|
|
590
|
+
clearTimeout(debounceTimer);
|
|
591
|
+
try {
|
|
592
|
+
watcher.close();
|
|
593
|
+
}
|
|
594
|
+
catch { /* idempotent */ }
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
// ── Internal: security(1) wrapper ────────────────────────────────────
|
|
598
|
+
function runSecurity(args) {
|
|
599
|
+
return new Promise((resolve, reject) => {
|
|
600
|
+
const child = spawn('security', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
601
|
+
let out = '';
|
|
602
|
+
let err = '';
|
|
603
|
+
child.stdout.on('data', (d) => { out += d.toString('utf8'); });
|
|
604
|
+
child.stderr.on('data', (d) => { err += d.toString('utf8'); });
|
|
605
|
+
child.on('close', (code) => {
|
|
606
|
+
if (code === 0) {
|
|
607
|
+
resolve(out);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* ⚠⚠ "NOT FOUND" IS AN ABSENCE, NOT A FAILURE — and reporting it as one is where the scary
|
|
612
|
+
* keychain text comes from. `security` exits 44 with
|
|
613
|
+
*
|
|
614
|
+
* security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
|
|
615
|
+
*
|
|
616
|
+
* for the perfectly ordinary case of reading a secret that was never stored, or deleting one
|
|
617
|
+
* that is already gone. Rejecting on it turns a routine miss into an error a user reads as
|
|
618
|
+
* "the keychain is broken" — and callers then log it, because from their side it arrived as a
|
|
619
|
+
* thrown Error rather than an empty answer.
|
|
620
|
+
*
|
|
621
|
+
* ⚠ ONLY THE NOT-FOUND CODE. A real failure — locked keychain, denied access, malformed
|
|
622
|
+
* arguments — still rejects with the full text, because those are the cases where the
|
|
623
|
+
* message is the whole diagnosis.
|
|
624
|
+
*/
|
|
625
|
+
if (code === 44 || /could not be found in the keychain/i.test(err)) {
|
|
626
|
+
resolve('');
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
reject(new Error(`security ${args[0]} failed (${code}): ${err.trim() || '(no stderr)'}`));
|
|
630
|
+
});
|
|
631
|
+
child.on('error', reject);
|
|
632
|
+
});
|
|
633
|
+
}
|