castle-web-cli 0.4.183 → 0.4.185
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 +1 -0
- package/dist/agent-failures.js +31 -4
- package/dist/agent-prompts.d.ts +0 -3
- package/dist/agent-prompts.js +14 -28
- package/dist/agent.d.ts +1 -13
- package/dist/agent.js +191 -938
- package/dist/byo-accounts.d.ts +4 -4
- package/dist/byo-accounts.js +17 -38
- package/dist/byo-auth.d.ts +2 -5
- package/dist/byo-auth.js +4 -52
- package/dist/byo-login.d.ts +4 -5
- package/dist/byo-login.js +28 -56
- package/dist/mcpPlaytest.js +1 -2
- package/dist/metering.d.ts +35 -23
- package/dist/metering.js +57 -52
- package/dist/openrouter-catalog.d.ts +0 -1
- package/dist/openrouter-catalog.js +0 -11
- package/dist/platformDoc.d.ts +1 -1
- package/dist/platformDoc.js +9 -12
- package/dist/serve.js +3 -5
- package/dist/shell/assets/index-BHWfx7Tz.css +1 -0
- package/dist/shell/assets/index-BMu42DTS.js +449 -0
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/CLAUDE.md +5 -1
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/editors/SceneEditor.jsx +2 -1
- package/kits/physics-2d/engine/autoInspector.jsx +32 -3
- package/kits/physics-2d/engine/fields/fields.jsx +305 -5
- package/kits/physics-2d/engine/fields/fields.module.css +76 -0
- package/kits/physics-2d/engine/paletteField.jsx +8 -3
- package/kits/physics-2d/engine/spriteField.jsx +3 -3
- package/kits/physics-2d/engine/ui.jsx +5 -277
- package/kits/physics-2d/engine/ui.module.css +0 -69
- package/package.json +1 -1
- package/dist/shell/assets/index-DnNy-Z5u.js +0 -447
- package/dist/shell/assets/index-cdsH2lna.css +0 -1
package/dist/byo-accounts.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type LoginProvider, type LoginState } from "./byo-login.js";
|
|
2
|
-
export type ProviderId = "anthropic" | "openrouter"
|
|
2
|
+
export type ProviderId = "anthropic" | "openrouter";
|
|
3
3
|
export interface ProviderStatus {
|
|
4
4
|
id: ProviderId;
|
|
5
5
|
label: string;
|
|
@@ -21,9 +21,9 @@ export declare function accountsSnapshot(): AccountsState;
|
|
|
21
21
|
/**
|
|
22
22
|
* Call `onChange` when a credential this module reports on changes on disk.
|
|
23
23
|
*
|
|
24
|
-
* The editor is not the only writer: `claude /login`
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* The editor is not the only writer: `claude /login` in the terminal panel, an
|
|
25
|
+
* agent run that refreshes an OAuth token, and a hand-edited
|
|
26
|
+
* `user-keys.json` all change the answer behind its back. Without
|
|
27
27
|
* this the account state is whatever was true when the page loaded, so a
|
|
28
28
|
* terminal sign-in looks like it did nothing until a reload.
|
|
29
29
|
*
|
package/dist/byo-accounts.js
CHANGED
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
// is allowed to write. The login subprocesses live in byo-login.ts; this module
|
|
9
9
|
// only reports their state as part of one snapshot.
|
|
10
10
|
import * as fs from "fs";
|
|
11
|
-
import * as os from "os";
|
|
12
11
|
import * as path from "path";
|
|
13
|
-
import { CASTLE_USER_KEYS_PATH, claudeCredentialsPath, claudeHasSavedLogin,
|
|
12
|
+
import { CASTLE_USER_KEYS_PATH, claudeCredentialsPath, claudeHasSavedLogin, readUserKeys, } from "./byo-auth.js";
|
|
14
13
|
import { activeLogin } from "./byo-login.js";
|
|
15
14
|
import { inCastleSandbox } from "./metering.js";
|
|
16
15
|
// Providers, not credentials: Anthropic is reachable by EITHER a key or a
|
|
@@ -21,19 +20,13 @@ const PROVIDERS = [
|
|
|
21
20
|
{
|
|
22
21
|
id: "anthropic",
|
|
23
22
|
label: "Anthropic",
|
|
24
|
-
key: { env: "ANTHROPIC_API_KEY", placeholder: "sk-ant-…"
|
|
23
|
+
key: { env: "ANTHROPIC_API_KEY", placeholder: "sk-ant-…" },
|
|
25
24
|
login: "claude",
|
|
26
25
|
},
|
|
27
26
|
{
|
|
28
27
|
id: "openrouter",
|
|
29
28
|
label: "OpenRouter",
|
|
30
|
-
key: { env: "OPENROUTER_API_KEY", placeholder: "sk-or-…"
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
id: "cursor",
|
|
34
|
-
label: "Cursor",
|
|
35
|
-
key: { env: "CURSOR_API_KEY", placeholder: "key_…", offered: false },
|
|
36
|
-
login: "cursor",
|
|
29
|
+
key: { env: "OPENROUTER_API_KEY", placeholder: "sk-or-…" },
|
|
37
30
|
},
|
|
38
31
|
];
|
|
39
32
|
// A stored value is never sent toward a browser -- only this. Short values get
|
|
@@ -41,39 +34,30 @@ const PROVIDERS = [
|
|
|
41
34
|
function hintFor(value) {
|
|
42
35
|
return value.length >= 12 ? `…${value.slice(-4)}` : "…";
|
|
43
36
|
}
|
|
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
37
|
function descriptorFor(id) {
|
|
54
38
|
if (typeof id !== "string")
|
|
55
39
|
return null;
|
|
56
|
-
return PROVIDERS.find((d) =>
|
|
40
|
+
return PROVIDERS.find((d) => d.id === id) ?? null;
|
|
57
41
|
}
|
|
58
42
|
export function accountsSnapshot() {
|
|
59
43
|
// 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
|
|
61
|
-
//
|
|
44
|
+
// Castle budget to escape, and the host machine's own claude login is ALREADY
|
|
45
|
+
// what its CLI uses -- offering to "sign in" there would report the
|
|
62
46
|
// developer's existing login back to them as a Castle setting, and invite them
|
|
63
47
|
// to sign out of it from a deck editor.
|
|
64
48
|
if (!inCastleSandbox())
|
|
65
49
|
return { providers: [], login: null };
|
|
66
50
|
const stored = readUserKeys();
|
|
67
51
|
return {
|
|
68
|
-
providers: PROVIDERS.
|
|
52
|
+
providers: PROVIDERS.map((d) => {
|
|
69
53
|
// typeof, not just truthiness: the file is hand-editable, and a
|
|
70
54
|
// non-string value would throw on trim -- see readUserKeys.
|
|
71
|
-
const raw = d.key
|
|
55
|
+
const raw = d.key ? stored[d.key.env] : undefined;
|
|
72
56
|
const value = typeof raw === "string" ? raw.trim() : undefined;
|
|
73
57
|
return {
|
|
74
58
|
id: d.id,
|
|
75
59
|
label: d.label,
|
|
76
|
-
...(d.key
|
|
60
|
+
...(d.key
|
|
77
61
|
? {
|
|
78
62
|
key: {
|
|
79
63
|
placeholder: d.key.placeholder,
|
|
@@ -83,7 +67,7 @@ export function accountsSnapshot() {
|
|
|
83
67
|
}
|
|
84
68
|
: {}),
|
|
85
69
|
...(d.login
|
|
86
|
-
? { login: { provider: d.login, loggedIn:
|
|
70
|
+
? { login: { provider: d.login, loggedIn: claudeHasSavedLogin() } }
|
|
87
71
|
: {}),
|
|
88
72
|
};
|
|
89
73
|
}),
|
|
@@ -97,9 +81,9 @@ const WATCH_DEBOUNCE_MS = 250;
|
|
|
97
81
|
/**
|
|
98
82
|
* Call `onChange` when a credential this module reports on changes on disk.
|
|
99
83
|
*
|
|
100
|
-
* The editor is not the only writer: `claude /login`
|
|
101
|
-
*
|
|
102
|
-
*
|
|
84
|
+
* The editor is not the only writer: `claude /login` in the terminal panel, an
|
|
85
|
+
* agent run that refreshes an OAuth token, and a hand-edited
|
|
86
|
+
* `user-keys.json` all change the answer behind its back. Without
|
|
103
87
|
* this the account state is whatever was true when the page loaded, so a
|
|
104
88
|
* terminal sign-in looks like it did nothing until a reload.
|
|
105
89
|
*
|
|
@@ -111,11 +95,7 @@ const WATCH_DEBOUNCE_MS = 250;
|
|
|
111
95
|
* costs freshness, never correctness.
|
|
112
96
|
*/
|
|
113
97
|
export function watchCredentials(onChange) {
|
|
114
|
-
const dirs = new Set([
|
|
115
|
-
claudeCredentialsPath(),
|
|
116
|
-
cursorAuthPath(os.homedir()),
|
|
117
|
-
CASTLE_USER_KEYS_PATH,
|
|
118
|
-
].map((p) => path.dirname(p)));
|
|
98
|
+
const dirs = new Set([claudeCredentialsPath(), CASTLE_USER_KEYS_PATH].map((p) => path.dirname(p)));
|
|
119
99
|
let timer = null;
|
|
120
100
|
const fire = () => {
|
|
121
101
|
if (timer)
|
|
@@ -203,7 +183,7 @@ export function writeCredential(id, value) {
|
|
|
203
183
|
return { ok: false, message: "only available in a Castle sandbox" };
|
|
204
184
|
}
|
|
205
185
|
const descriptor = descriptorFor(id);
|
|
206
|
-
if (!descriptor?.key
|
|
186
|
+
if (!descriptor?.key)
|
|
207
187
|
return { ok: false, message: "unknown credential" };
|
|
208
188
|
const env = descriptor.key.env;
|
|
209
189
|
const stored = readUserKeys();
|
|
@@ -230,9 +210,8 @@ export function writeCredential(id, value) {
|
|
|
230
210
|
// The login provider a client-supplied id names, or null. Keeps the WS handler
|
|
231
211
|
// from having to know the descriptor table. Null outside a sandbox for the
|
|
232
212
|
// same reason writeCredential refuses there -- most of all so no client can
|
|
233
|
-
// run `claude auth logout`
|
|
234
|
-
//
|
|
235
|
-
// prevent.
|
|
213
|
+
// run `claude auth logout` against the host machine's own login, which is
|
|
214
|
+
// exactly the outcome hiding the surface was meant to prevent.
|
|
236
215
|
export function loginProviderFor(id) {
|
|
237
216
|
if (!inCastleSandbox())
|
|
238
217
|
return null;
|
package/dist/byo-auth.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export declare const CASTLE_USER_KEYS_PATH: string;
|
|
2
|
-
export type UserKeyName = "ANTHROPIC_API_KEY" | "OPENROUTER_API_KEY"
|
|
2
|
+
export type UserKeyName = "ANTHROPIC_API_KEY" | "OPENROUTER_API_KEY";
|
|
3
3
|
export declare function readUserKeys(): Record<string, string>;
|
|
4
4
|
export declare function userKey(envName: UserKeyName): string | null;
|
|
5
5
|
export type AnthropicAuth = {
|
|
@@ -19,17 +19,14 @@ export type OpenrouterAuth = {
|
|
|
19
19
|
};
|
|
20
20
|
export declare function claudeCredentialsPath(): string;
|
|
21
21
|
export declare function claudeHasSavedLogin(): boolean;
|
|
22
|
-
export declare function cursorAuthPath(home: string): string;
|
|
23
|
-
export declare function cursorHasUserLogin(home: string): boolean;
|
|
24
22
|
export declare function resolveAnthropicAuth(): AnthropicAuth;
|
|
25
23
|
export declare const ANTHROPIC_CREDENTIAL_ENV: readonly ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN_HELPER", "CLAUDE_CODE_OAUTH_TOKEN"];
|
|
26
24
|
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"];
|
|
27
25
|
export declare function anthropicKeyHelperCommand(): string;
|
|
28
26
|
export declare function envForUserShell(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
29
|
-
export declare const SHIMMED_CLIS: readonly ["claude"
|
|
27
|
+
export declare const SHIMMED_CLIS: readonly ["claude"];
|
|
30
28
|
export type ShimmedCli = (typeof SHIMMED_CLIS)[number];
|
|
31
29
|
export declare function claudeShellEnvScript(env: NodeJS.ProcessEnv, args?: readonly string[]): string;
|
|
32
|
-
export declare function cursorShellEnvScript(env: NodeJS.ProcessEnv): string;
|
|
33
30
|
export declare function shimEnvScript(cli: string, env: NodeJS.ProcessEnv, shimDir: string, args?: readonly string[]): string;
|
|
34
31
|
export declare function ensureClaudeOnboarded(): void;
|
|
35
32
|
export declare function installCliShims(deckDir: string): string | null;
|
package/dist/byo-auth.js
CHANGED
|
@@ -94,32 +94,6 @@ export function claudeCredentialsPath() {
|
|
|
94
94
|
export function claudeHasSavedLogin() {
|
|
95
95
|
return fs.existsSync(claudeCredentialsPath());
|
|
96
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
|
-
}
|
|
122
|
-
}
|
|
123
97
|
export function resolveAnthropicAuth() {
|
|
124
98
|
const k = userKey("ANTHROPIC_API_KEY");
|
|
125
99
|
if (k)
|
|
@@ -209,13 +183,6 @@ export function envForUserShell(base) {
|
|
|
209
183
|
if (anthropic.mode === "user-key")
|
|
210
184
|
env.ANTHROPIC_API_KEY = anthropic.key;
|
|
211
185
|
}
|
|
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");
|
|
219
186
|
const openrouter = userKey("OPENROUTER_API_KEY");
|
|
220
187
|
if (openrouter) {
|
|
221
188
|
env.OPENROUTER_API_KEY = openrouter;
|
|
@@ -254,9 +221,9 @@ function realpath(p) {
|
|
|
254
221
|
return path.resolve(p);
|
|
255
222
|
}
|
|
256
223
|
}
|
|
257
|
-
// The CLIs a shim is installed for.
|
|
258
|
-
// that Castle may be supplying, so
|
|
259
|
-
export const SHIMMED_CLIS = ["claude"
|
|
224
|
+
// The CLIs a shim is installed for. Each takes a credential from the
|
|
225
|
+
// environment that Castle may be supplying, so the decision is re-made per run.
|
|
226
|
+
export const SHIMMED_CLIS = ["claude"];
|
|
260
227
|
// The real binary the shim should exec: the first executable one on `pathValue`
|
|
261
228
|
// that isn't the shim itself. Skipping by resolved directory (not by string) is
|
|
262
229
|
// what keeps the shim from exec'ing itself forever.
|
|
@@ -319,23 +286,8 @@ export function claudeShellEnvScript(env, args = []) {
|
|
|
319
286
|
}
|
|
320
287
|
return lines.join("\n");
|
|
321
288
|
}
|
|
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
289
|
export function shimEnvScript(cli, env, shimDir, args = []) {
|
|
336
|
-
const lines =
|
|
337
|
-
? cursorShellEnvScript(env)
|
|
338
|
-
: claudeShellEnvScript(env, args);
|
|
290
|
+
const lines = claudeShellEnvScript(env, args);
|
|
339
291
|
const real = findRealBin(env.PATH, shimDir, cli);
|
|
340
292
|
const all = [lines, real ? `CASTLE_REAL_BIN=${shellQuote(real)}` : ""].filter((l) => l !== "");
|
|
341
293
|
return all.length > 0 ? all.join("\n") + "\n" : "";
|
package/dist/byo-login.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export type LoginProvider = "claude"
|
|
2
|
-
export type LoginPhase = "starting" | "awaiting-
|
|
1
|
+
export type LoginProvider = "claude";
|
|
2
|
+
export type LoginPhase = "starting" | "awaiting-code" | "verifying" | "error";
|
|
3
3
|
export interface LoginState {
|
|
4
4
|
provider: LoginProvider;
|
|
5
5
|
phase: LoginPhase;
|
|
@@ -7,8 +7,7 @@ export interface LoginState {
|
|
|
7
7
|
message?: string;
|
|
8
8
|
}
|
|
9
9
|
export declare function activeLogin(): LoginState | null;
|
|
10
|
-
export declare function
|
|
11
|
-
export declare function startLogin(provider: LoginProvider, onChange: () => void): void;
|
|
10
|
+
export declare function startLogin(onChange: () => void): void;
|
|
12
11
|
export declare function submitLoginCode(code: string): void;
|
|
13
|
-
export declare function logout(
|
|
12
|
+
export declare function logout(onChange: () => void): void;
|
|
14
13
|
export declare function cancelLogin(): void;
|
package/dist/byo-login.js
CHANGED
|
@@ -1,59 +1,38 @@
|
|
|
1
|
-
// Driving `claude auth login`
|
|
2
|
-
//
|
|
1
|
+
// Driving `claude auth login` from the editor, so a user can put a run on their
|
|
2
|
+
// own subscription without knowing the terminal exists.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
// against claude 2.1.220 and
|
|
4
|
+
// The CLI is usable HEADLESS -- measured, not documented (probed 2026-07-30
|
|
5
|
+
// against claude 2.1.220): `claude auth login` with no TTY and stdin from a
|
|
6
|
+
// pipe prints "Opening browser to sign in…" then the OAuth URL, then blocks
|
|
7
|
+
// reading a pasted code from STDIN. The URL is wrapped in an OSC-8 hyperlink,
|
|
8
|
+
// so it appears TWICE in the raw bytes.
|
|
6
9
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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.
|
|
10
|
+
// It does not use a localhost callback, which is what makes this work at all in
|
|
11
|
+
// a sandbox: the browser is on the user's machine and the CLI is in a
|
|
12
|
+
// container, so a loopback redirect would have nowhere to land.
|
|
19
13
|
//
|
|
20
14
|
// `claude setup-token` is NOT usable here: with no TTY it prints nothing, and
|
|
21
15
|
// under one it launches the full Claude Code TUI.
|
|
22
16
|
import { spawn } from "child_process";
|
|
23
|
-
import
|
|
24
|
-
import { ANTHROPIC_PROXY_ENV, claudeHasSavedLogin, cursorHasUserLogin, } from "./byo-auth.js";
|
|
17
|
+
import { ANTHROPIC_PROXY_ENV, claudeHasSavedLogin } from "./byo-auth.js";
|
|
25
18
|
// A login is a singleton: two at once would race for the same credential file,
|
|
26
19
|
// and the UI only ever offers one. A second start replaces the first.
|
|
27
20
|
let active = null;
|
|
28
21
|
export function activeLogin() {
|
|
29
22
|
return active ? active.state : null;
|
|
30
23
|
}
|
|
31
|
-
export function providerHasLogin(provider) {
|
|
32
|
-
return provider === "claude"
|
|
33
|
-
? claudeHasSavedLogin()
|
|
34
|
-
: cursorHasUserLogin(os.homedir());
|
|
35
|
-
}
|
|
36
24
|
// Long enough for a real sign-in (find the browser, log in, maybe sign up),
|
|
37
25
|
// short enough that an abandoned flow doesn't leave a child running forever.
|
|
38
26
|
// The env override is a QA seam, like CASTLE_USER_KEYS_PATH: ten minutes is
|
|
39
27
|
// unwaitable in a test.
|
|
40
28
|
const LOGIN_TIMEOUT_MS = Number(process.env.CASTLE_LOGIN_TIMEOUT_MS ?? "") || 10 * 60 * 1000;
|
|
41
|
-
// OSC-8 hyperlinks and colour codes come through
|
|
42
|
-
//
|
|
29
|
+
// OSC-8 hyperlinks and colour codes come through; strip them before matching
|
|
30
|
+
// so a URL isn't cut short at an escape byte.
|
|
43
31
|
// eslint-disable-next-line no-control-regex
|
|
44
32
|
const ANSI = /\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g;
|
|
45
|
-
const URL_PATTERN =
|
|
46
|
-
|
|
47
|
-
cursor: /https:\/\/cursor\.com\/\S*/,
|
|
48
|
-
};
|
|
49
|
-
function commandFor(provider) {
|
|
33
|
+
const URL_PATTERN = /https:\/\/claude\.com\/\S*oauth\S*/;
|
|
34
|
+
function commandFor() {
|
|
50
35
|
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
36
|
// Castle's proxy pair outranks a claude.ai login (see byo-auth), and the
|
|
58
37
|
// sandbox always has it set -- leaving it in place would have claude decide
|
|
59
38
|
// it is already authenticated and skip the flow entirely.
|
|
@@ -107,28 +86,22 @@ function handleOutput(text) {
|
|
|
107
86
|
return;
|
|
108
87
|
}
|
|
109
88
|
if (!active.state.url) {
|
|
110
|
-
const match = URL_PATTERN
|
|
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
|
-
}
|
|
89
|
+
const match = URL_PATTERN.exec(clean);
|
|
90
|
+
if (match)
|
|
91
|
+
publish({ url: match[0], phase: "awaiting-code" });
|
|
118
92
|
}
|
|
119
93
|
}
|
|
120
|
-
export function startLogin(
|
|
94
|
+
export function startLogin(onChange) {
|
|
121
95
|
cancelLogin();
|
|
122
|
-
const { file, args, env } = commandFor(
|
|
96
|
+
const { file, args, env } = commandFor();
|
|
123
97
|
let child;
|
|
124
98
|
try {
|
|
125
99
|
child = spawn(file, args, { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
126
100
|
}
|
|
127
101
|
catch {
|
|
128
102
|
active = {
|
|
129
|
-
provider,
|
|
130
103
|
child: null,
|
|
131
|
-
state: { provider, phase: "error", message: `could not run ${file}` },
|
|
104
|
+
state: { provider: "claude", phase: "error", message: `could not run ${file}` },
|
|
132
105
|
timer: setTimeout(() => undefined, 0),
|
|
133
106
|
onChange,
|
|
134
107
|
};
|
|
@@ -136,9 +109,8 @@ export function startLogin(provider, onChange) {
|
|
|
136
109
|
return;
|
|
137
110
|
}
|
|
138
111
|
active = {
|
|
139
|
-
provider,
|
|
140
112
|
child,
|
|
141
|
-
state: { provider, phase: "starting" },
|
|
113
|
+
state: { provider: "claude", phase: "starting" },
|
|
142
114
|
timer: setTimeout(() => finish("Timed out waiting for sign-in."), LOGIN_TIMEOUT_MS),
|
|
143
115
|
onChange,
|
|
144
116
|
};
|
|
@@ -157,7 +129,7 @@ export function startLogin(provider, onChange) {
|
|
|
157
129
|
// The RESOLVER decides, never the exit code: what matters is whether the
|
|
158
130
|
// credential this process routes on is now there. A CLI that exits 0
|
|
159
131
|
// without leaving one behind must not read as success.
|
|
160
|
-
if (
|
|
132
|
+
if (claudeHasSavedLogin())
|
|
161
133
|
finish(null);
|
|
162
134
|
else
|
|
163
135
|
finish("Sign-in did not complete.");
|
|
@@ -176,12 +148,12 @@ export function submitLoginCode(code) {
|
|
|
176
148
|
// Sign out, so the modal isn't a one-way door. Fire-and-forget by shape but
|
|
177
149
|
// awaited for its close, because the snapshot is only right once the CLI has
|
|
178
150
|
// actually dropped the credential -- the resolver, again, not the exit code.
|
|
179
|
-
export function logout(
|
|
151
|
+
export function logout(onChange) {
|
|
180
152
|
cancelLogin();
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
:
|
|
184
|
-
|
|
153
|
+
const child = spawn("claude", ["auth", "logout"], {
|
|
154
|
+
env: commandFor().env,
|
|
155
|
+
stdio: "ignore",
|
|
156
|
+
});
|
|
185
157
|
child.on("error", onChange);
|
|
186
158
|
child.on("close", onChange);
|
|
187
159
|
}
|
package/dist/mcpPlaytest.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// A stdio MCP server exposing ONE tool -- `playtest` -- to a spawned claude
|
|
2
|
-
// task agent
|
|
3
|
-
// natively (native/tools.ts). It owns no browser: every call is relayed to the
|
|
2
|
+
// task agent. It owns no browser: every call is relayed to the
|
|
4
3
|
// running serve over its loopback control WebSocket (`playtest_request` in
|
|
5
4
|
// serve.ts), which is where the one warm Chromium and the task's frames dir
|
|
6
5
|
// live.
|
package/dist/metering.d.ts
CHANGED
|
@@ -7,29 +7,6 @@ export type MeteringRoute = "anthropic" | "openrouter";
|
|
|
7
7
|
* background-task spend" a prefix match with no extra column.
|
|
8
8
|
*/
|
|
9
9
|
export declare function newAgentSessionId(role: "router" | "task"): string;
|
|
10
|
-
/**
|
|
11
|
-
* Report a cursor run to the llm-proxy, which stamps the user + sandbox from
|
|
12
|
-
* our token and forwards it to the ledger alongside the anthropic/openrouter
|
|
13
|
-
* rows the proxy records itself.
|
|
14
|
-
*
|
|
15
|
-
* Cursor needs this because it is the one backend the proxy cannot see:
|
|
16
|
-
* cursor-agent has no base-URL override, so it talks to Cursor directly on its
|
|
17
|
-
* own key. There are no token counts to send -- cursor-agent's stream-json
|
|
18
|
-
* reports none -- so a row is a run count with its deck, agent run, model, and
|
|
19
|
-
* outcome, and the ledger's NULL tokens read as "not measured".
|
|
20
|
-
*
|
|
21
|
-
* Fire-and-forget by contract: the run is already over and its output already
|
|
22
|
-
* reached the user, so every failure here is swallowed. A dropped post loses
|
|
23
|
-
* one row rather than retrying, which is the right trade for a signal whose
|
|
24
|
-
* value is aggregate.
|
|
25
|
-
*/
|
|
26
|
-
export declare function reportCursorRun(opts: {
|
|
27
|
-
deckDir: string;
|
|
28
|
-
sessionId: string;
|
|
29
|
-
model: string;
|
|
30
|
-
durationMs: number;
|
|
31
|
-
ok: boolean;
|
|
32
|
-
}): void;
|
|
33
10
|
/**
|
|
34
11
|
* True when this serve runs inside a Castle sandbox -- i.e. the host injected the
|
|
35
12
|
* llm-proxy pair, which is what makes a run metered and limit-gated in the first
|
|
@@ -44,6 +21,7 @@ export interface CastleBudget {
|
|
|
44
21
|
resetAtMs: number;
|
|
45
22
|
blocked: boolean;
|
|
46
23
|
blockedModelPrefixes: string[];
|
|
24
|
+
freeModelPrefixes: string[];
|
|
47
25
|
}
|
|
48
26
|
export interface AiCredits {
|
|
49
27
|
plan: "credits" | "daily";
|
|
@@ -66,6 +44,40 @@ export interface AiCredits {
|
|
|
66
44
|
};
|
|
67
45
|
}
|
|
68
46
|
export declare function spendableMicros(budget: CastleBudget): number | null;
|
|
47
|
+
/**
|
|
48
|
+
* Whether Castle foots the bill for this model, so its runs are free to the
|
|
49
|
+
* user however their credits stand.
|
|
50
|
+
*
|
|
51
|
+
* A prefix matches either the full slug or the part after the last "/", so a
|
|
52
|
+
* vendor-qualified prefix still covers the variant suffixes OpenRouter allows
|
|
53
|
+
* ("meta/muse-spark-1.3-contributor:nitro" is the same model, billed the same)
|
|
54
|
+
* while stopping short of a shorter sibling ("meta/muse-spark-1.3" is a
|
|
55
|
+
* different model and is NOT free). Matching is the loose kind on purpose:
|
|
56
|
+
* being wrong here costs one proxy 403 with its own copy, never a run that
|
|
57
|
+
* silently spends credits the user was told were not being spent -- which is
|
|
58
|
+
* why the unqualified form is accepted too, rather than demanding the vendor
|
|
59
|
+
* segment the user may have typed differently.
|
|
60
|
+
*/
|
|
61
|
+
export declare function modelIsFree(model: string, prefixes: readonly string[]): boolean;
|
|
62
|
+
/** One agent role as the free-tier question sees it. */
|
|
63
|
+
export interface RoleSpend {
|
|
64
|
+
castlePaid: boolean;
|
|
65
|
+
slug: string | null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Whether this editor is entirely on models Castle pays for -- the signal the
|
|
69
|
+
* UI uses to stop talking about credits.
|
|
70
|
+
*
|
|
71
|
+
* "Every Castle-paid role", not "any": one role left on a metered model still
|
|
72
|
+
* spends the user's balance, and a UI that hid credits then would hide the
|
|
73
|
+
* thing about to run out. With no Castle-paid role at all there is nothing to
|
|
74
|
+
* call free (the usage frame is null in that case anyway), so it answers false.
|
|
75
|
+
*/
|
|
76
|
+
export declare function freeTierForRoles(roles: readonly RoleSpend[], prefixes: readonly string[]): boolean;
|
|
77
|
+
export declare function freeRolesFor(spends: readonly RoleSpend[], prefixes: readonly string[]): {
|
|
78
|
+
router: boolean;
|
|
79
|
+
tasks: boolean;
|
|
80
|
+
};
|
|
69
81
|
/**
|
|
70
82
|
* Coalesce asynchronous refresh triggers without letting an older read finish
|
|
71
83
|
* after and overwrite a newer one. `withFullState` is sticky across queued
|
package/dist/metering.js
CHANGED
|
@@ -48,58 +48,6 @@ function deckIdFor(deckDir) {
|
|
|
48
48
|
return null;
|
|
49
49
|
return parsed.deckId.replace(/[\r\n]/g, "") || null;
|
|
50
50
|
}
|
|
51
|
-
// Path on the proxy that accepts a run the proxy never routed. Must match
|
|
52
|
-
// CASTLE_USAGE_PATH in castle-sandboxes/shared/src/index.ts.
|
|
53
|
-
const CASTLE_USAGE_PATH = "/castle/usage";
|
|
54
|
-
// A metering post must never delay a turn that has already finished, and its
|
|
55
|
-
// result is never awaited -- so a proxy that has gone away costs one socket
|
|
56
|
-
// timeout in the background, not a hung run.
|
|
57
|
-
const USAGE_POST_TIMEOUT_MS = 5_000;
|
|
58
|
-
/**
|
|
59
|
-
* Report a cursor run to the llm-proxy, which stamps the user + sandbox from
|
|
60
|
-
* our token and forwards it to the ledger alongside the anthropic/openrouter
|
|
61
|
-
* rows the proxy records itself.
|
|
62
|
-
*
|
|
63
|
-
* Cursor needs this because it is the one backend the proxy cannot see:
|
|
64
|
-
* cursor-agent has no base-URL override, so it talks to Cursor directly on its
|
|
65
|
-
* own key. There are no token counts to send -- cursor-agent's stream-json
|
|
66
|
-
* reports none -- so a row is a run count with its deck, agent run, model, and
|
|
67
|
-
* outcome, and the ledger's NULL tokens read as "not measured".
|
|
68
|
-
*
|
|
69
|
-
* Fire-and-forget by contract: the run is already over and its output already
|
|
70
|
-
* reached the user, so every failure here is swallowed. A dropped post loses
|
|
71
|
-
* one row rather than retrying, which is the right trade for a signal whose
|
|
72
|
-
* value is aggregate.
|
|
73
|
-
*/
|
|
74
|
-
export function reportCursorRun(opts) {
|
|
75
|
-
const base = process.env.CASTLE_LLM_PROXY_URL;
|
|
76
|
-
const token = process.env.CASTLE_LLM_PROXY_TOKEN;
|
|
77
|
-
// Absent outside a sandbox, and on a host whose proxy predates this route --
|
|
78
|
-
// both cases correctly report nothing rather than posting into the void.
|
|
79
|
-
if (!base || !token)
|
|
80
|
-
return;
|
|
81
|
-
const deckId = deckIdFor(opts.deckDir);
|
|
82
|
-
void fetch(`${base}${CASTLE_USAGE_PATH}`, {
|
|
83
|
-
method: "POST",
|
|
84
|
-
headers: {
|
|
85
|
-
"content-type": "application/json",
|
|
86
|
-
authorization: `Bearer ${token}`,
|
|
87
|
-
},
|
|
88
|
-
body: JSON.stringify({
|
|
89
|
-
provider: "cursor",
|
|
90
|
-
model: opts.model,
|
|
91
|
-
...(deckId ? { deckId } : {}),
|
|
92
|
-
agentSessionId: opts.sessionId,
|
|
93
|
-
durationMs: opts.durationMs,
|
|
94
|
-
// A cursor run has no HTTP status; the ledger's column is one, and
|
|
95
|
-
// `>= 400 means it failed` is the reading every other row already gets.
|
|
96
|
-
status: opts.ok ? 200 : 500,
|
|
97
|
-
}),
|
|
98
|
-
signal: AbortSignal.timeout(USAGE_POST_TIMEOUT_MS),
|
|
99
|
-
}).catch(() => {
|
|
100
|
-
/* best-effort: metering must never surface in a finished run */
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
51
|
/**
|
|
104
52
|
* True when this serve runs inside a Castle sandbox -- i.e. the host injected the
|
|
105
53
|
* llm-proxy pair, which is what makes a run metered and limit-gated in the first
|
|
@@ -120,6 +68,60 @@ export function spendableMicros(budget) {
|
|
|
120
68
|
return null;
|
|
121
69
|
return Math.max(0, budget.limitMicros - budget.usedMicros);
|
|
122
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Whether Castle foots the bill for this model, so its runs are free to the
|
|
73
|
+
* user however their credits stand.
|
|
74
|
+
*
|
|
75
|
+
* A prefix matches either the full slug or the part after the last "/", so a
|
|
76
|
+
* vendor-qualified prefix still covers the variant suffixes OpenRouter allows
|
|
77
|
+
* ("meta/muse-spark-1.3-contributor:nitro" is the same model, billed the same)
|
|
78
|
+
* while stopping short of a shorter sibling ("meta/muse-spark-1.3" is a
|
|
79
|
+
* different model and is NOT free). Matching is the loose kind on purpose:
|
|
80
|
+
* being wrong here costs one proxy 403 with its own copy, never a run that
|
|
81
|
+
* silently spends credits the user was told were not being spent -- which is
|
|
82
|
+
* why the unqualified form is accepted too, rather than demanding the vendor
|
|
83
|
+
* segment the user may have typed differently.
|
|
84
|
+
*/
|
|
85
|
+
export function modelIsFree(model, prefixes) {
|
|
86
|
+
const slug = model.trim().toLowerCase();
|
|
87
|
+
if (!slug)
|
|
88
|
+
return false;
|
|
89
|
+
const bare = slug.slice(slug.lastIndexOf("/") + 1);
|
|
90
|
+
return prefixes.some((raw) => {
|
|
91
|
+
const prefix = raw.trim().toLowerCase();
|
|
92
|
+
// An empty prefix would make every model free -- refuse it rather than
|
|
93
|
+
// hand a malformed server list the power to turn metering off.
|
|
94
|
+
if (!prefix)
|
|
95
|
+
return false;
|
|
96
|
+
return slug.startsWith(prefix) || bare.startsWith(prefix);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Whether this editor is entirely on models Castle pays for -- the signal the
|
|
101
|
+
* UI uses to stop talking about credits.
|
|
102
|
+
*
|
|
103
|
+
* "Every Castle-paid role", not "any": one role left on a metered model still
|
|
104
|
+
* spends the user's balance, and a UI that hid credits then would hide the
|
|
105
|
+
* thing about to run out. With no Castle-paid role at all there is nothing to
|
|
106
|
+
* call free (the usage frame is null in that case anyway), so it answers false.
|
|
107
|
+
*/
|
|
108
|
+
export function freeTierForRoles(roles, prefixes) {
|
|
109
|
+
const paid = roles.filter((role) => role.castlePaid);
|
|
110
|
+
return (paid.length > 0 &&
|
|
111
|
+
paid.every((role) => role.slug !== null && modelIsFree(role.slug, prefixes)));
|
|
112
|
+
}
|
|
113
|
+
// Per-role version of the free-tier question, for the picker rather than the
|
|
114
|
+
// bill: which Castle-paid role is sitting on a free model. `freeTier` answers
|
|
115
|
+
// for the whole editor and cannot say which of the two rows earns the chip.
|
|
116
|
+
// A role paid through the user's credential, or on a fixed claude alias with
|
|
117
|
+
// no slug, is never free.
|
|
118
|
+
export function freeRolesFor(spends, prefixes) {
|
|
119
|
+
const [router, tasks] = spends;
|
|
120
|
+
const free = (spend) => spend.castlePaid &&
|
|
121
|
+
spend.slug !== null &&
|
|
122
|
+
modelIsFree(spend.slug, prefixes);
|
|
123
|
+
return { router: free(router), tasks: free(tasks) };
|
|
124
|
+
}
|
|
123
125
|
/**
|
|
124
126
|
* Coalesce asynchronous refresh triggers without letting an older read finish
|
|
125
127
|
* after and overwrite a newer one. `withFullState` is sticky across queued
|
|
@@ -268,6 +270,9 @@ export async function fetchBudget() {
|
|
|
268
270
|
blockedModelPrefixes: Array.isArray(body.blockedModelPrefixes)
|
|
269
271
|
? body.blockedModelPrefixes.filter((p) => typeof p === "string")
|
|
270
272
|
: [],
|
|
273
|
+
freeModelPrefixes: Array.isArray(body.freeModelPrefixes)
|
|
274
|
+
? body.freeModelPrefixes.filter((p) => typeof p === "string")
|
|
275
|
+
: [],
|
|
271
276
|
};
|
|
272
277
|
}
|
|
273
278
|
catch {
|
|
@@ -27,4 +27,3 @@ export declare function checkOpenrouterKey(apiKey: string, opts?: {
|
|
|
27
27
|
direct?: boolean;
|
|
28
28
|
}): Promise<KeyCheck>;
|
|
29
29
|
export declare function checkOpenrouterModel(slug: string): Promise<ModelCheck>;
|
|
30
|
-
export declare function openrouterCatalogEntry(slug: string): Promise<CatalogEntry | null>;
|