castle-web-cli 0.4.105 → 0.4.107

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, 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,28 @@ function applyAgentSettings(incoming, ctx) {
3034
3022
  ctx.broadcast({ type: "settings", settings });
3035
3023
  void broadcastSettingsWarnings(ctx);
3036
3024
  }
3025
+ // Store or remove one BYO credential for the editor's Accounts rows. Unlike a
3026
+ // settings change this persists nothing itself -- byo-accounts owns the file --
3027
+ // and it always answers with a fresh snapshot, including on a rejected write,
3028
+ // so a client that sent something invalid learns why instead of watching its
3029
+ // input silently do nothing. The snapshot carries presence and a hint only; no
3030
+ // frame from here ever moves a key value toward a browser.
3031
+ function broadcastAccounts(broadcast, error) {
3032
+ broadcast({
3033
+ type: "accounts",
3034
+ accounts: accountsSnapshot(),
3035
+ ...(error ? { accountsError: error } : {}),
3036
+ });
3037
+ }
3038
+ function applyCredentialChange(msg, ctx) {
3039
+ const clearing = msg.type === "clear-credential";
3040
+ if (!clearing && typeof msg.value !== "string")
3041
+ return;
3042
+ const result = writeCredential(msg.id, clearing ? null : msg.value);
3043
+ broadcastAccounts(ctx.broadcast, result.ok
3044
+ ? undefined
3045
+ : { id: typeof msg.id === "string" ? msg.id : "", message: result.message });
3046
+ }
3037
3047
  // Which slugs are worth a verdict: only roles actually routed at OpenRouter
3038
3048
  // (otherwise we'd warn about an inert leftover value), and only when a slug is
3039
3049
  // set at all.
@@ -3585,6 +3595,7 @@ export function createAgentServer(opts) {
3585
3595
  running: routerQueue.isRunning(),
3586
3596
  queued: routerQueue.queuedSnippets(),
3587
3597
  usage: usageFeed.latest(),
3598
+ accounts: accountsSnapshot(),
3588
3599
  };
3589
3600
  socket.send(JSON.stringify(hello));
3590
3601
  // A newly attached client is the one moment the cached value may be stale
