Public: anyone can read this; the crew can change it.
A model key is accepted as pasted: land steward/model-key-accept (e33b1de0), and MCP subscription_add refuses only a blank key #7720
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7628-a-model-key-is-accepted-as-pasted-land-s → main 6 commits fork 3 threads · 3 unresolved
11 files
changed +84
−25
README.md +8
| @@ -422,6 +422,14 @@ pan. The header shows its pixel size, file size and format; **Source**, | ||
| 422 | 422 | binary picture where the terminal can, and otherwise names the file and its |
| 423 | 423 | size; SVG there shows as its source text. |
| 424 | 424 | |
| 425 | A model key is taken as pasted, wherever one is given (Settings → | |
| 426 | Subscriptions, MCP `subscription_add`, the terminal's Crew subscriptions | |
| 427 | screen `a`, a new forge's | |
| 428 | `modelKey`): every space, line break, non-breaking or zero-width space and a | |
| 429 | leading `Bearer ` are dropped (`normalizeToken`, `src/core/subscriptions.ts`), | |
| 430 | and what is left is sealed if it is not empty and at most 4096 characters. Its | |
| 431 | shape is not judged; the provider answers a wrong key on the first call. | |
| 432 | ||
| 425 | 433 | The running list of what the forge does, one bullet each, lives in |
| 426 | 434 | [](docs/STATUS.md). |
| 427 | 435 |
docs/APPS.md +2 −1
| @@ -1374,7 +1374,8 @@ or until the next one. | ||
| 1374 | 1374 | One token in the environment is one account for the whole forge. An admin |
| 1375 | 1375 | with more than one — a personal plan and a work plan — adds each under |
| 1376 | 1376 | Settings → Subscriptions (a token from `claude setup-token`, or an API key; |
| 1377 | sealed under the forge's key, never shown again) and grants it: | |
| 1377 | whitespace and a `Bearer ` prefix from the paste are dropped; sealed under the | |
| 1378 | forge's key, never shown again) and grants it: | |
| 1378 | 1379 | |
| 1379 | 1380 | - on a repository's Settings → Crew, to everyone on the crew or to one agent; |
| 1380 | 1381 | - on an organization's page, for every repository under it. |
src/actions/spawn.ts +3
| @@ -45,6 +45,7 @@ import { | ||
| 45 | 45 | addSubscription, |
| 46 | 46 | getSubscriptionByName, |
| 47 | 47 | lockBudget, |
| 48 | normalizeToken, | |
| 48 | 49 | setGrants, |
| 49 | 50 | subscriptionAad, |
| 50 | 51 | } from "../core/subscriptions.ts"; |
| @@ -226,6 +227,8 @@ async function make( | ||
| 226 | 227 | plan: SpawnPlan, |
| 227 | 228 | deps: SpawnDeps, |
| 228 | 229 | ): Promise<Result<Spawned, SpawnError>> { |
| 230 | // The key as the person meant it: whitespace and a `Bearer ` prefix from the paste dropped. | |
| 231 | if (deps.modelKey !== undefined) deps = { ...deps, modelKey: normalizeToken(deps.modelKey) }; | |
| 229 | 232 | const planHash = sha256(JSON.stringify(plan)); |
| 230 | 233 | let ledger = await readLedger(app, plan.name); |
| 231 | 234 | if (ledger) { |
src/core/subscriptions.ts +17 −6
| @@ -65,7 +65,7 @@ interface Row { | ||
| 65 | 65 | } |
| 66 | 66 | |
| 67 | 67 | export const NAME_MAX = 40; |
| 68 | const TOKEN_MAX = 512; | |
| 68 | const TOKEN_MAX = 4096; | |
| 69 | 69 | |
| 70 | 70 | /** The forge's own token, from its environment: a row with no sealed token. */ |
| 71 | 71 | export const PLATFORM = "platform"; |
| @@ -199,19 +199,29 @@ export const envNameOf = (kind: SubscriptionKind): string => | ||
| 199 | 199 | /** Longest accepted Anthropic-compatible base URL. */ |
| 200 | 200 | export const API_BASE_MAX = 200; |
| 201 | 201 | |
| 202 | /** | |
| 203 | * A pasted key as the owner meant it: every whitespace character (a wrapped line, a trailing newline, a | |
| 204 | * non-breaking space from a web page) and a leading `Bearer ` are dropped. Keys never contain whitespace, | |
| 205 | * and a gateway may use any shape at all, so nothing else is judged. | |
| 206 | */ | |
| 207 | export function normalizeToken(raw: string): string { | |
| 208 | return raw.replace(/^\s*bearer\s+/i, "").replace(/[\s\u200b-\u200d\ufeff]+/g, ""); | |
| 209 | } | |
| 210 | ||
| 202 | 211 | /** |
| 203 | 212 | * What kind of token this is. A token from `claude setup-token` begins |
| 204 | 213 | * `sk-ant-oat…` and is a person's own plan: it goes under |
| 205 | 214 | * `CLAUDE_CODE_OAUTH_TOKEN` and never takes a base URL. Anything else that |
| 206 | * could be a key — one line, no spaces, not absurdly long — is an API key: | |
| 215 | * could be a key — not empty once normalized, not absurdly long — is an API key: | |
| 207 | 216 | * Anthropic's `sk-ant-api…`, the `sk-ant-usr…` keys that come with a |
| 208 | 217 | * subscription's API credits, OpenRouter's `sk-or-v1-…`, or whatever shape |
| 209 | 218 | * a gateway hands out with its base URL. The forge keeps no list of shapes; |
| 210 | 219 | * the provider answers a bad key on the first call, and the run says so. |
| 220 | * Takes a token already through `normalizeToken`, so the kind and the stored | |
| 221 | * value are read from the same string; whitespace left in is refused. | |
| 211 | 222 | */ |
| 212 | 223 | export function kindOfToken(token: string): SubscriptionKind | undefined { |
| 213 | if (token === "" || token.length > TOKEN_MAX || /\s|\0/.test(token)) | |
| 214 | return undefined; | |
| 224 | if (token === "" || token.length > TOKEN_MAX || /\s|\0/.test(token)) return undefined; | |
| 215 | 225 | if (/^sk-ant-oat\d*-/.test(token)) return "oauth"; |
| 216 | 226 | return "api_key"; |
| 217 | 227 | } |
| @@ -312,7 +322,8 @@ export function addSubscription( | ||
| 312 | 322 | if (parsed === undefined) return err("invalid_api_base"); |
| 313 | 323 | apiBase = parsed; |
| 314 | 324 | } |
outdated
kindOfToken normalises again; normalise once. |
||
| 315 | const kind = kindOfToken(input.token); | |
| 325 | const token = normalizeToken(input.token); | |
| 326 | const kind = kindOfToken(token); | |
| 316 | 327 | if (kind === undefined) return err("invalid_token"); |
| 317 | 328 | // A Claude subscription token is Anthropic's own plan — never a gateway. |
| 318 | 329 | if (kind === "oauth" && apiBase !== null) return err("invalid_api_base"); |
| @@ -325,7 +336,7 @@ export function addSubscription( | ||
| 325 | 336 | subscriptionId, |
| 326 | 337 | name, |
| 327 | 338 | kind, |
| 328 | seal(dataDir, input.token, aad(subscriptionId)), | |
| 339 | seal(dataDir, token, aad(subscriptionId)), | |
| 329 | 340 | apiBase, |
| 330 | 341 | input.createdBy ?? null, |
| 331 | 342 | ); |
src/core/subscriptions_words.ts +1 −1
| @@ -14,7 +14,7 @@ export const SUBSCRIPTION_WORDS = { | ||
| 14 | 14 | name_taken: (name: string): string => |
| 15 | 15 | `There is already a subscription called ${name.trim()}.`, |
| 16 | 16 | invalid_token: |
| 17 | "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).", | |
| 17 | "That token is empty or over 4096 characters once spaces, line breaks and a `Bearer ` prefix are dropped. 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).", | |
| 18 | 18 | invalid_api_base: |
| 19 | 19 | "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).", |
| 20 | 20 | headroom_range: `Headroom is a whole percent from 0 to ${HEADROOM_MAX}.`, |
src/spawn/door.ts +3 −3
| @@ -12,7 +12,7 @@ import type { App } from "../app.ts"; | ||
| 12 | 12 | import { maxChildren } from "../core/capacity.ts"; |
| 13 | 13 | import { getAgentByName } from "../core/agents.ts"; |
| 14 | 14 | import { canRead, getRepo } from "../core/repos.ts"; |
| 15 | import { kindOfToken, parseApiBase } from "../core/subscriptions.ts"; | |
| 15 | import { kindOfToken, normalizeToken, parseApiBase } from "../core/subscriptions.ts"; | |
| 16 | 16 | import type { User } from "../core/users.ts"; |
| 17 | 17 | import { repoDir } from "../git/repos.ts"; |
| 18 | 18 | import type { Ctx } from "../http/context.ts"; |
| @@ -215,11 +215,11 @@ export async function spawnDoor( | ||
| 215 | 215 | } |
| 216 | 216 | const modelKey = raw["modelKey"]; |
| 217 | 217 | if (modelKey !== undefined) { |
| 218 | const kind = typeof modelKey === "string" ? kindOfToken(modelKey) : undefined; | |
| 218 | const kind = typeof modelKey === "string" ? kindOfToken(normalizeToken(modelKey)) : undefined; | |
| 219 | 219 | if (kind === undefined) |
| 220 | 220 | return invalid( |
| 221 | 221 | "modelKey", |
| 222 | "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", | |
| 222 | "is empty or over 4096 characters once spaces, line breaks and a `Bearer ` prefix are dropped: paste an Anthropic key, a `claude setup-token` token, or your gateway's key (with its base URL)", | |
| 223 | 223 | ); |
| 224 | 224 | if (kind === "oauth" && modelEndpoint !== undefined) |
| 225 | 225 | return invalid("modelEndpoint", "a subscription token (sk-ant-oat…) talks to Anthropic itself and takes no base URL"); |
test/mcp-subscription-add.test.ts +2 −1
| @@ -88,9 +88,10 @@ describe("MCP subscription_add", () => { | ||
| 88 | 88 | }); |
| 89 | 89 | |
| 90 | 90 | test("a bad token is refused with the page's words", async () => { |
| 91 | const bad = await tool(alice, { name: "Other", token: "not a token" }); | |
| 91 | const bad = await tool(alice, { name: "Other", token: " \u200b " }); | |
| 92 | 92 | expect(bad.error).toContain(addRefusal("invalid_token", "Other")); |
| 93 | 93 | expect(bad.error).toContain("claude setup-token"); |
| 94 | expect(bad.error).not.toContain("no spaces"); | |
| 94 | 95 | const base = await tool(alice, { name: "Other", token: TOKEN, apiBase: "https://x.example" }); |
| 95 | 96 | expect(base.error).toContain(addRefusal("invalid_api_base", "Other")); |
| 96 | 97 | }); |
test/new-forge.test.ts +2 −2
| @@ -229,13 +229,13 @@ describe("+ New forge", () => { | ||
| 229 | 229 | const key = await post(admin, [ |
| 230 | 230 | ["name", "kai"], |
| 231 | 231 | ["apps", "alice/bakery"], |
| 232 | ["modelKey", "sk-ant-api03 pasted with a space"], | |
| 232 | ["modelKey", "\u200b"], | |
| 233 | 233 | ["budgetUsd", "5"], |
| 234 | 234 | ["do", "plan"], |
| 235 | 235 | ]); |
| 236 | 236 | expect(key.status).toBe(422); |
| 237 | 237 | expect(await key.text()).toMatch( |
| 238 | /id="f-modelKey-err"[^>]*>[^<]*one line with no spaces/, | |
| 238 | /id="f-modelKey-err"[^>]*>[^<]*is empty/, | |
| 239 | 239 | ); |
| 240 | 240 | }); |
| 241 | 241 |
test/spawn-keys.test.ts +8 −4
| @@ -116,7 +116,7 @@ describe("a spawned child's own keys and domain", () => { | ||
| 116 | 116 | await rm(dir, { recursive: true, force: true }); |
| 117 | 117 | }); |
| 118 | 118 | |
| 119 | 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 () => { | |
| 119 | test("a setup-token or a subscription's credits key may go to a child under a budget; a key needs a budget; an empty key is refused", async () => { | |
| 120 | 120 | const oauth = await post({ |
| 121 | 121 | name: "maria", |
| 122 | 122 | apps: ["alice/bakery"], |
| @@ -148,11 +148,11 @@ describe("a spawned child's own keys and domain", () => { | ||
| 148 | 148 | name: "maria", |
| 149 | 149 | apps: ["alice/bakery"], |
| 150 | 150 | dryRun: true, |
| 151 | modelKey: "sk-ant-api03 with a space", | |
| 151 | modelKey: " \u200b ", | |
| 152 | 152 | budgetUsd: 5, |
| 153 | 153 | }); |
| 154 | 154 | expect(spaced.status).toBe(422); |
| 155 | expect(await spaced.text()).toContain("one line with no spaces"); | |
| 155 | expect(await spaced.text()).toContain("is empty"); | |
| 156 | 156 | expect( |
| 157 | 157 | ( |
| 158 | 158 | await post({ |
| @@ -245,7 +245,8 @@ describe("a spawned child's own keys and domain", () => { | ||
| 245 | 245 | name: "maria", |
| 246 | 246 | apps: ["alice/bakery"], |
| 247 | 247 | dryRun: false, |
| 248 | modelKey: API_KEY, | |
| 248 | // Pasted with a Bearer prefix and a trailing newline: the child gets the key itself. | |
| 249 | modelKey: ` Bearer ${API_KEY}\n`, | |
| 249 | 250 | budgetUsd: 5, |
| 250 | 251 | }, |
| 251 | 252 | { "idempotency-key": "maria-1" }, |
| @@ -320,6 +321,9 @@ describe("a spawned child's own keys and domain", () => { | ||
| 320 | 321 | const same = await post({ name: "maria", apps: ["alice/bakery"], dryRun: false, modelKey: API_KEY, budgetUsd: 5 }); |
| 321 | 322 | expect(same.status).toBe(200); |
| 322 | 323 | expect(((await same.json()) as { existed: boolean }).existed).toBe(true); |
| 324 | // The same key pasted untidily is the same key: compared cleaned, not refused as another. | |
| 325 | const padded = await post({ name: "maria", apps: ["alice/bakery"], dryRun: false, modelKey: `\tbearer ${API_KEY} \n`, budgetUsd: 5 }); | |
| 326 | expect(padded.status).toBe(200); | |
| 323 | 327 | }); |
| 324 | 328 | |
| 325 | 329 | test("once the starter budget is spent, a run in the child gets no account: an assigned one, on any repository of its org", async () => { |
test/subscriptions.test.ts +12 −5
| @@ -21,6 +21,7 @@ import { | ||
| 21 | 21 | kindOfToken, |
outdated
One import name per line, in alphabetical order like the rest. |
||
| 22 | 22 | listGrants, |
| 23 | 23 | listSubscriptions, |
| 24 | normalizeToken, | |
| 24 | 25 | parseApiBase, |
| 25 | 26 | pickSubscription, |
| 26 | 27 | restSubscription, |
| @@ -41,15 +42,21 @@ const OR_KEY = "sk-or-v1-openrouter-key-for-the-test-aaaaaaaa"; | ||
| 41 | 42 | const GATEWAY = "https://openrouter.ai/api"; |
| 42 | 43 | |
| 43 | 44 | describe("a token's kind", () => { |
| 44 | test("a setup-token is a plan; any other one-line secret is an API key; spaces, nothing and absurd lengths are refused", () => { | |
| 45 | test("a setup-token is a plan; any other secret is an API key; whitespace and a Bearer prefix from the paste are dropped; nothing and absurd lengths are refused", () => { | |
| 45 | 46 | expect(kindOfToken(OAT)).toBe("oauth"); |
| 46 | 47 | expect(kindOfToken(KEY)).toBe("api_key"); |
| 47 | 48 | expect(kindOfToken(OR_KEY)).toBe("api_key"); |
| 48 | 49 | expect(kindOfToken("sk-ant-usr01-credits-with-a-subscription")).toBe("api_key"); |
| 49 | 50 | expect(kindOfToken("hunter2")).toBe("api_key"); |
| 50 | 51 | expect(kindOfToken("")).toBeUndefined(); |
| 51 | expect(kindOfToken("sk-ant-oat01-with space")).toBeUndefined(); | |
| 52 | expect(kindOfToken(`sk-ant-api03-${"x".repeat(600)}`)).toBeUndefined(); | |
| 52 | expect(kindOfToken(normalizeToken("sk-ant-oat01-wrapped\nacross lines"))).toBe("oauth"); | |
| 53 | expect(normalizeToken(`${KEY}\n`)).toBe(KEY); | |
| 54 | expect(normalizeToken(" Bearer my-own-gateway.key/v2:abc")).toBe("my-own-gateway.key/v2:abc"); | |
| 55 | expect(normalizeToken(" sk-ant-api03-a\u00a0b\u200bc \n")).toBe("sk-ant-api03-abc"); | |
| 56 | expect(kindOfToken(normalizeToken(" \n\u200b "))).toBeUndefined(); | |
| 57 | // Only a cleaned token is judged: whitespace left in is refused, never quietly kept. | |
| 58 | expect(kindOfToken(`${KEY}\n`)).toBeUndefined(); | |
| 59 | expect(kindOfToken(`sk-ant-api03-${"x".repeat(5000)}`)).toBeUndefined(); | |
| 53 | 60 | }); |
| 54 | 61 | |
| 55 | 62 | test("parses an Anthropic-compatible base URL, stripping /v1", () => { |
| @@ -96,7 +103,7 @@ describe("the pool", () => { | ||
| 96 | 103 | error: "invalid_name", |
| 97 | 104 | }); |
| 98 | 105 | expect( |
| 99 | addSubscription(app.db, dataDir, { name: "nope", token: "hunter 2" }), | |
| 106 | addSubscription(app.db, dataDir, { name: "nope", token: " \n " }), | |
| 100 | 107 | ).toEqual({ ok: false, error: "invalid_token" }); |
| 101 | 108 | expect(JSON.stringify(listSubscriptions(app.db))).not.toContain(OAT); |
| 102 | 109 | const raw = app.db |
| @@ -537,7 +544,7 @@ describe("the pages", () => { | ||
| 537 | 544 | expect(added.status).toBe(303); |
| 538 | 545 | const refused = await post("/settings/subscriptions", { |
| 539 | 546 | name: "nope", |
| 540 | token: "hunter 2", | |
| 547 | token: "\u200b", | |
| 541 | 548 | }); |
| 542 | 549 | expect(refused.status).toBe(303); |
| 543 | 550 | expect(listSubscriptions(app.db).map((s) => s.name)).toEqual(["work"]); |
test/tui-subscriptions-add.test.ts +26 −2
| @@ -7,7 +7,8 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 7 | 7 | import { tmpdir } from "node:os"; |
| 8 | 8 | import { join } from "node:path"; |
| 9 | 9 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 10 | import { addSubscription, deleteSubscription, listSubscriptions } from "../src/core/subscriptions.ts"; | |
| 10 | import { addSubscription, deleteSubscription, listSubscriptions, subscriptionAad } from "../src/core/subscriptions.ts"; | |
| 11 | import { open as unseal } from "../src/kernel/secret.ts"; | |
| 11 | 12 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 12 | 13 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 13 | 14 | import { subscriptionsScreen } from "../src/tui/screens/subscriptions.ts"; |
| @@ -123,7 +124,7 @@ describe("adding and removing a subscription in the terminal", () => { | ||
| 123 | 124 | await h.press("enter"); |
| 124 | 125 | await h.until("Token for empty"); |
| 125 | 126 | await h.press("enter"); |
| 126 | await h.until("That is not a model token"); | |
| 127 | await h.until("That token is empty or over 4096 characters"); | |
| 127 | 128 | expect(listSubscriptions(forge.db)).toHaveLength(1); |
| 128 | 129 | await h.press("esc", "a"); |
| 129 | 130 | await h.type("gamma"); |
| @@ -152,6 +153,29 @@ describe("adding and removing a subscription in the terminal", () => { | ||
| 152 | 153 | h.stop(); |
| 153 | 154 | }); |
| 154 | 155 | |
| 156 | test("a key pasted with a Bearer prefix and a space is sealed cleaned, as on the page and in MCP", async () => { | |
| 157 | const h = boot("alice"); | |
| 158 | await open(h); | |
| 159 | await h.press("a"); | |
| 160 | await h.type("pasted"); | |
| 161 | await h.press("enter"); | |
| 162 | await h.until("Token for pasted"); | |
| 163 | await h.type("Bearer sk-ant-oat01-secret pasted"); | |
| 164 | await h.press("enter"); | |
| 165 | await h.until("API base for pasted"); | |
| 166 | await h.press("enter"); | |
| 167 | await h.until("pasted is in the pool."); | |
| 168 | const [sub] = listSubscriptions(forge.db).filter((s) => s.name === "pasted"); | |
| 169 | // Judged after cleaning: with the prefix kept it would be taken as an API key. | |
| 170 | expect(sub!.kind).toBe("oauth"); | |
| 171 | const row = forge.db | |
| 172 | .query<{ token_enc: string }, [string]>("SELECT token_enc FROM subscription WHERE id = ?") | |
| 173 | .get(sub!.id)!; | |
| 174 | expect(unseal(dataDir, row.token_enc, subscriptionAad(sub!.id))).toEqual({ ok: true, value: "sk-ant-oat01-secretpasted" }); | |
| 175 | deleteSubscription(forge.db, sub!.id); | |
| 176 | h.stop(); | |
| 177 | }); | |
| 178 | ||
| 155 | 179 | test("an agent that is an admin is offered nothing and changes nothing", async () => { |
| 156 | 180 | forge.db.query("UPDATE user SET is_agent = 1 WHERE username = 'alice'").run(); |
| 157 | 181 | const h = boot("alice"); |
new above
offline
No test covers this line. Add one that would fail without it.