castle-web-cli 0.4.107 → 0.4.109

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
@@ -14,6 +14,7 @@ import * as fs from "fs";
14
14
  import * as os from "os";
15
15
  import * as path from "path";
16
16
  import { fileURLToPath } from "url";
17
+ import { inCastleSandbox } from "./metering.js";
17
18
  const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
18
19
  // A user's OWN provider credentials, kept SEPARATE from Castle's keys.json so
19
20
  // castle-www's per-serve re-sync of keys.json (cloudSandbox.ts syncCastleKeys)
@@ -55,23 +56,43 @@ export function userKey(envName) {
55
56
  const trimmed = v.trim();
56
57
  return trimmed ? trimmed : null;
57
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");
80
+ }
58
81
  // KNOWN GAP (macOS): a false negative for most logged-in users. `claude /login`
59
82
  // stores credentials in the KEYCHAIN there, not in ~/.claude/.credentials.json,
60
83
  // so this returns false and the run stays on Castle's proxy even though the
61
84
  // user has a perfectly good subscription login the CLI would have used. Left
62
- // alone deliberately -- reading the Keychain (`security find-generic-password`)
63
- // changes who pays for a run, which is a product decision, not a cleanup. In a
64
- // Linux sandbox (the case that matters for BYO routing) the file IS
65
- // 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.
66
88
  //
67
- // CASTLE_CLAUDE_CREDENTIALS_PATH overrides the location so the QA battery can
68
- // isolate from a developer's REAL ~/.claude login -- which, since this gates
69
- // proxy-vs-direct routing, would otherwise flip every plain-claude scenario to
70
- // "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.
71
94
  export function claudeHasSavedLogin() {
72
- const credPath = process.env.CASTLE_CLAUDE_CREDENTIALS_PATH ??
73
- path.join(os.homedir(), ".claude", ".credentials.json");
74
- return fs.existsSync(credPath);
95
+ return fs.existsSync(claudeCredentialsPath());
75
96
  }
76
97
  // cursor-agent rewrites ~/.config/cursor/auth.json on every successful run,
77
98
  // including Castle's own CURSOR_API_KEY runs -- so the file existing does NOT
