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.
- package/dist/agent-failures.d.ts +2 -1
- package/dist/agent-failures.js +9 -0
- package/dist/agent.js +155 -102
- package/dist/anthropic-key-helper.d.ts +1 -0
- package/dist/anthropic-key-helper.js +7 -0
- package/dist/byo-auth.d.ts +23 -0
- package/dist/byo-auth.js +130 -0
- package/dist/ide.js +18 -2
- package/dist/metering.d.ts +18 -0
- package/dist/metering.js +42 -0
- package/dist/native/openrouter.d.ts +3 -0
- package/dist/native/openrouter.js +63 -2
- package/dist/shell/assets/{index-BYQR39EF.js → index-BoH7rGKy.js} +38 -38
- package/dist/shell/assets/{index-BfN3y__V.css → index-DIcWN-RS.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/dist/vitePlugins.js +30 -2
- package/kits/basic-2d/castle.json +26 -1
- package/kits/physics-2d/behaviors/Collider.jsx +21 -46
- package/kits/physics-2d/castle.json +27 -2
- package/kits/physics-2d/engine/blueprint.js +16 -1
- package/package.json +1 -1
package/dist/agent-failures.d.ts
CHANGED
|
@@ -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;
|
package/dist/agent-failures.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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,
|
|
912
|
-
//
|
|
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
|
-
|
|
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
|
|
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;
|
package/dist/byo-auth.js
ADDED
|
@@ -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
|
|
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",
|