castle-web-cli 0.4.105 → 0.4.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.js +64 -33
- package/dist/byo-accounts.d.ts +38 -0
- package/dist/byo-accounts.js +186 -0
- package/dist/byo-auth.d.ts +7 -1
- package/dist/byo-auth.js +198 -9
- package/dist/byo-login.d.ts +14 -0
- package/dist/byo-login.js +196 -0
- package/dist/castleJson.d.ts +15 -0
- package/dist/castleJson.js +24 -0
- package/dist/claude-shim-env.d.ts +1 -0
- package/dist/claude-shim-env.js +6 -0
- package/dist/get-deck.js +1 -11
- package/dist/ide.js +45 -62
- package/dist/imports.js +31 -61
- package/dist/init.js +3 -116
- package/dist/metering.d.ts +7 -0
- package/dist/metering.js +9 -0
- package/dist/native/playtest-browser.js +1 -1
- package/dist/native/tools.js +20 -18
- package/dist/save-deck.js +6 -3
- package/dist/shell/assets/index-B5mHPsrX.js +144 -0
- package/dist/shell/assets/index-wU4ol--C.css +1 -0
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/editors/PxArtEditor.jsx +71 -1
- package/kits/physics-2d/editors/SceneEditor.jsx +72 -1
- package/kits/physics-2d/editors/editorHistory.js +18 -0
- package/kits/physics-2d/editors/pxArtTimeline.jsx +41 -8
- package/kits/physics-2d/editors/pxArtTimeline.module.css +18 -0
- package/kits/physics-2d/engine/gestureZoomPan.js +145 -0
- package/kits/physics-2d/engine/ui.jsx +208 -71
- package/kits/physics-2d/engine/ui.module.css +162 -0
- package/package.json +2 -2
- package/dist/shell/assets/index-B7nQarKK.js +0 -144
- package/dist/shell/assets/index-DIcWN-RS.css +0 -1
package/dist/agent.js
CHANGED
|
@@ -25,7 +25,9 @@ import { buildRouterPrompt, buildTaskPrompt, userTurnInstruction, CLAUDE_TASK_SY
|
|
|
25
25
|
import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from "./openrouter-catalog.js";
|
|
26
26
|
import { classifyProviderError, failureCopy, setReaderTimeZone, } from "./agent-failures.js";
|
|
27
27
|
import { fetchBudget, meteringHeaders, newAgentSessionId, reportCursorRun, withCustomHeaders, } from "./metering.js";
|
|
28
|
-
import { anthropicKeyHelperCommand, claudeHasSavedLogin, resolveAnthropicAuth, userKey, ANTHROPIC_CREDENTIAL_ENV, ANTHROPIC_PROXY_ENV, CASTLE_USER_KEYS_PATH, } from "./byo-auth.js";
|
|
28
|
+
import { anthropicKeyHelperCommand, claudeHasSavedLogin, cursorAuthPath, cursorHasUserLogin, resolveAnthropicAuth, userKey, ANTHROPIC_CREDENTIAL_ENV, ANTHROPIC_PROXY_ENV, CASTLE_USER_KEYS_PATH, } from "./byo-auth.js";
|
|
29
|
+
import { accountsSnapshot, loginProviderFor, writeCredential, } from "./byo-accounts.js";
|
|
30
|
+
import { cancelLogin, logout, startLogin, submitLoginCode, } from "./byo-login.js";
|
|
29
31
|
import { runAgentNative } from "./native/loop.js";
|
|
30
32
|
import { createPlaytestBrowserManager } from "./native/playtest-browser.js";
|
|
31
33
|
import { createPlaywrightPlaytestExecutor } from "./native/playtest-executor.js";
|
|
@@ -833,32 +835,6 @@ const BACKEND_KEY_ENV = {
|
|
|
833
835
|
claude: "ANTHROPIC_API_KEY",
|
|
834
836
|
cursor: "CURSOR_API_KEY",
|
|
835
837
|
};
|
|
836
|
-
// cursor-agent rewrites ~/.config/cursor/auth.json on every successful run,
|
|
837
|
-
// including Castle's own CURSOR_API_KEY runs -- so the file existing does NOT
|
|
838
|
-
// mean a user logged in. Distinguish by the apiKey field: a real login is OAuth,
|
|
839
|
-
// which drops apiKey and leaves only session tokens; any auth.json that still
|
|
840
|
-
// carries an apiKey is an env-key cache cursor wrote from an injected key --
|
|
841
|
-
// OURS, including a PREVIOUS key after a rotation. Treating that cache as a login
|
|
842
|
-
// suppresses the injected key and drops cursor into the stale (dead) session.
|
|
843
|
-
//
|
|
844
|
-
// An earlier apiKey === castleKeys().CURSOR_API_KEY comparison misfired on a key
|
|
845
|
-
// switch: the stale cache holds the OLD key, reads as "!= current" => "user
|
|
846
|
-
// login" => key withheld => auth fails until auth.json is deleted by hand.
|
|
847
|
-
// Deferring only on OAuth is rotation-proof. The cost: a tester's own-API-key
|
|
848
|
-
// login is no longer distinguishable from our stale cache, so it is not deferred
|
|
849
|
-
// to -- OAuth login still is (the common bypass path).
|
|
850
|
-
function cursorAuthPath(home) {
|
|
851
|
-
return path.join(home, ".config", "cursor", "auth.json");
|
|
852
|
-
}
|
|
853
|
-
function cursorHasUserLogin(home) {
|
|
854
|
-
try {
|
|
855
|
-
const auth = JSON.parse(fs.readFileSync(cursorAuthPath(home), "utf8"));
|
|
856
|
-
return !auth.apiKey && !!auth.accessToken;
|
|
857
|
-
}
|
|
858
|
-
catch {
|
|
859
|
-
return false;
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
838
|
// When we inject Castle's key, any auth.json cursor cached from a DIFFERENT key
|
|
863
839
|
// -- a rotated-out old key, or a tester's own key we've chosen to override -- is
|
|
864
840
|
// dead weight: cursor re-auths from the injected env key and ignores it. Leaving
|
|
@@ -1626,20 +1602,32 @@ function notifyAgentRunFinished() {
|
|
|
1626
1602
|
// True when THIS run spends Castle's credential rather than the user's own.
|
|
1627
1603
|
// Resolves exactly as buildAgentInvocation / runAgentSmith will, one step
|
|
1628
1604
|
// earlier, so the gate and the run can never disagree about who is paying.
|
|
1629
|
-
//
|
|
1630
|
-
//
|
|
1605
|
+
//
|
|
1606
|
+
// Cursor asks the same question as the others, just from a different file:
|
|
1607
|
+
// envForAgentSpawn withholds Castle's key when the user has an OAuth login, so
|
|
1608
|
+
// a run with one is already going to their account and the daily limit is not
|
|
1609
|
+
// Castle's to enforce over it. It used to answer `true` unconditionally, which
|
|
1610
|
+
// refused a user on their own subscription once Castle's budget ran out --
|
|
1611
|
+
// while the editor offered them that sign-in and promised it bypassed the
|
|
1612
|
+
// limit. Deferring only to a LOGIN is what keeps this from being a hole: a
|
|
1613
|
+
// cursor run on Castle's key stays gated, and cursor is unmetered (its traffic
|
|
1614
|
+
// never reaches the proxy), so an ungated one would be a free ride.
|
|
1631
1615
|
function runIsCastlePaid(backend, claudeModel, orAuth) {
|
|
1632
1616
|
if (backend === "cursor")
|
|
1633
|
-
return
|
|
1617
|
+
return !cursorHasUserLogin(os.homedir());
|
|
1634
1618
|
return roleUsesOpenrouter(backend, claudeModel)
|
|
1635
1619
|
? (orAuth ?? resolveOpenrouterAuth()).mode === "proxy"
|
|
1636
1620
|
: resolveAnthropicAuth().mode === "proxy";
|
|
1637
1621
|
}
|
|
1638
1622
|
// Whether Castle's budget is this editor's to spend at all, which is what makes
|
|
1639
1623
|
// the usage bar worth drawing. Both roles count, and they can disagree: an
|
|
1640
|
-
// Anthropic login covers the claude roles while a cursor role
|
|
1641
|
-
// Castle's
|
|
1642
|
-
// switching a role's backend) moves the bar on the next one.
|
|
1624
|
+
// Anthropic login covers the claude roles while a cursor role on Castle's key
|
|
1625
|
+
// still spends Castle's. Re-read per refresh, so adding or removing a
|
|
1626
|
+
// credential (or switching a role's backend) moves the bar on the next one.
|
|
1627
|
+
//
|
|
1628
|
+
// A cursor role on Castle's key does draw a bar its own runs never move --
|
|
1629
|
+
// cursor is unmetered by contract. That is a display question, not a payment
|
|
1630
|
+
// one, and it is not settled here.
|
|
1643
1631
|
function anyRoleIsCastlePaid(settings) {
|
|
1644
1632
|
return (runIsCastlePaid(settings.router, settings.routerClaudeModel, null) ||
|
|
1645
1633
|
runIsCastlePaid(settings.tasks, settings.tasksClaudeModel, null));
|
|
@@ -3034,6 +3022,28 @@ function applyAgentSettings(incoming, ctx) {
|
|
|
3034
3022
|
ctx.broadcast({ type: "settings", settings });
|
|
3035
3023
|
void broadcastSettingsWarnings(ctx);
|
|
3036
3024
|
}
|
|
3025
|
+
// Store or remove one BYO credential for the editor's Accounts rows. Unlike a
|
|
3026
|
+
// settings change this persists nothing itself -- byo-accounts owns the file --
|
|
3027
|
+
// and it always answers with a fresh snapshot, including on a rejected write,
|
|
3028
|
+
// so a client that sent something invalid learns why instead of watching its
|
|
3029
|
+
// input silently do nothing. The snapshot carries presence and a hint only; no
|
|
3030
|
+
// frame from here ever moves a key value toward a browser.
|
|
3031
|
+
function broadcastAccounts(broadcast, error) {
|
|
3032
|
+
broadcast({
|
|
3033
|
+
type: "accounts",
|
|
3034
|
+
accounts: accountsSnapshot(),
|
|
3035
|
+
...(error ? { accountsError: error } : {}),
|
|
3036
|
+
});
|
|
3037
|
+
}
|
|
3038
|
+
function applyCredentialChange(msg, ctx) {
|
|
3039
|
+
const clearing = msg.type === "clear-credential";
|
|
3040
|
+
if (!clearing && typeof msg.value !== "string")
|
|
3041
|
+
return;
|
|
3042
|
+
const result = writeCredential(msg.id, clearing ? null : msg.value);
|
|
3043
|
+
broadcastAccounts(ctx.broadcast, result.ok
|
|
3044
|
+
? undefined
|
|
3045
|
+
: { id: typeof msg.id === "string" ? msg.id : "", message: result.message });
|
|
3046
|
+
}
|
|
3037
3047
|
// Which slugs are worth a verdict: only roles actually routed at OpenRouter
|
|
3038
3048
|
// (otherwise we'd warn about an inert leftover value), and only when a slug is
|
|
3039
3049
|
// set at all.
|
|
@@ -3585,6 +3595,7 @@ export function createAgentServer(opts) {
|
|
|
3585
3595
|
running: routerQueue.isRunning(),
|
|
3586
3596
|
queued: routerQueue.queuedSnippets(),
|
|
3587
3597
|
usage: usageFeed.latest(),
|
|
3598
|
+
accounts: accountsSnapshot(),
|
|
3588
3599
|
};
|
|
3589
3600
|
socket.send(JSON.stringify(hello));
|
|
3590
3601
|
// A newly attached client is the one moment the cached value may be stale
|
|
@@ -3625,6 +3636,26 @@ export function createAgentServer(opts) {
|
|
|
3625
3636
|
else if (msg.type === "set-settings") {
|
|
3626
3637
|
applySettings(msg);
|
|
3627
3638
|
}
|
|
3639
|
+
else if (msg.type === "set-credential" ||
|
|
3640
|
+
msg.type === "clear-credential") {
|
|
3641
|
+
applyCredentialChange(msg, { broadcast });
|
|
3642
|
+
}
|
|
3643
|
+
else if (msg.type === "account-login") {
|
|
3644
|
+
const provider = loginProviderFor(msg.id);
|
|
3645
|
+
if (provider)
|
|
3646
|
+
startLogin(provider, () => broadcastAccounts(broadcast));
|
|
3647
|
+
}
|
|
3648
|
+
else if (msg.type === "account-login-code" && typeof msg.value === "string") {
|
|
3649
|
+
submitLoginCode(msg.value);
|
|
3650
|
+
}
|
|
3651
|
+
else if (msg.type === "account-login-cancel") {
|
|
3652
|
+
cancelLogin();
|
|
3653
|
+
}
|
|
3654
|
+
else if (msg.type === "account-logout") {
|
|
3655
|
+
const provider = loginProviderFor(msg.id);
|
|
3656
|
+
if (provider)
|
|
3657
|
+
logout(provider, () => broadcastAccounts(broadcast));
|
|
3658
|
+
}
|
|
3628
3659
|
else if (msg.type === "client-timezone" && typeof msg.timeZone === "string") {
|
|
3629
3660
|
setReaderTimeZone(msg.timeZone);
|
|
3630
3661
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type LoginProvider, type LoginState } from "./byo-login.js";
|
|
2
|
+
export type ProviderId = "anthropic" | "openrouter" | "cursor";
|
|
3
|
+
export interface ProviderStatus {
|
|
4
|
+
id: ProviderId;
|
|
5
|
+
label: string;
|
|
6
|
+
key?: {
|
|
7
|
+
placeholder: string;
|
|
8
|
+
present: boolean;
|
|
9
|
+
hint?: string;
|
|
10
|
+
};
|
|
11
|
+
login?: {
|
|
12
|
+
provider: LoginProvider;
|
|
13
|
+
loggedIn: boolean;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export interface AccountsState {
|
|
17
|
+
providers: ProviderStatus[];
|
|
18
|
+
login: LoginState | null;
|
|
19
|
+
}
|
|
20
|
+
export declare function accountsSnapshot(): AccountsState;
|
|
21
|
+
export type CredentialWriteResult = {
|
|
22
|
+
ok: true;
|
|
23
|
+
changed: boolean;
|
|
24
|
+
} | {
|
|
25
|
+
ok: false;
|
|
26
|
+
message: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Store one provider's key, or remove it when `value` is null.
|
|
30
|
+
*
|
|
31
|
+
* Merges into whatever is already in the file: a hand-added entry this module
|
|
32
|
+
* has no descriptor for MUST survive, since hand-editing stays a supported way
|
|
33
|
+
* in. Removing deletes the entry rather than blanking it -- the resolver reads
|
|
34
|
+
* an empty string as absent either way, but a leftover `"KEY": ""` reads as a
|
|
35
|
+
* configured key to anyone inspecting the file.
|
|
36
|
+
*/
|
|
37
|
+
export declare function writeCredential(id: unknown, value: string | null): CredentialWriteResult;
|
|
38
|
+
export declare function loginProviderFor(id: unknown): LoginProvider | null;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// The editor's read/write surface for the user's OWN provider credentials: what
|
|
2
|
+
// the settings popover's Accounts modal shows, and the only thing in this
|
|
3
|
+
// process that writes user-keys.json.
|
|
4
|
+
//
|
|
5
|
+
// Split from byo-auth.ts deliberately. That module is the RESOLVER -- pure,
|
|
6
|
+
// read-only, and imported by the terminal shim's helper on EVERY `claude` run
|
|
7
|
+
// (see installClaudeShim), so it must stay free of anything stateful. This one
|
|
8
|
+
// is allowed to write. The login subprocesses live in byo-login.ts; this module
|
|
9
|
+
// only reports their state as part of one snapshot.
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
import * as os from "os";
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
import { CASTLE_USER_KEYS_PATH, claudeHasSavedLogin, cursorHasUserLogin, readUserKeys, } from "./byo-auth.js";
|
|
14
|
+
import { activeLogin } from "./byo-login.js";
|
|
15
|
+
import { inCastleSandbox } from "./metering.js";
|
|
16
|
+
// Providers, not credentials: Anthropic is reachable by EITHER a key or a
|
|
17
|
+
// claude.ai login, and resolveAnthropicAuth already treats them as one
|
|
18
|
+
// question (key wins). Grouping them here is what lets the modal show a user
|
|
19
|
+
// the two ways into one account side by side.
|
|
20
|
+
const PROVIDERS = [
|
|
21
|
+
{
|
|
22
|
+
id: "anthropic",
|
|
23
|
+
label: "Anthropic",
|
|
24
|
+
key: { env: "ANTHROPIC_API_KEY", placeholder: "sk-ant-…", offered: true },
|
|
25
|
+
login: "claude",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "openrouter",
|
|
29
|
+
label: "OpenRouter",
|
|
30
|
+
key: { env: "OPENROUTER_API_KEY", placeholder: "sk-or-…", offered: true },
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "cursor",
|
|
34
|
+
label: "Cursor",
|
|
35
|
+
key: { env: "CURSOR_API_KEY", placeholder: "key_…", offered: false },
|
|
36
|
+
login: "cursor",
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
// A stored value is never sent toward a browser -- only this. Short values get
|
|
40
|
+
// no tail at all rather than a tail that is most of the secret.
|
|
41
|
+
function hintFor(value) {
|
|
42
|
+
return value.length >= 12 ? `…${value.slice(-4)}` : "…";
|
|
43
|
+
}
|
|
44
|
+
function hasLogin(provider) {
|
|
45
|
+
return provider === "claude"
|
|
46
|
+
? claudeHasSavedLogin()
|
|
47
|
+
: cursorHasUserLogin(os.homedir());
|
|
48
|
+
}
|
|
49
|
+
// Offered at all: a key field the user may fill, or a login they may start.
|
|
50
|
+
function isOffered(d) {
|
|
51
|
+
return Boolean(d.key?.offered) || Boolean(d.login);
|
|
52
|
+
}
|
|
53
|
+
function descriptorFor(id) {
|
|
54
|
+
if (typeof id !== "string")
|
|
55
|
+
return null;
|
|
56
|
+
return PROVIDERS.find((d) => isOffered(d) && d.id === id) ?? null;
|
|
57
|
+
}
|
|
58
|
+
export function accountsSnapshot() {
|
|
59
|
+
// Empty outside a sandbox, which hides the whole surface. A local serve has no
|
|
60
|
+
// Castle budget to escape, and the host machine's own claude/cursor logins are
|
|
61
|
+
// ALREADY what its CLIs use -- offering to "sign in" there would report the
|
|
62
|
+
// developer's existing login back to them as a Castle setting, and invite them
|
|
63
|
+
// to sign out of it from a deck editor.
|
|
64
|
+
if (!inCastleSandbox())
|
|
65
|
+
return { providers: [], login: null };
|
|
66
|
+
const stored = readUserKeys();
|
|
67
|
+
return {
|
|
68
|
+
providers: PROVIDERS.filter(isOffered).map((d) => {
|
|
69
|
+
// typeof, not just truthiness: the file is hand-editable, and a
|
|
70
|
+
// non-string value would throw on trim -- see readUserKeys.
|
|
71
|
+
const raw = d.key?.offered ? stored[d.key.env] : undefined;
|
|
72
|
+
const value = typeof raw === "string" ? raw.trim() : undefined;
|
|
73
|
+
return {
|
|
74
|
+
id: d.id,
|
|
75
|
+
label: d.label,
|
|
76
|
+
...(d.key?.offered
|
|
77
|
+
? {
|
|
78
|
+
key: {
|
|
79
|
+
placeholder: d.key.placeholder,
|
|
80
|
+
present: Boolean(value),
|
|
81
|
+
...(value ? { hint: hintFor(value) } : {}),
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
: {}),
|
|
85
|
+
...(d.login
|
|
86
|
+
? { login: { provider: d.login, loggedIn: hasLogin(d.login) } }
|
|
87
|
+
: {}),
|
|
88
|
+
};
|
|
89
|
+
}),
|
|
90
|
+
login: activeLogin(),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// Generous -- provider key formats are not ours to predict -- but bounded, so a
|
|
94
|
+
// stray paste of a whole file can't land in the credential store.
|
|
95
|
+
const MAX_KEY_LENGTH = 500;
|
|
96
|
+
// A credential reaches a child process as an environment value and, for the
|
|
97
|
+
// terminal, as a shell-quoted assignment. A control character in it is never a
|
|
98
|
+
// real key and is exactly the shape that turns one env var into two, so it is
|
|
99
|
+
// REFUSED rather than stripped: silently storing something other than what the
|
|
100
|
+
// user pasted would fail later, somewhere less obvious.
|
|
101
|
+
function hasControlCharacter(value) {
|
|
102
|
+
for (const ch of value) {
|
|
103
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
104
|
+
if (code < 0x20 || code === 0x7f)
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
function writeUserKeys(next) {
|
|
110
|
+
fs.mkdirSync(path.dirname(CASTLE_USER_KEYS_PATH), { recursive: true });
|
|
111
|
+
// Staged + renamed, not written in place: the sandbox checkpoints this
|
|
112
|
+
// directory on a timer, so an in-place write can be snapshotted half-done.
|
|
113
|
+
// The staged file carries the mode, and rename preserves it, so the result is
|
|
114
|
+
// 0600 even when an older file was not.
|
|
115
|
+
const staged = `${CASTLE_USER_KEYS_PATH}.staged`;
|
|
116
|
+
fs.writeFileSync(staged, JSON.stringify(next, null, 2) + "\n", {
|
|
117
|
+
mode: 0o600,
|
|
118
|
+
});
|
|
119
|
+
fs.renameSync(staged, CASTLE_USER_KEYS_PATH);
|
|
120
|
+
}
|
|
121
|
+
// A full disk or a permissions change comes back as a refusal the client is
|
|
122
|
+
// told about, not as an exception: this runs inside the WS message handler,
|
|
123
|
+
// where nothing catches a throw and it would take the whole serve down.
|
|
124
|
+
function persist(next) {
|
|
125
|
+
try {
|
|
126
|
+
writeUserKeys(next);
|
|
127
|
+
return { ok: true, changed: true };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return { ok: false, message: "could not write user-keys.json" };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Store one provider's key, or remove it when `value` is null.
|
|
135
|
+
*
|
|
136
|
+
* Merges into whatever is already in the file: a hand-added entry this module
|
|
137
|
+
* has no descriptor for MUST survive, since hand-editing stays a supported way
|
|
138
|
+
* in. Removing deletes the entry rather than blanking it -- the resolver reads
|
|
139
|
+
* an empty string as absent either way, but a leftover `"KEY": ""` reads as a
|
|
140
|
+
* configured key to anyone inspecting the file.
|
|
141
|
+
*/
|
|
142
|
+
export function writeCredential(id, value) {
|
|
143
|
+
// accountsSnapshot being empty outside a sandbox HIDES this surface; hiding
|
|
144
|
+
// is not refusing. The agent socket is reachable over LAN/tailnet hostnames
|
|
145
|
+
// and a cross-origin page can open a WebSocket to it, so without this check
|
|
146
|
+
// any client could still write the host's ~/.castle/user-keys.json from a
|
|
147
|
+
// plain local serve.
|
|
148
|
+
if (!inCastleSandbox()) {
|
|
149
|
+
return { ok: false, message: "only available in a Castle sandbox" };
|
|
150
|
+
}
|
|
151
|
+
const descriptor = descriptorFor(id);
|
|
152
|
+
if (!descriptor?.key?.offered)
|
|
153
|
+
return { ok: false, message: "unknown credential" };
|
|
154
|
+
const env = descriptor.key.env;
|
|
155
|
+
const stored = readUserKeys();
|
|
156
|
+
if (value === null) {
|
|
157
|
+
if (!(env in stored))
|
|
158
|
+
return { ok: true, changed: false };
|
|
159
|
+
delete stored[env];
|
|
160
|
+
return persist(stored);
|
|
161
|
+
}
|
|
162
|
+
const trimmed = value.trim();
|
|
163
|
+
if (!trimmed)
|
|
164
|
+
return { ok: false, message: "key is empty" };
|
|
165
|
+
if (trimmed.length > MAX_KEY_LENGTH) {
|
|
166
|
+
return { ok: false, message: "key is too long" };
|
|
167
|
+
}
|
|
168
|
+
if (hasControlCharacter(trimmed)) {
|
|
169
|
+
return { ok: false, message: "key contains invalid characters" };
|
|
170
|
+
}
|
|
171
|
+
if (stored[env] === trimmed)
|
|
172
|
+
return { ok: true, changed: false };
|
|
173
|
+
stored[env] = trimmed;
|
|
174
|
+
return persist(stored);
|
|
175
|
+
}
|
|
176
|
+
// The login provider a client-supplied id names, or null. Keeps the WS handler
|
|
177
|
+
// from having to know the descriptor table. Null outside a sandbox for the
|
|
178
|
+
// same reason writeCredential refuses there -- most of all so no client can
|
|
179
|
+
// run `claude auth logout` / `cursor-agent logout` against the host machine's
|
|
180
|
+
// own login, which is exactly the outcome hiding the surface was meant to
|
|
181
|
+
// prevent.
|
|
182
|
+
export function loginProviderFor(id) {
|
|
183
|
+
if (!inCastleSandbox())
|
|
184
|
+
return null;
|
|
185
|
+
return descriptorFor(id)?.login ?? null;
|
|
186
|
+
}
|
package/dist/byo-auth.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export declare const CASTLE_USER_KEYS_PATH: string;
|
|
2
|
-
export
|
|
2
|
+
export type UserKeyName = "ANTHROPIC_API_KEY" | "OPENROUTER_API_KEY" | "CURSOR_API_KEY";
|
|
3
|
+
export declare function readUserKeys(): Record<string, string>;
|
|
4
|
+
export declare function userKey(envName: UserKeyName): string | null;
|
|
3
5
|
export type AnthropicAuth = {
|
|
4
6
|
mode: "user-key";
|
|
5
7
|
key: string;
|
|
@@ -16,8 +18,12 @@ export type OpenrouterAuth = {
|
|
|
16
18
|
key: string;
|
|
17
19
|
};
|
|
18
20
|
export declare function claudeHasSavedLogin(): boolean;
|
|
21
|
+
export declare function cursorAuthPath(home: string): string;
|
|
22
|
+
export declare function cursorHasUserLogin(home: string): boolean;
|
|
19
23
|
export declare function resolveAnthropicAuth(): AnthropicAuth;
|
|
20
24
|
export declare const ANTHROPIC_CREDENTIAL_ENV: readonly ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN_HELPER", "CLAUDE_CODE_OAUTH_TOKEN"];
|
|
21
25
|
export declare const ANTHROPIC_PROXY_ENV: readonly ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_CUSTOM_HEADERS", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN_HELPER", "CLAUDE_CODE_OAUTH_TOKEN"];
|
|
22
26
|
export declare function anthropicKeyHelperCommand(): string;
|
|
23
27
|
export declare function envForUserShell(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
28
|
+
export declare function claudeShellEnvScript(env: NodeJS.ProcessEnv, shimDir: string): string;
|
|
29
|
+
export declare function installClaudeShim(deckDir: string): string | null;
|
package/dist/byo-auth.js
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
// terminal has to answer the same question for the `claude` a user runs by
|
|
5
5
|
// hand -- and must answer it the SAME way, or the terminal quietly spends
|
|
6
6
|
// Castle's budget while the agent panel spends the user's.
|
|
7
|
+
//
|
|
8
|
+
// The claude-CLI behaviors this file leans on (AUTH_TOKEN outranks a login,
|
|
9
|
+
// apiKeyHelper works headless, an env key alone does not) are measured, not
|
|
10
|
+
// documented, and the agent-qa battery fakes `claude` so it cannot notice them
|
|
11
|
+
// drifting -- scripts/tests/real-claude-smoke.mjs re-verifies them against a
|
|
12
|
+
// real binary; run it when the sandbox image's claude version bumps.
|
|
7
13
|
import * as fs from "fs";
|
|
8
14
|
import * as os from "os";
|
|
9
15
|
import * as path from "path";
|
|
@@ -11,26 +17,43 @@ import { fileURLToPath } from "url";
|
|
|
11
17
|
const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
12
18
|
// A user's OWN provider credentials, kept SEPARATE from Castle's keys.json so
|
|
13
19
|
// castle-www's per-serve re-sync of keys.json (cloudSandbox.ts syncCastleKeys)
|
|
14
|
-
// can't clobber them. Same shape as keys.json (env-var-name keys)
|
|
15
|
-
// a future editor UI) writes this file, nothing in-process does. When a key is
|
|
20
|
+
// can't clobber them. Same shape as keys.json (env-var-name keys). When a key is
|
|
16
21
|
// present the run goes DIRECT to that provider on the user's own credential and
|
|
17
22
|
// is NOT metered -- deleting the key reverts to Castle's proxy on the next run.
|
|
18
23
|
// The path override mirrors CASTLE_KEYS_PATH so the QA battery stays isolated
|
|
19
24
|
// from a developer's real ~/.castle. No env fallback on read: the file is the
|
|
20
25
|
// only source, so a delete fully reverts.
|
|
26
|
+
//
|
|
27
|
+
// Written by hand, or by the editor's Accounts rows -- byo-accounts.ts is the
|
|
28
|
+
// only writer in this process, and it merges rather than replaces so a key it
|
|
29
|
+
// has no descriptor for still survives an edit.
|
|
21
30
|
export const CASTLE_USER_KEYS_PATH = process.env.CASTLE_USER_KEYS_PATH ??
|
|
22
31
|
path.join(os.homedir(), ".castle", "user-keys.json");
|
|
23
|
-
|
|
32
|
+
// Exported for byo-accounts.ts, whose writes must preserve entries this file
|
|
33
|
+
// has no name for; every other reader wants userKey() instead.
|
|
34
|
+
//
|
|
35
|
+
// The file is hand-editable, so its shape is user input: a top-level primitive
|
|
36
|
+
// would make every `stored[k]` assignment downstream a strict-mode TypeError
|
|
37
|
+
// inside the serve's WS handler, where nothing catches it. VALUES are not
|
|
38
|
+
// validated -- a non-string entry survives a merge untouched -- so anything
|
|
39
|
+
// that uses one must typeof-check it first.
|
|
40
|
+
export function readUserKeys() {
|
|
24
41
|
try {
|
|
25
|
-
|
|
42
|
+
const parsed = JSON.parse(fs.readFileSync(CASTLE_USER_KEYS_PATH, "utf8"));
|
|
43
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
44
|
+
? parsed
|
|
45
|
+
: {};
|
|
26
46
|
}
|
|
27
47
|
catch {
|
|
28
48
|
return {};
|
|
29
49
|
}
|
|
30
50
|
}
|
|
31
51
|
export function userKey(envName) {
|
|
32
|
-
const v =
|
|
33
|
-
|
|
52
|
+
const v = readUserKeys()[envName];
|
|
53
|
+
if (typeof v !== "string")
|
|
54
|
+
return null;
|
|
55
|
+
const trimmed = v.trim();
|
|
56
|
+
return trimmed ? trimmed : null;
|
|
34
57
|
}
|
|
35
58
|
// KNOWN GAP (macOS): a false negative for most logged-in users. `claude /login`
|
|
36
59
|
// stores credentials in the KEYCHAIN there, not in ~/.claude/.credentials.json,
|
|
@@ -50,6 +73,32 @@ export function claudeHasSavedLogin() {
|
|
|
50
73
|
path.join(os.homedir(), ".claude", ".credentials.json");
|
|
51
74
|
return fs.existsSync(credPath);
|
|
52
75
|
}
|
|
76
|
+
// cursor-agent rewrites ~/.config/cursor/auth.json on every successful run,
|
|
77
|
+
// including Castle's own CURSOR_API_KEY runs -- so the file existing does NOT
|
|
78
|
+
// mean a user logged in. Distinguish by the apiKey field: a real login is OAuth,
|
|
79
|
+
// which drops apiKey and leaves only session tokens; any auth.json that still
|
|
80
|
+
// carries an apiKey is an env-key cache cursor wrote from an injected key --
|
|
81
|
+
// OURS, including a PREVIOUS key after a rotation. Treating that cache as a login
|
|
82
|
+
// suppresses the injected key and drops cursor into the stale (dead) session.
|
|
83
|
+
//
|
|
84
|
+
// An earlier apiKey === castleKeys().CURSOR_API_KEY comparison misfired on a key
|
|
85
|
+
// switch: the stale cache holds the OLD key, reads as "!= current" => "user
|
|
86
|
+
// login" => key withheld => auth fails until auth.json is deleted by hand.
|
|
87
|
+
// Deferring only on OAuth is rotation-proof. The cost: a tester's own-API-key
|
|
88
|
+
// login is no longer distinguishable from our stale cache, so it is not deferred
|
|
89
|
+
// to -- OAuth login still is (the common bypass path).
|
|
90
|
+
export function cursorAuthPath(home) {
|
|
91
|
+
return path.join(home, ".config", "cursor", "auth.json");
|
|
92
|
+
}
|
|
93
|
+
export function cursorHasUserLogin(home) {
|
|
94
|
+
try {
|
|
95
|
+
const auth = JSON.parse(fs.readFileSync(cursorAuthPath(home), "utf8"));
|
|
96
|
+
return !auth.apiKey && !!auth.accessToken;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
53
102
|
export function resolveAnthropicAuth() {
|
|
54
103
|
const k = userKey("ANTHROPIC_API_KEY");
|
|
55
104
|
if (k)
|
|
@@ -100,13 +149,25 @@ function shellQuote(value) {
|
|
|
100
149
|
export function anthropicKeyHelperCommand() {
|
|
101
150
|
return `${shellQuote(process.execPath)} ${shellQuote(path.join(DIST_DIR, "anthropic-key-helper.js"))}`;
|
|
102
151
|
}
|
|
152
|
+
// Where the shell's inherited proxy pair goes when a credential of the user's
|
|
153
|
+
// own displaces it. envForUserShell answers "whose credential" once, but
|
|
154
|
+
// claudeShellEnvScript re-answers it per `claude` run and may need to put
|
|
155
|
+
// Castle's routing BACK (the user deleted their key mid-session) -- which it
|
|
156
|
+
// can only do if the values survived somewhere. Nothing reads these names by
|
|
157
|
+
// accident, so stashing doesn't weaken the guarantee below.
|
|
158
|
+
const PROXY_STASH_PREFIX = "CASTLE_PTY_";
|
|
103
159
|
// Env for the editor's PTY terminal. The container env the serve inherits
|
|
104
160
|
// carries the llm-proxy pair, and Claude Code ranks ANTHROPIC_AUTH_TOKEN ABOVE
|
|
105
161
|
// a claude.ai login -- so without this a user who ran `claude /login` in the
|
|
106
162
|
// terminal still gets "claude.ai connectors are disabled because
|
|
107
163
|
// ANTHROPIC_API_KEY or another auth source is set", their login unused and
|
|
108
|
-
// their session billed to Castle.
|
|
109
|
-
//
|
|
164
|
+
// their session billed to Castle.
|
|
165
|
+
//
|
|
166
|
+
// This is the shell's STARTING state only; the per-run answer is the shim's
|
|
167
|
+
// (see installClaudeShim). It still matters on its own, because anything else
|
|
168
|
+
// the user runs in that terminal -- a script that calls claude by absolute
|
|
169
|
+
// path, an SDK program reading ANTHROPIC_API_KEY -- must not find Castle's
|
|
170
|
+
// token sitting in the environment while the user has a credential of their own.
|
|
110
171
|
//
|
|
111
172
|
// A user KEY goes in as ANTHROPIC_API_KEY here, unlike the agent path's
|
|
112
173
|
// apiKeyHelper (see above): an interactive claude CAN show the one-time
|
|
@@ -116,8 +177,12 @@ export function envForUserShell(base) {
|
|
|
116
177
|
const env = { ...base };
|
|
117
178
|
const anthropic = resolveAnthropicAuth();
|
|
118
179
|
if (anthropic.mode !== "proxy") {
|
|
119
|
-
for (const name of ANTHROPIC_PROXY_ENV)
|
|
180
|
+
for (const name of ANTHROPIC_PROXY_ENV) {
|
|
181
|
+
const inherited = env[name];
|
|
182
|
+
if (inherited !== undefined)
|
|
183
|
+
env[PROXY_STASH_PREFIX + name] = inherited;
|
|
120
184
|
delete env[name];
|
|
185
|
+
}
|
|
121
186
|
if (anthropic.mode === "user-key")
|
|
122
187
|
env.ANTHROPIC_API_KEY = anthropic.key;
|
|
123
188
|
}
|
|
@@ -128,3 +193,127 @@ export function envForUserShell(base) {
|
|
|
128
193
|
}
|
|
129
194
|
return env;
|
|
130
195
|
}
|
|
196
|
+
// --- the terminal's per-run credential decision ------------------------------
|
|
197
|
+
//
|
|
198
|
+
// A process's environment is fixed at spawn, and the editor keeps ONE shell per
|
|
199
|
+
// serve -- closing the terminal panel drops the socket, not the shell (see
|
|
200
|
+
// ide.ts ensureSession), so envForUserShell's answer is the answer that shell
|
|
201
|
+
// keeps for as long as the serve lives. That made `claude /login` in the
|
|
202
|
+
// terminal a no-op for the terminal itself: the login lands, the agent panel
|
|
203
|
+
// (which resolves per run) starts using it, and every later `claude` in that
|
|
204
|
+
// same shell still finds the inherited ANTHROPIC_AUTH_TOKEN outranking it and
|
|
205
|
+
// still prints "claude.ai connectors are disabled...". Restarting claude, or
|
|
206
|
+
// the panel, changed nothing -- only restarting the serve did.
|
|
207
|
+
//
|
|
208
|
+
// So the decision moves to a `claude` shim first on the terminal's PATH: it
|
|
209
|
+
// resolves the credential at invocation time, fixes up the environment, and
|
|
210
|
+
// execs the real binary. A login taken thirty seconds ago applies to the very
|
|
211
|
+
// next run, in the same shell.
|
|
212
|
+
//
|
|
213
|
+
// Caveat: PATH ordering is not ours to guarantee. A login shell can reorder it
|
|
214
|
+
// (macOS /etc/zprofile runs path_helper, which demotes an inherited entry below
|
|
215
|
+
// /usr/local/bin) or replace it outright (Debian's /etc/profile does, for root
|
|
216
|
+
// -- though only bash reads it; the sandbox's `zsh -l` keeps our entry first).
|
|
217
|
+
// When the shim loses, the credential is simply the one envForUserShell picked
|
|
218
|
+
// at shell start -- the old behavior, not a broken one.
|
|
219
|
+
function realpath(p) {
|
|
220
|
+
try {
|
|
221
|
+
return fs.realpathSync(p);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return path.resolve(p);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// The `claude` the shim should exec: the first executable one on `pathValue`
|
|
228
|
+
// that isn't the shim itself. Skipping by resolved directory (not by string) is
|
|
229
|
+
// what keeps the shim from exec'ing itself forever.
|
|
230
|
+
function findRealClaude(pathValue, shimDir) {
|
|
231
|
+
const shimReal = realpath(shimDir);
|
|
232
|
+
for (const entry of (pathValue ?? "").split(path.delimiter)) {
|
|
233
|
+
if (!entry || realpath(entry) === shimReal)
|
|
234
|
+
continue;
|
|
235
|
+
const candidate = path.join(entry, "claude");
|
|
236
|
+
try {
|
|
237
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
238
|
+
if (fs.statSync(candidate).isFile())
|
|
239
|
+
return candidate;
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
/* not this entry */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
// Shell statements the shim evals before exec'ing claude: the environment that
|
|
248
|
+
// makes THIS run use the credential resolveAnthropicAuth picks right now.
|
|
249
|
+
// Emitting `unset` rather than an empty value matters -- Claude Code treats an
|
|
250
|
+
// empty ANTHROPIC_BASE_URL as configured and fails to reach anything.
|
|
251
|
+
export function claudeShellEnvScript(env, shimDir) {
|
|
252
|
+
const lines = [];
|
|
253
|
+
const auth = resolveAnthropicAuth();
|
|
254
|
+
if (auth.mode === "proxy") {
|
|
255
|
+
for (const name of ANTHROPIC_PROXY_ENV) {
|
|
256
|
+
const stashed = env[PROXY_STASH_PREFIX + name];
|
|
257
|
+
if (stashed !== undefined && env[name] === undefined) {
|
|
258
|
+
lines.push(`${name}=${shellQuote(stashed)}; export ${name}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
for (const name of ANTHROPIC_PROXY_ENV)
|
|
264
|
+
lines.push(`unset ${name}`);
|
|
265
|
+
if (auth.mode === "user-key") {
|
|
266
|
+
lines.push(`ANTHROPIC_API_KEY=${shellQuote(auth.key)}; export ANTHROPIC_API_KEY`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const real = findRealClaude(env.PATH, shimDir);
|
|
270
|
+
if (real)
|
|
271
|
+
lines.push(`CASTLE_REAL_CLAUDE=${shellQuote(real)}`);
|
|
272
|
+
return lines.length > 0 ? lines.join("\n") + "\n" : "";
|
|
273
|
+
}
|
|
274
|
+
// The baked CASTLE_REAL_CLAUDE is a fallback, not the answer: if the helper
|
|
275
|
+
// runs, its own resolution (against the SHELL's PATH, which a login shell may
|
|
276
|
+
// have rewritten) replaces it. It only survives when the helper failed to run
|
|
277
|
+
// at all, and then it is the difference between the old static behavior and a
|
|
278
|
+
// terminal where `claude` is suddenly missing.
|
|
279
|
+
function shimScript(shimDir) {
|
|
280
|
+
const helper = [
|
|
281
|
+
shellQuote(process.execPath),
|
|
282
|
+
shellQuote(path.join(DIST_DIR, "claude-shim-env.js")),
|
|
283
|
+
shellQuote(shimDir),
|
|
284
|
+
].join(" ");
|
|
285
|
+
return [
|
|
286
|
+
"#!/bin/sh",
|
|
287
|
+
"# Generated by `castle-web serve` -- see installClaudeShim in castle-web-cli.",
|
|
288
|
+
`CASTLE_REAL_CLAUDE=${shellQuote(findRealClaude(process.env.PATH, shimDir) ?? "")}`,
|
|
289
|
+
`eval "$(${helper})"`,
|
|
290
|
+
'if [ -z "$CASTLE_REAL_CLAUDE" ]; then',
|
|
291
|
+
' echo "castle-web: claude is not installed on this PATH" >&2',
|
|
292
|
+
" exit 127",
|
|
293
|
+
"fi",
|
|
294
|
+
'exec "$CASTLE_REAL_CLAUDE" "$@"',
|
|
295
|
+
"",
|
|
296
|
+
].join("\n");
|
|
297
|
+
}
|
|
298
|
+
// Returns the directory to put first on the terminal's PATH, or null when there
|
|
299
|
+
// is no shim to install -- a failure here costs the per-run re-resolution, so it
|
|
300
|
+
// degrades to envForUserShell's spawn-time answer rather than breaking the shell.
|
|
301
|
+
export function installClaudeShim(deckDir) {
|
|
302
|
+
if (process.platform === "win32")
|
|
303
|
+
return null;
|
|
304
|
+
const dir = path.join(deckDir, ".castle", "shims");
|
|
305
|
+
try {
|
|
306
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
307
|
+
// Write-then-rename, because /bin/sh reads a script incrementally: a second
|
|
308
|
+
// serve reinstalling over this deck while a claude is running through the
|
|
309
|
+
// shim would otherwise pull the file out from under that sh mid-read.
|
|
310
|
+
const staged = path.join(dir, "claude.staged");
|
|
311
|
+
fs.writeFileSync(staged, shimScript(dir));
|
|
312
|
+
fs.chmodSync(staged, 0o755);
|
|
313
|
+
fs.renameSync(staged, path.join(dir, "claude"));
|
|
314
|
+
return dir;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|