@@ -149,12 +170,12 @@ function shellQuote(value) {
149
170
  export function anthropicKeyHelperCommand() {
150
171
  return `${shellQuote(process.execPath)} ${shellQuote(path.join(DIST_DIR, "anthropic-key-helper.js"))}`;
151
172
  }
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.
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.
158
179
  const PROXY_STASH_PREFIX = "CASTLE_PTY_";
159
180
  // Env for the editor's PTY terminal. The container env the serve inherits
160
181
  // carries the llm-proxy pair, and Claude Code ranks ANTHROPIC_AUTH_TOKEN ABOVE
@@ -163,8 +184,8 @@ const PROXY_STASH_PREFIX = "CASTLE_PTY_";
163
184
  // ANTHROPIC_API_KEY or another auth source is set", their login unused and
164
185
  // their session billed to Castle.
165
186
  //
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
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
168
189
  // the user runs in that terminal -- a script that calls claude by absolute
169
190
  // path, an SDK program reading ANTHROPIC_API_KEY -- must not find Castle's
170
191
  // token sitting in the environment while the user has a credential of their own.
@@ -175,17 +196,26 @@ const PROXY_STASH_PREFIX = "CASTLE_PTY_";
175
196
  // person is there to answer it.
176
197
  export function envForUserShell(base) {
177
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
+ };
178
205
  const anthropic = resolveAnthropicAuth();
179
206
  if (anthropic.mode !== "proxy") {
180
- for (const name of ANTHROPIC_PROXY_ENV) {
181
- const inherited = env[name];
182
- if (inherited !== undefined)
183
- env[PROXY_STASH_PREFIX + name] = inherited;
184
- delete env[name];
185
- }
207
+ for (const name of ANTHROPIC_PROXY_ENV)
208
+ stash(name);
186
209
  if (anthropic.mode === "user-key")
187
210
  env.ANTHROPIC_API_KEY = anthropic.key;
188
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");
189
219
  const openrouter = userKey("OPENROUTER_API_KEY");
190
220
  if (openrouter) {
191
221
  env.OPENROUTER_API_KEY = openrouter;
@@ -205,10 +235,10 @@ export function envForUserShell(base) {
205
235
  // still prints "claude.ai connectors are disabled...". Restarting claude, or
206
236
  // the panel, changed nothing -- only restarting the serve did.
207
237
  //
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.
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.
212
242
  //
213
243
  // Caveat: PATH ordering is not ours to guarantee. A login shell can reorder it
214
244
  // (macOS /etc/zprofile runs path_helper, which demotes an inherited entry below
@@ -224,15 +254,18 @@ function realpath(p) {
224
254
  return path.resolve(p);
225
255
  }
226
256
  }
227
- // The `claude` the shim should exec: the first executable one on `pathValue`
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`
228
261
  // that isn't the shim itself. Skipping by resolved directory (not by string) is
229
262
  // what keeps the shim from exec'ing itself forever.
230
- function findRealClaude(pathValue, shimDir) {
263
+ function findRealBin(pathValue, shimDir, name) {
231
264
  const shimReal = realpath(shimDir);
232
265
  for (const entry of (pathValue ?? "").split(path.delimiter)) {
233
266
  if (!entry || realpath(entry) === shimReal)
234
267
  continue;
235
- const candidate = path.join(entry, "claude");
268
+ const candidate = path.join(entry, name);
236
269
  try {
237
270
  fs.accessSync(candidate, fs.constants.X_OK);
238
271
  if (fs.statSync(candidate).isFile())
@@ -244,14 +277,30 @@ function findRealClaude(pathValue, shimDir) {
244
277
  }
245
278
  return null;
246
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
+ }
247
296
  // Shell statements the shim evals before exec'ing claude: the environment that
248
297
  // makes THIS run use the credential resolveAnthropicAuth picks right now.
249
298
  // Emitting `unset` rather than an empty value matters -- Claude Code treats an
250
299
  // empty ANTHROPIC_BASE_URL as configured and fails to reach anything.
251
- export function claudeShellEnvScript(env, shimDir) {
300
+ export function claudeShellEnvScript(env, args = []) {
252
301
  const lines = [];
253
302
  const auth = resolveAnthropicAuth();
254
- if (auth.mode === "proxy") {
303
+ if (auth.mode === "proxy" && !isLoginRun(args)) {
255
304
  for (const name of ANTHROPIC_PROXY_ENV) {
256
305
  const stashed = env[PROXY_STASH_PREFIX + name];
257
306
  if (stashed !== undefined && env[name] === undefined) {
@@ -262,55 +311,133 @@ export function claudeShellEnvScript(env, shimDir) {
262
311
  else {
263
312
  for (const name of ANTHROPIC_PROXY_ENV)
264
313
  lines.push(`unset ${name}`);
265
- if (auth.mode === "user-key") {
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)) {
266
317
  lines.push(`ANTHROPIC_API_KEY=${shellQuote(auth.key)}; export ANTHROPIC_API_KEY`);
267
318
  }
268
319
  }
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" : "";
320
+ return lines.join("\n");
273
321
  }
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) {
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) {
280
353
  const helper = [
281
354
  shellQuote(process.execPath),
282
- shellQuote(path.join(DIST_DIR, "claude-shim-env.js")),
355
+ shellQuote(path.join(DIST_DIR, "cli-shim-env.js")),
283
356
  shellQuote(shimDir),
357
+ shellQuote(cli),
284
358
  ].join(" ");
285
359
  return [
286
360
  "#!/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',
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`,
292
366
  " exit 127",
293
367
  "fi",
294
- 'exec "$CASTLE_REAL_CLAUDE" "$@"',
368
+ 'exec "$CASTLE_REAL_BIN" "$@"',
295
369
  "",
296
370
  ].join("\n");
297
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
+ }
298
423
  // 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
424
+ // is none to install -- a failure here costs the per-run re-resolution, so it
300
425
  // degrades to envForUserShell's spawn-time answer rather than breaking the shell.
301
- export function installClaudeShim(deckDir) {
426
+ export function installCliShims(deckDir) {
302
427
  if (process.platform === "win32")
303
428
  return null;
304
429
  const dir = path.join(deckDir, ".castle", "shims");
305
430
  try {
306
431
  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"));
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
+ }
314
441
  return dir;
315
442
  }
316
443
  catch {
@@ -0,0 +1,13 @@
1
+ // Printed for the editor terminal's shims to eval: the environment this
2
+ // particular run of this particular CLI should have, plus the real binary to
3
+ // exec (see installCliShims in byo-auth.ts for why the decision can't live in
4
+ // the shell's own environment).
5
+ //
6
+ // argv is `<shimDir> <cli> [the run's own arguments...]`.
7
+ import { ensureClaudeOnboarded, shimEnvScript } from "./byo-auth.js";
8
+ const [shimDir = "", cli = "", ...args] = process.argv.slice(2);
9
+ // Its own call rather than a side effect of building the script: this WRITES,
10
+ // and shimEnvScript is read-only by contract.
11
+ if (cli === "claude")
12
+ ensureClaudeOnboarded();
13
+ process.stdout.write(shimEnvScript(cli, process.env, shimDir, args));
package/dist/ide.js CHANGED
@@ -15,7 +15,7 @@ import headlessPkg from "@xterm/headless";
15
15
  import { SerializeAddon } from "@xterm/addon-serialize";
16
16
  import { WebSocketServer } from "ws";
17
17
  import { IMPORTS_DIR, importStatuses, updateImport } from "./imports.js";
18
- import { envForUserShell, installClaudeShim } from "./byo-auth.js";
18
+ import { envForUserShell, installCliShims } from "./byo-auth.js";
19
19
  const HeadlessTerminal = headlessPkg.Terminal;
20
20
  const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
21
21
  // The bundled shell app (vite build output). `/` serves its index.html and
@@ -651,7 +651,7 @@ export function createIdeServer(opts) {
651
651
  let session = null;
652
652
  function spawnSession() {
653
653
  const { command, args } = defaultShell();
654
- const shimDir = installClaudeShim(deckDir);
654
+ const shimDir = installCliShims(deckDir);
655
655
  const screen = new HeadlessTerminal({
656
656
  allowProposedApi: true,
657
657
  cols: INITIAL_COLS,