castle-web-cli 0.4.106 → 0.4.108

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/dist/agent.js CHANGED
@@ -25,7 +25,9 @@ import { buildRouterPrompt, buildTaskPrompt, userTurnInstruction, CLAUDE_TASK_SY
25
25
  import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from "./openrouter-catalog.js";
26
26
  import { classifyProviderError, failureCopy, setReaderTimeZone, } from "./agent-failures.js";
27
27
  import { fetchBudget, meteringHeaders, newAgentSessionId, reportCursorRun, withCustomHeaders, } from "./metering.js";
28
- import { anthropicKeyHelperCommand, claudeHasSavedLogin, resolveAnthropicAuth, userKey, ANTHROPIC_CREDENTIAL_ENV, ANTHROPIC_PROXY_ENV, CASTLE_USER_KEYS_PATH, } from "./byo-auth.js";
28
+ import { anthropicKeyHelperCommand, claudeHasSavedLogin, cursorAuthPath, cursorHasUserLogin, resolveAnthropicAuth, userKey, ANTHROPIC_CREDENTIAL_ENV, ANTHROPIC_PROXY_ENV, CASTLE_USER_KEYS_PATH, } from "./byo-auth.js";
29
+ import { accountsSnapshot, loginProviderFor, watchCredentials, writeCredential, } from "./byo-accounts.js";
30
+ import { cancelLogin, logout, startLogin, submitLoginCode, } from "./byo-login.js";
29
31
  import { runAgentNative } from "./native/loop.js";
30
32
  import { createPlaytestBrowserManager } from "./native/playtest-browser.js";
31
33
  import { createPlaywrightPlaytestExecutor } from "./native/playtest-executor.js";
@@ -833,32 +835,6 @@ const BACKEND_KEY_ENV = {
833
835
  claude: "ANTHROPIC_API_KEY",
834
836
  cursor: "CURSOR_API_KEY",
835
837
  };
836
- // cursor-agent rewrites ~/.config/cursor/auth.json on every successful run,
837
- // including Castle's own CURSOR_API_KEY runs -- so the file existing does NOT
838
- // mean a user logged in. Distinguish by the apiKey field: a real login is OAuth,
839
- // which drops apiKey and leaves only session tokens; any auth.json that still
840
- // carries an apiKey is an env-key cache cursor wrote from an injected key --
841
- // OURS, including a PREVIOUS key after a rotation. Treating that cache as a login
842
- // suppresses the injected key and drops cursor into the stale (dead) session.
843
- //
844
- // An earlier apiKey === castleKeys().CURSOR_API_KEY comparison misfired on a key
845
- // switch: the stale cache holds the OLD key, reads as "!= current" => "user
846
- // login" => key withheld => auth fails until auth.json is deleted by hand.
847
- // Deferring only on OAuth is rotation-proof. The cost: a tester's own-API-key
848
- // login is no longer distinguishable from our stale cache, so it is not deferred
849
- // to -- OAuth login still is (the common bypass path).
850
- function cursorAuthPath(home) {
851
- return path.join(home, ".config", "cursor", "auth.json");
852
- }
853
- function cursorHasUserLogin(home) {
854
- try {
855
- const auth = JSON.parse(fs.readFileSync(cursorAuthPath(home), "utf8"));
856
- return !auth.apiKey && !!auth.accessToken;
857
- }
858
- catch {
859
- return false;
860
- }
861
- }
862
838
  // When we inject Castle's key, any auth.json cursor cached from a DIFFERENT key
863
839
  // -- a rotated-out old key, or a tester's own key we've chosen to override -- is
864
840
  // dead weight: cursor re-auths from the injected env key and ignores it. Leaving
@@ -1626,20 +1602,32 @@ function notifyAgentRunFinished() {
1626
1602
  // True when THIS run spends Castle's credential rather than the user's own.
1627
1603
  // Resolves exactly as buildAgentInvocation / runAgentSmith will, one step
1628
1604
  // earlier, so the gate and the run can never disagree about who is paying.
1629
- // Cursor counts: it runs on Castle's key by default, and gating it here is the
1630
- // only gate there is -- its traffic never reaches the proxy.
1605
+ //
1606
+ // Cursor asks the same question as the others, just from a different file:
1607
+ // envForAgentSpawn withholds Castle's key when the user has an OAuth login, so
1608
+ // a run with one is already going to their account and the daily limit is not
1609
+ // Castle's to enforce over it. It used to answer `true` unconditionally, which
1610
+ // refused a user on their own subscription once Castle's budget ran out --
1611
+ // while the editor offered them that sign-in and promised it bypassed the
1612
+ // limit. Deferring only to a LOGIN is what keeps this from being a hole: a
1613
+ // cursor run on Castle's key stays gated, and cursor is unmetered (its traffic
1614
+ // never reaches the proxy), so an ungated one would be a free ride.
1631
1615
  function runIsCastlePaid(backend, claudeModel, orAuth) {
1632
1616
  if (backend === "cursor")
1633
- return true;
1617
+ return !cursorHasUserLogin(os.homedir());
1634
1618
  return roleUsesOpenrouter(backend, claudeModel)
1635
1619
  ? (orAuth ?? resolveOpenrouterAuth()).mode === "proxy"
1636
1620
  : resolveAnthropicAuth().mode === "proxy";
1637
1621
  }
1638
1622
  // Whether Castle's budget is this editor's to spend at all, which is what makes
1639
1623
  // the usage bar worth drawing. Both roles count, and they can disagree: an
1640
- // Anthropic login covers the claude roles while a cursor role still spends
1641
- // Castle's key. Re-read per refresh, so adding or removing a credential (or
1642
- // switching a role's backend) moves the bar on the next one.
1624
+ // Anthropic login covers the claude roles while a cursor role on Castle's key
1625
+ // still spends Castle's. Re-read per refresh, so adding or removing a
1626
+ // credential (or switching a role's backend) moves the bar on the next one.
1627
+ //
1628
+ // A cursor role on Castle's key does draw a bar its own runs never move --
1629
+ // cursor is unmetered by contract. That is a display question, not a payment
1630
+ // one, and it is not settled here.
1643
1631
  function anyRoleIsCastlePaid(settings) {
1644
1632
  return (runIsCastlePaid(settings.router, settings.routerClaudeModel, null) ||
1645
1633
  runIsCastlePaid(settings.tasks, settings.tasksClaudeModel, null));
@@ -3034,6 +3022,76 @@ function applyAgentSettings(incoming, ctx) {
3034
3022
  ctx.broadcast({ type: "settings", settings });
3035
3023
  void broadcastSettingsWarnings(ctx);
3036
3024
  }
3025
+ // Belt to watchCredentials' braces: a directory that didn't exist at boot has
3026
+ // no watcher on it, and an inotify watch can be lost. Slow, because the watch is
3027
+ // what makes this feel live -- this only has to stop a miss from lasting.
3028
+ const ACCOUNTS_POLL_MS = 15_000;
3029
+ // The editor's view of which credentials exist, kept live. Unlike settings --
3030
+ // which nothing outside this process writes -- a credential changes behind the
3031
+ // editor's back all the time: `claude /login` or `cursor-agent logout` in the
3032
+ // terminal panel, a hand-edited user-keys.json, an agent run that refreshes an
3033
+ // OAuth token. Push those, or the popover reports whatever was true when the
3034
+ // page loaded and a terminal sign-in reads as having done nothing.
3035
+ //
3036
+ // The snapshot carries presence and a hint only; no frame from here ever moves
3037
+ // a key value toward a browser.
3038
+ function createAccountsFeed(opts) {
3039
+ let last = "";
3040
+ // Tracked apart from the whole snapshot so a sign-in's phase transitions
3041
+ // (starting -> awaiting-code -> ...) don't each cost a budget fetch: they
3042
+ // move `login`, never a credential.
3043
+ let lastProviders = "";
3044
+ function push(error) {
3045
+ const accounts = accountsSnapshot();
3046
+ const serialized = JSON.stringify(accounts);
3047
+ const providers = JSON.stringify(accounts.providers);
3048
+ if (providers !== lastProviders) {
3049
+ lastProviders = providers;
3050
+ opts.onCredentialChange();
3051
+ }
3052
+ // A refusal always goes out. It is the only answer a rejected write gets,
3053
+ // and a rejected write leaves the snapshot identical by definition -- so
3054
+ // deduping on the snapshot alone would swallow exactly the frame that
3055
+ // carries the reason.
3056
+ if (serialized === last && !error)
3057
+ return;
3058
+ last = serialized;
3059
+ opts.broadcast({
3060
+ type: "accounts",
3061
+ accounts,
3062
+ ...(error ? { accountsError: error } : {}),
3063
+ });
3064
+ }
3065
+ function snapshot() {
3066
+ const accounts = accountsSnapshot();
3067
+ last = JSON.stringify(accounts);
3068
+ lastProviders = JSON.stringify(accounts.providers);
3069
+ return accounts;
3070
+ }
3071
+ const stopWatch = watchCredentials(() => push());
3072
+ const timer = setInterval(() => {
3073
+ if (opts.hasClients())
3074
+ push();
3075
+ }, ACCOUNTS_POLL_MS);
3076
+ timer.unref?.();
3077
+ return {
3078
+ snapshot,
3079
+ push,
3080
+ stop: () => {
3081
+ stopWatch();
3082
+ clearInterval(timer);
3083
+ },
3084
+ };
3085
+ }
3086
+ function applyCredentialChange(msg, ctx) {
3087
+ const clearing = msg.type === "clear-credential";
3088
+ if (!clearing && typeof msg.value !== "string")
3089
+ return;
3090
+ const result = writeCredential(msg.id, clearing ? null : msg.value);
3091
+ ctx.pushAccounts(result.ok
3092
+ ? undefined
3093
+ : { id: typeof msg.id === "string" ? msg.id : "", message: result.message });
3094
+ }
3037
3095
  // Which slugs are worth a verdict: only roles actually routed at OpenRouter
3038
3096
  // (otherwise we'd warn about an inert leftover value), and only when a slug is
3039
3097
  // set at all.
@@ -3508,6 +3566,11 @@ export function createAgentServer(opts) {
3508
3566
  hasClients: () => clients.size > 0,
3509
3567
  castlePaid: () => anyRoleIsCastlePaid(settings),
3510
3568
  });
3569
+ const accountsFeed = createAccountsFeed({
3570
+ broadcast,
3571
+ hasClients: () => clients.size > 0,
3572
+ onCredentialChange: () => usageFeed.refresh(),
3573
+ });
3511
3574
  const applySettings = (incoming) => {
3512
3575
  applyAgentSettings(incoming, { settings, settingsPath, broadcast });
3513
3576
  // A backend switch can change who pays (a cursor role always spends
@@ -3585,6 +3648,7 @@ export function createAgentServer(opts) {
3585
3648
  running: routerQueue.isRunning(),
3586
3649
  queued: routerQueue.queuedSnippets(),
3587
3650
  usage: usageFeed.latest(),
3651
+ accounts: accountsFeed.snapshot(),
3588
3652
  };
3589
3653
  socket.send(JSON.stringify(hello));
3590
3654
  // A newly attached client is the one moment the cached value may be stale
@@ -3625,6 +3689,26 @@ export function createAgentServer(opts) {
3625
3689
  else if (msg.type === "set-settings") {
3626
3690
  applySettings(msg);
3627
3691
  }
3692
+ else if (msg.type === "set-credential" ||
3693
+ msg.type === "clear-credential") {
3694
+ applyCredentialChange(msg, { pushAccounts: accountsFeed.push });
3695
+ }
3696
+ else if (msg.type === "account-login") {
3697
+ const provider = loginProviderFor(msg.id);
3698
+ if (provider)
3699
+ startLogin(provider, () => accountsFeed.push());
3700
+ }
3701
+ else if (msg.type === "account-login-code" && typeof msg.value === "string") {
3702
+ submitLoginCode(msg.value);
3703
+ }
3704
+ else if (msg.type === "account-login-cancel") {
3705
+ cancelLogin();
3706
+ }
3707
+ else if (msg.type === "account-logout") {
3708
+ const provider = loginProviderFor(msg.id);
3709
+ if (provider)
3710
+ logout(provider, () => accountsFeed.push());
3711
+ }
3628
3712
  else if (msg.type === "client-timezone" && typeof msg.timeZone === "string") {
3629
3713
  setReaderTimeZone(msg.timeZone);
3630
3714
  }
@@ -3659,6 +3743,7 @@ export function createAgentServer(opts) {
3659
3743
  }
3660
3744
  stopChildRegistry();
3661
3745
  usageFeed.stop();
3746
+ accountsFeed.stop();
3662
3747
  wss.close();
3663
3748
  void playtestBrowserManager.shutdown();
3664
3749
  }
@@ -0,0 +1,55 @@
1
+ import { type LoginProvider, type LoginState } from "./byo-login.js";
2
+ export type ProviderId = "anthropic" | "openrouter" | "cursor";
3
+ export interface ProviderStatus {
4
+ id: ProviderId;
5
+ label: string;
6
+ key?: {
7
+ placeholder: string;
8
+ present: boolean;
9
+ hint?: string;
10
+ };
11
+ login?: {
12
+ provider: LoginProvider;
13
+ loggedIn: boolean;
14
+ };
15
+ }
16
+ export interface AccountsState {
17
+ providers: ProviderStatus[];
18
+ login: LoginState | null;
19
+ }
20
+ export declare function accountsSnapshot(): AccountsState;
21
+ /**
22
+ * Call `onChange` when a credential this module reports on changes on disk.
23
+ *
24
+ * The editor is not the only writer: `claude /login` and `cursor-agent login`
25
+ * in the terminal panel, an agent run that refreshes an OAuth token, and a
26
+ * hand-edited `user-keys.json` all change the answer behind its back. Without
27
+ * this the account state is whatever was true when the page loaded, so a
28
+ * terminal sign-in looks like it did nothing until a reload.
29
+ *
30
+ * Directories, never the credential files themselves: the CLIs replace those by
31
+ * rename, which leaves a file watch holding the old inode and silently deaf --
32
+ * the same reason castle-sandboxes persists these paths as directory symlinks.
33
+ * A directory that doesn't exist yet (no `~/.castle` until the first key) simply
34
+ * isn't watched; the caller's poll is what covers that, and losing a watcher
35
+ * costs freshness, never correctness.
36
+ */
37
+ export declare function watchCredentials(onChange: () => void): () => void;
38
+ export type CredentialWriteResult = {
39
+ ok: true;
40
+ changed: boolean;
41
+ } | {
42
+ ok: false;
43
+ message: string;
44
+ };
45
+ /**
46
+ * Store one provider's key, or remove it when `value` is null.
47
+ *
48
+ * Merges into whatever is already in the file: a hand-added entry this module
49
+ * has no descriptor for MUST survive, since hand-editing stays a supported way
50
+ * in. Removing deletes the entry rather than blanking it -- the resolver reads
51
+ * an empty string as absent either way, but a leftover `"KEY": ""` reads as a
52
+ * configured key to anyone inspecting the file.
53
+ */
54
+ export declare function writeCredential(id: unknown, value: string | null): CredentialWriteResult;
55
+ export declare function loginProviderFor(id: unknown): LoginProvider | null;
@@ -0,0 +1,240 @@
1
+ // The editor's read/write surface for the user's OWN provider credentials: what
2
+ // the settings popover's Accounts modal shows, and the only thing in this
3
+ // process that writes user-keys.json.
4
+ //
5
+ // Split from byo-auth.ts deliberately. That module is the RESOLVER -- pure,
6
+ // read-only, and imported by the terminal shim's helper on EVERY `claude` run
7
+ // (see installCliShims), so it must stay free of anything stateful. This one
8
+ // is allowed to write. The login subprocesses live in byo-login.ts; this module
9
+ // only reports their state as part of one snapshot.
10
+ import * as fs from "fs";
11
+ import * as os from "os";
12
+ import * as path from "path";
13
+ import { CASTLE_USER_KEYS_PATH, claudeCredentialsPath, claudeHasSavedLogin, cursorAuthPath, cursorHasUserLogin, readUserKeys, } from "./byo-auth.js";
14
+ import { activeLogin } from "./byo-login.js";
15
+ import { inCastleSandbox } from "./metering.js";
16
+ // Providers, not credentials: Anthropic is reachable by EITHER a key or a
17
+ // claude.ai login, and resolveAnthropicAuth already treats them as one
18
+ // question (key wins). Grouping them here is what lets the modal show a user
19
+ // the two ways into one account side by side.
20
+ const PROVIDERS = [
21
+ {
22
+ id: "anthropic",
23
+ label: "Anthropic",
24
+ key: { env: "ANTHROPIC_API_KEY", placeholder: "sk-ant-…", offered: true },
25
+ login: "claude",
26
+ },
27
+ {
28
+ id: "openrouter",
29
+ label: "OpenRouter",
30
+ key: { env: "OPENROUTER_API_KEY", placeholder: "sk-or-…", offered: true },
31
+ },
32
+ {
33
+ id: "cursor",
34
+ label: "Cursor",
35
+ key: { env: "CURSOR_API_KEY", placeholder: "key_…", offered: false },
36
+ login: "cursor",
37
+ },
38
+ ];
39
+ // A stored value is never sent toward a browser -- only this. Short values get
40
+ // no tail at all rather than a tail that is most of the secret.
41
+ function hintFor(value) {
42
+ return value.length >= 12 ? `…${value.slice(-4)}` : "…";
43
+ }
44
+ function hasLogin(provider) {
45
+ return provider === "claude"
46
+ ? claudeHasSavedLogin()
47
+ : cursorHasUserLogin(os.homedir());
48
+ }
49
+ // Offered at all: a key field the user may fill, or a login they may start.
50
+ function isOffered(d) {
51
+ return Boolean(d.key?.offered) || Boolean(d.login);
52
+ }
53
+ function descriptorFor(id) {
54
+ if (typeof id !== "string")
55
+ return null;
56
+ return PROVIDERS.find((d) => isOffered(d) && d.id === id) ?? null;
57
+ }
58
+ export function accountsSnapshot() {
59
+ // Empty outside a sandbox, which hides the whole surface. A local serve has no
60
+ // Castle budget to escape, and the host machine's own claude/cursor logins are
61
+ // ALREADY what its CLIs use -- offering to "sign in" there would report the
62
+ // developer's existing login back to them as a Castle setting, and invite them
63
+ // to sign out of it from a deck editor.
64
+ if (!inCastleSandbox())
65
+ return { providers: [], login: null };
66
+ const stored = readUserKeys();
67
+ return {
68
+ providers: PROVIDERS.filter(isOffered).map((d) => {
69
+ // typeof, not just truthiness: the file is hand-editable, and a
70
+ // non-string value would throw on trim -- see readUserKeys.
71
+ const raw = d.key?.offered ? stored[d.key.env] : undefined;
72
+ const value = typeof raw === "string" ? raw.trim() : undefined;
73
+ return {
74
+ id: d.id,
75
+ label: d.label,
76
+ ...(d.key?.offered
77
+ ? {
78
+ key: {
79
+ placeholder: d.key.placeholder,
80
+ present: Boolean(value),
81
+ ...(value ? { hint: hintFor(value) } : {}),
82
+ },
83
+ }
84
+ : {}),
85
+ ...(d.login
86
+ ? { login: { provider: d.login, loggedIn: hasLogin(d.login) } }
87
+ : {}),
88
+ };
89
+ }),
90
+ login: activeLogin(),
91
+ };
92
+ }
93
+ // Several events land for one credential change (a rename is a delete plus a
94
+ // create, and the CLIs write neighbouring files in the same breath); one
95
+ // snapshot after the burst is what the editor wants.
96
+ const WATCH_DEBOUNCE_MS = 250;
97
+ /**
98
+ * Call `onChange` when a credential this module reports on changes on disk.
99
+ *
100
+ * The editor is not the only writer: `claude /login` and `cursor-agent login`
101
+ * in the terminal panel, an agent run that refreshes an OAuth token, and a
102
+ * hand-edited `user-keys.json` all change the answer behind its back. Without
103
+ * this the account state is whatever was true when the page loaded, so a
104
+ * terminal sign-in looks like it did nothing until a reload.
105
+ *
106
+ * Directories, never the credential files themselves: the CLIs replace those by
107
+ * rename, which leaves a file watch holding the old inode and silently deaf --
108
+ * the same reason castle-sandboxes persists these paths as directory symlinks.
109
+ * A directory that doesn't exist yet (no `~/.castle` until the first key) simply
110
+ * isn't watched; the caller's poll is what covers that, and losing a watcher
111
+ * costs freshness, never correctness.
112
+ */
113
+ export function watchCredentials(onChange) {
114
+ const dirs = new Set([
115
+ claudeCredentialsPath(),
116
+ cursorAuthPath(os.homedir()),
117
+ CASTLE_USER_KEYS_PATH,
118
+ ].map((p) => path.dirname(p)));
119
+ let timer = null;
120
+ const fire = () => {
121
+ if (timer)
122
+ clearTimeout(timer);
123
+ timer = setTimeout(onChange, WATCH_DEBOUNCE_MS);
124
+ timer.unref?.();
125
+ };
126
+ const watchers = [];
127
+ for (const dir of dirs) {
128
+ try {
129
+ const w = fs.watch(dir, fire);
130
+ // A watch that dies later (the directory is removed, inotify runs out)
131
+ // must not take the serve with it -- fs.watch reports those as an error
132
+ // event, which is fatal when nothing is listening.
133
+ w.on("error", () => undefined);
134
+ watchers.push(w);
135
+ }
136
+ catch {
137
+ /* not watchable here */
138
+ }
139
+ }
140
+ return () => {
141
+ if (timer)
142
+ clearTimeout(timer);
143
+ for (const w of watchers)
144
+ w.close();
145
+ };
146
+ }
147
+ // Generous -- provider key formats are not ours to predict -- but bounded, so a
148
+ // stray paste of a whole file can't land in the credential store.
149
+ const MAX_KEY_LENGTH = 500;
150
+ // A credential reaches a child process as an environment value and, for the
151
+ // terminal, as a shell-quoted assignment. A control character in it is never a
152
+ // real key and is exactly the shape that turns one env var into two, so it is
153
+ // REFUSED rather than stripped: silently storing something other than what the
154
+ // user pasted would fail later, somewhere less obvious.
155
+ function hasControlCharacter(value) {
156
+ for (const ch of value) {
157
+ const code = ch.codePointAt(0) ?? 0;
158
+ if (code < 0x20 || code === 0x7f)
159
+ return true;
160
+ }
161
+ return false;
162
+ }
163
+ function writeUserKeys(next) {
164
+ fs.mkdirSync(path.dirname(CASTLE_USER_KEYS_PATH), { recursive: true });
165
+ // Staged + renamed, not written in place: the sandbox checkpoints this
166
+ // directory on a timer, so an in-place write can be snapshotted half-done.
167
+ // The staged file carries the mode, and rename preserves it, so the result is
168
+ // 0600 even when an older file was not.
169
+ const staged = `${CASTLE_USER_KEYS_PATH}.staged`;
170
+ fs.writeFileSync(staged, JSON.stringify(next, null, 2) + "\n", {
171
+ mode: 0o600,
172
+ });
173
+ fs.renameSync(staged, CASTLE_USER_KEYS_PATH);
174
+ }
175
+ // A full disk or a permissions change comes back as a refusal the client is
176
+ // told about, not as an exception: this runs inside the WS message handler,
177
+ // where nothing catches a throw and it would take the whole serve down.
178
+ function persist(next) {
179
+ try {
180
+ writeUserKeys(next);
181
+ return { ok: true, changed: true };
182
+ }
183
+ catch {
184
+ return { ok: false, message: "could not write user-keys.json" };
185
+ }
186
+ }
187
+ /**
188
+ * Store one provider's key, or remove it when `value` is null.
189
+ *
190
+ * Merges into whatever is already in the file: a hand-added entry this module
191
+ * has no descriptor for MUST survive, since hand-editing stays a supported way
192
+ * in. Removing deletes the entry rather than blanking it -- the resolver reads
193
+ * an empty string as absent either way, but a leftover `"KEY": ""` reads as a
194
+ * configured key to anyone inspecting the file.
195
+ */
196
+ export function writeCredential(id, value) {
197
+ // accountsSnapshot being empty outside a sandbox HIDES this surface; hiding
198
+ // is not refusing. The agent socket is reachable over LAN/tailnet hostnames
199
+ // and a cross-origin page can open a WebSocket to it, so without this check
200
+ // any client could still write the host's ~/.castle/user-keys.json from a
201
+ // plain local serve.
202
+ if (!inCastleSandbox()) {
203
+ return { ok: false, message: "only available in a Castle sandbox" };
204
+ }
205
+ const descriptor = descriptorFor(id);
206
+ if (!descriptor?.key?.offered)
207
+ return { ok: false, message: "unknown credential" };
208
+ const env = descriptor.key.env;
209
+ const stored = readUserKeys();
210
+ if (value === null) {
211
+ if (!(env in stored))
212
+ return { ok: true, changed: false };
213
+ delete stored[env];
214
+ return persist(stored);
215
+ }
216
+ const trimmed = value.trim();
217
+ if (!trimmed)
218
+ return { ok: false, message: "key is empty" };
219
+ if (trimmed.length > MAX_KEY_LENGTH) {
220
+ return { ok: false, message: "key is too long" };
221
+ }
222
+ if (hasControlCharacter(trimmed)) {
223
+ return { ok: false, message: "key contains invalid characters" };
224
+ }
225
+ if (stored[env] === trimmed)
226
+ return { ok: true, changed: false };
227
+ stored[env] = trimmed;
228
+ return persist(stored);
229
+ }
230
+ // The login provider a client-supplied id names, or null. Keeps the WS handler
231
+ // from having to know the descriptor table. Null outside a sandbox for the
232
+ // same reason writeCredential refuses there -- most of all so no client can
233
+ // run `claude auth logout` / `cursor-agent logout` against the host machine's
234
+ // own login, which is exactly the outcome hiding the surface was meant to
235
+ // prevent.
236
+ export function loginProviderFor(id) {
237
+ if (!inCastleSandbox())
238
+ return null;
239
+ return descriptorFor(id)?.login ?? null;
240
+ }
@@ -1,5 +1,7 @@
1
1
  export declare const CASTLE_USER_KEYS_PATH: string;
2
- export declare function userKey(envName: "ANTHROPIC_API_KEY" | "OPENROUTER_API_KEY"): string | null;
2
+ export type UserKeyName = "ANTHROPIC_API_KEY" | "OPENROUTER_API_KEY" | "CURSOR_API_KEY";
3
+ export declare function readUserKeys(): Record<string, string>;
4
+ export declare function userKey(envName: UserKeyName): string | null;
3
5
  export type AnthropicAuth = {
4
6
  mode: "user-key";
5
7
  key: string;
@@ -15,9 +17,19 @@ export type OpenrouterAuth = {
15
17
  mode: "proxy";
16
18
  key: string;
17
19
  };
20
+ export declare function claudeCredentialsPath(): string;
18
21
  export declare function claudeHasSavedLogin(): boolean;
22
+ export declare function cursorAuthPath(home: string): string;
23
+ export declare function cursorHasUserLogin(home: string): boolean;
19
24
  export declare function resolveAnthropicAuth(): AnthropicAuth;
20
25
  export declare const ANTHROPIC_CREDENTIAL_ENV: readonly ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN_HELPER", "CLAUDE_CODE_OAUTH_TOKEN"];
21
26
  export declare const ANTHROPIC_PROXY_ENV: readonly ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_CUSTOM_HEADERS", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN_HELPER", "CLAUDE_CODE_OAUTH_TOKEN"];
22
27
  export declare function anthropicKeyHelperCommand(): string;
23
28
  export declare function envForUserShell(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
29
+ export declare const SHIMMED_CLIS: readonly ["claude", "cursor-agent"];
30
+ export type ShimmedCli = (typeof SHIMMED_CLIS)[number];
31
+ export declare function claudeShellEnvScript(env: NodeJS.ProcessEnv, args?: readonly string[]): string;
32
+ export declare function cursorShellEnvScript(env: NodeJS.ProcessEnv): string;
33
+ export declare function shimEnvScript(cli: string, env: NodeJS.ProcessEnv, shimDir: string, args?: readonly string[]): string;
34
+ export declare function ensureClaudeOnboarded(): void;
35
+ export declare function installCliShims(deckDir: string): string | null;