Public: anyone can read this; nobody can change it.
Any one-line secret is a model key; a setup-token may go to a child under a budget #28
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
any-model-key → main 1 commit
9 files
changed +65
−42
src/actions/spawn.ts +2 −2
| @@ -82,8 +82,8 @@ export interface SpawnDeps { | ||
| 82 | 82 | readonly request: Request; |
| 83 | 83 | readonly url: URL; |
| 84 | 84 | /** |
| 85 | * The child's own model key, when the spawner gives it one: an API-billed | |
| 86 | * key (never a person's subscription token), sealed in the child with the | |
| 85 | * The child's own model key, when the spawner gives it one: an API key, or | |
| 86 | * a person's own setup-token under a budget (their choice), sealed in the child with the | |
| 87 | 87 | * child's own `secret.key`, granted to its organisation, and never shown |
| 88 | 88 | * again or kept by the parent. Its whole-life budget is the plan's |
| 89 | 89 | * `budgetUsd`, locked in the child. |
src/core/subscriptions.ts +12 −15
| @@ -180,22 +180,19 @@ export const API_BASE_MAX = 200; | ||
| 180 | 180 | |
| 181 | 181 | /** |
| 182 | 182 | * What kind of token this is. A token from `claude setup-token` begins |
| 183 | * `sk-ant-oat…`; an Anthropic API key `sk-ant-api…`; an OpenRouter key | |
| 184 | * `sk-or-v1-…`. With an API base URL set (OpenRouter or another Anthropic- | |
| 185 | * compatible gateway), any other non-empty token is accepted as an API key | |
| 186 | * so a gateway's own key shape can seal. OAuth tokens never take a base URL. | |
| 183 | * `sk-ant-oat…` and is a person's own plan: it goes under | |
| 184 | * `CLAUDE_CODE_OAUTH_TOKEN` and never takes a base URL. Anything else that | |
| 185 | * could be a key — one line, no spaces, not absurdly long — is an API key: | |
| 186 | * Anthropic's `sk-ant-api…`, the `sk-ant-usr…` keys that come with a | |
| 187 | * subscription's API credits, OpenRouter's `sk-or-v1-…`, or whatever shape | |
| 188 | * a gateway hands out with its base URL. The forge keeps no list of shapes; | |
| 189 | * the provider answers a bad key on the first call, and the run says so. | |
| 187 | 190 | */ |
| 188 | export function kindOfToken( | |
| 189 | token: string, | |
| 190 | opts?: { readonly apiBase?: string | null | undefined }, | |
| 191 | ): SubscriptionKind | undefined { | |
| 192 | if (token.length > TOKEN_MAX || /\s|\0/.test(token)) return undefined; | |
| 191 | export function kindOfToken(token: string): SubscriptionKind | undefined { | |
| 192 | if (token === "" || token.length > TOKEN_MAX || /\s|\0/.test(token)) | |
| 193 | return undefined; | |
| 193 | 194 | if (/^sk-ant-oat\d*-/.test(token)) return "oauth"; |
| 194 | if (/^sk-ant-api\d*-/.test(token)) return "api_key"; | |
| 195 | if (/^sk-or-v1-/.test(token)) return "api_key"; | |
| 196 | const base = (opts?.apiBase ?? "").trim(); | |
| 197 | if (base !== "" && parseApiBase(base) !== undefined) return "api_key"; | |
| 198 | return undefined; | |
| 195 | return "api_key"; | |
| 199 | 196 | } |
| 200 | 197 | |
| 201 | 198 | /** |
| @@ -294,7 +291,7 @@ export function addSubscription( | ||
| 294 | 291 | if (parsed === undefined) return err("invalid_api_base"); |
| 295 | 292 | apiBase = parsed; |
| 296 | 293 | } |
| 297 | const kind = kindOfToken(input.token, { apiBase }); | |
| 294 | const kind = kindOfToken(input.token); | |
| 298 | 295 | if (kind === undefined) return err("invalid_token"); |
| 299 | 296 | // A Claude subscription token is Anthropic's own plan — never a gateway. |
| 300 | 297 | if (kind === "oauth" && apiBase !== null) return err("invalid_api_base"); |
src/spawn/door.ts +6 −6
| @@ -167,7 +167,7 @@ export async function spawnDoor( | ||
| 167 | 167 | "modelKey", |
| 168 | 168 | "anonymous spawns carry no model key; claim the forge and add one there", |
| 169 | 169 | ); |
| 170 | // The child's model key: an API-billed key with a whole-life budget, or none. A | |
| 170 | // The child's model key: a key or subscription token with a whole-life budget, or none. A | |
| 171 | 171 | // subscription token is a person's own plan and never travels. |
| 172 | 172 | const modelEndpointRaw = raw["modelEndpoint"]; |
| 173 | 173 | let modelEndpoint: string | undefined; |
| @@ -181,14 +181,14 @@ export async function spawnDoor( | ||
| 181 | 181 | } |
| 182 | 182 | const modelKey = raw["modelKey"]; |
| 183 | 183 | if (modelKey !== undefined) { |
| 184 | if ( | |
| 185 | typeof modelKey !== "string" || | |
| 186 | kindOfToken(modelKey, { apiBase: modelEndpoint }) !== "api_key" | |
| 187 | ) | |
| 184 | const kind = typeof modelKey === "string" ? kindOfToken(modelKey) : undefined; | |
| 185 | if (kind === undefined) | |
| 188 | 186 | return invalid( |
| 189 | 187 | "modelKey", |
| 190 | "must be an API key (sk-ant-api… or sk-or-v1-…); a subscription token is a person's own plan and never goes to a child", | |
| 188 | "must be one line with no spaces: an Anthropic key (sk-ant-api…, sk-ant-usr…), a `claude setup-token` token (sk-ant-oat…), or a gateway's key with its base URL", | |
| 191 | 189 | ); |
| 190 | if (kind === "oauth" && modelEndpoint !== undefined) | |
| 191 | return invalid("modelEndpoint", "a subscription token (sk-ant-oat…) talks to Anthropic itself and takes no base URL"); | |
| 192 | 192 | if (raw["budgetUsd"] === undefined) return invalid("budgetUsd", "a model key needs a budget in dollars, for its whole life"); |
| 193 | 193 | } else if (raw["budgetUsd"] !== undefined) { |
| 194 | 194 | return invalid("budgetUsd", "a budget is for a model key; send modelKey with it"); |
src/spawn/plan.ts +1 −1
| @@ -223,7 +223,7 @@ export function dryRun(plan: SpawnPlan, source: SpawnSource): DryRun { | ||
| 223 | 223 | ...(plan.keepAccess ? ["your owner access, kept after the child is claimed"] : []), |
| 224 | 224 | ...(plan.budgetUsd === null |
| 225 | 225 | ? [] |
| 226 | : [`its own model key (API-billed, sealed, never shown again), with a $${plan.budgetUsd} budget it cannot raise`]), | |
| 226 | : [`its own model key (sealed, never shown again), with a $${plan.budgetUsd} budget it cannot raise`]), | |
| 227 | 227 | ]; |
| 228 | 228 | const bytes = |
| 229 | 229 | plan.apps.reduce((n, a) => n + a.bytes, 0) + |
src/web/pages/new-forge.ts +1 −1
| @@ -202,7 +202,7 @@ function formView( | ||
| 202 | 202 | required: values.hadKey, |
| 203 | 203 | hint: values.hadKey |
| 204 | 204 | ? "Type the key again to spawn: it is never sent back to your browser." |
| 205 | : "An API-billed key (sk-ant-api… or sk-or-v1-…) with its own spend limit; sealed in the child, never shown again. Never a subscription token.", | |
| 205 | : "An Anthropic key (sk-ant-api…, sk-ant-usr…), a `claude setup-token` token (sk-ant-oat…), or any gateway's key with its base URL below; with its own spend limit, sealed in the child, never shown again.", | |
| 206 | 206 | ...(errors["modelKey"] ? { error: errors["modelKey"] } : {}), |
| 207 | 207 | })} |
| 208 | 208 | ${anon |
src/web/pages/subscriptions.ts +4 −4
| @@ -301,7 +301,7 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 301 | 301 | id="f-token" |
| 302 | 302 | name="token" |
| 303 | 303 | type="password" |
| 304 | placeholder="sk-ant-… or sk-or-v1-…" | |
| 304 | placeholder="sk-ant-… or any provider's key" | |
| 305 | 305 | required |
| 306 | 306 | autocomplete="off" |
| 307 | 307 | spellcheck="false" |
| @@ -327,8 +327,8 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 327 | 327 | <span class="hint" |
| 328 | 328 | >Anthropic-compatible gateway for Claude Code. OpenRouter: |
| 329 | 329 | <code>https://openrouter.ai/api</code> (no <code>/v1</code> — the |
| 330 | SDK adds it). Leave empty for Anthropic itself. With a base URL set, | |
| 331 | non-<code>sk-ant</code> API keys are accepted.</span | |
| 330 | SDK adds it). Leave empty for Anthropic itself; a setup-token | |
| 331 | (sk-ant-oat…) takes none.</span | |
| 332 | 332 | > |
| 333 | 333 | </div> |
| 334 | 334 | <div>${btn("Add", { variant: "primary", size: "sm", icon: "plus" })}</div> |
| @@ -420,7 +420,7 @@ export async function subscriptionAdd(ctx: Ctx): Promise<Response> { | ||
| 420 | 420 | invalid_name: `A name is a word or two, up to ${NAME_MAX} characters.`, |
| 421 | 421 | name_taken: `There is already a subscription called ${name.trim()}.`, |
| 422 | 422 | invalid_token: |
| 423 | "That is not a model token. One from `claude setup-token` begins sk-ant-oat; an Anthropic API key begins sk-ant-api; an OpenRouter key begins sk-or-v1. With an API base URL set, other API keys are accepted too.", | |
| 423 | "That is not a model token: one line, no spaces. One from `claude setup-token` begins sk-ant-oat; anything else is taken as an API key (sk-ant-api…, sk-ant-usr…, sk-or-v1-…, or a gateway's own with its base URL).", | |
| 424 | 424 | invalid_api_base: |
| 425 | 425 | "The API base URL must be https, with no credentials, query, or hash. For OpenRouter use https://openrouter.ai/api (a Claude subscription token cannot use a gateway).", |
| 426 | 426 | }; |
test/new-forge.test.ts +2 −2
| @@ -225,13 +225,13 @@ describe("+ New forge", () => { | ||
| 225 | 225 | const key = await post(admin, [ |
| 226 | 226 | ["name", "kai"], |
| 227 | 227 | ["apps", "alice/bakery"], |
| 228 | ["modelKey", "sk-ant-oat01-a-persons-own-plan-XXXXXXXX"], | |
| 228 | ["modelKey", "sk-ant-api03 pasted with a space"], | |
| 229 | 229 | ["budgetUsd", "5"], |
| 230 | 230 | ["do", "plan"], |
| 231 | 231 | ]); |
| 232 | 232 | expect(key.status).toBe(422); |
| 233 | 233 | expect(await key.text()).toMatch( |
| 234 | /id="f-modelKey-err"[^>]*>[^<]*a subscription token is a person/, | |
| 234 | /id="f-modelKey-err"[^>]*>[^<]*one line with no spaces/, | |
| 235 | 235 | ); |
| 236 | 236 | }); |
| 237 | 237 |
test/spawn-keys.test.ts +31 −6
| @@ -114,18 +114,43 @@ describe("a spawned child's own keys and domain", () => { | ||
| 114 | 114 | await rm(dir, { recursive: true, force: true }); |
| 115 | 115 | }); |
| 116 | 116 | |
| 117 | test("a subscription token never goes to a child, and a key needs a budget", async () => { | |
| 117 | test("a setup-token or a subscription's credits key may go to a child under a budget; a key needs a budget; a token with a space is refused", async () => { | |
| 118 | 118 | const oauth = await post({ |
| 119 | 119 | name: "maria", |
| 120 | 120 | apps: ["alice/bakery"], |
| 121 | dryRun: false, | |
| 121 | dryRun: true, | |
| 122 | 122 | modelKey: OAUTH, |
| 123 | 123 | budgetUsd: 5, |
| 124 | 124 | }); |
| 125 | expect(oauth.status).toBe(422); | |
| 126 | expect(await oauth.text()).toContain( | |
| 127 | "a subscription token is a person's own plan", | |
| 128 | ); | |
| 125 | expect(oauth.status).toBe(200); | |
| 126 | expect(await oauth.json()).toMatchObject({ dryRun: true }); | |
| 127 | const credits = await post({ | |
| 128 | name: "maria", | |
| 129 | apps: ["alice/bakery"], | |
| 130 | dryRun: true, | |
| 131 | modelKey: "sk-ant-usr01-credits-that-come-with-the-plan-CCCCCCCC", | |
| 132 | budgetUsd: 5, | |
| 133 | }); | |
| 134 | expect(credits.status).toBe(200); | |
| 135 | const gatewayForOauth = await post({ | |
| 136 | name: "maria", | |
| 137 | apps: ["alice/bakery"], | |
| 138 | dryRun: true, | |
| 139 | modelKey: OAUTH, | |
| 140 | modelEndpoint: "https://openrouter.ai/api", | |
| 141 | budgetUsd: 5, | |
| 142 | }); | |
| 143 | expect(gatewayForOauth.status).toBe(422); | |
| 144 | expect(await gatewayForOauth.text()).toContain("takes no base URL"); | |
| 145 | const spaced = await post({ | |
| 146 | name: "maria", | |
| 147 | apps: ["alice/bakery"], | |
| 148 | dryRun: true, | |
| 149 | modelKey: "sk-ant-api03 with a space", | |
| 150 | budgetUsd: 5, | |
| 151 | }); | |
| 152 | expect(spaced.status).toBe(422); | |
| 153 | expect(await spaced.text()).toContain("one line with no spaces"); | |
| 129 | 154 | expect( |
| 130 | 155 | ( |
| 131 | 156 | await post({ |
test/subscriptions.test.ts +6 −5
| @@ -39,12 +39,13 @@ const OR_KEY = "sk-or-v1-openrouter-key-for-the-test-aaaaaaaa"; | ||
| 39 | 39 | const GATEWAY = "https://openrouter.ai/api"; |
| 40 | 40 | |
| 41 | 41 | describe("a token's kind", () => { |
| 42 | test("is read from its shape, and anything else is refused", () => { | |
| 42 | test("a setup-token is a plan; any other one-line secret is an API key; spaces, nothing and absurd lengths are refused", () => { | |
| 43 | 43 | expect(kindOfToken(OAT)).toBe("oauth"); |
| 44 | 44 | expect(kindOfToken(KEY)).toBe("api_key"); |
| 45 | 45 | expect(kindOfToken(OR_KEY)).toBe("api_key"); |
| 46 | expect(kindOfToken("hunter2")).toBeUndefined(); | |
| 47 | expect(kindOfToken("hunter2", { apiBase: GATEWAY })).toBe("api_key"); | |
| 46 | expect(kindOfToken("sk-ant-usr01-credits-with-a-subscription")).toBe("api_key"); | |
| 47 | expect(kindOfToken("hunter2")).toBe("api_key"); | |
| 48 | expect(kindOfToken("")).toBeUndefined(); | |
| 48 | 49 | expect(kindOfToken("sk-ant-oat01-with space")).toBeUndefined(); |
| 49 | 50 | expect(kindOfToken(`sk-ant-api03-${"x".repeat(600)}`)).toBeUndefined(); |
| 50 | 51 | }); |
| @@ -93,7 +94,7 @@ describe("the pool", () => { | ||
| 93 | 94 | error: "invalid_name", |
| 94 | 95 | }); |
| 95 | 96 | expect( |
| 96 | addSubscription(app.db, dataDir, { name: "nope", token: "hunter2" }), | |
| 97 | addSubscription(app.db, dataDir, { name: "nope", token: "hunter 2" }), | |
| 97 | 98 | ).toEqual({ ok: false, error: "invalid_token" }); |
| 98 | 99 | expect(JSON.stringify(listSubscriptions(app.db))).not.toContain(OAT); |
| 99 | 100 | const raw = app.db |
| @@ -516,7 +517,7 @@ describe("the pages", () => { | ||
| 516 | 517 | expect(added.status).toBe(303); |
| 517 | 518 | const refused = await post("/settings/subscriptions", { |
| 518 | 519 | name: "nope", |
| 519 | token: "hunter2", | |
| 520 | token: "hunter 2", | |
| 520 | 521 | }); |
| 521 | 522 | expect(refused.status).toBe(303); |
| 522 | 523 | expect(listSubscriptions(app.db).map((s) => s.name)).toEqual(["work"]); |
new above
offline