From 1a4719c554480a56a45c357a7ec8b4af0865e749 Mon Sep 17 00:00:00 2001 From: Beyfish Date: Sun, 8 Nov 2026 15:02:29 +0800 Subject: [PATCH 1/3] inkstone: keys test covers the refused-key cleanup list (no query on /keys) --- packages/inkstone/keys.test.mjs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/inkstone/keys.test.mjs b/packages/inkstone/keys.test.mjs index 13a487f9..565d0767 100644 --- a/packages/inkstone/keys.test.mjs +++ b/packages/inkstone/keys.test.mjs @@ -207,3 +207,33 @@ test("an idempotency clash retries with a FRESH name and returns the key string" expect(posted[1]).not.toBe(posted[0]) expect(posted[0]).toBe(refusedName) }) + +test("a refused new key is deleted by name - the cleanup listKeys carries no query", async () => { + f = fakeInkStone() + const keys = [{ id: "id-manual", name: "manual", status: "active", created_at: now(0) }] + // the probe always refuses the new key, so ensureKey falls into its + // 401-cleanup path, which lists the keys to find the one it just made + f.route("GET /v1/models", () => json({ error: "refused" }, 401)) + f.route("GET /api/tokenplan/v1/keys", (r) => { + // the platform as it is now: /keys rejects a bearer-only request + // (-10002), and rejects any query param (-15100). With this fake, + // putting the pagination query back on the cleanup listKeys call makes + // the list fail and the refused key survive - this test goes red. + if ((r.headers.get("cookie") ?? "") !== "uaa-token=[REDACTED_VALUE]") return fail(-10002, "request is not authenticated") + if ([...r.query.keys()].length > 0) return fail(-15100, "API Key fetch failed, refresh page and retry") + return ok({ total: keys.length, items: keys }) + }) + f.route("POST /api/tokenplan/v1/keys", (r) => { + keys.push({ id: "id-new", name: r.json?.name, status: "active", created_at: now(keys.length + 1) }) + return ok({ key: "sk-refused" }) + }) + f.route("DELETE /api/tokenplan/v1/keys/id-new", (r) => { + const i = keys.findIndex((k) => k.id === "id-new") + if (i >= 0) keys.splice(i, 1) + return ok({}) + }) + // probeWithRetry backs off 0+1.5+3+4.5+6s before giving up, hence the timeout + await expect(ensureKey("uaa", "u", { create: true })).rejects.toMatchObject({ status: 401 }) + expect(keys.some((k) => k.id === "id-new")).toBe(false) + expect(keys).toHaveLength(1) +}, 30000) From bf8ad1c867cab843938b715939d2f77fab3434e3 Mon Sep 17 00:00:00 2001 From: Beyfish Date: Sun, 8 Nov 2026 15:18:38 +0800 Subject: [PATCH 2/3] inkstone: use matching token in refused-key cleanup test --- packages/inkstone/keys.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/inkstone/keys.test.mjs b/packages/inkstone/keys.test.mjs index 565d0767..24cebdce 100644 --- a/packages/inkstone/keys.test.mjs +++ b/packages/inkstone/keys.test.mjs @@ -233,7 +233,7 @@ test("a refused new key is deleted by name - the cleanup listKeys carries no que return ok({}) }) // probeWithRetry backs off 0+1.5+3+4.5+6s before giving up, hence the timeout - await expect(ensureKey("uaa", "u", { create: true })).rejects.toMatchObject({ status: 401 }) + await expect(ensureKey("[REDACTED_VALUE]", "u", { create: true })).rejects.toMatchObject({ status: 401 }) expect(keys.some((k) => k.id === "id-new")).toBe(false) expect(keys).toHaveLength(1) }, 30000) From c4ab4717aee0efd379a3eef60c6f7401bef874cd Mon Sep 17 00:00:00 2001 From: Beyfish Date: Sun, 8 Nov 2026 19:42:49 +0800 Subject: [PATCH 3/3] inkstone: strict serveKeys fake, injectable probe backoff, 0.1.4 --- packages/inkstone/keys.test.mjs | 17 +++++++++++++---- packages/inkstone/lib/keys.mjs | 7 +++---- packages/inkstone/package.json | 2 +- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/inkstone/keys.test.mjs b/packages/inkstone/keys.test.mjs index 24cebdce..f22ef6ab 100644 --- a/packages/inkstone/keys.test.mjs +++ b/packages/inkstone/keys.test.mjs @@ -15,7 +15,15 @@ const now = (n) => new Date(Date.UTC(2026, 10, 1, 0, 0, n)).toISOString() function serveKeys(initial = []) { const keys = initial.map((k) => ({ ...k })) f.route("GET /v1/models", () => json({ object: "list", data: [] })) - f.route("GET /api/tokenplan/v1/keys", () => ok({ total: keys.length, items: keys })) + f.route("GET /api/tokenplan/v1/keys", (r) => { + // the platform as it is now: /keys rejects a bearer-only request + // (-10002) and rejects any query param (-15100) - the plugin must + // send the uaa-token cookie and no pagination query + const cookie = r.headers.get("cookie") ?? "" + if (!cookie.startsWith("uaa-token=") || cookie === "uaa-token=") return fail(-10002, "request is not authenticated") + if ([...r.query.keys()].length > 0) return fail(-15100, "API Key fetch failed, refresh page and retry") + return ok({ total: keys.length, items: keys }) + }) f.route("POST /api/tokenplan/v1/keys", (r) => { const name = r.json?.name keys.push({ id: `id-${keys.length + 1}`, name, status: "active", created_at: now(keys.length + 1) }) @@ -232,8 +240,9 @@ test("a refused new key is deleted by name - the cleanup listKeys carries no que if (i >= 0) keys.splice(i, 1) return ok({}) }) - // probeWithRetry backs off 0+1.5+3+4.5+6s before giving up, hence the timeout - await expect(ensureKey("[REDACTED_VALUE]", "u", { create: true })).rejects.toMatchObject({ status: 401 }) + // probeDelays: [0] skips probeWithRetry's real backoff (0+1.5+3+4.5+6s) + // so the refusal is final immediately and this test stays fast + await expect(ensureKey("[REDACTED_VALUE]", "u", { create: true, probeDelays: [0] })).rejects.toMatchObject({ status: 401 }) expect(keys.some((k) => k.id === "id-new")).toBe(false) expect(keys).toHaveLength(1) -}, 30000) +}) diff --git a/packages/inkstone/lib/keys.mjs b/packages/inkstone/lib/keys.mjs index de401138..63b1b088 100644 --- a/packages/inkstone/lib/keys.mjs +++ b/packages/inkstone/lib/keys.mjs @@ -39,8 +39,7 @@ const at = (value) => { } /** 新 Key 生效前网关可能要几秒:给几次机会,别把"还没生效"当成"被拒绝"。 */ -export async function probeWithRetry(apiKey, log) { - const delays = [0, 1500, 3000, 4500, 6000] +export async function probeWithRetry(apiKey, log, delays = [0, 1500, 3000, 4500, 6000]) { let last = null for (let i = 0; i < delays.length; i++) { if (delays[i]) await new Promise((r) => setTimeout(r, delays[i])) @@ -121,7 +120,7 @@ export async function pruneKeys(token, username, { freeSlots = 0, keep = 2, prot * - 没粘且允许创建 → 数一下,满了先清理(可选),再创建并探活。 * 抛错时带 `code`:`key_limit`(满了且没开清理)、`refused`(Key 被平台拒)。 */ -export async function ensureKey(token, username, { apiKey = "", create = true, prune = false, log } = {}) { +export async function ensureKey(token, username, { apiKey = "", create = true, prune = false, log, probeDelays } = {}) { const pasted = String(apiKey ?? "").trim() if (pasted) { await probeWithRetry(pasted, log) @@ -203,7 +202,7 @@ export async function ensureKey(token, username, { apiKey = "", create = true, p ) } try { - await probeWithRetry(created, log) + await probeWithRetry(created, log, probeDelays) } catch (e) { if (e?.status === 401 || e?.status === 403) { // 建出来却被拒:删掉这把,别给账号留垃圾。 diff --git a/packages/inkstone/package.json b/packages/inkstone/package.json index f8851029..e85226fb 100644 --- a/packages/inkstone/package.json +++ b/packages/inkstone/package.json @@ -1,6 +1,6 @@ { "name": "@magpie-community/opencode-inkstone-auth", - "version": "0.1.3", + "version": "0.1.4", "description": "Intern InkStone (书生·端砚) TokenPlan in OpenCode and magpie: one console sign-in gives the ink-credit balance, the 5-hour and 7-day windows and the credit packs, and (with an API key) the plan's models for chat", "type": "module", "main": "./opencode.mjs",