castle-web-cli 0.4.102 → 0.4.104

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.
@@ -1,4 +1,4 @@
1
- export type FailureKind = "config" | "transient" | "no-work" | "spawn" | "timeout" | "exit";
1
+ export type FailureKind = "config" | "limit" | "transient" | "no-work" | "spawn" | "timeout" | "exit";
2
2
  export type ConfigReason = "no-key" | "bad-key" | "no-credits" | "unknown-model" | "no-tools" | "no-endpoints" | "flagged" | "context-length";
3
3
  export interface AgentFailure {
4
4
  kind: FailureKind;
@@ -7,6 +7,7 @@ export interface AgentFailure {
7
7
  verbose?: string;
8
8
  model?: string;
9
9
  suggestion?: string;
10
+ resetAtMs?: number;
10
11
  }
11
12
  export declare function failureForStatus(status: number, body: string, model?: string): AgentFailure | undefined;
12
13
  export declare function classifyProviderError(text: string | undefined, model?: string): AgentFailure | undefined;
@@ -8,6 +8,9 @@
8
8
  // config - deterministic and won't fix itself (bad slug, bad key, no
9
9
  // credits). Never retried: retrying is pure latency. Copy names
10
10
  // what's wrong and points at settings / Castle.
11
+ // limit - Castle's daily AI allowance for this user is spent. Nothing to
12
+ // fix in the session and nothing to retry until the reset, so the
13
+ // copy gives the time and the one way around it (own credential).
11
14
  // transient - the provider was busy or broke. NOT auto-retried either (see
12
15
  // below), but the copy invites the user to send again.
13
16
  // no-work - the model answered instead of working. Config is fine; the
@@ -137,6 +140,12 @@ export function failureCopy(opts) {
137
140
  switch (opts.failure.kind) {
138
141
  case "config":
139
142
  return `${configCopy(opts.failure)}${tasksNote}`;
143
+ case "limit": {
144
+ const resets = opts.failure.resetAtMs
145
+ ? ` -- resets ${new Date(opts.failure.resetAtMs).toLocaleString()}`
146
+ : "";
147
+ return `Daily Castle AI limit reached${resets}. Runs on your own API key or login aren't limited.${tasksNote}`;
148
+ }
140
149
  case "transient":
141
150
  return `OpenRouter is busy right now and I couldn't get through. Send that again in a moment.${tasksNote}`;
142
151
  case "no-work":
package/dist/agent.js CHANGED
@@ -24,7 +24,8 @@ import { rawDataToString } from "./ide.js";
24
24
  import { buildRouterPrompt, buildTaskPrompt, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from "./agent-prompts.js";
25
25
  import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from "./openrouter-catalog.js";
26
26
  import { classifyProviderError, failureCopy, } from "./agent-failures.js";
27
- import { meteringHeaders, newAgentSessionId, reportCursorRun, withCustomHeaders, } from "./metering.js";
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
29
  import { runAgentNative } from "./native/loop.js";
29
30
  import { createPlaytestBrowserManager } from "./native/playtest-browser.js";
30
31
  import { createPlaywrightPlaytestExecutor } from "./native/playtest-executor.js";
@@ -153,17 +154,6 @@ function openrouterAnthropicBase() {
153
154
  const injected = process.env.OPENROUTER_BASE_URL;
154
155
  return injected ? injected.replace(/\/v1\/?$/, "") : "https://openrouter.ai/api";
155
156
  }
156
- // Anthropic credential sources the claude CLI will fall back to on its own.
157
- // This env points the CLI at a THIRD PARTY, so every one of these has to be
158
- // cleared or that third party receives the user's Anthropic credential. Note
159
- // there is no file to check for here: on macOS the CLI reads a saved login
160
- // from the Keychain, so `~/.claude/.credentials.json` can be absent while the
161
- // CLI is still perfectly able to authenticate as the user.
162
- const ANTHROPIC_CREDENTIAL_ENV = [
163
- "ANTHROPIC_API_KEY",
164
- "ANTHROPIC_AUTH_TOKEN_HELPER",
165
- "CLAUDE_CODE_OAUTH_TOKEN",
166
- ];
167
157
  // Env for a claude CLI spawn routed at OpenRouter (claudeModel "openrouter",
168
158
  // Path A). Two things make this deterministic regardless of the user's own
169
159
  // Anthropic auth (verified live against the real `claude` binary while
@@ -359,6 +349,18 @@ const OPENROUTER_ALLOWED_TOOLS = "--allowedTools=Edit,Write,NotebookEdit,Bash";
359
349
  // Cursor's proprietary model. Also the slug reported to the metering ledger, so
360
350
  // the two can never drift into disagreeing about what a cursor row ran on.
361
351
  const CURSOR_MODEL = "composer-2.5-fast";
352
+ // Keep runs independent of the machine's user config: no user plugins (LSP
353
+ // servers etc.), no user MCP servers. CLAUDE.md auto-discovery and OAuth still
354
+ // work. On a user's own Anthropic KEY this also carries the apiKeyHelper that
355
+ // actually authenticates the run (see anthropicKeyHelperCommand).
356
+ function claudeSettingsArg(auth) {
357
+ return JSON.stringify({
358
+ enabledPlugins: {},
359
+ ...(auth?.mode === "user-key"
360
+ ? { apiKeyHelper: anthropicKeyHelperCommand() }
361
+ : {}),
362
+ });
363
+ }
362
364
  function buildAgentInvocation(backend, role, prompt, claudeModel,
363
365
  // Already resolved for this role by the caller (router turns pass
364
366
  // settings.routerOpenrouterModel, task spawns settings.tasksOpenrouterModel).
@@ -399,11 +401,8 @@ metering) {
399
401
  // summary before prose). Undocumented in --help but honored.
400
402
  "--thinking-display",
401
403
  "summarized",
402
- // Keep runs independent of the machine's user config: no user plugins
403
- // (LSP servers etc.), no user MCP servers. CLAUDE.md auto-discovery
404
- // and OAuth still work.
405
404
  "--settings",
406
- '{"enabledPlugins": {}}',
405
+ claudeSettingsArg(anAuth),
407
406
  "--strict-mcp-config",
408
407
  ...(role === "task"
409
408
  ? ["--append-system-prompt", CLAUDE_TASK_SYSTEM_REMINDER]
@@ -767,37 +766,6 @@ function castleKeys() {
767
766
  return {};
768
767
  }
769
768
  }
770
- // A user's OWN provider credentials, kept SEPARATE from Castle's keys.json so
771
- // castle-www's per-serve re-sync of keys.json (cloudSandbox.ts syncCastleKeys)
772
- // can't clobber them. Same shape as keys.json (env-var-name keys); the user (or
773
- // a future editor UI) writes this file, nothing in-process does. When a key is
774
- // present the run goes DIRECT to that provider on the user's own credential and
775
- // is NOT metered -- deleting the key reverts to Castle's proxy on the next run.
776
- // The path override mirrors CASTLE_KEYS_PATH so the QA battery stays isolated
777
- // from a developer's real ~/.castle. No env fallback on read: the file is the
778
- // only source, so a delete fully reverts.
779
- const CASTLE_USER_KEYS_PATH = process.env.CASTLE_USER_KEYS_PATH ??
780
- path.join(os.homedir(), ".castle", "user-keys.json");
781
- function userKeys() {
782
- try {
783
- return JSON.parse(fs.readFileSync(CASTLE_USER_KEYS_PATH, "utf8"));
784
- }
785
- catch {
786
- return {};
787
- }
788
- }
789
- function userKey(envName) {
790
- const v = userKeys()[envName]?.trim();
791
- return v ? v : null;
792
- }
793
- function resolveAnthropicAuth() {
794
- const k = userKey("ANTHROPIC_API_KEY");
795
- if (k)
796
- return { mode: "user-key", key: k };
797
- if (backendHasSavedAuth("claude"))
798
- return { mode: "user-login" };
799
- return { mode: "proxy" };
800
- }
801
769
  function resolveOpenrouterAuth() {
802
770
  const k = userKey("OPENROUTER_API_KEY");
803
771
  if (k)
@@ -856,50 +824,15 @@ function purgeStaleCursorAuth(home, injectedKey) {
856
824
  }
857
825
  }
858
826
  // True when the user has their OWN saved auth for this backend -- a login we
859
- // route to directly (and bill to them) instead of Castle's proxy / key.
860
- //
861
- // KNOWN GAP (macOS): the claude check is a false negative for most logged-in
862
- // users. `claude /login` stores credentials in the KEYCHAIN there, not in
863
- // ~/.claude/.credentials.json, so this returns false and the run stays on
864
- // Castle's proxy even though the user has a perfectly good subscription login
865
- // the CLI would have used. Verified while tracing the OpenRouter credential
866
- // leak: on a machine with no .credentials.json at all, the CLI still
867
- // authenticated from the Keychain. Left alone deliberately -- reading the
868
- // Keychain (`security find-generic-password`) changes who pays for a run, which
869
- // is a product decision, not a cleanup. In a Linux sandbox (the case that
870
- // matters for BYO routing) the file IS authoritative, so the gap doesn't bite.
871
- //
872
- // CASTLE_CLAUDE_CREDENTIALS_PATH overrides the claude credentials location so
873
- // the QA battery can isolate from a developer's REAL ~/.claude login -- which,
874
- // now that this gates proxy-vs-direct routing (see resolveAnthropicAuth), would
875
- // otherwise flip every plain-claude scenario to "direct" on a logged-in machine.
876
- // Mirrors the CASTLE_KEYS_PATH seam.
827
+ // route to directly (and bill to them) instead of Castle's proxy / key. The
828
+ // claude side lives in byo-auth.ts, which the editor terminal shares.
877
829
  function backendHasSavedAuth(backend) {
878
- const home = os.homedir();
879
- if (backend === "claude") {
880
- const credPath = process.env.CASTLE_CLAUDE_CREDENTIALS_PATH ??
881
- path.join(home, ".claude", ".credentials.json");
882
- return fs.existsSync(credPath);
883
- }
884
- if (backend === "cursor") {
885
- return cursorHasUserLogin(home);
886
- }
830
+ if (backend === "claude")
831
+ return claudeHasSavedLogin();
832
+ if (backend === "cursor")
833
+ return cursorHasUserLogin(os.homedir());
887
834
  return false;
888
835
  }
889
- // Everything that points the claude CLI at Castle's llm-proxy, or that it could
890
- // otherwise send there: the injected proxy pair, every Anthropic credential the
891
- // CLI falls back to, and an inherited ANTHROPIC_CUSTOM_HEADERS. That last one
892
- // matters because the sandbox host-agent's pty env carries x-castle-* metering
893
- // lines (castle-sandboxes agentRunner), so a serve started from that terminal
894
- // inherits them and withCustomHeaders deliberately preserves inherited values.
895
- // Cleared wholesale on any DIRECT run so none of it can ride along to
896
- // api.anthropic.com or to the user's own account.
897
- const ANTHROPIC_PROXY_ENV = [
898
- "ANTHROPIC_BASE_URL",
899
- "ANTHROPIC_AUTH_TOKEN",
900
- "ANTHROPIC_CUSTOM_HEADERS",
901
- ...ANTHROPIC_CREDENTIAL_ENV,
902
- ];
903
836
  // Env for the plain claude CLI path (NOT claude-via-OpenRouter -- that's
904
837
  // envForOpenrouterSpawn). resolveAnthropicAuth decides the routing:
905
838
  // - proxy: inherit the host-injected ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN
@@ -908,16 +841,17 @@ const ANTHROPIC_PROXY_ENV = [
908
841
  // - user-login: strip the proxy pair + all stray Anthropic creds so the CLI
909
842
  // authenticates from the user's own saved login (~/.claude/.credentials.json
910
843
  // or, on macOS, the Keychain) and bills them, direct + unmetered.
911
- // - user-key: same strip, then set ANTHROPIC_API_KEY (x-api-key) to the user's
912
- // own key, direct + unmetered.
844
+ // - user-key: same strip, and the key arrives through the --settings
845
+ // apiKeyHelper buildAgentInvocation adds, NOT through this env -- a headless
846
+ // claude ignores an unapproved ANTHROPIC_API_KEY (see
847
+ // anthropicKeyHelperCommand), so setting it here would look like the
848
+ // credential while doing nothing.
913
849
  function envForClaudeSpawn(auth) {
914
850
  const env = { ...process.env };
915
851
  if (auth.mode === "proxy")
916
852
  return env;
917
853
  for (const name of ANTHROPIC_PROXY_ENV)
918
854
  delete env[name];
919
- if (auth.mode === "user-key")
920
- env.ANTHROPIC_API_KEY = auth.key;
921
855
  return env;
922
856
  }
923
857
  // Env for a cursor-agent spawn: inject Castle's key ONLY when the backend has no
@@ -1319,8 +1253,12 @@ function makeAgentEventHandler(opts, state) {
1319
1253
  }
1320
1254
  else if (ev.type === "result") {
1321
1255
  state.sawResult = true;
1256
+ // An EMPTY string result falls back to the streamed text. Models that
1257
+ // return their blocks as `text, thinking` (gemini through OpenRouter's
1258
+ // anthropic-compatible endpoint) end the turn on an empty thinking block,
1259
+ // and the CLI reports `result: ""` even though the answer streamed fine.
1322
1260
  state.finalText =
1323
- typeof ev.result === "string" ? ev.result : state.accumulated;
1261
+ typeof ev.result === "string" && ev.result ? ev.result : state.accumulated;
1324
1262
  state.resultIsError = ev.is_error === true;
1325
1263
  state.usage = parseCliUsage(ev.usage);
1326
1264
  }
@@ -1355,8 +1293,12 @@ function makeAgentEventHandler(opts, state) {
1355
1293
  }
1356
1294
  else if (ev.type === "result") {
1357
1295
  state.sawResult = true;
1296
+ // An EMPTY string result falls back to the streamed text. Models that
1297
+ // return their blocks as `text, thinking` (gemini through OpenRouter's
1298
+ // anthropic-compatible endpoint) end the turn on an empty thinking block,
1299
+ // and the CLI reports `result: ""` even though the answer streamed fine.
1358
1300
  state.finalText =
1359
- typeof ev.result === "string" ? ev.result : state.accumulated;
1301
+ typeof ev.result === "string" && ev.result ? ev.result : state.accumulated;
1360
1302
  state.resultIsError = ev.is_error === true;
1361
1303
  state.usage = parseCliUsage(ev.usage);
1362
1304
  }
@@ -1613,6 +1555,98 @@ async function preflightOpenrouterRun(opts) {
1613
1555
  }
1614
1556
  return null;
1615
1557
  }
1558
+ // Every run that Castle pays for moves the usage bar, and runAgentTurn is the
1559
+ // one place that knows a run just ended. The serve subscribes to refresh the
1560
+ // number it pushes to the editor; a run with no serve attached has no listener.
1561
+ const runFinishedListeners = new Set();
1562
+ function onAgentRunFinished(listener) {
1563
+ runFinishedListeners.add(listener);
1564
+ return () => {
1565
+ runFinishedListeners.delete(listener);
1566
+ };
1567
+ }
1568
+ function notifyAgentRunFinished() {
1569
+ for (const listener of runFinishedListeners)
1570
+ listener();
1571
+ }
1572
+ // True when THIS run spends Castle's credential rather than the user's own.
1573
+ // Resolves exactly as buildAgentInvocation / runAgentSmith will, one step
1574
+ // earlier, so the gate and the run can never disagree about who is paying.
1575
+ // Cursor counts: it runs on Castle's key by default, and gating it here is the
1576
+ // only gate there is -- its traffic never reaches the proxy.
1577
+ function runIsCastlePaid(backend, claudeModel, orAuth) {
1578
+ if (backend === "cursor")
1579
+ return true;
1580
+ return roleUsesOpenrouter(backend, claudeModel)
1581
+ ? (orAuth ?? resolveOpenrouterAuth()).mode === "proxy"
1582
+ : resolveAnthropicAuth().mode === "proxy";
1583
+ }
1584
+ // Whether Castle's budget is this editor's to spend at all, which is what makes
1585
+ // the usage bar worth drawing. Both roles count, and they can disagree: an
1586
+ // Anthropic login covers the claude roles while a cursor role still spends
1587
+ // Castle's key. Re-read per refresh, so adding or removing a credential (or
1588
+ // switching a role's backend) moves the bar on the next one.
1589
+ function anyRoleIsCastlePaid(settings) {
1590
+ return (runIsCastlePaid(settings.router, settings.routerClaudeModel, null) ||
1591
+ runIsCastlePaid(settings.tasks, settings.tasksClaudeModel, null));
1592
+ }
1593
+ // The proxy 403s a spent-out user mid-stream, which a CLI surfaces as a generic
1594
+ // provider error after a spawn. Asking first turns that into one sentence and
1595
+ // no spawn. Fails open on every non-answer: the proxy is the real backstop.
1596
+ async function budgetRefusal(backend, claudeModel, orAuth) {
1597
+ if (!runIsCastlePaid(backend, claudeModel, orAuth))
1598
+ return null;
1599
+ const budget = await fetchBudget();
1600
+ if (!budget?.blocked)
1601
+ return null;
1602
+ return {
1603
+ kind: "limit",
1604
+ detail: "daily Castle AI limit reached",
1605
+ resetAtMs: budget.resetAtMs || undefined,
1606
+ };
1607
+ }
1608
+ // Slow on purpose: usage only moves because a run spent something, and a
1609
+ // finished run already refreshes. This is for the spend this serve never sees
1610
+ // -- a `claude` invoked straight from the sandbox terminal.
1611
+ const USAGE_POLL_MS = 60_000;
1612
+ /**
1613
+ * The editor's daily-usage feed, pushed over the agent socket exactly the way
1614
+ * settings are: the current value rides `hello`, and a change is broadcast.
1615
+ * Outside a sandbox fetchBudget answers null forever, so nothing is ever sent
1616
+ * and the shell simply has no bar to show.
1617
+ *
1618
+ * Null is a value here, not just "no answer yet": a user running on their own
1619
+ * credential is told nothing about Castle's daily limit, because none of their
1620
+ * runs spend it and none of them are gated on it -- showing the bar (worse, a
1621
+ * spent-out red one) would describe a limit that doesn't apply to them. It has
1622
+ * to be broadcast rather than merely withheld, so the bar clears when a
1623
+ * credential appears mid-session.
1624
+ */
1625
+ function createUsageFeed(opts) {
1626
+ let latest = null;
1627
+ async function refreshAsync() {
1628
+ const next = opts.castlePaid() ? await fetchBudget() : null;
1629
+ if (JSON.stringify(next ?? null) === JSON.stringify(latest ?? null))
1630
+ return;
1631
+ latest = next;
1632
+ opts.broadcast({ type: "usage", usage: next });
1633
+ }
1634
+ const refresh = () => void refreshAsync();
1635
+ const stopRunWatch = onAgentRunFinished(refresh);
1636
+ const timer = setInterval(() => {
1637
+ if (opts.hasClients())
1638
+ refresh();
1639
+ }, USAGE_POLL_MS);
1640
+ timer.unref?.();
1641
+ return {
1642
+ latest: () => latest,
1643
+ refresh,
1644
+ stop: () => {
1645
+ stopRunWatch();
1646
+ clearInterval(timer);
1647
+ },
1648
+ };
1649
+ }
1616
1650
  // The one backend dispatch point for running an agent turn: smith runs
1617
1651
  // in-process (runAgentSmith -> runAgentNative); cursor/claude spawn a CLI
1618
1652
  // (buildAgentInvocation -> runAgentCli). Everything downstream consumes the
@@ -1627,8 +1661,11 @@ async function runAgentTurn(opts) {
1627
1661
  // Deterministic config errors stop here: nothing spawned, no request issued,
1628
1662
  // nothing billed. Returned (not thrown) because the callers' catch paths
1629
1663
  // emit generic "something went wrong" copy, which would bury the specific
1630
- // reason this pre-flight exists to produce.
1631
- const failure = await preflightOpenrouterRun({ ...opts, orAuth });
1664
+ // reason this pre-flight exists to produce. A spent-out daily budget is the
1665
+ // same shape of answer, and comes second so a misconfigured run is still
1666
+ // reported as misconfigured.
1667
+ const failure = (await preflightOpenrouterRun({ ...opts, orAuth })) ??
1668
+ (await budgetRefusal(opts.backend, opts.claudeModel, orAuth));
1632
1669
  if (failure) {
1633
1670
  return {
1634
1671
  ok: false,
@@ -1644,10 +1681,11 @@ async function runAgentTurn(opts) {
1644
1681
  // in runTaskAgentIn calls this once per attempt, and each attempt is its own
1645
1682
  // conversation -- nothing is resumed).
1646
1683
  const sessionId = newAgentSessionId(opts.role);
1684
+ const settled = (run) => run.finally(() => notifyAgentRunFinished());
1647
1685
  if (opts.backend === "smith") {
1648
1686
  // roleUsesOpenrouter is true for smith, so orAuth is non-null here.
1649
1687
  const direct = orAuth.mode === "user-key";
1650
- return runAgentSmith({
1688
+ return settled(runAgentSmith({
1651
1689
  cwd: opts.cwd,
1652
1690
  role: opts.role,
1653
1691
  apiKey: orAuth.key,
@@ -1674,7 +1712,7 @@ async function runAgentTurn(opts) {
1674
1712
  onActivity: opts.onActivity,
1675
1713
  onThinking: opts.onThinking,
1676
1714
  onSpawn: opts.onSpawn,
1677
- });
1715
+ }));
1678
1716
  }
1679
1717
  const invocation = buildAgentInvocation(opts.backend, opts.role, opts.prompt, opts.claudeModel, opts.openrouterModel, { sessionId, deckDir: opts.cwd });
1680
1718
  const startedMs = Date.now();
@@ -1700,8 +1738,8 @@ async function runAgentTurn(opts) {
1700
1738
  // run is reported from here. Every other backend is already recorded upstream,
1701
1739
  // and reporting them here too would double-count them in the same table.
1702
1740
  if (opts.backend !== "cursor")
1703
- return run;
1704
- return run.then((result) => {
1741
+ return settled(run);
1742
+ return settled(run.then((result) => {
1705
1743
  reportCursorRun({
1706
1744
  deckDir: opts.cwd,
1707
1745
  sessionId,
@@ -1710,7 +1748,7 @@ async function runAgentTurn(opts) {
1710
1748
  ok: result.ok,
1711
1749
  });
1712
1750
  return result;
1713
- });
1751
+ }));
1714
1752
  }
1715
1753
  // -- task store ---------------------------------------------------------------
1716
1754
  function persistTaskFile(tasksDir, task) {
@@ -3463,7 +3501,17 @@ export function createAgentServer(opts) {
3463
3501
  tasksProviderTier: normalizeProviderTier(storedSettings?.tasksProviderTier) ??
3464
3502
  DEFAULT_SETTINGS.tasksProviderTier,
3465
3503
  };
3466
- const applySettings = (incoming) => applyAgentSettings(incoming, { settings, settingsPath, broadcast });
3504
+ const usageFeed = createUsageFeed({
3505
+ broadcast,
3506
+ hasClients: () => clients.size > 0,
3507
+ castlePaid: () => anyRoleIsCastlePaid(settings),
3508
+ });
3509
+ const applySettings = (incoming) => {
3510
+ applyAgentSettings(incoming, { settings, settingsPath, broadcast });
3511
+ // A backend switch can change who pays (a cursor role always spends
3512
+ // Castle's), so the bar follows the change instead of the poll.
3513
+ usageFeed.refresh();
3514
+ };
3467
3515
  const taskFeeds = createTaskFeeds(broadcast);
3468
3516
  const messageThinking = createMessageThinking(broadcast);
3469
3517
  const taskStore = createTaskStore({
@@ -3534,8 +3582,12 @@ export function createAgentServer(opts) {
3534
3582
  thinking: messageThinking.snapshot(),
3535
3583
  running: routerQueue.isRunning(),
3536
3584
  queued: routerQueue.queuedSnippets(),
3585
+ usage: usageFeed.latest(),
3537
3586
  };
3538
3587
  socket.send(JSON.stringify(hello));
3588
+ // A newly attached client is the one moment the cached value may be stale
3589
+ // by a whole poll interval (nothing refreshes while nobody is watching).
3590
+ usageFeed.refresh();
3539
3591
  // Slug verdicts follow hello rather than riding it: they're async, and the
3540
3592
  // boot snapshot must never wait on a network call. This is what makes a
3541
3593
  // stored-but-bad slug warn the first time the popover opens -- without it,
@@ -3601,6 +3653,7 @@ export function createAgentServer(opts) {
3601
3653
  }
3602
3654
  }
3603
3655
  stopChildRegistry();
3656
+ usageFeed.stop();
3604
3657
  wss.close();
3605
3658
  void playtestBrowserManager.shutdown();
3606
3659
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ // Printed to stdout for the claude CLI's `apiKeyHelper` setting: the user's own
2
+ // Anthropic key, read from ~/.castle/user-keys.json at call time (see
3
+ // byo-auth.ts anthropicKeyHelperCommand for why a helper and not an env var).
4
+ // Reading it here rather than passing it in keeps the key out of the command
5
+ // line and out of the spawned CLI's environment.
6
+ import { userKey } from "./byo-auth.js";
7
+ process.stdout.write(userKey("ANTHROPIC_API_KEY") ?? "");
@@ -0,0 +1,23 @@
1
+ export declare const CASTLE_USER_KEYS_PATH: string;
2
+ export declare function userKey(envName: "ANTHROPIC_API_KEY" | "OPENROUTER_API_KEY"): string | null;
3
+ export type AnthropicAuth = {
4
+ mode: "user-key";
5
+ key: string;
6
+ } | {
7
+ mode: "user-login";
8
+ } | {
9
+ mode: "proxy";
10
+ };
11
+ export type OpenrouterAuth = {
12
+ mode: "user-key";
13
+ key: string;
14
+ } | {
15
+ mode: "proxy";
16
+ key: string;
17
+ };
18
+ export declare function claudeHasSavedLogin(): boolean;
19
+ export declare function resolveAnthropicAuth(): AnthropicAuth;
20
+ export declare const ANTHROPIC_CREDENTIAL_ENV: readonly ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN_HELPER", "CLAUDE_CODE_OAUTH_TOKEN"];
21
+ 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
+ export declare function anthropicKeyHelperCommand(): string;
23
+ export declare function envForUserShell(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
@@ -0,0 +1,130 @@
1
+ // Whose Anthropic credential a run spends: the user's own (straight to
2
+ // api.anthropic.com, unmetered) or Castle's (through the sandbox llm-proxy on
3
+ // the injected scoped token). Split out of agent.ts because the editor's PTY
4
+ // terminal has to answer the same question for the `claude` a user runs by
5
+ // hand -- and must answer it the SAME way, or the terminal quietly spends
6
+ // Castle's budget while the agent panel spends the user's.
7
+ import * as fs from "fs";
8
+ import * as os from "os";
9
+ import * as path from "path";
10
+ import { fileURLToPath } from "url";
11
+ const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
12
+ // A user's OWN provider credentials, kept SEPARATE from Castle's keys.json so
13
+ // 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
16
+ // present the run goes DIRECT to that provider on the user's own credential and
17
+ // is NOT metered -- deleting the key reverts to Castle's proxy on the next run.
18
+ // The path override mirrors CASTLE_KEYS_PATH so the QA battery stays isolated
19
+ // from a developer's real ~/.castle. No env fallback on read: the file is the
20
+ // only source, so a delete fully reverts.
21
+ export const CASTLE_USER_KEYS_PATH = process.env.CASTLE_USER_KEYS_PATH ??
22
+ path.join(os.homedir(), ".castle", "user-keys.json");
23
+ function userKeys() {
24
+ try {
25
+ return JSON.parse(fs.readFileSync(CASTLE_USER_KEYS_PATH, "utf8"));
26
+ }
27
+ catch {
28
+ return {};
29
+ }
30
+ }
31
+ export function userKey(envName) {
32
+ const v = userKeys()[envName]?.trim();
33
+ return v ? v : null;
34
+ }
35
+ // KNOWN GAP (macOS): a false negative for most logged-in users. `claude /login`
36
+ // stores credentials in the KEYCHAIN there, not in ~/.claude/.credentials.json,
37
+ // so this returns false and the run stays on Castle's proxy even though the
38
+ // 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.
43
+ //
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.
48
+ 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);
52
+ }
53
+ export function resolveAnthropicAuth() {
54
+ const k = userKey("ANTHROPIC_API_KEY");
55
+ if (k)
56
+ return { mode: "user-key", key: k };
57
+ if (claudeHasSavedLogin())
58
+ return { mode: "user-login" };
59
+ return { mode: "proxy" };
60
+ }
61
+ // Anthropic credential sources the claude CLI will fall back to on its own.
62
+ // A spawn pointed at a THIRD PARTY (OpenRouter) has to clear every one of
63
+ // these or that third party receives the user's Anthropic credential.
64
+ export const ANTHROPIC_CREDENTIAL_ENV = [
65
+ "ANTHROPIC_API_KEY",
66
+ "ANTHROPIC_AUTH_TOKEN_HELPER",
67
+ "CLAUDE_CODE_OAUTH_TOKEN",
68
+ ];
69
+ // Everything that points the claude CLI at Castle's llm-proxy, or that it could
70
+ // otherwise send there: the injected proxy pair, every Anthropic credential the
71
+ // CLI falls back to, and an inherited ANTHROPIC_CUSTOM_HEADERS. That last one
72
+ // matters because the sandbox host-agent's pty env carries x-castle-* metering
73
+ // lines (castle-sandboxes agentRunner), so a serve started from that terminal
74
+ // inherits them and withCustomHeaders deliberately preserves inherited values.
75
+ // Cleared wholesale on any DIRECT run so none of it can ride along to
76
+ // api.anthropic.com or to the user's own account.
77
+ export const ANTHROPIC_PROXY_ENV = [
78
+ "ANTHROPIC_BASE_URL",
79
+ "ANTHROPIC_AUTH_TOKEN",
80
+ "ANTHROPIC_CUSTOM_HEADERS",
81
+ ...ANTHROPIC_CREDENTIAL_ENV,
82
+ ];
83
+ function shellQuote(value) {
84
+ return `'${value.replace(/'/g, `'\\''`)}'`;
85
+ }
86
+ // How a user's own Anthropic KEY actually reaches a headless `claude -p` run.
87
+ //
88
+ // Not ANTHROPIC_API_KEY: verified against claude 2.1.220 that a key in the
89
+ // environment is IGNORED unless the user approved it interactively first
90
+ // (/config's "Use custom API key"), and an unapproved key doesn't fall back to
91
+ // anything either -- the run dies with "Not logged in - Please run /login"
92
+ // having issued no request at all. Pre-seeding the approval
93
+ // (customApiKeyResponses.approved in .claude.json) did NOT help. apiKeyHelper
94
+ // is honored with no login, no approval and no prompt: claude runs the command
95
+ // and sends its stdout as both x-api-key and Authorization: Bearer.
96
+ //
97
+ // The helper reads the key from the file at call time rather than taking it as
98
+ // an argument, so the user's key never lands in a command line or in the
99
+ // spawned CLI's environment.
100
+ export function anthropicKeyHelperCommand() {
101
+ return `${shellQuote(process.execPath)} ${shellQuote(path.join(DIST_DIR, "anthropic-key-helper.js"))}`;
102
+ }
103
+ // Env for the editor's PTY terminal. The container env the serve inherits
104
+ // carries the llm-proxy pair, and Claude Code ranks ANTHROPIC_AUTH_TOKEN ABOVE
105
+ // a claude.ai login -- so without this a user who ran `claude /login` in the
106
+ // terminal still gets "claude.ai connectors are disabled because
107
+ // 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.
110
+ //
111
+ // A user KEY goes in as ANTHROPIC_API_KEY here, unlike the agent path's
112
+ // apiKeyHelper (see above): an interactive claude CAN show the one-time
113
+ // "use this custom API key?" approval, and the terminal is the one place a
114
+ // person is there to answer it.
115
+ export function envForUserShell(base) {
116
+ const env = { ...base };
117
+ const anthropic = resolveAnthropicAuth();
118
+ if (anthropic.mode !== "proxy") {
119
+ for (const name of ANTHROPIC_PROXY_ENV)
120
+ delete env[name];
121
+ if (anthropic.mode === "user-key")
122
+ env.ANTHROPIC_API_KEY = anthropic.key;
123
+ }
124
+ const openrouter = userKey("OPENROUTER_API_KEY");
125
+ if (openrouter) {
126
+ env.OPENROUTER_API_KEY = openrouter;
127
+ delete env.OPENROUTER_BASE_URL;
128
+ }
129
+ return env;
130
+ }
package/dist/ide.js CHANGED
@@ -15,6 +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 } from "./byo-auth.js";
18
19
  const HeadlessTerminal = headlessPkg.Terminal;
19
20
  const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
20
21
  // The bundled shell app (vite build output). `/` serves its index.html and
@@ -178,6 +179,12 @@ function listDeckFiles(deckDir) {
178
179
  out.sort((a, b) => a.localeCompare(b));
179
180
  return out;
180
181
  }
182
+ function asFileTypes(value) {
183
+ if (!Array.isArray(value))
184
+ return undefined;
185
+ const out = value.filter((e) => Boolean(e) && typeof e === "object" && typeof e.ext === "string");
186
+ return out.length > 0 ? out : undefined;
187
+ }
181
188
  function asStringArray(value) {
182
189
  if (Array.isArray(value) && value.every((e) => typeof e === "string")) {
183
190
  return value;
@@ -202,7 +209,9 @@ function readEditorConfig(deckDir) {
202
209
  : undefined,
203
210
  hiddenPaths: asStringArray(editor.hiddenPaths),
204
211
  visiblePaths: asStringArray(editor.visiblePaths),
212
+ fileTypes: asFileTypes(editor.fileTypes),
205
213
  extensions: asStringArray(editor.extensions) ?? asStringArray(data.editorExtensions),
214
+ defaultPlayFile: typeof editor.defaultPlayFile === "string" ? editor.defaultPlayFile : undefined,
206
215
  };
207
216
  return config;
208
217
  }
@@ -233,7 +242,12 @@ function findKitRoot(deckDir) {
233
242
  return null;
234
243
  }
235
244
  function kitEditorExtensions(deckDir) {
236
- const configured = readEditorConfig(deckDir).extensions;
245
+ const config = readEditorConfig(deckDir);
246
+ // Declared file types win: they say outright which extensions the kit edits.
247
+ if (config.fileTypes) {
248
+ return config.fileTypes.filter((t) => t.editor === "kit").map((t) => t.ext);
249
+ }
250
+ const configured = config.extensions;
237
251
  if (configured)
238
252
  return configured;
239
253
  const kitRoot = findKitRoot(deckDir);
@@ -476,6 +490,8 @@ function handleFilesApi(deckDir, req, res, reqPath) {
476
490
  // Stable per-deck id for client-side layout persistence (localStorage key).
477
491
  deckId: path.resolve(deckDir),
478
492
  kitEditorExtensions: kitEditorExtensions(deckDir),
493
+ fileTypes: config.fileTypes ?? null,
494
+ defaultPlayFile: config.defaultPlayFile ?? null,
479
495
  initialPanels: config.initialPanels ?? null,
480
496
  hiddenPaths: config.hiddenPaths ?? [],
481
497
  visiblePaths: config.visiblePaths ?? [],
@@ -597,7 +613,7 @@ function defaultShell() {
597
613
  }
598
614
  function ptyEnv() {
599
615
  const env = {
600
- ...process.env,
616
+ ...envForUserShell(process.env),
601
617
  TERM: PTY_TERM,
602
618
  COLORTERM: "truecolor",
603
619
  CLICOLOR: "1",