@@ -3625,6 +3636,26 @@ export function createAgentServer(opts) {
3625
3636
  else if (msg.type === "set-settings") {
3626
3637
  applySettings(msg);
3627
3638
  }
3639
+ else if (msg.type === "set-credential" ||
3640
+ msg.type === "clear-credential") {
3641
+ applyCredentialChange(msg, { broadcast });
3642
+ }
3643
+ else if (msg.type === "account-login") {
3644
+ const provider = loginProviderFor(msg.id);
3645
+ if (provider)
3646
+ startLogin(provider, () => broadcastAccounts(broadcast));
3647
+ }
3648
+ else if (msg.type === "account-login-code" && typeof msg.value === "string") {
3649
+ submitLoginCode(msg.value);
3650
+ }
3651
+ else if (msg.type === "account-login-cancel") {
3652
+ cancelLogin();
3653
+ }
3654
+ else if (msg.type === "account-logout") {
3655
+ const provider = loginProviderFor(msg.id);
3656
+ if (provider)
3657
+ logout(provider, () => broadcastAccounts(broadcast));
3658
+ }
3628
3659
  else if (msg.type === "client-timezone" && typeof msg.timeZone === "string") {
3629
3660
  setReaderTimeZone(msg.timeZone);
3630
3661
  }
@@ -0,0 +1,38 @@
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
+ export type CredentialWriteResult = {
22
+ ok: true;
23
+ changed: boolean;
24
+ } | {
25
+ ok: false;
26
+ message: string;
27
+ };
28
+ /**
29
+ * Store one provider's key, or remove it when `value` is null.
30
+ *
31
+ * Merges into whatever is already in the file: a hand-added entry this module
32
+ * has no descriptor for MUST survive, since hand-editing stays a supported way
33
+ * in. Removing deletes the entry rather than blanking it -- the resolver reads
34
+ * an empty string as absent either way, but a leftover `"KEY": ""` reads as a
35
+ * configured key to anyone inspecting the file.
36
+ */
37
+ export declare function writeCredential(id: unknown, value: string | null): CredentialWriteResult;
38
+ export declare function loginProviderFor(id: unknown): LoginProvider | null;
@@ -0,0 +1,186 @@
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 installClaudeShim), 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, claudeHasSavedLogin, 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
+ // Generous -- provider key formats are not ours to predict -- but bounded, so a
94
+ // stray paste of a whole file can't land in the credential store.
95
+ const MAX_KEY_LENGTH = 500;
96
+ // A credential reaches a child process as an environment value and, for the
97
+ // terminal, as a shell-quoted assignment. A control character in it is never a
98
+ // real key and is exactly the shape that turns one env var into two, so it is
99
+ // REFUSED rather than stripped: silently storing something other than what the
100
+ // user pasted would fail later, somewhere less obvious.
101
+ function hasControlCharacter(value) {
102
+ for (const ch of value) {
103
+ const code = ch.codePointAt(0) ?? 0;
104
+ if (code < 0x20 || code === 0x7f)
105
+ return true;
106
+ }
107
+ return false;
108
+ }
109
+ function writeUserKeys(next) {
110
+ fs.mkdirSync(path.dirname(CASTLE_USER_KEYS_PATH), { recursive: true });
111
+ // Staged + renamed, not written in place: the sandbox checkpoints this
112
+ // directory on a timer, so an in-place write can be snapshotted half-done.
113
+ // The staged file carries the mode, and rename preserves it, so the result is
114
+ // 0600 even when an older file was not.
115
+ const staged = `${CASTLE_USER_KEYS_PATH}.staged`;
116
+ fs.writeFileSync(staged, JSON.stringify(next, null, 2) + "\n", {
117
+ mode: 0o600,
118
+ });
119
+ fs.renameSync(staged, CASTLE_USER_KEYS_PATH);
120
+ }
121
+ // A full disk or a permissions change comes back as a refusal the client is
122
+ // told about, not as an exception: this runs inside the WS message handler,
123
+ // where nothing catches a throw and it would take the whole serve down.
124
+ function persist(next) {
125
+ try {
126
+ writeUserKeys(next);
127
+ return { ok: true, changed: true };
128
+ }
129
+ catch {
130
+ return { ok: false, message: "could not write user-keys.json" };
131
+ }
132
+ }
133
+ /**
134
+ * Store one provider's key, or remove it when `value` is null.
135
+ *
136
+ * Merges into whatever is already in the file: a hand-added entry this module
137
+ * has no descriptor for MUST survive, since hand-editing stays a supported way
138
+ * in. Removing deletes the entry rather than blanking it -- the resolver reads
139
+ * an empty string as absent either way, but a leftover `"KEY": ""` reads as a
140
+ * configured key to anyone inspecting the file.
141
+ */
142
+ export function writeCredential(id, value) {
143
+ // accountsSnapshot being empty outside a sandbox HIDES this surface; hiding
144
+ // is not refusing. The agent socket is reachable over LAN/tailnet hostnames
145
+ // and a cross-origin page can open a WebSocket to it, so without this check
146
+ // any client could still write the host's ~/.castle/user-keys.json from a
147
+ // plain local serve.
148
+ if (!inCastleSandbox()) {
149
+ return { ok: false, message: "only available in a Castle sandbox" };
150
+ }
151
+ const descriptor = descriptorFor(id);
152
+ if (!descriptor?.key?.offered)
153
+ return { ok: false, message: "unknown credential" };
154
+ const env = descriptor.key.env;
155
+ const stored = readUserKeys();
156
+ if (value === null) {
157
+ if (!(env in stored))
158
+ return { ok: true, changed: false };
159
+ delete stored[env];
160
+ return persist(stored);
161
+ }
162
+ const trimmed = value.trim();
163
+ if (!trimmed)
164
+ return { ok: false, message: "key is empty" };
165
+ if (trimmed.length > MAX_KEY_LENGTH) {
166
+ return { ok: false, message: "key is too long" };
167
+ }
168
+ if (hasControlCharacter(trimmed)) {
169
+ return { ok: false, message: "key contains invalid characters" };
170
+ }
171
+ if (stored[env] === trimmed)
172
+ return { ok: true, changed: false };
173
+ stored[env] = trimmed;
174
+ return persist(stored);
175
+ }
176
+ // The login provider a client-supplied id names, or null. Keeps the WS handler
177
+ // from having to know the descriptor table. Null outside a sandbox for the
178
+ // same reason writeCredential refuses there -- most of all so no client can
179
+ // run `claude auth logout` / `cursor-agent logout` against the host machine's
180
+ // own login, which is exactly the outcome hiding the surface was meant to
181
+ // prevent.
182
+ export function loginProviderFor(id) {
183
+ if (!inCastleSandbox())
184
+ return null;
185
+ return descriptorFor(id)?.login ?? null;
186
+ }
@@ -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;
@@ -16,8 +18,12 @@ export type OpenrouterAuth = {
16
18
  key: string;
17
19
  };
