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/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, watchCredentials, 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,76 @@ function applyAgentSettings(incoming, ctx) {
|
|
|
3034
3022
|
ctx.broadcast({ type: "settings", settings });
|
|
3035
3023
|
void broadcastSettingsWarnings(ctx);
|
|
3036
3024
|
}
|
|
3025
|
+
// Belt to watchCredentials' braces: a directory that didn't exist at boot has
|
|
3026
|
+
// no watcher on it, and an inotify watch can be lost. Slow, because the watch is
|
|
3027
|
+
// what makes this feel live -- this only has to stop a miss from lasting.
|
|
3028
|
+
const ACCOUNTS_POLL_MS = 15_000;
|
|
3029
|
+
// The editor's view of which credentials exist, kept live. Unlike settings --
|
|
3030
|
+
// which nothing outside this process writes -- a credential changes behind the
|
|
3031
|
+
// editor's back all the time: `claude /login` or `cursor-agent logout` in the
|
|
3032
|
+
// terminal panel, a hand-edited user-keys.json, an agent run that refreshes an
|
|
3033
|
+
// OAuth token. Push those, or the popover reports whatever was true when the
|
|
3034
|
+
// page loaded and a terminal sign-in reads as having done nothing.
|
|
3035
|
+
//
|
|
3036
|
+
// The snapshot carries presence and a hint only; no frame from here ever moves
|
|
3037
|
+
// a key value toward a browser.
|
|
3038
|
+
function createAccountsFeed(opts) {
|
|
3039
|
+
let last = "";
|
|
3040
|
+
// Tracked apart from the whole snapshot so a sign-in's phase transitions
|
|
3041
|
+
// (starting -> awaiting-code -> ...) don't each cost a budget fetch: they
|
|
3042
|
+
// move `login`, never a credential.
|
|
3043
|
+
let lastProviders = "";
|
|
3044
|
+
function push(error) {
|
|
3045
|
+
const accounts = accountsSnapshot();
|
|
3046
|
+
const serialized = JSON.stringify(accounts);
|
|
3047
|
+
const providers = JSON.stringify(accounts.providers);
|
|
3048
|
+
if (providers !== lastProviders) {
|
|
3049
|
+
lastProviders = providers;
|
|
3050
|
+
opts.onCredentialChange();
|
|
3051
|
+
}
|
|
3052
|
+
// A refusal always goes out. It is the only answer a rejected write gets,
|
|
3053
|
+
// and a rejected write leaves the snapshot identical by definition -- so
|
|
3054
|
+
// deduping on the snapshot alone would swallow exactly the frame that
|
|
3055
|
+
// carries the reason.
|
|
3056
|
+
if (serialized === last && !error)
|
|
3057
|
+
return;
|
|
3058
|
+
last = serialized;
|
|
3059
|
+
opts.broadcast({
|
|
3060
|
+
type: "accounts",
|
|
3061
|
+
accounts,
|
|
3062
|
+
...(error ? { accountsError: error } : {}),
|
|
3063
|
+
});
|
|
3064
|
+
}
|
|
3065
|
+
function snapshot() {
|
|
3066
|
+
const accounts = accountsSnapshot();
|
|
3067
|
+
last = JSON.stringify(accounts);
|
|
3068
|
+
lastProviders = JSON.stringify(accounts.providers);
|
|
3069
|
+
return accounts;
|
|
3070
|
+
}
|
|
3071
|
+
const stopWatch = watchCredentials(() => push());
|
|
3072
|
+
const timer = setInterval(() => {
|
|
3073
|
+
if (opts.hasClients())
|
|
3074
|
+
push();
|
|
3075
|
+
}, ACCOUNTS_POLL_MS);
|
|
3076
|
+
timer.unref?.();
|
|
3077
|
+
return {
|
|
3078
|
+
snapshot,
|
|
3079
|
+
push,
|
|
3080
|
+
stop: () => {
|
|
3081
|
+
stopWatch();
|
|
3082
|
+
clearInterval(timer);
|
|
3083
|
+
},
|
|
3084
|
+
};
|
|
3085
|
+
}
|
|
3086
|
+
function applyCredentialChange(msg, ctx) {
|
|
3087
|
+
const clearing = msg.type === "clear-credential";
|
|
3088
|
+
if (!clearing && typeof msg.value !== "string")
|
|
3089
|
+
return;
|
|
3090
|
+
const result = writeCredential(msg.id, clearing ? null : msg.value);
|
|
3091
|
+
ctx.pushAccounts(result.ok
|
|
3092
|
+
? undefined
|
|
3093
|
+
: { id: typeof msg.id === "string" ? msg.id : "", message: result.message });
|
|
3094
|
+
}
|
|
3037
3095
|
// Which slugs are worth a verdict: only roles actually routed at OpenRouter
|
|
3038
3096
|
// (otherwise we'd warn about an inert leftover value), and only when a slug is
|
|
3039
3097
|
// set at all.
|
|
@@ -3508,6 +3566,11 @@ export function createAgentServer(opts) {
|
|
|
3508
3566
|
hasClients: () => clients.size > 0,
|
|
3509
3567
|
castlePaid: () => anyRoleIsCastlePaid(settings),
|
|
3510
3568
|
});
|
|
3569
|
+
const accountsFeed = createAccountsFeed({
|
|
3570
|
+
broadcast,
|
|
3571
|
+
hasClients: () => clients.size > 0,
|
|
3572
|
+
onCredentialChange: () => usageFeed.refresh(),
|
|
3573
|
+
});
|
|
3511
3574
|
const applySettings = (incoming) => {
|
|
3512
3575
|
applyAgentSettings(incoming, { settings, settingsPath, broadcast });
|
|
3513
3576
|
// A backend switch can change who pays (a cursor role always spends
|
|
@@ -3585,6 +3648,7 @@ export function createAgentServer(opts) {
|
|
|
3585
3648
|
running: routerQueue.isRunning(),
|
|
3586
3649
|
queued: routerQueue.queuedSnippets(),
|
|
3587
3650
|
usage: usageFeed.latest(),
|
|
3651
|
+
accounts: accountsFeed.snapshot(),
|
|
3588
3652
|
};
|
|
3589
3653
|
socket.send(JSON.stringify(hello));
|
|
3590
3654
|
// A newly attached client is the one moment the cached value may be stale
|
|
@@ -3625,6 +3689,26 @@ export function createAgentServer(opts) {
|
|
|
3625
3689
|
else if (msg.type === "set-settings") {
|
|
3626
3690
|
applySettings(msg);
|
|
3627
3691
|
}
|
|
3692
|
+
else if (msg.type === "set-credential" ||
|
|
3693
|
+
msg.type === "clear-credential") {
|
|
3694
|
+
applyCredentialChange(msg, { pushAccounts: accountsFeed.push });
|
|
3695
|
+
}
|
|
3696
|
+
else if (msg.type === "account-login") {
|
|
3697
|
+
const provider = loginProviderFor(msg.id);
|
|
3698
|
+
if (provider)
|
|
3699
|
+
startLogin(provider, () => accountsFeed.push());
|
|
3700
|
+
}
|
|
3701
|
+
else if (msg.type === "account-login-code" && typeof msg.value === "string") {
|
|
3702
|
+
submitLoginCode(msg.value);
|
|
3703
|
+
}
|
|
3704
|
+
else if (msg.type === "account-login-cancel") {
|
|
3705
|
+
cancelLogin();
|
|
3706
|
+
}
|
|
3707
|
+
else if (msg.type === "account-logout") {
|
|
3708
|
+
const provider = loginProviderFor(msg.id);
|
|
3709
|
+
if (provider)
|
|
3710
|
+
logout(provider, () => accountsFeed.push());
|
|
3711
|
+
}
|
|
3628
3712
|
else if (msg.type === "client-timezone" && typeof msg.timeZone === "string") {
|
|
3629
3713
|
setReaderTimeZone(msg.timeZone);
|
|
3630
3714
|
}
|
|
@@ -3659,6 +3743,7 @@ export function createAgentServer(opts) {
|
|
|
3659
3743
|
}
|
|
3660
3744
|
stopChildRegistry();
|
|
3661
3745
|
usageFeed.stop();
|
|
3746
|
+
accountsFeed.stop();
|
|
3662
3747
|
wss.close();
|
|
3663
3748
|
void playtestBrowserManager.shutdown();
|
|
3664
3749
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
/**
|
|
22
|
+
* Call `onChange` when a credential this module reports on changes on disk.
|
|
23
|
+
*
|
|
24
|
+
* The editor is not the only writer: `claude /login` and `cursor-agent login`
|
|
25
|
+
* in the terminal panel, an agent run that refreshes an OAuth token, and a
|
|
26
|
+
* hand-edited `user-keys.json` all change the answer behind its back. Without
|
|
27
|
+
* this the account state is whatever was true when the page loaded, so a
|
|
28
|
+
* terminal sign-in looks like it did nothing until a reload.
|
|
29
|
+
*
|
|
30
|
+
* Directories, never the credential files themselves: the CLIs replace those by
|
|
31
|
+
* rename, which leaves a file watch holding the old inode and silently deaf --
|
|
32
|
+
* the same reason castle-sandboxes persists these paths as directory symlinks.
|
|
33
|
+
* A directory that doesn't exist yet (no `~/.castle` until the first key) simply
|
|
34
|
+
* isn't watched; the caller's poll is what covers that, and losing a watcher
|
|
35
|
+
* costs freshness, never correctness.
|
|
36
|
+
*/
|
|
37
|
+
export declare function watchCredentials(onChange: () => void): () => void;
|
|
38
|
+
export type CredentialWriteResult = {
|
|
39
|
+
ok: true;
|
|
40
|
+
changed: boolean;
|
|
41
|
+
} | {
|
|
42
|
+
ok: false;
|
|
43
|
+
message: string;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Store one provider's key, or remove it when `value` is null.
|
|
47
|
+
*
|
|
48
|
+
* Merges into whatever is already in the file: a hand-added entry this module
|
|
49
|
+
* has no descriptor for MUST survive, since hand-editing stays a supported way
|
|
50
|
+
* in. Removing deletes the entry rather than blanking it -- the resolver reads
|
|
51
|
+
* an empty string as absent either way, but a leftover `"KEY": ""` reads as a
|
|
52
|
+
* configured key to anyone inspecting the file.
|
|
53
|
+
*/
|
|
54
|
+
export declare function writeCredential(id: unknown, value: string | null): CredentialWriteResult;
|
|
55
|
+
export declare function loginProviderFor(id: unknown): LoginProvider | null;
|
|
@@ -0,0 +1,240 @@
|
|
|
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 installCliShims), 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, claudeCredentialsPath, claudeHasSavedLogin, cursorAuthPath, 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
|
+
// Several events land for one credential change (a rename is a delete plus a
|
|
94
|
+
// create, and the CLIs write neighbouring files in the same breath); one
|
|
95
|
+
// snapshot after the burst is what the editor wants.
|
|
96
|
+
const WATCH_DEBOUNCE_MS = 250;
|
|
97
|
+
/**
|
|
98
|
+
* Call `onChange` when a credential this module reports on changes on disk.
|
|
99
|
+
*
|
|
100
|
+
* The editor is not the only writer: `claude /login` and `cursor-agent login`
|
|
101
|
+
* in the terminal panel, an agent run that refreshes an OAuth token, and a
|
|
102
|
+
* hand-edited `user-keys.json` all change the answer behind its back. Without
|
|
103
|
+
* this the account state is whatever was true when the page loaded, so a
|
|
104
|
+
* terminal sign-in looks like it did nothing until a reload.
|
|
105
|
+
*
|
|
106
|
+
* Directories, never the credential files themselves: the CLIs replace those by
|
|
107
|
+
* rename, which leaves a file watch holding the old inode and silently deaf --
|
|
108
|
+
* the same reason castle-sandboxes persists these paths as directory symlinks.
|
|
109
|
+
* A directory that doesn't exist yet (no `~/.castle` until the first key) simply
|
|
110
|
+
* isn't watched; the caller's poll is what covers that, and losing a watcher
|
|
111
|
+
* costs freshness, never correctness.
|
|
112
|
+
*/
|
|
113
|
+
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)));
|
|
119
|
+
let timer = null;
|
|
120
|
+
const fire = () => {
|
|
121
|
+
if (timer)
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
timer = setTimeout(onChange, WATCH_DEBOUNCE_MS);
|
|
124
|
+
timer.unref?.();
|
|
125
|
+
};
|
|
126
|
+
const watchers = [];
|
|
127
|
+
for (const dir of dirs) {
|
|
128
|
+
try {
|
|
129
|
+
const w = fs.watch(dir, fire);
|
|
130
|
+
// A watch that dies later (the directory is removed, inotify runs out)
|
|
131
|
+
// must not take the serve with it -- fs.watch reports those as an error
|
|
132
|
+
// event, which is fatal when nothing is listening.
|
|
133
|
+
w.on("error", () => undefined);
|
|
134
|
+
watchers.push(w);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* not watchable here */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return () => {
|
|
141
|
+
if (timer)
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
for (const w of watchers)
|
|
144
|
+
w.close();
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// Generous -- provider key formats are not ours to predict -- but bounded, so a
|
|
148
|
+
// stray paste of a whole file can't land in the credential store.
|
|
149
|
+
const MAX_KEY_LENGTH = 500;
|
|
150
|
+
// A credential reaches a child process as an environment value and, for the
|
|
151
|
+
// terminal, as a shell-quoted assignment. A control character in it is never a
|
|
152
|
+
// real key and is exactly the shape that turns one env var into two, so it is
|
|
153
|
+
// REFUSED rather than stripped: silently storing something other than what the
|
|
154
|
+
// user pasted would fail later, somewhere less obvious.
|
|
155
|
+
function hasControlCharacter(value) {
|
|
156
|
+
for (const ch of value) {
|
|
157
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
158
|
+
if (code < 0x20 || code === 0x7f)
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
function writeUserKeys(next) {
|
|
164
|
+
fs.mkdirSync(path.dirname(CASTLE_USER_KEYS_PATH), { recursive: true });
|
|
165
|
+
// Staged + renamed, not written in place: the sandbox checkpoints this
|
|
166
|
+
// directory on a timer, so an in-place write can be snapshotted half-done.
|
|
167
|
+
// The staged file carries the mode, and rename preserves it, so the result is
|
|
168
|
+
// 0600 even when an older file was not.
|
|
169
|
+
const staged = `${CASTLE_USER_KEYS_PATH}.staged`;
|
|
170
|
+
fs.writeFileSync(staged, JSON.stringify(next, null, 2) + "\n", {
|
|
171
|
+
mode: 0o600,
|
|
172
|
+
});
|
|
173
|
+
fs.renameSync(staged, CASTLE_USER_KEYS_PATH);
|
|
174
|
+
}
|
|
175
|
+
// A full disk or a permissions change comes back as a refusal the client is
|
|
176
|
+
// told about, not as an exception: this runs inside the WS message handler,
|
|
177
|
+
// where nothing catches a throw and it would take the whole serve down.
|
|
178
|
+
function persist(next) {
|
|
179
|
+
try {
|
|
180
|
+
writeUserKeys(next);
|
|
181
|
+
return { ok: true, changed: true };
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return { ok: false, message: "could not write user-keys.json" };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Store one provider's key, or remove it when `value` is null.
|
|
189
|
+
*
|
|
190
|
+
* Merges into whatever is already in the file: a hand-added entry this module
|
|
191
|
+
* has no descriptor for MUST survive, since hand-editing stays a supported way
|
|
192
|
+
* in. Removing deletes the entry rather than blanking it -- the resolver reads
|
|
193
|
+
* an empty string as absent either way, but a leftover `"KEY": ""` reads as a
|
|
194
|
+
* configured key to anyone inspecting the file.
|
|
195
|
+
*/
|
|
196
|
+
export function writeCredential(id, value) {
|
|
197
|
+
// accountsSnapshot being empty outside a sandbox HIDES this surface; hiding
|
|
198
|
+
// is not refusing. The agent socket is reachable over LAN/tailnet hostnames
|
|
199
|
+
// and a cross-origin page can open a WebSocket to it, so without this check
|
|
200
|
+
// any client could still write the host's ~/.castle/user-keys.json from a
|
|
201
|
+
// plain local serve.
|
|
202
|
+
if (!inCastleSandbox()) {
|
|
203
|
+
return { ok: false, message: "only available in a Castle sandbox" };
|
|
204
|
+
}
|
|
205
|
+
const descriptor = descriptorFor(id);
|
|
206
|
+
if (!descriptor?.key?.offered)
|
|
207
|
+
return { ok: false, message: "unknown credential" };
|
|
208
|
+
const env = descriptor.key.env;
|
|
209
|
+
const stored = readUserKeys();
|
|
210
|
+
if (value === null) {
|
|
211
|
+
if (!(env in stored))
|
|
212
|
+
return { ok: true, changed: false };
|
|
213
|
+
delete stored[env];
|
|
214
|
+
return persist(stored);
|
|
215
|
+
}
|
|
216
|
+
const trimmed = value.trim();
|
|
217
|
+
if (!trimmed)
|
|
218
|
+
return { ok: false, message: "key is empty" };
|
|
219
|
+
if (trimmed.length > MAX_KEY_LENGTH) {
|
|
220
|
+
return { ok: false, message: "key is too long" };
|
|
221
|
+
}
|
|
222
|
+
if (hasControlCharacter(trimmed)) {
|
|
223
|
+
return { ok: false, message: "key contains invalid characters" };
|
|
224
|
+
}
|
|
225
|
+
if (stored[env] === trimmed)
|
|
226
|
+
return { ok: true, changed: false };
|
|
227
|
+
stored[env] = trimmed;
|
|
228
|
+
return persist(stored);
|
|
229
|
+
}
|
|
230
|
+
// The login provider a client-supplied id names, or null. Keeps the WS handler
|
|
231
|
+
// from having to know the descriptor table. Null outside a sandbox for the
|
|
232
|
+
// same reason writeCredential refuses there -- most of all so no client can
|
|
233
|
+
// run `claude auth logout` / `cursor-agent logout` against the host machine's
|
|
234
|
+
// own login, which is exactly the outcome hiding the surface was meant to
|
|
235
|
+
// prevent.
|
|
236
|
+
export function loginProviderFor(id) {
|
|
237
|
+
if (!inCastleSandbox())
|
|
238
|
+
return null;
|
|
239
|
+
return descriptorFor(id)?.login ?? null;
|
|
240
|
+
}
|
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;
|
|
@@ -15,9 +17,19 @@ export type OpenrouterAuth = {
|
|
|
15
17
|
mode: "proxy";
|
|
16
18
|
key: string;
|
|
17
19
|
};
|
|
20
|
+
export declare function claudeCredentialsPath(): string;
|
|
18
21
|
export declare function claudeHasSavedLogin(): boolean;
|
|
22
|
+
export declare function cursorAuthPath(home: string): string;
|
|
23
|
+
export declare function cursorHasUserLogin(home: string): boolean;
|
|
19
24
|
export declare function resolveAnthropicAuth(): AnthropicAuth;
|
|
20
25
|
export declare const ANTHROPIC_CREDENTIAL_ENV: readonly ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN_HELPER", "CLAUDE_CODE_OAUTH_TOKEN"];
|
|
21
26
|
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
27
|
export declare function anthropicKeyHelperCommand(): string;
|
|
23
28
|
export declare function envForUserShell(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
29
|
+
export declare const SHIMMED_CLIS: readonly ["claude", "cursor-agent"];
|
|
30
|
+
export type ShimmedCli = (typeof SHIMMED_CLIS)[number];
|
|
31
|
+
export declare function claudeShellEnvScript(env: NodeJS.ProcessEnv, args?: readonly string[]): string;
|
|
32
|
+
export declare function cursorShellEnvScript(env: NodeJS.ProcessEnv): string;
|
|
33
|
+
export declare function shimEnvScript(cli: string, env: NodeJS.ProcessEnv, shimDir: string, args?: readonly string[]): string;
|
|
34
|
+
export declare function ensureClaudeOnboarded(): void;
|
|
35
|
+
export declare function installCliShims(deckDir: string): string | null;
|