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