18
20
  export declare function claudeHasSavedLogin(): boolean;
21
+ export declare function cursorAuthPath(home: string): string;
22
+ export declare function cursorHasUserLogin(home: string): boolean;
19
23
  export declare function resolveAnthropicAuth(): AnthropicAuth;
20
24
  export declare const ANTHROPIC_CREDENTIAL_ENV: readonly ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN_HELPER", "CLAUDE_CODE_OAUTH_TOKEN"];
21
25
  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
26
  export declare function anthropicKeyHelperCommand(): string;
23
27
  export declare function envForUserShell(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
28
+ export declare function claudeShellEnvScript(env: NodeJS.ProcessEnv, shimDir: string): string;
29
+ export declare function installClaudeShim(deckDir: string): string | null;
package/dist/byo-auth.js CHANGED
@@ -4,6 +4,12 @@
4
4
  // terminal has to answer the same question for the `claude` a user runs by
5
5
  // hand -- and must answer it the SAME way, or the terminal quietly spends
6
6
  // Castle's budget while the agent panel spends the user's.
7
+ //
8
+ // The claude-CLI behaviors this file leans on (AUTH_TOKEN outranks a login,
9
+ // apiKeyHelper works headless, an env key alone does not) are measured, not
10
+ // documented, and the agent-qa battery fakes `claude` so it cannot notice them
11
+ // drifting -- scripts/tests/real-claude-smoke.mjs re-verifies them against a
12
+ // real binary; run it when the sandbox image's claude version bumps.
7
13
  import * as fs from "fs";
8
14
  import * as os from "os";
9
15
  import * as path from "path";
@@ -11,26 +17,43 @@ import { fileURLToPath } from "url";
11
17
  const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
12
18
  // A user's OWN provider credentials, kept SEPARATE from Castle's keys.json so
13
19
  // castle-www's per-serve re-sync of keys.json (cloudSandbox.ts syncCastleKeys)
14
- // can't clobber them. Same shape as keys.json (env-var-name keys); the user (or
15
- // a future editor UI) writes this file, nothing in-process does. When a key is
20
+ // can't clobber them. Same shape as keys.json (env-var-name keys). When a key is
16
21
  // present the run goes DIRECT to that provider on the user's own credential and
17
22
  // is NOT metered -- deleting the key reverts to Castle's proxy on the next run.
18
23
  // The path override mirrors CASTLE_KEYS_PATH so the QA battery stays isolated
19
24
  // from a developer's real ~/.castle. No env fallback on read: the file is the
20
25
  // only source, so a delete fully reverts.
26
+ //
27
+ // Written by hand, or by the editor's Accounts rows -- byo-accounts.ts is the
28
+ // only writer in this process, and it merges rather than replaces so a key it
29
+ // has no descriptor for still survives an edit.
21
30
  export const CASTLE_USER_KEYS_PATH = process.env.CASTLE_USER_KEYS_PATH ??
22
31
  path.join(os.homedir(), ".castle", "user-keys.json");
23
- function userKeys() {
32
+ // Exported for byo-accounts.ts, whose writes must preserve entries this file
33
+ // has no name for; every other reader wants userKey() instead.
34
+ //
35
+ // The file is hand-editable, so its shape is user input: a top-level primitive
36
+ // would make every `stored[k]` assignment downstream a strict-mode TypeError
37
+ // inside the serve's WS handler, where nothing catches it. VALUES are not
38
+ // validated -- a non-string entry survives a merge untouched -- so anything
39
+ // that uses one must typeof-check it first.
40
+ export function readUserKeys() {
24
41
  try {
25
- return JSON.parse(fs.readFileSync(CASTLE_USER_KEYS_PATH, "utf8"));
42
+ const parsed = JSON.parse(fs.readFileSync(CASTLE_USER_KEYS_PATH, "utf8"));
43
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
44
+ ? parsed
45
+ : {};
26
46
  }
27
47
  catch {
28
48
  return {};
29
49
  }
30
50
  }
31
51
  export function userKey(envName) {
32
- const v = userKeys()[envName]?.trim();
33
- return v ? v : null;
52
+ const v = readUserKeys()[envName];
53
+ if (typeof v !== "string")
54
+ return null;
55
+ const trimmed = v.trim();
56
+ return trimmed ? trimmed : null;
34
57
  }
35
58
  // KNOWN GAP (macOS): a false negative for most logged-in users. `claude /login`
36
59
  // stores credentials in the KEYCHAIN there, not in ~/.claude/.credentials.json,
@@ -50,6 +73,32 @@ export function claudeHasSavedLogin() {
50
73
  path.join(os.homedir(), ".claude", ".credentials.json");
51
74
  return fs.existsSync(credPath);
52
75
  }
76
+ // cursor-agent rewrites ~/.config/cursor/auth.json on every successful run,
77
+ // including Castle's own CURSOR_API_KEY runs -- so the file existing does NOT
78
+ // mean a user logged in. Distinguish by the apiKey field: a real login is OAuth,
79
+ // which drops apiKey and leaves only session tokens; any auth.json that still
80
+ // carries an apiKey is an env-key cache cursor wrote from an injected key --
81
+ // OURS, including a PREVIOUS key after a rotation. Treating that cache as a login
82
+ // suppresses the injected key and drops cursor into the stale (dead) session.
83
+ //
84
+ // An earlier apiKey === castleKeys().CURSOR_API_KEY comparison misfired on a key
85
+ // switch: the stale cache holds the OLD key, reads as "!= current" => "user
86
+ // login" => key withheld => auth fails until auth.json is deleted by hand.
87
+ // Deferring only on OAuth is rotation-proof. The cost: a tester's own-API-key
88
+ // login is no longer distinguishable from our stale cache, so it is not deferred
89
+ // to -- OAuth login still is (the common bypass path).
90
+ export function cursorAuthPath(home) {
91
+ return path.join(home, ".config", "cursor", "auth.json");
92
+ }
93
+ export function cursorHasUserLogin(home) {
94
+ try {
95
+ const auth = JSON.parse(fs.readFileSync(cursorAuthPath(home), "utf8"));
96
+ return !auth.apiKey && !!auth.accessToken;
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
53
102
  export function resolveAnthropicAuth() {
54
103
  const k = userKey("ANTHROPIC_API_KEY");
55
104
  if (k)
@@ -100,13 +149,25 @@ function shellQuote(value) {
100
149
  export function anthropicKeyHelperCommand() {
101
150
  return `${shellQuote(process.execPath)} ${shellQuote(path.join(DIST_DIR, "anthropic-key-helper.js"))}`;
102
151
  }
152
+ // Where the shell's inherited proxy pair goes when a credential of the user's
153
+ // own displaces it. envForUserShell answers "whose credential" once, but
154
+ // claudeShellEnvScript re-answers it per `claude` run and may need to put
155
+ // Castle's routing BACK (the user deleted their key mid-session) -- which it
156
+ // can only do if the values survived somewhere. Nothing reads these names by
157
+ // accident, so stashing doesn't weaken the guarantee below.
158
+ const PROXY_STASH_PREFIX = "CASTLE_PTY_";
103
159
  // Env for the editor's PTY terminal. The container env the serve inherits
104
160
  // carries the llm-proxy pair, and Claude Code ranks ANTHROPIC_AUTH_TOKEN ABOVE
105
161
  // a claude.ai login -- so without this a user who ran `claude /login` in the
106
162
  // terminal still gets "claude.ai connectors are disabled because
107
163
  // ANTHROPIC_API_KEY or another auth source is set", their login unused and
108
- // their session billed to Castle. Resolved at shell start, so a login taken in
109
- // an open terminal applies to the next one.
164
+ // their session billed to Castle.
165
+ //
166
+ // This is the shell's STARTING state only; the per-run answer is the shim's
167
+ // (see installClaudeShim). It still matters on its own, because anything else
168
+ // the user runs in that terminal -- a script that calls claude by absolute
169
+ // path, an SDK program reading ANTHROPIC_API_KEY -- must not find Castle's
170
+ // token sitting in the environment while the user has a credential of their own.
110
171
  //
111
172
  // A user KEY goes in as ANTHROPIC_API_KEY here, unlike the agent path's
112
173
  // apiKeyHelper (see above): an interactive claude CAN show the one-time
@@ -116,8 +177,12 @@ export function envForUserShell(base) {
116
177
  const env = { ...base };
117
178
  const anthropic = resolveAnthropicAuth();
118
179
  if (anthropic.mode !== "proxy") {
119
- for (const name of ANTHROPIC_PROXY_ENV)
180
+ for (const name of ANTHROPIC_PROXY_ENV) {
181
+ const inherited = env[name];
182
+ if (inherited !== undefined)
183
+ env[PROXY_STASH_PREFIX + name] = inherited;
120
184
  delete env[name];
185
+ }
121
186
  if (anthropic.mode === "user-key")
122
187
  env.ANTHROPIC_API_KEY = anthropic.key;
123
188
  }
@@ -128,3 +193,127 @@ export function envForUserShell(base) {
128
193
  }
129
194
  return env;
130
195
  }
196
+ // --- the terminal's per-run credential decision ------------------------------
197
+ //
198
+ // A process's environment is fixed at spawn, and the editor keeps ONE shell per
199
+ // serve -- closing the terminal panel drops the socket, not the shell (see
200
+ // ide.ts ensureSession), so envForUserShell's answer is the answer that shell
201
+ // keeps for as long as the serve lives. That made `claude /login` in the
202
+ // terminal a no-op for the terminal itself: the login lands, the agent panel
203
+ // (which resolves per run) starts using it, and every later `claude` in that
204
+ // same shell still finds the inherited ANTHROPIC_AUTH_TOKEN outranking it and
205
+ // still prints "claude.ai connectors are disabled...". Restarting claude, or
206
+ // the panel, changed nothing -- only restarting the serve did.
207
+ //
208
+ // So the decision moves to a `claude` shim first on the terminal's PATH: it
209
+ // resolves the credential at invocation time, fixes up the environment, and
210
+ // execs the real binary. A login taken thirty seconds ago applies to the very
211
+ // next run, in the same shell.
212
+ //
213
+ // Caveat: PATH ordering is not ours to guarantee. A login shell can reorder it
214
+ // (macOS /etc/zprofile runs path_helper, which demotes an inherited entry below
215
+ // /usr/local/bin) or replace it outright (Debian's /etc/profile does, for root
216
+ // -- though only bash reads it; the sandbox's `zsh -l` keeps our entry first).
217
+ // When the shim loses, the credential is simply the one envForUserShell picked
218
+ // at shell start -- the old behavior, not a broken one.
219
+ function realpath(p) {
220
+ try {
221
+ return fs.realpathSync(p);
222
+ }
223
+ catch {
224
+ return path.resolve(p);
225
+ }
226
+ }
227
+ // The `claude` the shim should exec: the first executable one on `pathValue`
228
+ // that isn't the shim itself. Skipping by resolved directory (not by string) is
229
+ // what keeps the shim from exec'ing itself forever.
230
+ function findRealClaude(pathValue, shimDir) {
231
+ const shimReal = realpath(shimDir);
232
+ for (const entry of (pathValue ?? "").split(path.delimiter)) {
233
+ if (!entry || realpath(entry) === shimReal)
234
+ continue;
235
+ const candidate = path.join(entry, "claude");
236
+ try {
237
+ fs.accessSync(candidate, fs.constants.X_OK);
238
+ if (fs.statSync(candidate).isFile())
239
+ return candidate;
240
+ }
241
+ catch {
242
+ /* not this entry */
243
+ }
244
+ }
245
+ return null;
246
+ }
247
+ // Shell statements the shim evals before exec'ing claude: the environment that
248
+ // makes THIS run use the credential resolveAnthropicAuth picks right now.
249
+ // Emitting `unset` rather than an empty value matters -- Claude Code treats an
250
+ // empty ANTHROPIC_BASE_URL as configured and fails to reach anything.
251
+ export function claudeShellEnvScript(env, shimDir) {
252
+ const lines = [];
253
+ const auth = resolveAnthropicAuth();
254
+ if (auth.mode === "proxy") {
255
+ for (const name of ANTHROPIC_PROXY_ENV) {
256
+ const stashed = env[PROXY_STASH_PREFIX + name];
257
+ if (stashed !== undefined && env[name] === undefined) {
258
+ lines.push(`${name}=${shellQuote(stashed)}; export ${name}`);
259
+ }
260
+ }
261
+ }
262
+ else {
263
+ for (const name of ANTHROPIC_PROXY_ENV)
264
+ lines.push(`unset ${name}`);
265
+ if (auth.mode === "user-key") {
266
+ lines.push(`ANTHROPIC_API_KEY=${shellQuote(auth.key)}; export ANTHROPIC_API_KEY`);
267
+ }
268
+ }
269
+ const real = findRealClaude(env.PATH, shimDir);
270
+ if (real)
271
+ lines.push(`CASTLE_REAL_CLAUDE=${shellQuote(real)}`);
272
+ return lines.length > 0 ? lines.join("\n") + "\n" : "";
273
+ }
274
+ // The baked CASTLE_REAL_CLAUDE is a fallback, not the answer: if the helper
275
+ // runs, its own resolution (against the SHELL's PATH, which a login shell may
276
+ // have rewritten) replaces it. It only survives when the helper failed to run
277
+ // at all, and then it is the difference between the old static behavior and a
278
+ // terminal where `claude` is suddenly missing.
279
+ function shimScript(shimDir) {
280
+ const helper = [
281
+ shellQuote(process.execPath),
282
+ shellQuote(path.join(DIST_DIR, "claude-shim-env.js")),
283
+ shellQuote(shimDir),
284
+ ].join(" ");
285
+ return [
286
+ "#!/bin/sh",
287
+ "# Generated by `castle-web serve` -- see installClaudeShim in castle-web-cli.",
288
+ `CASTLE_REAL_CLAUDE=${shellQuote(findRealClaude(process.env.PATH, shimDir) ?? "")}`,
289
+ `eval "$(${helper})"`,
290
+ 'if [ -z "$CASTLE_REAL_CLAUDE" ]; then',
291
+ ' echo "castle-web: claude is not installed on this PATH" >&2',
292
+ " exit 127",
293
+ "fi",
294
+ 'exec "$CASTLE_REAL_CLAUDE" "$@"',
295
+ "",
296
+ ].join("\n");
297
+ }
298
+ // Returns the directory to put first on the terminal's PATH, or null when there
299
+ // is no shim to install -- a failure here costs the per-run re-resolution, so it
300
+ // degrades to envForUserShell's spawn-time answer rather than breaking the shell.
301
+ export function installClaudeShim(deckDir) {
302
+ if (process.platform === "win32")
303
+ return null;
304
+ const dir = path.join(deckDir, ".castle", "shims");
305
+ try {
306
+ fs.mkdirSync(dir, { recursive: true });
307
+ // Write-then-rename, because /bin/sh reads a script incrementally: a second
308
+ // serve reinstalling over this deck while a claude is running through the
309
+ // shim would otherwise pull the file out from under that sh mid-read.
310
+ const staged = path.join(dir, "claude.staged");
311
+ fs.writeFileSync(staged, shimScript(dir));
312
+ fs.chmodSync(staged, 0o755);
313
+ fs.renameSync(staged, path.join(dir, "claude"));
314
+ return dir;
315
+ }
316
+ catch {
317
+ return null;
318
+ }
319
+ }