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/byo-auth.js CHANGED
@@ -4,51 +4,121 @@
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";
10
16
  import { fileURLToPath } from "url";
17
+ import { inCastleSandbox } from "./metering.js";
11
18
  const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
12
19
  // A user's OWN provider credentials, kept SEPARATE from Castle's keys.json so
13
20
  // 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
21
+ // can't clobber them. Same shape as keys.json (env-var-name keys). When a key is
16
22
  // present the run goes DIRECT to that provider on the user's own credential and
17
23
  // is NOT metered -- deleting the key reverts to Castle's proxy on the next run.
18
24
  // The path override mirrors CASTLE_KEYS_PATH so the QA battery stays isolated
19
25
  // from a developer's real ~/.castle. No env fallback on read: the file is the
20
26
  // only source, so a delete fully reverts.
27
+ //
28
+ // Written by hand, or by the editor's Accounts rows -- byo-accounts.ts is the
29
+ // only writer in this process, and it merges rather than replaces so a key it
30
+ // has no descriptor for still survives an edit.
21
31
  export const CASTLE_USER_KEYS_PATH = process.env.CASTLE_USER_KEYS_PATH ??
22
32
  path.join(os.homedir(), ".castle", "user-keys.json");
23
- function userKeys() {
33
+ // Exported for byo-accounts.ts, whose writes must preserve entries this file
34
+ // has no name for; every other reader wants userKey() instead.
35
+ //
36
+ // The file is hand-editable, so its shape is user input: a top-level primitive
37
+ // would make every `stored[k]` assignment downstream a strict-mode TypeError
38
+ // inside the serve's WS handler, where nothing catches it. VALUES are not
39
+ // validated -- a non-string entry survives a merge untouched -- so anything
40
+ // that uses one must typeof-check it first.
41
+ export function readUserKeys() {
24
42
  try {
25
- return JSON.parse(fs.readFileSync(CASTLE_USER_KEYS_PATH, "utf8"));
43
+ const parsed = JSON.parse(fs.readFileSync(CASTLE_USER_KEYS_PATH, "utf8"));
44
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
45
+ ? parsed
46
+ : {};
26
47
  }
27
48
  catch {
28
49
  return {};
29
50
  }
30
51
  }
31
52
  export function userKey(envName) {
32
- const v = userKeys()[envName]?.trim();
33
- return v ? v : null;
53
+ const v = readUserKeys()[envName];
54
+ if (typeof v !== "string")
55
+ return null;
56
+ const trimmed = v.trim();
57
+ return trimmed ? trimmed : null;
58
+ }
59
+ // CLAUDE_CONFIG_DIR moves BOTH of claude's own files, and the sandbox sets it
60
+ // (castle-sandboxes points it at the directory it persists, so `.claude.json` --
61
+ // which holds the account record and the onboarding state -- lands inside the
62
+ // symlink alongside the credential rather than beside it). Reading os.homedir()
63
+ // unconditionally would mean reading a path claude has been told not to use.
64
+ function claudeConfigDir() {
65
+ const dir = process.env.CLAUDE_CONFIG_DIR?.trim();
66
+ return dir ? dir : null;
67
+ }
68
+ // CASTLE_CLAUDE_CREDENTIALS_PATH overrides the location so the QA battery can
69
+ // isolate from a developer's REAL ~/.claude login -- which, since this gates
70
+ // proxy-vs-direct routing, would otherwise flip every plain-claude scenario to
71
+ // "direct" on a logged-in machine. Mirrors the CASTLE_KEYS_PATH seam.
72
+ export function claudeCredentialsPath() {
73
+ const override = process.env.CASTLE_CLAUDE_CREDENTIALS_PATH;
74
+ if (override !== undefined)
75
+ return override;
76
+ const dir = claudeConfigDir();
77
+ return dir
78
+ ? path.join(dir, ".credentials.json")
79
+ : path.join(os.homedir(), ".claude", ".credentials.json");
34
80
  }
35
81
  // KNOWN GAP (macOS): a false negative for most logged-in users. `claude /login`
36
82
  // stores credentials in the KEYCHAIN there, not in ~/.claude/.credentials.json,
37
83
  // so this returns false and the run stays on Castle's proxy even though the
38
84
  // user has a perfectly good subscription login the CLI would have used. Left
39
- // alone deliberately -- reading the Keychain (`security find-generic-password`)
40
- // changes who pays for a run, which is a product decision, not a cleanup. In a
41
- // Linux sandbox (the case that matters for BYO routing) the file IS
42
- // authoritative, so the gap doesn't bite.
85
+ // alone deliberately -- reading the Keychain changes who pays for a run, which
86
+ // is a product decision, not a cleanup. In a Linux sandbox (the case that
87
+ // matters for BYO routing) the file IS authoritative, so the gap doesn't bite.
43
88
  //
44
- // CASTLE_CLAUDE_CREDENTIALS_PATH overrides the location so the QA battery can
45
- // isolate from a developer's REAL ~/.claude login -- which, since this gates
46
- // proxy-vs-direct routing, would otherwise flip every plain-claude scenario to
47
- // "direct" on a logged-in machine. Mirrors the CASTLE_KEYS_PATH seam.
89
+ // `claude auth status --json` would close it: it reads the Keychain and reports
90
+ // `authMethod` ("claude.ai" for a real login, "oauth_token" for an env
91
+ // ANTHROPIC_AUTH_TOKEN -- so it means nothing unless Castle's pair is stripped
92
+ // first). It is a subprocess, and this is called per `claude` run and per
93
+ // account snapshot, so taking it costs a spawn on both.
48
94
  export function claudeHasSavedLogin() {
49
- const credPath = process.env.CASTLE_CLAUDE_CREDENTIALS_PATH ??
50
- path.join(os.homedir(), ".claude", ".credentials.json");
51
- return fs.existsSync(credPath);
95
+ return fs.existsSync(claudeCredentialsPath());
96
+ }
97
+ // cursor-agent rewrites ~/.config/cursor/auth.json on every successful run,
98
+ // including Castle's own CURSOR_API_KEY runs -- so the file existing does NOT
99
+ // mean a user logged in. Distinguish by the apiKey field: a real login is OAuth,
100
+ // which drops apiKey and leaves only session tokens; any auth.json that still
101
+ // carries an apiKey is an env-key cache cursor wrote from an injected key --
102
+ // OURS, including a PREVIOUS key after a rotation. Treating that cache as a login
103
+ // suppresses the injected key and drops cursor into the stale (dead) session.
104
+ //
105
+ // An earlier apiKey === castleKeys().CURSOR_API_KEY comparison misfired on a key
106
+ // switch: the stale cache holds the OLD key, reads as "!= current" => "user
107
+ // login" => key withheld => auth fails until auth.json is deleted by hand.
108
+ // Deferring only on OAuth is rotation-proof. The cost: a tester's own-API-key
109
+ // login is no longer distinguishable from our stale cache, so it is not deferred
110
+ // to -- OAuth login still is (the common bypass path).
111
+ export function cursorAuthPath(home) {
112
+ return path.join(home, ".config", "cursor", "auth.json");
113
+ }
114
+ export function cursorHasUserLogin(home) {
115
+ try {
116
+ const auth = JSON.parse(fs.readFileSync(cursorAuthPath(home), "utf8"));
117
+ return !auth.apiKey && !!auth.accessToken;
118
+ }
119
+ catch {
120
+ return false;
121
+ }
52
122
  }
53
123
  export function resolveAnthropicAuth() {
54
124
  const k = userKey("ANTHROPIC_API_KEY");
@@ -100,13 +170,25 @@ function shellQuote(value) {
100
170
  export function anthropicKeyHelperCommand() {
101
171
  return `${shellQuote(process.execPath)} ${shellQuote(path.join(DIST_DIR, "anthropic-key-helper.js"))}`;
102
172
  }
173
+ // Where the shell's inherited Castle credential goes when one of the user's own
174
+ // displaces it. envForUserShell answers "whose credential" once, but the shims
175
+ // re-answer it per run and may need to put Castle's back (the user deleted their
176
+ // key, or signed out, mid-session) -- which they can only do if the values
177
+ // survived somewhere. Nothing reads these names by accident, so stashing doesn't
178
+ // weaken the guarantee below.
179
+ const PROXY_STASH_PREFIX = "CASTLE_PTY_";
103
180
  // Env for the editor's PTY terminal. The container env the serve inherits
104
181
  // carries the llm-proxy pair, and Claude Code ranks ANTHROPIC_AUTH_TOKEN ABOVE
105
182
  // a claude.ai login -- so without this a user who ran `claude /login` in the
106
183
  // terminal still gets "claude.ai connectors are disabled because
107
184
  // 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.
185
+ // their session billed to Castle.
186
+ //
187
+ // This is the shell's STARTING state only; the per-run answer is the shims'
188
+ // (see installCliShims). It still matters on its own, because anything else
189
+ // the user runs in that terminal -- a script that calls claude by absolute
190
+ // path, an SDK program reading ANTHROPIC_API_KEY -- must not find Castle's
191
+ // token sitting in the environment while the user has a credential of their own.
110
192
  //
111
193
  // A user KEY goes in as ANTHROPIC_API_KEY here, unlike the agent path's
112
194
  // apiKeyHelper (see above): an interactive claude CAN show the one-time
@@ -114,13 +196,26 @@ export function anthropicKeyHelperCommand() {
114
196
  // person is there to answer it.
115
197
  export function envForUserShell(base) {
116
198
  const env = { ...base };
199
+ const stash = (name) => {
200
+ const inherited = env[name];
201
+ if (inherited !== undefined)
202
+ env[PROXY_STASH_PREFIX + name] = inherited;
203
+ delete env[name];
204
+ };
117
205
  const anthropic = resolveAnthropicAuth();
118
206
  if (anthropic.mode !== "proxy") {
119
207
  for (const name of ANTHROPIC_PROXY_ENV)
120
- delete env[name];
208
+ stash(name);
121
209
  if (anthropic.mode === "user-key")
122
210
  env.ANTHROPIC_API_KEY = anthropic.key;
123
211
  }
212
+ // The same withholding envForAgentSpawn does for a cursor run: Castle's key
213
+ // would let cursor-agent consider itself authenticated and never look at the
214
+ // user's own OAuth session, so their terminal would keep spending Castle's
215
+ // budget after they signed in. Cursor has no key of its own to put back --
216
+ // user-keys.json's CURSOR_API_KEY is described but not yet injected anywhere.
217
+ if (cursorHasUserLogin(os.homedir()))
218
+ stash("CURSOR_API_KEY");
124
219
  const openrouter = userKey("OPENROUTER_API_KEY");
125
220
  if (openrouter) {
126
221
  env.OPENROUTER_API_KEY = openrouter;
@@ -128,3 +223,224 @@ export function envForUserShell(base) {
128
223
  }
129
224
  return env;
130
225
  }
226
+ // --- the terminal's per-run credential decision ------------------------------
227
+ //
228
+ // A process's environment is fixed at spawn, and the editor keeps ONE shell per
229
+ // serve -- closing the terminal panel drops the socket, not the shell (see
230
+ // ide.ts ensureSession), so envForUserShell's answer is the answer that shell
231
+ // keeps for as long as the serve lives. That made `claude /login` in the
232
+ // terminal a no-op for the terminal itself: the login lands, the agent panel
233
+ // (which resolves per run) starts using it, and every later `claude` in that
234
+ // same shell still finds the inherited ANTHROPIC_AUTH_TOKEN outranking it and
235
+ // still prints "claude.ai connectors are disabled...". Restarting claude, or
236
+ // the panel, changed nothing -- only restarting the serve did.
237
+ //
238
+ // So the decision moves to a shim first on the terminal's PATH -- one per CLI
239
+ // that has a credential to decide: it resolves at invocation time, fixes up the
240
+ // environment, and execs the real binary. A login taken thirty seconds ago
241
+ // applies to the very next run, in the same shell.
242
+ //
243
+ // Caveat: PATH ordering is not ours to guarantee. A login shell can reorder it
244
+ // (macOS /etc/zprofile runs path_helper, which demotes an inherited entry below
245
+ // /usr/local/bin) or replace it outright (Debian's /etc/profile does, for root
246
+ // -- though only bash reads it; the sandbox's `zsh -l` keeps our entry first).
247
+ // When the shim loses, the credential is simply the one envForUserShell picked
248
+ // at shell start -- the old behavior, not a broken one.
249
+ function realpath(p) {
250
+ try {
251
+ return fs.realpathSync(p);
252
+ }
253
+ catch {
254
+ return path.resolve(p);
255
+ }
256
+ }
257
+ // The CLIs a shim is installed for. Both take a credential from the environment
258
+ // that Castle may be supplying, so both need the decision re-made per run.
259
+ export const SHIMMED_CLIS = ["claude", "cursor-agent"];
260
+ // The real binary the shim should exec: the first executable one on `pathValue`
261
+ // that isn't the shim itself. Skipping by resolved directory (not by string) is
262
+ // what keeps the shim from exec'ing itself forever.
263
+ function findRealBin(pathValue, shimDir, name) {
264
+ const shimReal = realpath(shimDir);
265
+ for (const entry of (pathValue ?? "").split(path.delimiter)) {
266
+ if (!entry || realpath(entry) === shimReal)
267
+ continue;
268
+ const candidate = path.join(entry, name);
269
+ try {
270
+ fs.accessSync(candidate, fs.constants.X_OK);
271
+ if (fs.statSync(candidate).isFile())
272
+ return candidate;
273
+ }
274
+ catch {
275
+ /* not this entry */
276
+ }
277
+ }
278
+ return null;
279
+ }
280
+ // `claude /login` -- the slash command as an ARGUMENT, which Claude Code runs at
281
+ // startup. This run is about signing in, so it is given no credential of
282
+ // Castle's at all: measured, an inherited ANTHROPIC_AUTH_TOKEN outranks whatever
283
+ // the login stores, so the session that just signed in would still spend
284
+ // Castle's budget and still print "claude.ai connectors are disabled" -- the
285
+ // thing the user ran /login to stop. Stripped, the run opens "Not logged in",
286
+ // the login lands with nothing above it, and that same session is on the user's
287
+ // account.
288
+ //
289
+ // Only reachable for `claude X` typed as a command. A `/login` typed INSIDE a
290
+ // running claude cannot be helped by anything out here: its environment was
291
+ // fixed at exec and no one can change it from outside, so that session stays on
292
+ // whatever it started with and the NEXT run picks the login up.
293
+ function isLoginRun(args) {
294
+ return args.some((a) => a.trim() === "/login");
295
+ }
296
+ // Shell statements the shim evals before exec'ing claude: the environment that
297
+ // makes THIS run use the credential resolveAnthropicAuth picks right now.
298
+ // Emitting `unset` rather than an empty value matters -- Claude Code treats an
299
+ // empty ANTHROPIC_BASE_URL as configured and fails to reach anything.
300
+ export function claudeShellEnvScript(env, args = []) {
301
+ const lines = [];
302
+ const auth = resolveAnthropicAuth();
303
+ if (auth.mode === "proxy" && !isLoginRun(args)) {
304
+ for (const name of ANTHROPIC_PROXY_ENV) {
305
+ const stashed = env[PROXY_STASH_PREFIX + name];
306
+ if (stashed !== undefined && env[name] === undefined) {
307
+ lines.push(`${name}=${shellQuote(stashed)}; export ${name}`);
308
+ }
309
+ }
310
+ }
311
+ else {
312
+ for (const name of ANTHROPIC_PROXY_ENV)
313
+ lines.push(`unset ${name}`);
314
+ // Not on a login run: a key sitting in the environment is one more thing
315
+ // ranked above the login being made, and this run exists to make it.
316
+ if (auth.mode === "user-key" && !isLoginRun(args)) {
317
+ lines.push(`ANTHROPIC_API_KEY=${shellQuote(auth.key)}; export ANTHROPIC_API_KEY`);
318
+ }
319
+ }
320
+ return lines.join("\n");
321
+ }
322
+ // The cursor half. Simpler, because there is one credential and no proxy pair:
323
+ // Castle's CURSOR_API_KEY is either withheld (the user has their own OAuth
324
+ // session, which cursor-agent would otherwise never look at) or put back from
325
+ // the stash when they sign out again.
326
+ export function cursorShellEnvScript(env) {
327
+ if (cursorHasUserLogin(os.homedir()))
328
+ return "unset CURSOR_API_KEY";
329
+ const stashed = env[PROXY_STASH_PREFIX + "CURSOR_API_KEY"];
330
+ if (stashed !== undefined && env.CURSOR_API_KEY === undefined) {
331
+ return `CURSOR_API_KEY=${shellQuote(stashed)}; export CURSOR_API_KEY`;
332
+ }
333
+ return "";
334
+ }
335
+ export function shimEnvScript(cli, env, shimDir, args = []) {
336
+ const lines = cli === "cursor-agent"
337
+ ? cursorShellEnvScript(env)
338
+ : claudeShellEnvScript(env, args);
339
+ const real = findRealBin(env.PATH, shimDir, cli);
340
+ const all = [lines, real ? `CASTLE_REAL_BIN=${shellQuote(real)}` : ""].filter((l) => l !== "");
341
+ return all.length > 0 ? all.join("\n") + "\n" : "";
342
+ }
343
+ // The baked CASTLE_REAL_BIN is a fallback, not the answer: if the helper runs,
344
+ // its own resolution (against the SHELL's PATH, which a login shell may have
345
+ // rewritten) replaces it. It only survives when the helper failed to run at all,
346
+ // and then it is the difference between the old static behavior and a terminal
347
+ // where the CLI is suddenly missing.
348
+ //
349
+ // "$@" reaches the helper so it can see arguments the decision turns on --
350
+ // `claude /login` (see isLoginRun). The shell expands it into separate words
351
+ // inside the substitution, so nothing in it is re-parsed.
352
+ function shimScript(shimDir, cli) {
353
+ const helper = [
354
+ shellQuote(process.execPath),
355
+ shellQuote(path.join(DIST_DIR, "cli-shim-env.js")),
356
+ shellQuote(shimDir),
357
+ shellQuote(cli),
358
+ ].join(" ");
359
+ return [
360
+ "#!/bin/sh",
361
+ "# Generated by `castle-web serve` -- see installCliShims in castle-web-cli.",
362
+ `CASTLE_REAL_BIN=${shellQuote(findRealBin(process.env.PATH, shimDir, cli) ?? "")}`,
363
+ `eval "$(${helper} "$@")"`,
364
+ 'if [ -z "$CASTLE_REAL_BIN" ]; then',
365
+ ` echo "castle-web: ${cli} is not installed on this PATH" >&2`,
366
+ " exit 127",
367
+ "fi",
368
+ 'exec "$CASTLE_REAL_BIN" "$@"',
369
+ "",
370
+ ].join("\n");
371
+ }
372
+ // Claude Code's first-run onboarding asks for a login even when one is already
373
+ // stored. Measured against 2.1.220 through a real pty: with onboarding
374
+ // incomplete, a `.credentials.json` -- with or without a matching `oauthAccount`
375
+ // in `.claude.json` -- still lands on "Select login method", while the same
376
+ // credential with onboarding complete goes straight through. What normally
377
+ // carries a sandbox past that step is Castle's injected ANTHROPIC_AUTH_TOKEN, an
378
+ // env credential, which is exactly what this shim takes away the moment the user
379
+ // has one of their own. So a user who signs in from the editor BEFORE ever
380
+ // opening the terminal is asked by their first `claude` to sign in again, and
381
+ // pays a second OAuth round trip for a login they already completed.
382
+ //
383
+ // Marking onboarding done is what makes claude accept the stored login --
384
+ // `hasCompletedOnboarding` alone is enough, measured, so no version flag is
385
+ // written and there is nothing to drift. Kept narrow deliberately: only in a
386
+ // sandbox, only once the user actually has a credential of their own, only when
387
+ // onboarding has not already run. A developer's own machine is never touched,
388
+ // and neither is claude's first-run experience for anyone on Castle's account.
389
+ export function ensureClaudeOnboarded() {
390
+ if (!inCastleSandbox())
391
+ return;
392
+ if (resolveAnthropicAuth().mode === "proxy")
393
+ return;
394
+ const dir = claudeConfigDir();
395
+ const file = dir
396
+ ? path.join(dir, ".claude.json")
397
+ : path.join(os.homedir(), ".claude.json");
398
+ let config = {};
399
+ try {
400
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
401
+ // Not ours to repair: a file we can't read is one claude may still be able
402
+ // to, and replacing it wholesale would cost the user their project history.
403
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
404
+ return;
405
+ config = parsed;
406
+ }
407
+ catch {
408
+ /* no file yet -- the normal case in a sandbox nobody has run claude in */
409
+ }
410
+ if (config.hasCompletedOnboarding === true)
411
+ return;
412
+ config.hasCompletedOnboarding = true;
413
+ try {
414
+ fs.mkdirSync(path.dirname(file), { recursive: true });
415
+ const staged = `${file}.castle-staged`;
416
+ fs.writeFileSync(staged, JSON.stringify(config, null, 2) + "\n");
417
+ fs.renameSync(staged, file);
418
+ }
419
+ catch {
420
+ /* claude asks again; nothing else depends on this */
421
+ }
422
+ }
423
+ // Returns the directory to put first on the terminal's PATH, or null when there
424
+ // is none to install -- a failure here costs the per-run re-resolution, so it
425
+ // degrades to envForUserShell's spawn-time answer rather than breaking the shell.
426
+ export function installCliShims(deckDir) {
427
+ if (process.platform === "win32")
428
+ return null;
429
+ const dir = path.join(deckDir, ".castle", "shims");
430
+ try {
431
+ fs.mkdirSync(dir, { recursive: true });
432
+ for (const cli of SHIMMED_CLIS) {
433
+ // Write-then-rename, because /bin/sh reads a script incrementally: a
434
+ // second serve reinstalling over this deck while a run is going through
435
+ // the shim would otherwise pull the file out from under that sh mid-read.
436
+ const staged = path.join(dir, `${cli}.staged`);
437
+ fs.writeFileSync(staged, shimScript(dir, cli));
438
+ fs.chmodSync(staged, 0o755);
439
+ fs.renameSync(staged, path.join(dir, cli));
440
+ }
441
+ return dir;
442
+ }
443
+ catch {
444
+ return null;
445
+ }
446
+ }
@@ -0,0 +1,14 @@
1
+ export type LoginProvider = "claude" | "cursor";
2
+ export type LoginPhase = "starting" | "awaiting-user" | "awaiting-code" | "verifying" | "error";
3
+ export interface LoginState {
4
+ provider: LoginProvider;
5
+ phase: LoginPhase;
6
+ url?: string;
7
+ message?: string;
8
+ }
9
+ export declare function activeLogin(): LoginState | null;
10
+ export declare function providerHasLogin(provider: LoginProvider): boolean;
11
+ export declare function startLogin(provider: LoginProvider, onChange: () => void): void;
12
+ export declare function submitLoginCode(code: string): void;
13
+ export declare function logout(provider: LoginProvider, onChange: () => void): void;
14
+ export declare function cancelLogin(): void;
@@ -0,0 +1,196 @@
1
+ // Driving `claude auth login` / `cursor-agent login` from the editor, so a user
2
+ // can put a run on their own subscription without knowing the terminal exists.
3
+ //
4
+ // Both CLIs are usable HEADLESS -- measured, not documented (probed 2026-07-30
5
+ // against claude 2.1.220 and the current cursor-agent):
6
+ //
7
+ // claude auth login with no TTY and stdin from a pipe, prints
8
+ // "Opening browser to sign in…" then the OAuth URL, then
9
+ // blocks reading a pasted code from STDIN. The URL is
10
+ // wrapped in an OSC-8 hyperlink, so it appears TWICE in
11
+ // the raw bytes.
12
+ // cursor-agent login with NO_OPEN_BROWSER=1, prints
13
+ // "Open a browser and navigate to this link: <url>" and
14
+ // then POLLS the challenge itself -- no code to paste.
15
+ //
16
+ // Neither uses a localhost callback, which is what makes this work at all in a
17
+ // sandbox: the browser is on the user's machine and the CLI is in a container,
18
+ // so a loopback redirect would have nowhere to land.
19
+ //
20
+ // `claude setup-token` is NOT usable here: with no TTY it prints nothing, and
21
+ // under one it launches the full Claude Code TUI.
22
+ import { spawn } from "child_process";
23
+ import * as os from "os";
24
+ import { ANTHROPIC_PROXY_ENV, claudeHasSavedLogin, cursorHasUserLogin, } from "./byo-auth.js";
25
+ // A login is a singleton: two at once would race for the same credential file,
26
+ // and the UI only ever offers one. A second start replaces the first.
27
+ let active = null;
28
+ export function activeLogin() {
29
+ return active ? active.state : null;
30
+ }
31
+ export function providerHasLogin(provider) {
32
+ return provider === "claude"
33
+ ? claudeHasSavedLogin()
34
+ : cursorHasUserLogin(os.homedir());
35
+ }
36
+ // Long enough for a real sign-in (find the browser, log in, maybe sign up),
37
+ // short enough that an abandoned flow doesn't leave a child running forever.
38
+ // The env override is a QA seam, like CASTLE_USER_KEYS_PATH: ten minutes is
39
+ // unwaitable in a test.
40
+ const LOGIN_TIMEOUT_MS = Number(process.env.CASTLE_LOGIN_TIMEOUT_MS ?? "") || 10 * 60 * 1000;
41
+ // OSC-8 hyperlinks and colour codes come through on both CLIs; strip them
42
+ // before matching so a URL isn't cut short at an escape byte.
43
+ // eslint-disable-next-line no-control-regex
44
+ const ANSI = /\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g;
45
+ const URL_PATTERN = {
46
+ claude: /https:\/\/claude\.com\/\S*oauth\S*/,
47
+ cursor: /https:\/\/cursor\.com\/\S*/,
48
+ };
49
+ function commandFor(provider) {
50
+ const env = { ...process.env, NO_OPEN_BROWSER: "1" };
51
+ if (provider === "cursor") {
52
+ // Castle's key would let cursor-agent consider itself authenticated and
53
+ // skip the OAuth it was just asked for.
54
+ delete env.CURSOR_API_KEY;
55
+ return { file: "cursor-agent", args: ["login"], env };
56
+ }
57
+ // Castle's proxy pair outranks a claude.ai login (see byo-auth), and the
58
+ // sandbox always has it set -- leaving it in place would have claude decide
59
+ // it is already authenticated and skip the flow entirely.
60
+ for (const name of ANTHROPIC_PROXY_ENV)
61
+ delete env[name];
62
+ // Nothing here should open a browser on the SERVE's machine: in a sandbox
63
+ // that is the container, where it would silently fail; locally it would
64
+ // steal focus from the person who clicked.
65
+ env.BROWSER = "true";
66
+ return { file: "claude", args: ["auth", "login"], env };
67
+ }
68
+ function publish(next) {
69
+ if (!active)
70
+ return;
71
+ active.state = { ...active.state, ...next };
72
+ active.onChange();
73
+ }
74
+ function finish(message) {
75
+ if (!active)
76
+ return;
77
+ clearTimeout(active.timer);
78
+ const { onChange } = active;
79
+ if (message === null) {
80
+ active = null;
81
+ }
82
+ else {
83
+ // The flow is over, so nothing will read from the child again -- without
84
+ // this kill a timed-out `claude auth login` sits blocked on stdin forever,
85
+ // the very leak LOGIN_TIMEOUT_MS exists to prevent. A no-op on the paths
86
+ // where the child already exited.
87
+ active.child?.kill();
88
+ active.state = { ...active.state, phase: "error", message };
89
+ // Kept, not cleared: the phase IS the error surface, and a cleared state
90
+ // would read to the client as "no flow ran" -- indistinguishable from a
91
+ // button that did nothing. cancelLogin / the next start clears it.
92
+ }
93
+ onChange();
94
+ }
95
+ function handleOutput(text) {
96
+ if (!active)
97
+ return;
98
+ const clean = text.replace(ANSI, "");
99
+ // A mistyped code does NOT end the process -- claude prints this and prompts
100
+ // again (measured: "Invalid code. Please make sure the full code was
101
+ // copied."). Without catching it the flow sits in `verifying` until the
102
+ // timeout, looking like a hang, and the user has no way to retry short of
103
+ // cancelling. Put them back on the code step with the CLI's own wording.
104
+ const invalid = /Invalid code[^\n]*/.exec(clean);
105
+ if (invalid) {
106
+ publish({ phase: "awaiting-code", message: invalid[0].trim() });
107
+ return;
108
+ }
109
+ if (!active.state.url) {
110
+ const match = URL_PATTERN[active.provider].exec(clean);
111
+ if (match) {
112
+ publish({
113
+ url: match[0],
114
+ // claude will ask for a pasted code next; cursor polls on its own.
115
+ phase: active.provider === "claude" ? "awaiting-code" : "awaiting-user",
116
+ });
117
+ }
118
+ }
119
+ }
120
+ export function startLogin(provider, onChange) {
121
+ cancelLogin();
122
+ const { file, args, env } = commandFor(provider);
123
+ let child;
124
+ try {
125
+ child = spawn(file, args, { env, stdio: ["pipe", "pipe", "pipe"] });
126
+ }
127
+ catch {
128
+ active = {
129
+ provider,
130
+ child: null,
131
+ state: { provider, phase: "error", message: `could not run ${file}` },
132
+ timer: setTimeout(() => undefined, 0),
133
+ onChange,
134
+ };
135
+ onChange();
136
+ return;
137
+ }
138
+ active = {
139
+ provider,
140
+ child,
141
+ state: { provider, phase: "starting" },
142
+ timer: setTimeout(() => finish("Timed out waiting for sign-in."), LOGIN_TIMEOUT_MS),
143
+ onChange,
144
+ };
145
+ child.stdout?.on("data", (b) => handleOutput(b.toString()));
146
+ child.stderr?.on("data", (b) => handleOutput(b.toString()));
147
+ child.on("error", () => finish(`could not run ${file}`));
148
+ child.on("close", () => {
149
+ if (!active || active.child !== child)
150
+ return;
151
+ // Already settled as an error: this close is finish()'s own kill landing
152
+ // (the timeout path), and evaluating it as a fresh outcome would overwrite
153
+ // the message that explains what happened.
154
+ if (active.state.phase === "error")
155
+ return;
156
+ publish({ phase: "verifying" });
157
+ // The RESOLVER decides, never the exit code: what matters is whether the
158
+ // credential this process routes on is now there. A CLI that exits 0
159
+ // without leaving one behind must not read as success.
160
+ if (providerHasLogin(provider))
161
+ finish(null);
162
+ else
163
+ finish("Sign-in did not complete.");
164
+ });
165
+ onChange();
166
+ }
167
+ export function submitLoginCode(code) {
168
+ if (!active || active.state.phase !== "awaiting-code")
169
+ return;
170
+ const trimmed = code.trim();
171
+ if (!trimmed)
172
+ return;
173
+ active.child.stdin?.write(trimmed + "\n");
174
+ publish({ phase: "verifying" });
175
+ }
176
+ // Sign out, so the modal isn't a one-way door. Fire-and-forget by shape but
177
+ // awaited for its close, because the snapshot is only right once the CLI has
178
+ // actually dropped the credential -- the resolver, again, not the exit code.
179
+ export function logout(provider, onChange) {
180
+ cancelLogin();
181
+ const { file, args, env } = provider === "cursor"
182
+ ? { file: "cursor-agent", args: ["logout"], env: process.env }
183
+ : { file: "claude", args: ["auth", "logout"], env: commandFor("claude").env };
184
+ const child = spawn(file, args, { env, stdio: "ignore" });
185
+ child.on("error", onChange);
186
+ child.on("close", onChange);
187
+ }
188
+ export function cancelLogin() {
189
+ if (!active)
190
+ return;
191
+ clearTimeout(active.timer);
192
+ active.child?.kill();
193
+ const { onChange } = active;
194
+ active = null;
195
+ onChange();
196
+ }
@@ -0,0 +1,15 @@
1
+ export interface DeckImport {
2
+ deckId?: string;
3
+ source?: 'builtin';
4
+ kit?: string;
5
+ via?: string;
6
+ version: string;
7
+ }
8
+ export interface CastleJson {
9
+ deckId?: string;
10
+ cardId?: string;
11
+ imports?: Record<string, DeckImport>;
12
+ [key: string]: unknown;
13
+ }
14
+ export declare function readCastleJson(dir: string): CastleJson | null;
15
+ export declare function readCastleJsonOrThrow(dir: string): CastleJson | null;
@@ -0,0 +1,24 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ // A missing castle.json is normal (a deck has none until its first save), so it
4
+ // reads as `null` rather than an error. An unparseable one is not -- `onInvalid`
5
+ // is how a caller says whether that should be fatal here or just another `null`.
6
+ function readCastleJsonFile(dir, onInvalid) {
7
+ const file = path.join(dir, 'castle.json');
8
+ if (!fs.existsSync(file))
9
+ return null;
10
+ try {
11
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
12
+ }
13
+ catch (e) {
14
+ return onInvalid(file, e);
15
+ }
16
+ }
17
+ export function readCastleJson(dir) {
18
+ return readCastleJsonFile(dir, () => null);
19
+ }
20
+ export function readCastleJsonOrThrow(dir) {
21
+ return readCastleJsonFile(dir, (file, e) => {
22
+ throw new Error(`Could not read ${file}: ${e instanceof Error ? e.message : String(e)}`);
23
+ });
24
+ }