From 5e3c99c43311c6237ff6efa36169b1ede37c46e0 Mon Sep 17 00:00:00 2001 From: yunwang2442-creator Date: Sun, 8 Nov 2026 01:09:24 +0800 Subject: [PATCH 1/7] inkstone: the review follow-ups close #839 and #840 - the wiring test's key-list step is driven through ensureKey with a fake that refuses a paginated /keys (B05: the query going back now turns it red), chatFetch parses the body once and carries no dead key or empty catch, the fake browser builds under a pid-suffixed temp name and sweeps its sibling hashes, the #825 test covers the reuse method, the host-shape copy drops the status the host does not take, the soft-refusal retry checks the key list before it builds a second key, and off Windows the sign-in watches the profile's cookie store for the session so the window closes itself (#840) --- packages/inkstone/README.md | 16 +++-- packages/inkstone/api.test.mjs | 50 ++++++++++++-- packages/inkstone/cookie-probe.test.mjs | 90 +++++++++++++++++++++++++ packages/inkstone/fake-browser.test.mjs | 31 ++++++--- packages/inkstone/index.mjs | 22 +++--- packages/inkstone/lib/browser.mjs | 43 ++++++++++-- packages/inkstone/lib/cookieProbe.mjs | 50 ++++++++++++++ packages/inkstone/lib/keys.mjs | 29 ++++++++ packages/inkstone/lib/preflight.mjs | 35 +++++----- packages/inkstone/preflight.test.mjs | 2 +- 10 files changed, 310 insertions(+), 58 deletions(-) create mode 100644 packages/inkstone/cookie-probe.test.mjs create mode 100644 packages/inkstone/lib/cookieProbe.mjs diff --git a/packages/inkstone/README.md b/packages/inkstone/README.md index a1c3d55c..3854b07a 100644 --- a/packages/inkstone/README.md +++ b/packages/inkstone/README.md @@ -27,7 +27,7 @@ API Key 是可选的:不填,账号仍显示额度,只是不服务模型。 | 方式 | 做什么 | 产出 | |---|---|---| -| **浏览器登录(推荐)** | **零提示**:点一下就直接开窗。用**插件自己的专用浏览器 profile** 打开控制台,你在里面登录(密码 / 短信 / 扫码),登录完成后自动创建 `ink-*` API Key。**Windows** 上插件用窗口标题判断登录完成、**自己关窗**;**macOS / Linux** 读不了窗口标题,登录完成后请**自己关掉窗口**,插件接着自动收尾。之后的续期/重登都静默完成 | 额度 + 模型 | +| **浏览器登录(推荐)** | **零提示**:点一下就直接开窗。用**插件自己的专用浏览器 profile** 打开控制台,你在里面登录(密码 / 短信 / 扫码),登录完成后自动创建 `ink-*` API Key。**所有平台插件都自己判断登录完成并自动关窗**(Windows 读窗口标题;macOS / Linux 轮询专用 profile 的 cookie 存储,看到控制台会话就关窗——通常不需要你手动关闭浏览器,开着也行)。之后的续期/重登都静默完成 | 额度 + 模型 | | **浏览器登录 + 复用已有 Key** | 同上,但开窗前会先问一句要不要粘贴现有 Key(留空则新建) | 额度 + 模型 | | **仅 API Key** | 只粘贴 Key;`GET /v1/models` 探活 | 只有模型 | | **API Key + 控制台 token** | 粘贴 Key,再粘贴 `uaa-token=…` 那一项(DevTools → Application → Cookies,复制整条 `uaa-token=…` 即可,插件只取值) | 额度 + 模型 | @@ -57,8 +57,10 @@ API Key 是可选的:不填,账号仍显示额度,只是不服务模型。 决策模型 `intern-decision-4b` 只支持 SystemOne,不是聊天接口,不列入。 **不做思考强度档位**:平台明确不支持通过请求参数开启/关闭/调整思考强度 -(实测 `reasoning_effort`、`reasoning.effort`、`thinking.type` 都被静默忽略), -插件会把这些字段从请求里剥掉,免得别的入口带来的参数造成干扰。 +(实测依据:2026-11 在免费三模型 `Agents-A1 / intern-s2 / Sage-A1.5` 上, +`reasoning_effort: "high"`、`reasoning.effort: "high"`、 +`thinking.type: "enabled"` 全部返回 200 且回复内容与不带时相同——参数被 +静默忽略),插件会把这些字段从请求里剥掉,免得别的入口带来的参数造成干扰。 **免费标记与倍率(magpie 专有展示字段,OpenCode 忽略):** @@ -185,8 +187,9 @@ Unofficial. One console sign-in gives both the ink credits (balance, 5-hour and Four sign-in methods produce the same `intern-inkstone` account: a **zero-prompt browser sign-in** (one click opens the plugin's own browser profile; the plugin -closes the window itself on Windows, or reads the session after you quit it on -macOS/Linux, then creates an `ink-*` API key), the same browser sign-in with an +watches for the console session — the window title on Windows, the profile's +cookie store elsewhere — and closes the window itself, then creates an `ink-*` +API key), the same browser sign-in with an optional pasted key to reuse, API key only (models, no credit card), and API key + console token (pasted `uaa-token=…`, models and card). The sign-in window carries no debugging port, so the console's captcha sees an ordinary browser. @@ -215,7 +218,8 @@ would misread (its "cumulative image bytes exceeded …" hits magpie's quota wor and would rest a healthy account), are translated to the same shape; the plan's own wording never reaches the response body. Inline images missing a `detail` get `detail: "auto"`, and the reasoning fields the plan silently ignores are -stripped from the body. +stripped from the body (checked live: `reasoning_effort`, `reasoning.effort` +and `thinking.type` all answer 200 with identical output on the free models). `intern-decision-4b` is a SystemOne decision model, not a chat candidate. The plan takes no reasoning-effort controls (it silently ignores them), so the diff --git a/packages/inkstone/api.test.mjs b/packages/inkstone/api.test.mjs index 6c88c44b..cd040db5 100644 --- a/packages/inkstone/api.test.mjs +++ b/packages/inkstone/api.test.mjs @@ -162,6 +162,13 @@ test("with no Chromium browser the browser sign-in fails at authorize and names // the names it gives are the other methods' own labels expect(p.auth.methods[2].label).toStartWith("API key only") expect(p.auth.methods[3].label).toStartWith("API key + console token") + // the reuse-a-key method goes through the same browserAuthorize path: + // it fails at authorize too, never with a window it cannot open + const reuse = p.auth.methods[1].authorize({}) + expect(reuse.type).toBe("failed") + expect(reuse.opened).toBeUndefined() + expect(reuse.callback).toBeUndefined() + expect(reuse.error).toContain("Microsoft Edge, Google Chrome or Chromium") if (process.platform === "darwin") expect(result.error).toContain("Safari can't be used") else expect(result.error).not.toContain("Safari") } finally { @@ -275,15 +282,24 @@ test("the fetch wiring is the request path — each piece breaks it in a way the expect("reasoning" in received).toBe(false) expect(received.messages[0].content[1].image_url.detail).toBe("auto") - // ④ /keys 的 cookie + 无分页 query(真机修复的回归防线——query 加回去会红) + // ④ /keys 的 cookie + 无分页 query(#839 的回归防线)。走 ensureKey 驱动 + // 而不是直接调 protocol.listKeys:query 由 lib/keys.mjs 的调用点传入, + // 直调永远看不到它(作者 B05:query 加回 keys.mjs 时 101/101 依然绿)。 + // fake 对带 page/page_size 的 /keys 回 -15100——keys.mjs 的任何调用点把 + // query 加回来,这里立刻红。 const seen = { query: "", cookie: "" } f.route("GET /api/tokenplan/v1/keys", (r) => { seen.query = r.url seen.cookie = r.headers.get("cookie") ?? "" + if (/[?&]page(size)?=/.test(r.url)) return json({ code: -15100, msg: "API Key fetch failed, refresh page and retry" }) return json({ code: 0, data: { total: 0, items: [] } }) }) - const { listKeys } = await import("./lib/protocol.mjs") - await listKeys("fake-token") + const { ensureKey } = await import("./lib/keys.mjs") + // keyCount is ensureKey's first call on the create path: the list request + // this drives is the one whose query the fix removed + f.route("POST /api/tokenplan/v1/keys", () => ok({ key: "sk-wiring" })) + const wiringKey = await ensureKey("fake-token", "acct", { create: true, prune: false, log: () => {} }) + expect(wiringKey).toBe("sk-wiring") expect(seen.cookie).toContain("uaa-token=fake-token") expect(seen.query).not.toContain("page=") expect(seen.query).not.toContain("page_size=") @@ -295,8 +311,7 @@ test("the fetch wiring is the request path — each piece breaks it in a way the if (attempts.length === 1) return json({ code: -15100, msg: "API Key fetch failed, refresh page and retry" }) return ok({ key: "sk-retried" }) }) - const { ensureKey } = await import("./lib/keys.mjs") - const key = await ensureKey("fake-token", "acct", { create: true, log: () => {} }) + const key = await ensureKey("fake-token", "acct2", { create: true, log: () => {} }) expect(key).toBe("sk-retried") expect(attempts.length).toBe(2) expect(attempts[0]).not.toBe(attempts[1]) @@ -308,7 +323,30 @@ test("cleanDetail masks real uaa-token, sk-, Bearer and JWT runs", async () => { expect(cleanDetail("cookie: uaa_token: secretValue123456; other")).toBe("cookie: ***; other") expect(cleanDetail("auth header: Bearer opaqueToken123456")).toBe("auth header: ***") expect(cleanDetail("key is sk-12345678abcdef")).toBe("key is ***") - expect(cleanDetail("jwt: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.sig")).toBe("jwt: ***") + expect(cleanDetail("jwt: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJVadQssw5c")).toBe("jwt: ***") // diagnostic words must stay expect(cleanDetail("error: token expired")).toBe("error: token expired") }) + +test("a soft refusal that actually created the key is not retried into a second one", async () => { + // -15100 有时吞掉的是已成功的 POST 的明文:重试前先列 Key,名字在就按 + // -15108 说(明文拿不到),绝不建成两把。 + f = fakeInkStone() + const created = [] + f.route("GET /api/tokenplan/v1/keys", () => json({ code: 0, data: { total: created.length, items: created } })) + f.route("POST /api/tokenplan/v1/keys", (r) => { + created.push({ id: `ak_${created.length + 1}`, name: r.json?.name, status: "active", created_at: new Date().toISOString() }) + // 平台建成了,但软风控把回复换成 -15100(明文没给) + return json({ code: -15100, msg: "API Key fetch failed, refresh page and retry" }) + }) + const { ensureKey } = await import("./lib/keys.mjs") + let said = "" + try { + await ensureKey("fake-token", "acct7", { create: true, log: () => {} }) + } catch (e) { + said = String(e?.message ?? e) + } + expect(said).toContain("did not return it") + // 只有第一把:没有重试出第二把 + expect(created.length).toBe(1) +}) diff --git a/packages/inkstone/cookie-probe.test.mjs b/packages/inkstone/cookie-probe.test.mjs new file mode 100644 index 00000000..aeead625 --- /dev/null +++ b/packages/inkstone/cookie-probe.test.mjs @@ -0,0 +1,90 @@ +// #840 的测试:Cookies sqlite 探针与非 Windows 完成信号。 +// bun:sqlite 是真的(Bun 内置),所以探针的读写都是真库——只是库是我们 +// 造的临时文件,不带任何真实 cookie。 +import "./nonet.mjs" +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, expect, test } from "bun:test" +import { profileHasCookie } from "./lib/cookieProbe.mjs" +import { waitForConsoleWindow } from "./lib/browser.mjs" + +let dir = "" +afterEach(() => { + if (dir) { + try { + rmSync(dir, { recursive: true, force: true }) + } catch {} + dir = "" + } +}) + +/** 一个真的 sqlite 库:/Default/Cookies,行由 rows 给出。 */ +function fakeProfileWith(rows) { + dir = mkdtempSync(join(tmpdir(), "inkstone-probe-profile-")) + mkdirSync(join(dir, "Default"), { recursive: true }) + const { Database } = require("bun:sqlite") + const db = new Database(join(dir, "Default", "Cookies")) + db.query("CREATE TABLE cookies (name TEXT, value TEXT)").run() + for (const row of rows) db.query("INSERT INTO cookies (name, value) VALUES (?, ?)").run(row.name, row.value) + db.close() + return dir +} + +test("the probe finds the uaa-token row in a real sqlite profile", () => { + const profile = fakeProfileWith([{ name: "sid", value: "x" }, { name: "uaa-token", value: "secret" }]) + expect(profileHasCookie(profile, "uaa-token")).toBe(true) + expect(profileHasCookie(profile, "other-cookie")).toBe(false) +}) + +test("a profile without the Default/Cookies file, or a locked one, reads as not-yet", () => { + dir = mkdtempSync(join(tmpdir(), "inkstone-probe-empty-")) + expect(profileHasCookie(dir, "uaa-token")).toBe(false) + // a file that is not a database: the probe must swallow the error + mkdirSync(join(dir, "Default"), { recursive: true }) + writeFileSync(join(dir, "Default", "Cookies"), "this is not sqlite") + expect(profileHasCookie(dir, "uaa-token")).toBe(false) +}) + +test("off Windows the console title is never read, and a cookie row ends the wait", async () => { + const { spawn } = await import("node:child_process") + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 10000)"], { stdio: "ignore" }) + try { + const profile = fakeProfileWith([]) // starts empty + let probes = 0 + const probe = (p) => { + probes += 1 + return probes >= 3 // the "console writes the session" moment, on poll 3 + } + const logs = [] + const how = await waitForConsoleWindow(child, { + platform: "darwin", + profile, + cookieProbe: (p) => probe(p), + timeoutMs: 5000, + pollMs: 20, + log: (level, message) => logs.push(message), + }) + expect(how).toBe("console") + expect(probes).toBeGreaterThanOrEqual(3) + // the progress lines #840 asks for + expect(logs.some((l) => l.includes("cookie store"))).toBe(true) + expect(logs.some((l) => l.includes("closing the window"))).toBe(true) + } finally { + try { child.kill() } catch {} + } +}) + +test("off Windows with no probe (a one-off profile without the store) the wait still ends only on exit", async () => { + const { spawn } = await import("node:child_process") + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 10000)"], { stdio: "ignore" }) + try { + const early = await waitForConsoleWindow(child, { platform: "linux", timeoutMs: 120, pollMs: 40 }) + expect(early).toBe("timeout") + child.kill() + const how = await waitForConsoleWindow(child, { platform: "linux", timeoutMs: 2000, pollMs: 50 }) + expect(how).toBe("closed") + } finally { + try { child.kill() } catch {} + } +}) diff --git a/packages/inkstone/fake-browser.test.mjs b/packages/inkstone/fake-browser.test.mjs index 7d61324d..1b6b8502 100644 --- a/packages/inkstone/fake-browser.test.mjs +++ b/packages/inkstone/fake-browser.test.mjs @@ -4,7 +4,7 @@ // port-file → CDP cookie read → graceful close → key creation path runs here. import "./nonet.mjs" import { execSync } from "node:child_process" -import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, renameSync } from "node:fs" +import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, renameSync, readdirSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { afterAll, afterEach, beforeAll, expect, test } from "bun:test" @@ -29,9 +29,11 @@ beforeAll(() => { // The dir is per-user (`os.userInfo().username`): a fixed name in the // shared /tmp could be pre-created by another user on a Linux CI runner. // A half-written build never parks at the final name — the compile goes to - // a temp name that is renamed over, so `existsSync(out)` below is only - // true of a complete exe (the reviewer's point about a crashed compile - // keeping junk forever). + // a temp name (pid-suffixed: two concurrent runs of the same source would + // otherwise race on it) that is renamed over, so `existsSync(out)` below + // is only true of a complete exe. Older builds (a different hash, left by + // a previous version of testdata/fake-browser.mjs) are removed here, so + // a change to the source does not accumulate ~60 MB directories. const entry = join(import.meta.dir, "testdata/fake-browser.mjs") const { createHash } = require("node:crypto") const { userInfo } = require("node:os") @@ -45,18 +47,25 @@ beforeAll(() => { const key = createHash("sha256").update(readFileSync(entry)).digest("hex").slice(0, 12) fakeDir = join(tmpdir(), `inkstone-fake-browser-${user}-${key}`) mkdirSync(fakeDir, { recursive: true }) + // siblings: same user, another source hash — a previous build, never the + // concurrent one (its hash is ours) + for (const sibling of existsSync(tmpdir()) ? readdirSync(tmpdir()) : []) { + if (sibling.startsWith(`inkstone-fake-browser-${user}-`) && sibling !== `inkstone-fake-browser-${user}-${key}`) { + try { + rmSync(join(tmpdir(), sibling), { recursive: true, force: true }) + } catch {} + } + } const exeName = process.platform === "win32" ? "fake-chrome.exe" : "fake-chrome" const out = join(fakeDir, exeName) if (!existsSync(out)) { // bun build --compile appends its own .exe on Windows, so the temp name - // is computed after the build: look for whatever `*.building*` came out - const tmpOut = join(fakeDir, `${exeName}.building`) - try { - rmSync(tmpOut, { force: true }) - } catch {} - rmSync(join(fakeDir, `${exeName}.building.exe`), { force: true }) + // is computed after the build: look for whatever `*.building-*` + // came out. The pid suffix keeps two concurrent runs of the same source + // from racing on one temp file. + const tmpOut = join(fakeDir, `${exeName}.building-${process.pid}`) execSync(`${JSON.stringify(process.execPath)} build --compile ${JSON.stringify(entry)} --outfile ${JSON.stringify(tmpOut)}`, { stdio: "pipe" }) - const built = existsSync(tmpOut) ? tmpOut : join(fakeDir, `${exeName}.building.exe`) + const built = existsSync(tmpOut) ? tmpOut : join(fakeDir, `${exeName}.building-${process.pid}.exe`) renameSync(built, out) } fakeExe = out diff --git a/packages/inkstone/index.mjs b/packages/inkstone/index.mjs index 62b3cf34..553b9015 100644 --- a/packages/inkstone/index.mjs +++ b/packages/inkstone/index.mjs @@ -29,7 +29,7 @@ import { import { bootModels, createCatalog, liveRowsOf, publicRowsOf } from "./lib/models.mjs" import { cardOf } from "./lib/quota.mjs" import { ensureKey, pruneKeys } from "./lib/keys.mjs" -import { precheckImages, rewriteBody, translateError } from "./lib/preflight.mjs" +import { precheckImages, rewriteBodyObj, translateError } from "./lib/preflight.mjs" import { readUaaToken, requireBrowser, @@ -52,7 +52,7 @@ const apiBase = () => ENDPOINTS.api * 剥无效参数)→ 真发 → 错误翻译(端砚的图片错误宿主读不对,见 preflight.mjs)。 * 只认 chat/completions 和 models 两个端点;别的原样透传。 */ -function chatFetch(key, log = () => {}) { +function chatFetch(log = () => {}) { const upstream = (...args) => globalThis.fetch(...args) return async (input, init = {}) => { const url = typeof input === "string" ? input : input?.url ?? "" @@ -66,7 +66,7 @@ function chatFetch(key, log = () => {}) { else if (ArrayBuffer.isView(raw)) text = new TextDecoder().decode(raw) else if (typeof input === "object" && typeof input?.text === "function" && init.body === undefined) text = await input.text().catch(() => "") if (text) { - // ①预检:超限不发请求,直接给宿主认得的"换下家"形状。 + // 解析一次,预检与改写共用同一个对象(近 40MB 的 body 解析两遍是浪费) let body try { body = JSON.parse(text) @@ -74,6 +74,7 @@ function chatFetch(key, log = () => {}) { body = null } if (body) { + // ①预检:超限不发请求,直接给宿主认得的"换下家"形状。 const refusal = precheckImages(body) if (refusal) { return new Response(refusal.body, { @@ -81,17 +82,12 @@ function chatFetch(key, log = () => {}) { headers: { "content-type": "application/json" }, }) } + // ②③改写:补 detail、剥无效参数(对象原地改写后重新序列化)。 + init = { ...init, body: JSON.stringify(rewriteBodyObj(body)) } } - // ②③改写:补 detail、剥无效参数。 - init = { ...init, body: rewriteBody(text) } } // 真发。 - let res - try { - res = await upstream(input, init) - } catch (e) { - throw e - } + const res = await upstream(input, init) // ④错误翻译:端砚的图片错误码宿主读不对,换成宿主认得的形状。 if (!res.ok && res.status !== 429) { let said = "" @@ -380,7 +376,7 @@ export async function InkStoneAuthPlugin({ client, magpie } = {}, options = {}) instructions: process.platform === "win32" ? "A browser window opens on the InkStone console. Sign in there (password, SMS or QR code); the plugin reads the completed sign-in by itself and closes the window. You do not need to close it. After the sign-in the plugin creates an ink-* API key on your account — the plaintext is shown once, and the plugin keeps it for you." - : "A browser window opens on the InkStone console. Sign in there (password, SMS or QR code), then quit that browser yourself — Cmd+Q on macOS (closing its last window is not enough), or close its last window on Linux. The plugin then reads the session from the profile and finishes. After the sign-in the plugin creates an ink-* API key on your account — the plaintext is shown once, and the plugin keeps it for you.", + : "A browser window opens on the InkStone console. Sign in there (password, SMS or QR code); the plugin watches the profile's cookie store and closes the window itself once the console session lands — you can leave the window open. After the sign-in the plugin creates an ink-* API key on your account — the plaintext is shown once, and the plugin keeps it for you.", method: "auto", async callback(_code, { signal } = {}) { let token = "" @@ -664,7 +660,7 @@ export async function InkStoneAuthPlugin({ client, magpie } = {}, options = {}) const auth = await getAuth() const key = apiKeyOf(auth) if (!key) return {} - return { baseURL: apiBase(), apiKey: key, fetch: chatFetch(key, log) } + return { baseURL: apiBase(), apiKey: key, fetch: chatFetch(log) } }, /** diff --git a/packages/inkstone/lib/browser.mjs b/packages/inkstone/lib/browser.mjs index ecfca452..710f0c0a 100644 --- a/packages/inkstone/lib/browser.mjs +++ b/packages/inkstone/lib/browser.mjs @@ -23,6 +23,7 @@ import { spawn, spawnSync } from "node:child_process" import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" +import { profileHasCookie } from "./cookieProbe.mjs" const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const LOGIN_URL = "https://discovery.intern-ai.org.cn/token-plan/home" @@ -533,18 +534,38 @@ export async function closeWindowGracefully(child, { timeoutMs = 15_000, log = ( */ export async function waitForConsoleWindow( child, - { timeoutMs = 8 * 60_000, pollMs = 1000, signal, log = () => {}, platform = process.platform, titleOf = windowTitleOf, settleMs = 5000 } = {}, + { + timeoutMs = 8 * 60_000, + pollMs = 1000, + signal, + log = () => {}, + platform = process.platform, + titleOf = windowTitleOf, + settleMs = 5000, + // #840:非 Windows 的完成信号——轮询 profile 的 Cookies sqlite 找 + // `uaa-token` 行(name 列明文)。给了 profile 才启用;测试传假实现。 + profile = "", + cookieProbe = null, + } = {}, ) { const deadline = Date.now() + timeoutMs const started = Date.now() - // 窗口标题只能靠 Windows 的 Get-Process 读;别的平台就以“用户关掉窗口” - // 作为完成信号(提示语里会说明),否则只能干等满整个超时。 + // Windows 用窗口标题;别的平台加 Cookies 轮询(有 profile 与探针才启用), + // 都没有时退回"用户关掉窗口"。 const canReadTitle = platform === "win32" + const canProbeCookies = platform !== "win32" && Boolean(profile) && typeof cookieProbe === "function" + if (canProbeCookies) { + log("info", "watching the profile's cookie store for the console session (the window can be left open)") + } let hits = 0 while (Date.now() < deadline) { if (child.exitCode !== null || child.signalCode !== null) return "closed" if (signal?.aborted) return "aborted" if (!canReadTitle) { + if (canProbeCookies && cookieProbe(profile)) { + log("info", "the console session is in the profile; closing the window") + return "console" + } await sleep(pollMs) continue } @@ -590,7 +611,18 @@ export async function signInWithBrowser({ browserPath, keepSignIn = true, timeou // wait is replaced entirely and nothing in this module reads the env. const how = waitForLogin ? await waitForLogin({ child, signal }) - : await waitForConsoleWindow(child, { timeoutMs, signal, log }) + : await waitForConsoleWindow(child, { + timeoutMs, + signal, + log, + // #840: off Windows the profile's cookie store is the completion + // signal — the window closes itself once the session lands, no + // Cmd+Q needed. keepSignIn's profile is the persistent one; a + // one-off profile is probed the same way (it is where the + // session lands either way). + profile: dir, + cookieProbe: (probeProfile) => profileHasCookie(probeProfile, "uaa-token"), + }) log(`login window finished (${how})`) if (how === "aborted") throw Object.assign(new Error("the sign-in was cancelled"), { cancelled: true }) // 登录完成:让页面稳定一下,然后**优雅关闭**(WM_CLOSE),让 Edge @@ -604,11 +636,14 @@ export async function signInWithBrowser({ browserPath, keepSignIn = true, timeou // 用 profile 里刚写下的会话读 token;遇到残留进程造成的"立刻退出"重试一次。 for (let attempt = 1; attempt <= 2; attempt++) { if (cancelled()) throw Object.assign(new Error("the sign-in was cancelled"), { cancelled: true }) + log(`reading the session from the profile (attempt ${attempt}/2)`) let s try { s = await openSession({ browserPath: exe, profile: dir, headless: true, log }) + log("profile session open; waiting for the uaa-token cookie") const token = await waitForToken(s, { timeoutMs: 20_000, signal, log }) if (!token) throw new Error("could not read the console session after the sign-in") + log("session read; validating the account and creating the key") return token } catch (e) { if (cancelled()) throw Object.assign(new Error("the sign-in was cancelled"), { cancelled: true }) diff --git a/packages/inkstone/lib/cookieProbe.mjs b/packages/inkstone/lib/cookieProbe.mjs new file mode 100644 index 00000000..30074aec --- /dev/null +++ b/packages/inkstone/lib/cookieProbe.mjs @@ -0,0 +1,50 @@ +// #840 之①:非 Windows 的完成信号——轮询 profile 的 Cookies sqlite。 +// +// Chrome/Chromium 把 cookie 存在 /Default/Cookies 的 sqlite 里; +// name 列是明文(value 才加密),所以「有没有 uaa-token 行」不需要解密。 +// 控制台写入时 Chrome 持有库锁,只读连接常失败——所以每次轮询**先拷贝到 +// 临时文件再查**,拷不到(文件锁住)就静默等下一轮。 +// +// 与 waitForConsoleWindow 的关系:Windows 继续用窗口标题;非 Windows 在原 +// 「只认进程退出」之上加这个信号——登录完成即自动关窗(不再要求 Cmd+Q), +// 关不掉时用户照旧可以自己关(进程退出仍算结束)。轮询器由参数注入, +// 生产路径传真的实现,测试传假实现。 +import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +/** + * 只读地查 profile 的 Cookies 里有没有 `name === cookieName` 的行。 + * 先拷贝(Chrome 锁库时只读连接大概率失败),拷不动或查不动都算「还没有」。 + */ +export function profileHasCookie(profile, cookieName, { sqliteOpen } = {}) { + const db = join(profile, "Default", "Cookies") + if (!existsSync(db)) return false + const open = sqliteOpen ?? defaultSqliteOpen + let tmp = "" + try { + tmp = join(tmpdir(), `inkstone-cookie-probe-${process.pid}-${Math.random().toString(36).slice(2, 8)}`) + copyFileSync(db, tmp) + return open(tmp).some((row) => row?.name === cookieName) + } catch { + return false // 库锁住 / sqlite 不可用:当作还没写入,等下一轮 + } finally { + if (tmp) { + try { + rmSync(tmp, { force: true }) + } catch {} + } + } +} + +/** 默认的 sqlite 打开器:bun:sqlite 只读,返回 [{name}]。测试注入假实现。 */ +function defaultSqliteOpen(dbPath) { + // bun:sqlite 是内置模块,动态引(避免顶层 import 在无 sqlite 的构建上报错) + const { Database } = require("bun:sqlite") + const db = new Database(dbPath, { readonly: true }) + try { + return db.query("SELECT name FROM cookies").all() + } finally { + db.close() + } +} diff --git a/packages/inkstone/lib/keys.mjs b/packages/inkstone/lib/keys.mjs index 63b1b088..552d6643 100644 --- a/packages/inkstone/lib/keys.mjs +++ b/packages/inkstone/lib/keys.mjs @@ -75,6 +75,19 @@ export async function keyCount(token) { return Math.max(Number.isFinite(total) ? total : 0, items.length) } +/** + * 名字为 `name` 的 Key 是否已在账号上(软风控 -15100 后的查证:POST 可能 + * 实际建成而明文被吞——重试会多建一把,这里按 -15108 说)。 + */ +async function keyCreatedUnderName(token, name) { + try { + const listed = await listKeys(token) + return keyItems(listed).some((k) => k.name === name) + } catch { + return false // 列表读不到时不武断:让软重试继续 + } +} + /** * 删掉本机本账号的旧 `ink-…` Key。 * freeSlots = 0:保留最新两把;freeSlots > 0:为新 Key 腾出位置,最旧先删。 @@ -177,7 +190,16 @@ export async function ensureKey(token, username, { apiKey = "", create = true, p // 「API Key fetch failed, refresh page and retry」:登录窗口刚关、 // headless 刚读完 cookie 就建 Key 时的软风控(真机实测,2026-11-07—— // 同一枚 cookie 稍后重试就成功)。稍等重试两次,换名字换幂等键。 + // 但第一次 POST 可能实际已建成(软风控只是把明文吞了):重试前先列 + // 一遍 Key,刚才的名字在,就按 -15108 说——不是再建一把。 else if (/fetch failed, refresh page/i.test(String(e?.message ?? ""))) { + const made = await keyCreatedUnderName(token, name) + if (made) { + throw new Error( + "the console created an API key but did not return it (the plaintext is shown only once). " + + "Create one in the console and paste it instead.", + ) + } let last = e for (let attempt = 0; attempt < 2; attempt++) { await new Promise((r) => setTimeout(r, 2500)) @@ -187,6 +209,13 @@ export async function ensureKey(token, username, { apiKey = "", create = true, p last = null break } catch (e2) { + // 重试落进同一个软风控:同样先查是不是建成了 + if (/fetch failed, refresh page/i.test(String(e2?.message ?? "")) && (await keyCreatedUnderName(token, name))) { + throw new Error( + "the console created an API key but did not return it (the plaintext is shown only once). " + + "Create one in the console and paste it instead.", + ) + } last = e2 } } diff --git a/packages/inkstone/lib/preflight.mjs b/packages/inkstone/lib/preflight.mjs index 61734b4d..bd602b9d 100644 --- a/packages/inkstone/lib/preflight.mjs +++ b/packages/inkstone/lib/preflight.mjs @@ -125,39 +125,40 @@ function refused(detail, detailZh) { /** * 改写请求体:①每张内联图补 `detail`(缺了补 auto,用户写了尊重不动—— * 实测非必填,low 省 token 是用户自己的选择)②剥端砚不收的参数。 - * 返回新字符串;body 不能解析时原样返回(让端砚自己拒绝)。 + * 接收已解析的对象(chatFetch 解析一次,预检与改写共用);原地改写并返回 + * 同一个对象。JSON 不能解析时调用方根本不会走到这里。 */ const UNSUPPORTED_FIELDS = ["reasoning", "reasoning_effort", "thinking"] -export function rewriteBody(text) { - let body - try { - body = JSON.parse(String(text)) - } catch { - return text - } - if (!body || typeof body !== "object" || Array.isArray(body)) return text - let touched = false +export function rewriteBodyObj(body) { + if (!body || typeof body !== "object" || Array.isArray(body)) return body for (const field of UNSUPPORTED_FIELDS) { - if (field in body) { - delete body[field] - touched = true - } + delete body[field] } if (body.output_config && typeof body.output_config === "object" && !Array.isArray(body.output_config) && "effort" in body.output_config) { delete body.output_config.effort - touched = true } for (const message of Array.isArray(body.messages) ? body.messages : []) { if (!Array.isArray(message?.content)) continue for (const part of message.content) { if (part?.type === "image_url" && part?.image_url && typeof part.image_url === "object" && part.image_url.detail === undefined) { part.image_url.detail = "auto" - touched = true } } } - return touched ? JSON.stringify(body) : text + return body +} + +/** 字符串版:解析 → rewriteBodyObj → 序列化;解析失败原样返回(兼容直接调用)。 */ +export function rewriteBody(text) { + let body + try { + body = JSON.parse(String(text)) + } catch { + return text + } + const rewritten = rewriteBodyObj(body) + return JSON.stringify(rewritten) } /** diff --git a/packages/inkstone/preflight.test.mjs b/packages/inkstone/preflight.test.mjs index c42f7b8f..0b2dace3 100644 --- a/packages/inkstone/preflight.test.mjs +++ b/packages/inkstone/preflight.test.mjs @@ -28,7 +28,7 @@ const HOST = { /** 宿主 shapeRefused 的等价实现:400/422 + 命中形状词 + 无排除词 → 问下家不休息。 */ function hostShapeRefused(status, bodyText) { return ( - (status === 400 || status === 226 || status === 422) && + (status === 400 || status === 422) && HOST.shapeWords.test(bodyText) && !HOST.quotaWords.test(bodyText) && !HOST.creditWords.test(bodyText) && From 483474ea5dbb499a143f5cc46c3d2eb9a43cc746 Mon Sep 17 00:00:00 2001 From: nonamelane <242740720+nonamelane@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:31:47 +0800 Subject: [PATCH 2/7] inkstone: the cookie probe accepts only a session newer than launch, with the real wiring and the sign-in progress tested --- packages/inkstone/api.test.mjs | 2 +- packages/inkstone/cookie-probe.test.mjs | 108 +++++++++++++++----- packages/inkstone/fake-browser.test.mjs | 65 +++++++++++- packages/inkstone/lib/browser.mjs | 46 +++++++-- packages/inkstone/lib/cookieProbe.mjs | 85 +++++++++++---- packages/inkstone/testdata/fake-browser.mjs | 24 +++++ 6 files changed, 274 insertions(+), 56 deletions(-) diff --git a/packages/inkstone/api.test.mjs b/packages/inkstone/api.test.mjs index cd040db5..0428d76c 100644 --- a/packages/inkstone/api.test.mjs +++ b/packages/inkstone/api.test.mjs @@ -323,7 +323,7 @@ test("cleanDetail masks real uaa-token, sk-, Bearer and JWT runs", async () => { expect(cleanDetail("cookie: uaa_token: secretValue123456; other")).toBe("cookie: ***; other") expect(cleanDetail("auth header: Bearer opaqueToken123456")).toBe("auth header: ***") expect(cleanDetail("key is sk-12345678abcdef")).toBe("key is ***") - expect(cleanDetail("jwt: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJVadQssw5c")).toBe("jwt: ***") + expect(cleanDetail("jwt: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.sig")).toBe("jwt: ***") // diagnostic words must stay expect(cleanDetail("error: token expired")).toBe("error: token expired") }) diff --git a/packages/inkstone/cookie-probe.test.mjs b/packages/inkstone/cookie-probe.test.mjs index aeead625..fe5388b6 100644 --- a/packages/inkstone/cookie-probe.test.mjs +++ b/packages/inkstone/cookie-probe.test.mjs @@ -1,12 +1,11 @@ -// #840 的测试:Cookies sqlite 探针与非 Windows 完成信号。 -// bun:sqlite 是真的(Bun 内置),所以探针的读写都是真库——只是库是我们 -// 造的临时文件,不带任何真实 cookie。 +// #840/#847 的测试:Cookies sqlite 探针、基线判定与非 Windows 完成信号。 +// bun:sqlite 是真的(Bun 内置),库是我们造的临时文件,不带任何真实 cookie。 import "./nonet.mjs" import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { afterEach, expect, test } from "bun:test" -import { profileHasCookie } from "./lib/cookieProbe.mjs" +import { chromiumMicros, cookieBaseline, hostMatches, profileHasNewCookie } from "./lib/cookieProbe.mjs" import { waitForConsoleWindow } from "./lib/browser.mjs" let dir = "" @@ -25,45 +24,106 @@ function fakeProfileWith(rows) { mkdirSync(join(dir, "Default"), { recursive: true }) const { Database } = require("bun:sqlite") const db = new Database(join(dir, "Default", "Cookies")) - db.query("CREATE TABLE cookies (name TEXT, value TEXT)").run() - for (const row of rows) db.query("INSERT INTO cookies (name, value) VALUES (?, ?)").run(row.name, row.value) + db.query("CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, creation_utc INTEGER, last_update_utc INTEGER, expires_utc INTEGER)").run() + for (const row of rows) { + db.query("INSERT INTO cookies (host_key, name, value, creation_utc, last_update_utc, expires_utc) VALUES (?, ?, ?, ?, ?, ?)").run( + row.host_key ?? ".intern-ai.org.cn", + row.name, + row.value ?? "ciphertext", + row.creation_utc ?? chromiumMicros(), + row.last_update_utc ?? chromiumMicros(), + row.expires_utc ?? chromiumMicros(Date.now() + 86400_000), + ) + } db.close() return dir } -test("the probe finds the uaa-token row in a real sqlite profile", () => { - const profile = fakeProfileWith([{ name: "sid", value: "x" }, { name: "uaa-token", value: "secret" }]) - expect(profileHasCookie(profile, "uaa-token")).toBe(true) - expect(profileHasCookie(profile, "other-cookie")).toBe(false) -}) +const DOMAIN = "intern-ai.org.cn" -test("a profile without the Default/Cookies file, or a locked one, reads as not-yet", () => { +test("the baseline is empty on a fresh profile and carries the row on a kept one", () => { + const profile = fakeProfileWith([{ name: "uaa-token" }]) + expect(cookieBaseline(profile, "uaa-token", DOMAIN)).toHaveLength(1) + rmSync(dir, { recursive: true, force: true }) dir = mkdtempSync(join(tmpdir(), "inkstone-probe-empty-")) - expect(profileHasCookie(dir, "uaa-token")).toBe(false) - // a file that is not a database: the probe must swallow the error + expect(cookieBaseline(dir, "uaa-token", DOMAIN)).toEqual([]) +}) + +test("a row that was there at launch is not this sign-in (the #847 blocking case)", () => { + // keep_sign_in's profile: the old uaa-token is already there; the page + // writes no new cookie — the wait must NOT read that as a finished sign-in + const profile = fakeProfileWith([{ name: "uaa-token" }]) + const baseline = cookieBaseline(profile, "uaa-token", DOMAIN) + expect(profileHasNewCookie(profile, "uaa-token", baseline, DOMAIN)).toBe(false) +}) + +test("a new row, or an overwritten one, is this sign-in", () => { + // a brand-new row (creation_utc advanced) + let profile = fakeProfileWith([{ name: "uaa-token", creation_utc: chromiumMicros(Date.now() - 60_000) }]) + const baseline = cookieBaseline(profile, "uaa-token", DOMAIN) + // Chrome overwrites in place: creation_utc stays, last_update_utc moves + const { Database } = require("bun:sqlite") + const db = new Database(join(profile, "Default", "Cookies")) + db.query("UPDATE cookies SET last_update_utc = ? WHERE name = 'uaa-token'").run(chromiumMicros()) + db.close() + expect(profileHasNewCookie(profile, "uaa-token", baseline, DOMAIN)).toBe(true) +}) + +test("an expired row, another domain's row, another cookie's row: all refused", () => { + const profile = fakeProfileWith([ + { name: "uaa-token", host_key: ".some-other.test" }, + { name: "sid" }, + { name: "uaa-token", expires_utc: chromiumMicros(Date.now() - 3600_000) }, + ]) + expect(profileHasNewCookie(profile, "uaa-token", [], DOMAIN)).toBe(false) +}) + +test("no store, a locked store, a file that is not sqlite: not-yet, never a throw", () => { + dir = mkdtempSync(join(tmpdir(), "inkstone-probe-none-")) + expect(profileHasNewCookie(dir, "uaa-token", [], DOMAIN)).toBe(false) mkdirSync(join(dir, "Default"), { recursive: true }) writeFileSync(join(dir, "Default", "Cookies"), "this is not sqlite") - expect(profileHasCookie(dir, "uaa-token")).toBe(false) + expect(profileHasNewCookie(dir, "uaa-token", [], DOMAIN)).toBe(false) +}) + +test("the probe copy lives in the profile (0700), not the system temp (N6)", () => { + const profile = fakeProfileWith([{ name: "uaa-token" }]) + let sawPath = "" + profileHasNewCookie(profile, "uaa-token", [], DOMAIN, { + sqliteOpen: (p) => { + sawPath = p + return [{ host_key: ".intern-ai.org.cn", name: "uaa-token", creation_utc: chromiumMicros(), last_update_utc: chromiumMicros(), expires_utc: chromiumMicros(Date.now() + 60_000) }] + }, + }) + expect(sawPath.startsWith(profile)).toBe(true) + expect(sawPath.includes("inkstone-cookie-probe")).toBe(true) +}) + +test("hostMatches takes the console root, its dot form and its subdomains", () => { + expect(hostMatches(".intern-ai.org.cn", DOMAIN)).toBe(true) + expect(hostMatches("intern-ai.org.cn", DOMAIN)).toBe(true) + expect(hostMatches("discovery.intern-ai.org.cn", DOMAIN)).toBe(true) + expect(hostMatches(".evilintern-ai.org.cn", DOMAIN)).toBe(false) + expect(hostMatches(".other.test", DOMAIN)).toBe(false) }) -test("off Windows the console title is never read, and a cookie row ends the wait", async () => { +test("off Windows the console title is never read, and a new cookie ends the wait", async () => { const { spawn } = await import("node:child_process") const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 10000)"], { stdio: "ignore" }) try { - const profile = fakeProfileWith([]) // starts empty + const profile = fakeProfileWith([]) let probes = 0 - const probe = (p) => { - probes += 1 - return probes >= 3 // the "console writes the session" moment, on poll 3 - } const logs = [] const how = await waitForConsoleWindow(child, { platform: "darwin", profile, - cookieProbe: (p) => probe(p), + cookieProbe: () => { + probes += 1 + return probes >= 3 // the console writes the session, on poll 3 + }, timeoutMs: 5000, pollMs: 20, - log: (level, message) => logs.push(message), + log: (...args) => logs.push(args.map(String).join(" ")), }) expect(how).toBe("console") expect(probes).toBeGreaterThanOrEqual(3) @@ -75,7 +135,7 @@ test("off Windows the console title is never read, and a cookie row ends the wai } }) -test("off Windows with no probe (a one-off profile without the store) the wait still ends only on exit", async () => { +test("off Windows with no probe the wait still ends only on exit", async () => { const { spawn } = await import("node:child_process") const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 10000)"], { stdio: "ignore" }) try { diff --git a/packages/inkstone/fake-browser.test.mjs b/packages/inkstone/fake-browser.test.mjs index 1b6b8502..b5b900a1 100644 --- a/packages/inkstone/fake-browser.test.mjs +++ b/packages/inkstone/fake-browser.test.mjs @@ -9,7 +9,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { afterAll, afterEach, beforeAll, expect, test } from "bun:test" import { InkStoneAuthPlugin } from "./index.mjs" -import { renewSilently } from "./lib/browser.mjs" +import { renewSilently, signInWithBrowser } from "./lib/browser.mjs" import { fakeInkStone, json, ok, fail, jwtOf, poolUsageFixture, packagesFixture } from "./fake.mjs" let fakeExe = "" @@ -72,7 +72,7 @@ beforeAll(() => { profile = mkdtempSync(join(tmpdir(), "inkstone-fake-profile-")) // save what we override: bun test runs every file in one process, so these // would leak into whatever test file runs next - for (const key of ["MAGPIE_INKSTONE_PROFILE", "MAGPIE_INKSTONE_BROWSER", "MAGPIE_INKSTONE_LOGIN_EXIT", "DISPLAY"]) { + for (const key of ["MAGPIE_INKSTONE_PROFILE", "MAGPIE_INKSTONE_BROWSER", "MAGPIE_INKSTONE_LOGIN_EXIT", "FAKE_BROWSER_HOLD_COOKIE", "DISPLAY"]) { savedEnv[key] = process.env[key] } process.env.MAGPIE_INKSTONE_PROFILE = profile @@ -225,3 +225,64 @@ test("the reuse method keeps a pasted key instead of creating one", async () => // the pasted key is kept: nothing was created on the console expect(keys.length).toBe(0) }, 60_000) + +test("off Windows the production wiring closes the window on a new cookie", async () => { + // B4: the real signInWithBrowser path (baseline → probe → close), with the + // sqlite probe injected so the test does not depend on where a compiled + // Chromium stand-in happens to write its profile on each OS. Removing the + // cookieProbe wiring from signInWithBrowser makes the wait ignore the + // injected probe and end on the child's exit, not "console" — this goes red. + f = fakeInkStone() + serveKeyRoutes() + const logs = [] + let probes = 0 + const token = await signInWithBrowser({ + browserPath: fakeExe, + keepSignIn: true, + timeoutMs: 6000, + loginWait: { + platform: "linux", + pollMs: 50, + cookieProbe: () => ++probes >= 3, + }, + log: (...args) => logs.push(args.map(String).join(" ")), + }) + expect(token).toBe("fake-uaa-token-for-tests") + expect(logs.some((l) => l.includes("login window finished (console)"))).toBe(true) + expect(logs.some((l) => l.includes("cookie store"))).toBe(true) + expect(probes).toBeGreaterThanOrEqual(3) +}, 30_000) + +test("off Windows an old cookie in the kept profile is not this sign-in", async () => { + // B1 end-to-end with the real sqlite probe: keep_sign_in's profile carries + // the previous uaa-token. The launch baseline must keep it from counting as + // "console" (if the baseline is dropped, this immediately sees "console" + // and goes red). The stand-in writes no new cookie in this run. + f = fakeInkStone() + serveKeyRoutes() + try { rmSync(join(profile, "Default", "Cookies"), { force: true }) } catch {} + mkdirSync(join(profile, "Default"), { recursive: true }) + const { Database } = require("bun:sqlite") + const db = new Database(join(profile, "Default", "Cookies")) + db.query("CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, creation_utc INTEGER, last_update_utc INTEGER, expires_utc INTEGER)").run() + const old = (Date.now() - 3600_000 + 11644473600000) * 1000 + db.query( + "INSERT INTO cookies (host_key, name, value, creation_utc, last_update_utc, expires_utc) VALUES ('.intern-ai.org.cn', 'uaa-token', 'old', ?, ?, ?)", + ).run(old, old, old + 86400_000_000) + db.close() + process.env.FAKE_BROWSER_HOLD_COOKIE = "1" + const logs = [] + try { + await signInWithBrowser({ + browserPath: fakeExe, + keepSignIn: true, + timeoutMs: 2500, + loginWait: { platform: "linux", pollMs: 50 }, + log: (...args) => logs.push(args.map(String).join(" ")), + }).catch(() => "") + expect(logs.some((l) => l.includes("login window finished (console)"))).toBe(false) + } finally { + delete process.env.FAKE_BROWSER_HOLD_COOKIE + try { rmSync(join(profile, "Default", "Cookies"), { force: true }) } catch {} + } +}, 30_000) diff --git a/packages/inkstone/lib/browser.mjs b/packages/inkstone/lib/browser.mjs index 710f0c0a..ae2a9cf4 100644 --- a/packages/inkstone/lib/browser.mjs +++ b/packages/inkstone/lib/browser.mjs @@ -23,11 +23,25 @@ import { spawn, spawnSync } from "node:child_process" import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { profileHasCookie } from "./cookieProbe.mjs" +import { profileHasNewCookie, cookieBaseline } from "./cookieProbe.mjs" const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const LOGIN_URL = "https://discovery.intern-ai.org.cn/token-plan/home" +/** + * 控制台的 cookie 根域(`intern-ai.org.cn`):取 LOGIN_URL 主机的最后两段。 + * 不硬编码产品域名,换域名时跟着走;cookie 的 host_key 通常是 `.intern-ai.org.cn`。 + */ +function consoleCookieDomain() { + try { + const host = new URL(LOGIN_URL).hostname.toLowerCase() + const parts = host.split(".") + return parts.length > 2 ? parts.slice(-2).join(".") : host + } catch { + return "" + } +} + // 同一时刻只允许一个浏览器实例使用插件专用 profile:登录窗口、静默续期、 // headless 读 cookie 都在这个 profile 上,两个实例同时开会把命令互相委托, // 后启动的那个"立刻退出"。续期遇到登录进行中就跳过(下一次额度刷新再来)。 @@ -555,7 +569,7 @@ export async function waitForConsoleWindow( const canReadTitle = platform === "win32" const canProbeCookies = platform !== "win32" && Boolean(profile) && typeof cookieProbe === "function" if (canProbeCookies) { - log("info", "watching the profile's cookie store for the console session (the window can be left open)") + log("watching the profile's cookie store for the console session (the window can be left open)") } let hits = 0 while (Date.now() < deadline) { @@ -563,7 +577,7 @@ export async function waitForConsoleWindow( if (signal?.aborted) return "aborted" if (!canReadTitle) { if (canProbeCookies && cookieProbe(profile)) { - log("info", "the console session is in the profile; closing the window") + log("the console session is in the profile; closing the window") return "console" } await sleep(pollMs) @@ -592,7 +606,7 @@ export async function waitForConsoleWindow( * * 登录态留在专用 profile 里(keepSignIn),续期和下次登录都直接用它。 */ -export async function signInWithBrowser({ browserPath, keepSignIn = true, timeoutMs = 8 * 60_000, waitForLogin, signal, log = () => {} } = {}) { +export async function signInWithBrowser({ browserPath, keepSignIn = true, timeoutMs = 8 * 60_000, waitForLogin, loginWait, signal, log = () => {} } = {}) { // before the one-off profile is made, so a missing browser leaves no folder const exe = requireBrowser(browserPath) const dir = keepSignIn ? profileDir() : oneOffProfileDir() @@ -601,6 +615,13 @@ export async function signInWithBrowser({ browserPath, keepSignIn = true, timeou log("opening the sign-in window") killProfileProcesses(dir, log) if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }) + // #847: the profile keeps the previous sign-in's uaa-token row + // (keep_sign_in defaults on), so "a row exists" is not "this sign-in + // finished" — remember the rows there now, and accept only one newer + // than this launch (or an overwritten one, whose last_update_utc moved). + const consoleDomain = consoleCookieDomain() + // 所有平台都取基线:测试可在 Windows 上注入 platform:"linux" 走探针路径。 + const baseline = cookieBaseline(dir, "uaa-token", consoleDomain) try { const child = spawn(exe, browserArgs(dir, 0, LOGIN_URL, { cdp: false, login: true }), { stdio: "ignore", @@ -612,16 +633,21 @@ export async function signInWithBrowser({ browserPath, keepSignIn = true, timeou const how = waitForLogin ? await waitForLogin({ child, signal }) : await waitForConsoleWindow(child, { + // injection point for tests: platform/pollMs, so the real wiring + // (baseline + probe + close) runs on a fast clock off Windows + ...(loginWait ?? {}), timeoutMs, signal, log, - // #840: off Windows the profile's cookie store is the completion - // signal — the window closes itself once the session lands, no - // Cmd+Q needed. keepSignIn's profile is the persistent one; a - // one-off profile is probed the same way (it is where the - // session lands either way). + // #840/#847: off Windows the profile's cookie store is the + // completion signal — a row newer than this launch, unexpired, + // on the console's domain. The window closes itself, no Cmd+Q + // needed (Chrome writes cookies to disk on its own ~30s timer). + // loginWait.cookieProbe is the test injection point; production + // always uses the real sqlite probe with the launch baseline. profile: dir, - cookieProbe: (probeProfile) => profileHasCookie(probeProfile, "uaa-token"), + cookieProbe: loginWait?.cookieProbe ?? ((probeProfile) => + profileHasNewCookie(probeProfile, "uaa-token", baseline, consoleDomain)), }) log(`login window finished (${how})`) if (how === "aborted") throw Object.assign(new Error("the sign-in was cancelled"), { cancelled: true }) diff --git a/packages/inkstone/lib/cookieProbe.mjs b/packages/inkstone/lib/cookieProbe.mjs index 30074aec..f1e2627d 100644 --- a/packages/inkstone/lib/cookieProbe.mjs +++ b/packages/inkstone/lib/cookieProbe.mjs @@ -1,33 +1,54 @@ -// #840 之①:非 Windows 的完成信号——轮询 profile 的 Cookies sqlite。 +// #840/#847:非 Windows 的完成信号——轮询 profile 的 Cookies sqlite。 // // Chrome/Chromium 把 cookie 存在 /Default/Cookies 的 sqlite 里; -// name 列是明文(value 才加密),所以「有没有 uaa-token 行」不需要解密。 -// 控制台写入时 Chrome 持有库锁,只读连接常失败——所以每次轮询**先拷贝到 -// 临时文件再查**,拷不到(文件锁住)就静默等下一轮。 +// name 与 host_key 列是明文(value 才加密),所以判断「控制台会话写进来了」 +// 不需要解密。 // -// 与 waitForConsoleWindow 的关系:Windows 继续用窗口标题;非 Windows 在原 -// 「只认进程退出」之上加这个信号——登录完成即自动关窗(不再要求 Cmd+Q), -// 关不掉时用户照旧可以自己关(进程退出仍算结束)。轮询器由参数注入, -// 生产路径传真的实现,测试传假实现。 -import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" +// **只认本次启动后新增的行**(#847 blocking 1):keep_sign_in 默认开着, +// profile 里留着上次登录的 uaa-token 行;只查「有没有这一行」会让重登 +// (页面不写新 cookie 时)在 0–1ms 内误判完成、提前关窗、随后 headless +// 读到旧 token。所以调用方先取基线(启动前该 cookie 的行元组集合), +// 之后只接受不在基线里、未过期、且 host_key 属于控制台域的行。 +// +// 控制台写入时 Chrome 持有库锁,只读连接常失败——所以每次轮询**先拷贝再查** +// (拷到 profile 目录自己的 0700 里,不放系统 temp:那里是明文 cookie 副本)。 +// 拷不到(文件锁住)就静默等下一轮——Chrome 大约每 30 秒把 cookie 写盘。 +import { copyFileSync, existsSync, rmSync } from "node:fs" import { join } from "node:path" +// Chromium 的时间是「自 1601-01-01 起的微秒」。Unix 毫秒 = 微秒/1000 - 11644473600000。 +const CHROMIUM_EPOCH_OFFSET_MS = 11644473600000 +export const chromiumMicros = (unixMs = Date.now()) => (unixMs + CHROMIUM_EPOCH_OFFSET_MS) * 1000 +const unixMsOf = (chromiumUs) => Number(chromiumUs) / 1000 - CHROMIUM_EPOCH_OFFSET_MS + +/** host_key 是否属于 cookieDomain(`intern-ai.org.cn`):精确、点前缀或子域。 */ +export function hostMatches(hostKey, cookieDomain) { + const h = String(hostKey ?? "") + .replace(/^\./, "") + .toLowerCase() + const d = String(cookieDomain ?? "").toLowerCase() + return h === d || h.endsWith(`.${d}`) +} + +/** 一行的身份:Chrome 覆写 cookie 时保留 creation_utc、更新 last_update_utc。 */ +const rowKey = (r) => `${r.host_key}\u0000${r.creation_utc}\u0000${r.last_update_utc}` + /** - * 只读地查 profile 的 Cookies 里有没有 `name === cookieName` 的行。 - * 先拷贝(Chrome 锁库时只读连接大概率失败),拷不动或查不动都算「还没有」。 + * 只读地读出 profile 的 Cookies 里所有行(拷贝一份再查,避免 Chrome 的写锁)。 + * 无库 / 锁住 / 非 sqlite / 无 sqlite 运行时时返回 []——调用方按「还没有」处理。 */ -export function profileHasCookie(profile, cookieName, { sqliteOpen } = {}) { +export function cookieRows(profile, { sqliteOpen } = {}) { const db = join(profile, "Default", "Cookies") - if (!existsSync(db)) return false + if (!existsSync(db)) return [] const open = sqliteOpen ?? defaultSqliteOpen let tmp = "" try { - tmp = join(tmpdir(), `inkstone-cookie-probe-${process.pid}-${Math.random().toString(36).slice(2, 8)}`) + // 0700 的 profile 目录内(N6):明文 cookie 副本不落系统 temp + tmp = join(profile, `.inkstone-cookie-probe-${process.pid}-${Math.random().toString(36).slice(2, 8)}`) copyFileSync(db, tmp) - return open(tmp).some((row) => row?.name === cookieName) + return open(tmp) } catch { - return false // 库锁住 / sqlite 不可用:当作还没写入,等下一轮 + return [] // 库锁住 / sqlite 不可用:当作还没写入,等下一轮 } finally { if (tmp) { try { @@ -37,13 +58,39 @@ export function profileHasCookie(profile, cookieName, { sqliteOpen } = {}) { } } -/** 默认的 sqlite 打开器:bun:sqlite 只读,返回 [{name}]。测试注入假实现。 */ +/** + * 基线:启动登录窗口之前,`name` 且 host_key 属于 `domain` 的所有行元组。 + * 空 profile(首次登录)天然得到空集合。 + */ +export function cookieBaseline(profile, name, domain, opts = {}) { + const rows = cookieRows(profile, opts) + return rows.filter((r) => r?.name === name && hostMatches(r.host_key, domain)).map(rowKey) +} + +/** + * 登录是否完成:存在一行 `name`(host_key 属于 domain)、未过期、且不在 + * baseline 里。`nowMs` 便于测试固定时间。 + */ +export function profileHasNewCookie(profile, name, baseline = [], domain = "", { sqliteOpen, nowMs = Date.now() } = {}) { + const seen = new Set(baseline) + const now = nowMs + return cookieRows(profile, { sqliteOpen }).some((r) => { + if (r?.name !== name) return false + if (!hostMatches(r.host_key, domain)) return false + if (seen.has(rowKey(r))) return false // 启动前就在:旧会话,不算 + const expires = Number(r.expires_utc ?? 0) + // 0 是会话 cookie(关窗即失效)——有效;有值的必须还没过期 + return !expires || unixMsOf(expires) > now + }) +} + +/** 默认的 sqlite 打开器:bun:sqlite 只读,返回行对象。测试注入假实现。 */ function defaultSqliteOpen(dbPath) { // bun:sqlite 是内置模块,动态引(避免顶层 import 在无 sqlite 的构建上报错) const { Database } = require("bun:sqlite") const db = new Database(dbPath, { readonly: true }) try { - return db.query("SELECT name FROM cookies").all() + return db.query("SELECT host_key, name, creation_utc, last_update_utc, expires_utc FROM cookies").all() } finally { db.close() } diff --git a/packages/inkstone/testdata/fake-browser.mjs b/packages/inkstone/testdata/fake-browser.mjs index 0ede32b7..5085d03f 100644 --- a/packages/inkstone/testdata/fake-browser.mjs +++ b/packages/inkstone/testdata/fake-browser.mjs @@ -33,6 +33,30 @@ if (!headless) { } catch {} process.exit(0) } + // B1/B4: after "the user signed in", write the console session into the + // profile's real Cookies sqlite — the plugin watches it off Windows to + // close the window itself. FAKE_BROWSER_HOLD_COOKIE keeps it out + // (an old session with no new cookie: the wait must not read that as done). + if (!process.env.FAKE_BROWSER_HOLD_COOKIE) { + setTimeout(() => { + try { + mkdirSync(join(profilePath, "Default"), { recursive: true }) + const { Database } = require("bun:sqlite") + const db = new Database(join(profilePath, "Default", "Cookies")) + db.query( + "CREATE TABLE IF NOT EXISTS cookies (host_key TEXT, name TEXT, value TEXT, creation_utc INTEGER, last_update_utc INTEGER, expires_utc INTEGER)", + ).run() + const now = Date.now() + const micros = (ms) => (ms + 11644473600000) * 1000 + db.query( + "INSERT INTO cookies (host_key, name, value, creation_utc, last_update_utc, expires_utc) VALUES ('.intern-ai.org.cn', 'uaa-token', 'fake-uaa-token', ?, ?, ?)", + ).run(micros(now), micros(now), micros(now + 86400_000)) + db.close() + } catch (e) { + console.error("[fake-chrome] cookie write failed:", e?.message ?? e) + } + }, 300) + } if (process.platform === "win32") { setTimeout(() => process.exit(0), 500) } else { From 90e14a88de68eab1997fed89bb88bae29411b576 Mon Sep 17 00:00:00 2001 From: nonamelane <242740720+nonamelane@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:05:32 +0800 Subject: [PATCH 3/7] inkstone: the refused-key path removes its orphan and the fetch layer keeps an unchanged body byte-for-byte and re-attaches a Request body (blocking 3, nits) --- packages/inkstone/api.test.mjs | 40 +++++++++--- packages/inkstone/index.mjs | 18 ++++-- packages/inkstone/keys.test.mjs | 20 +++++- packages/inkstone/lib/keys.mjs | 97 ++++++++++++++++------------ packages/inkstone/lib/preflight.mjs | 24 ++++++- packages/inkstone/preflight.test.mjs | 4 ++ 6 files changed, 143 insertions(+), 60 deletions(-) diff --git a/packages/inkstone/api.test.mjs b/packages/inkstone/api.test.mjs index 0428d76c..66aaa3df 100644 --- a/packages/inkstone/api.test.mjs +++ b/packages/inkstone/api.test.mjs @@ -315,6 +315,22 @@ test("the fetch wiring is the request path — each piece breaks it in a way the expect(key).toBe("sk-retried") expect(attempts.length).toBe(2) expect(attempts[0]).not.toBe(attempts[1]) + + // ⑥ N2:input 是 Request 且 body 非 JSON 时,读完要原样交回上游—— + // 否则 fetch 带上一个已被 text() 消费的流。fake 的 r.text 就是收到的原文。 + let gotRaw = null + f.route("POST /v1/chat/completions", (r) => { + gotRaw = r.text + return json({ ok: true }) + }) + const req = new Request(`${f.origin}/v1/chat/completions`, { + method: "POST", + body: "not json at all", + headers: { "content-type": "text/plain" }, + }) + const r4 = await loader.fetch(req) + expect(r4.status).toBe(200) + expect(gotRaw).toBe("not json at all") }) test("cleanDetail masks real uaa-token, sk-, Bearer and JWT runs", async () => { @@ -328,25 +344,33 @@ test("cleanDetail masks real uaa-token, sk-, Bearer and JWT runs", async () => { expect(cleanDetail("error: token expired")).toBe("error: token expired") }) -test("a soft refusal that actually created the key is not retried into a second one", async () => { - // -15100 有时吞掉的是已成功的 POST 的明文:重试前先列 Key,名字在就按 - // -15108 说(明文拿不到),绝不建成两把。 +test("a soft refusal leaves no orphan key behind - each made one is deleted before the retry", async () => { + // -15100 有时吞掉的是已成功的 POST 的明文(真机:孤儿曾留下)。blocking 3: + // 每个建成的孤儿按名字删掉,重试换新名;耗尽后如实报错。账号上零孤儿。 f = fakeInkStone() const created = [] + let deleted = 0 f.route("GET /api/tokenplan/v1/keys", () => json({ code: 0, data: { total: created.length, items: created } })) f.route("POST /api/tokenplan/v1/keys", (r) => { - created.push({ id: `ak_${created.length + 1}`, name: r.json?.name, status: "active", created_at: new Date().toISOString() }) + const id = `ak_${created.length + 1}` + created.push({ id, name: r.json?.name, status: "active", created_at: new Date().toISOString() }) + // fake.mjs 只认精确字符串路由键——DELETE 按这把 Key 的真实 id 注册, + // 这样「按名字删孤儿」才真的能命中(正则键永远匹配不上)。 + f.route(`DELETE /api/tokenplan/v1/keys/${id}`, () => (deleted++, json({ code: 0 }))) // 平台建成了,但软风控把回复换成 -15100(明文没给) return json({ code: -15100, msg: "API Key fetch failed, refresh page and retry" }) }) const { ensureKey } = await import("./lib/keys.mjs") let said = "" try { - await ensureKey("fake-token", "acct7", { create: true, log: () => {} }) + await ensureKey("fake-token", "acct7", { create: true, log: () => {}, softRetryDelays: [0, 0] }) } catch (e) { said = String(e?.message ?? e) } - expect(said).toContain("did not return it") - // 只有第一把:没有重试出第二把 - expect(created.length).toBe(1) + // 三次尝试(1 首发重试 2),每次建成即删:账号上零孤儿 + expect(created.length).toBe(3) + expect(deleted).toBe(3) + expect(created.length - deleted).toBe(0) + // 耗尽后如实报 -15100 + expect(said).toContain("refresh page and retry") }) diff --git a/packages/inkstone/index.mjs b/packages/inkstone/index.mjs index 553b9015..51b3661c 100644 --- a/packages/inkstone/index.mjs +++ b/packages/inkstone/index.mjs @@ -29,7 +29,7 @@ import { import { bootModels, createCatalog, liveRowsOf, publicRowsOf } from "./lib/models.mjs" import { cardOf } from "./lib/quota.mjs" import { ensureKey, pruneKeys } from "./lib/keys.mjs" -import { precheckImages, rewriteBodyObj, translateError } from "./lib/preflight.mjs" +import { precheckImages, rewriteBodyChanged, rewriteBodyObj, translateError } from "./lib/preflight.mjs" import { readUaaToken, requireBrowser, @@ -59,12 +59,16 @@ function chatFetch(log = () => {}) { const method = (init.method ?? (typeof input === "object" ? input?.method : undefined) ?? "GET").toUpperCase() const isChat = url.includes("/chat/completions") && method === "POST" if (!isChat) return upstream(input, init) + // N2: a Request whose body is not JSON is read (its stream is consumed by + // text()) and must be handed back, or upstream(input, init) sends a spent + // body. A plain string/ArrayBuffer body needs no such thing. + const isRequest = typeof input === "object" && typeof input?.text === "function" && init.body === undefined const raw = init.body let text = "" if (typeof raw === "string") text = raw else if (raw instanceof ArrayBuffer) text = new TextDecoder().decode(raw) else if (ArrayBuffer.isView(raw)) text = new TextDecoder().decode(raw) - else if (typeof input === "object" && typeof input?.text === "function" && init.body === undefined) text = await input.text().catch(() => "") + else if (isRequest) text = await input.text().catch(() => "") if (text) { // 解析一次,预检与改写共用同一个对象(近 40MB 的 body 解析两遍是浪费) let body @@ -82,10 +86,14 @@ function chatFetch(log = () => {}) { headers: { "content-type": "application/json" }, }) } - // ②③改写:补 detail、剥无效参数(对象原地改写后重新序列化)。 - init = { ...init, body: JSON.stringify(rewriteBodyObj(body)) } + // ②③改写:补 detail、剥无效参数。一字未动就保留原字节(review N1, + // 不再无条件重新序列化);改过才原地改写后重新序列化。 + const rewritten = rewriteBodyObj(body) + if (rewriteBodyChanged(rewritten)) init = { ...init, body: JSON.stringify(rewritten) } } } + // N2: 若是 Request 且上面没重挂 body,把读到的字节原样交回去。 + if (isRequest && init.body === undefined) init = { ...init, body: text } // 真发。 const res = await upstream(input, init) // ④错误翻译:端砚的图片错误码宿主读不对,换成宿主认得的形状。 @@ -376,7 +384,7 @@ export async function InkStoneAuthPlugin({ client, magpie } = {}, options = {}) instructions: process.platform === "win32" ? "A browser window opens on the InkStone console. Sign in there (password, SMS or QR code); the plugin reads the completed sign-in by itself and closes the window. You do not need to close it. After the sign-in the plugin creates an ink-* API key on your account — the plaintext is shown once, and the plugin keeps it for you." - : "A browser window opens on the InkStone console. Sign in there (password, SMS or QR code); the plugin watches the profile's cookie store and closes the window itself once the console session lands — you can leave the window open. After the sign-in the plugin creates an ink-* API key on your account — the plaintext is shown once, and the plugin keeps it for you.", + : "A browser window opens on the InkStone console. Sign in there (password, SMS or QR code); the plugin watches the profile's cookie store and closes the window itself within about half a minute of the console session landing (Chrome writes cookies to disk on a timer) — you can leave the window open. After the sign-in the plugin creates an ink-* API key on your account — the plaintext is shown once, and the plugin keeps it for you.", method: "auto", async callback(_code, { signal } = {}) { let token = "" diff --git a/packages/inkstone/keys.test.mjs b/packages/inkstone/keys.test.mjs index f22ef6ab..ec494fb2 100644 --- a/packages/inkstone/keys.test.mjs +++ b/packages/inkstone/keys.test.mjs @@ -183,9 +183,23 @@ test("pruneKeys with a protected name really deletes the others when it should", test("a key created without a plaintext is a clear failure, not a silent empty key", async () => { f = fakeInkStone() f.route("GET /v1/models", () => json({ object: "list", data: [] })) - f.route("GET /api/tokenplan/v1/keys", () => ok({ total: 0, items: [] })) - f.route("POST /api/tokenplan/v1/keys", () => fail(-15108, "created but hidden")) - await expect(ensureKey("uaa", "u", { create: true })).rejects.toThrow(/plaintext|did not return/i) + // -15108(建成了但明文拿不到)与软风控同类:孤儿不能留在账号上, + // ensureKey 删掉它再换名重试,耗尽后如实报错(#847 blocking 3)。 + const keys = [] + f.route("GET /api/tokenplan/v1/keys", () => ok({ total: keys.length, items: keys })) + f.route("POST /api/tokenplan/v1/keys", (r) => { + const id = `k${keys.length + 1}` + keys.push({ id, name: r.json?.name, status: "active" }) + f.route(`DELETE /api/tokenplan/v1/keys/${id}`, () => { + const at = keys.findIndex((k) => k.id === id) + if (at >= 0) keys.splice(at, 1) + return ok({}) + }) + return fail(-15108, "created but hidden") + }) + await expect(ensureKey("uaa", "u", { create: true, softRetryDelays: [0, 0] })).rejects.toThrow(/plaintext/i) + // 每把拿不到明文的孤儿都被删干净 + expect(keys.length).toBe(0) }) test("an idempotency clash retries with a FRESH name and returns the key string", async () => { diff --git a/packages/inkstone/lib/keys.mjs b/packages/inkstone/lib/keys.mjs index 552d6643..2c8cf23a 100644 --- a/packages/inkstone/lib/keys.mjs +++ b/packages/inkstone/lib/keys.mjs @@ -76,15 +76,21 @@ export async function keyCount(token) { } /** - * 名字为 `name` 的 Key 是否已在账号上(软风控 -15100 后的查证:POST 可能 - * 实际建成而明文被吞——重试会多建一把,这里按 -15108 说)。 + * 名字为 `name` 的孤儿 Key(软风控 -15100 吞了明文但 POST 实际建成): + * 按名字找到就删掉,别留在账号上(#847 blocking 3——孤儿不是死胡同, + * 删掉换名重试,不是让用户去粘贴)。 */ -async function keyCreatedUnderName(token, name) { +async function deleteKeyByName(token, name, log) { try { const listed = await listKeys(token) - return keyItems(listed).some((k) => k.name === name) - } catch { - return false // 列表读不到时不武断:让软重试继续 + const match = keyItems(listed).find((k) => k.name === name) + if (match) { + await deleteKey(token, match.id) + log?.("warn", "inkstone: deleted the refused key by name to retry with a fresh one") + } + } catch (e) { + // 列表读不到(同一风控):孤儿留给下次 prune,重试继续 + log?.("warn", "inkstone: could not list the keys to delete the refused one (" + (e?.message ?? e) + ")") } } @@ -133,7 +139,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, probeDelays } = {}) { +export async function ensureKey(token, username, { apiKey = "", create = true, prune = false, log, probeDelays, softRetryDelays = [2500, 2500] } = {}) { const pasted = String(apiKey ?? "").trim() if (pasted) { await probeWithRetry(pasted, log) @@ -164,7 +170,7 @@ export async function ensureKey(token, username, { apiKey = "", create = true, p reply = await createKey(token, name, randomUUID()) } catch (e) { // 幂等冲突(-15109):换一个 Idempotency-Key 和一个新名字再试(名字也 - // 可能撞),最多三次;-15108 是另一回事:创建成功但明文拿不到了。 + // 可能撞),最多三次。 if (e?.code === -15109) { let last = e for (let attempt = 0; attempt < 3; attempt++) { @@ -180,54 +186,61 @@ export async function ensureKey(token, username, { apiKey = "", create = true, p } if (last) throw last } - // -15108:创建成功但明文拿不到了,这把 Key 已无法使用,直接说明。 - else if (e?.code === -15108) { - throw new Error( - "the console created an API key but did not return it (the plaintext is shown only once). " + - "Create one in the console and paste it instead.", - ) - } - // 「API Key fetch failed, refresh page and retry」:登录窗口刚关、 - // headless 刚读完 cookie 就建 Key 时的软风控(真机实测,2026-11-07—— - // 同一枚 cookie 稍后重试就成功)。稍等重试两次,换名字换幂等键。 - // 但第一次 POST 可能实际已建成(软风控只是把明文吞了):重试前先列 - // 一遍 Key,刚才的名字在,就按 -15108 说——不是再建一把。 - else if (/fetch failed, refresh page/i.test(String(e?.message ?? ""))) { - const made = await keyCreatedUnderName(token, name) - if (made) { - throw new Error( - "the console created an API key but did not return it (the plaintext is shown only once). " + - "Create one in the console and paste it instead.", - ) - } + // 「建成了但明文没给」:-15108(平台明确答"创建成功、明文拿不到")与 + // -15100 软风控(吞掉明文)是同一个情形——账号上多了一把拿不到明文的 + // 孤儿 Key。blocking 3:不留死胡同,按名字删掉孤儿、换新名重试;重试 + // 期间再落进同一情形就再删再试,绝不指引用户去「粘贴一把 Key」。 + else if (e?.code === -15108 || /fetch failed, refresh page/i.test(String(e?.message ?? ""))) { + await deleteKeyByName(token, name, log) let last = e - for (let attempt = 0; attempt < 2; attempt++) { - await new Promise((r) => setTimeout(r, 2500)) + for (let attempt = 0; attempt < softRetryDelays.length; attempt++) { + if (softRetryDelays[attempt]) await new Promise((r) => setTimeout(r, softRetryDelays[attempt])) try { name = keyNameFor(username) reply = await createKey(token, name, randomUUID()) last = null break } catch (e2) { - // 重试落进同一个软风控:同样先查是不是建成了 - if (/fetch failed, refresh page/i.test(String(e2?.message ?? "")) && (await keyCreatedUnderName(token, name))) { - throw new Error( - "the console created an API key but did not return it (the plaintext is shown only once). " + - "Create one in the console and paste it instead.", - ) + if (e2?.code === -15108 || /fetch failed, refresh page/i.test(String(e2?.message ?? ""))) { + await deleteKeyByName(token, name, log) } last = e2 } } - if (last) throw last + if (last) { + if (last?.code === -15108) { + // 抬头就是"没拿到明文",不说"去粘贴" + throw Object.assign( + new Error( + "the console created an API key but did not return its plaintext; the orphan key was removed — please try the sign-in again.", + ), + { code: "no_plaintext" }, + ) + } + throw last + } } else throw e } - const created = String(reply?.data?.key ?? "").trim() + let created = String(reply?.data?.key ?? "").trim() + // 回复成功却没有明文(罕见):同上——删孤儿、换名重试,最多两次,耗尽后 + // 如实报错,但不说「去粘贴」。 + for (let attempt = 0; !created && attempt < 2; attempt++) { + await deleteKeyByName(token, name, log) + try { + name = keyNameFor(username) + reply = await createKey(token, name, randomUUID()) + } catch (e2) { + if (e2?.code === -15108 || /fetch failed, refresh page/i.test(String(e2?.message ?? ""))) continue + throw e2 + } + created = String(reply?.data?.key ?? "").trim() + } if (!created) { - // -15108:创建成功但明文拿不到了,这把 Key 已无法使用,直接说明。 - throw new Error( - "the console created an API key but did not return it (the plaintext is shown only once). " + - "Create one in the console and paste it instead.", + throw Object.assign( + new Error( + "the console kept creating API keys without returning their plaintext; the orphan keys were removed from the account — please try the sign-in again.", + ), + { code: "no_plaintext" }, ) } try { diff --git a/packages/inkstone/lib/preflight.mjs b/packages/inkstone/lib/preflight.mjs index bd602b9d..90c5a142 100644 --- a/packages/inkstone/lib/preflight.mjs +++ b/packages/inkstone/lib/preflight.mjs @@ -130,26 +130,45 @@ function refused(detail, detailZh) { */ const UNSUPPORTED_FIELDS = ["reasoning", "reasoning_effort", "thinking"] +/** + * rewriteBodyObj 是否真改过东西,挂在一个不可枚举的 symbol 上——调用方据此 + * 在「一字未动」时把原字节原样发出(review N1:未变的 body 以前是逐字节 + * 透传,重新 JSON.stringify 会改掉空格与键序,同一请求体不再稳定)。 + */ +const REWRITTEN = Symbol("inkstone.rewritten") + export function rewriteBodyObj(body) { if (!body || typeof body !== "object" || Array.isArray(body)) return body + let changed = false for (const field of UNSUPPORTED_FIELDS) { - delete body[field] + if (field in body) { + delete body[field] + changed = true + } } if (body.output_config && typeof body.output_config === "object" && !Array.isArray(body.output_config) && "effort" in body.output_config) { delete body.output_config.effort + changed = true } for (const message of Array.isArray(body.messages) ? body.messages : []) { if (!Array.isArray(message?.content)) continue for (const part of message.content) { if (part?.type === "image_url" && part?.image_url && typeof part.image_url === "object" && part.image_url.detail === undefined) { part.image_url.detail = "auto" + changed = true } } } + Object.defineProperty(body, REWRITTEN, { value: changed, enumerable: false, configurable: true }) return body } -/** 字符串版:解析 → rewriteBodyObj → 序列化;解析失败原样返回(兼容直接调用)。 */ +/** rewriteBodyObj 改过没有(没改就把原字节发出去,不重新序列化)。 */ +export function rewriteBodyChanged(body) { + return Boolean(body && typeof body === "object" && body[REWRITTEN]) +} + +/** 字符串版:解析 → rewriteBodyObj → 序列化;未改动或解析失败时原样返回。 */ export function rewriteBody(text) { let body try { @@ -158,6 +177,7 @@ export function rewriteBody(text) { return text } const rewritten = rewriteBodyObj(body) + if (!rewriteBodyChanged(rewritten)) return text return JSON.stringify(rewritten) } diff --git a/packages/inkstone/preflight.test.mjs b/packages/inkstone/preflight.test.mjs index 0b2dace3..5c3dee80 100644 --- a/packages/inkstone/preflight.test.mjs +++ b/packages/inkstone/preflight.test.mjs @@ -184,6 +184,10 @@ describe("rewriteBody", () => { expect("thinking" in out).toBe(false) expect("effort" in out.output_config).toBe(false) }) + test("a body that parses clean goes out byte-for-byte, not re-serialized (review N1)", () => { + const spaced = '{\n "model": "Sage-A1.5",\n "messages": [ { "role": "user", "content": "hi" } ]\n}' + expect(rewriteBody(spaced)).toBe(spaced) + }) test("a body that is not JSON, not an object, or already clean goes back as it came", () => { expect(rewriteBody("not json")).toBe("not json") expect(rewriteBody("[1,2]")).toBe("[1,2]") From 5957460c478435820051a7a8c41cfff40a883f73 Mon Sep 17 00:00:00 2001 From: nonamelane <242740720+nonamelane@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:05:37 +0800 Subject: [PATCH 4/7] inkstone 0.1.5: the window closes within about half a minute, the browser tests are scoped to this tree, and the cookie-probe claim is asserted (blocking 5, nits) --- packages/inkstone/README.md | 10 ++++++---- packages/inkstone/cookie-probe.test.mjs | 9 +++++++++ packages/inkstone/fake-browser.test.mjs | 17 +++++++++++++---- packages/inkstone/package.json | 2 +- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/inkstone/README.md b/packages/inkstone/README.md index 3854b07a..a4ad6bff 100644 --- a/packages/inkstone/README.md +++ b/packages/inkstone/README.md @@ -27,7 +27,7 @@ API Key 是可选的:不填,账号仍显示额度,只是不服务模型。 | 方式 | 做什么 | 产出 | |---|---|---| -| **浏览器登录(推荐)** | **零提示**:点一下就直接开窗。用**插件自己的专用浏览器 profile** 打开控制台,你在里面登录(密码 / 短信 / 扫码),登录完成后自动创建 `ink-*` API Key。**所有平台插件都自己判断登录完成并自动关窗**(Windows 读窗口标题;macOS / Linux 轮询专用 profile 的 cookie 存储,看到控制台会话就关窗——通常不需要你手动关闭浏览器,开着也行)。之后的续期/重登都静默完成 | 额度 + 模型 | +| **浏览器登录(推荐)** | **零提示**:点一下就直接开窗。用**插件自己的专用浏览器 profile** 打开控制台,你在里面登录(密码 / 短信 / 扫码),登录完成后自动创建 `ink-*` API Key。**所有平台插件都自己判断登录完成并自动关窗**(Windows 读窗口标题;macOS / Linux 轮询专用 profile 的 cookie 存储,看到控制台会话就关窗——Chrome 约每 30 秒才把 cookie 写盘,所以通常在登录完成后**半分钟内**关窗,不必手动关闭,开着也行)。之后的续期/重登都静默完成 | 额度 + 模型 | | **浏览器登录 + 复用已有 Key** | 同上,但开窗前会先问一句要不要粘贴现有 Key(留空则新建) | 额度 + 模型 | | **仅 API Key** | 只粘贴 Key;`GET /v1/models` 探活 | 只有模型 | | **API Key + 控制台 token** | 粘贴 Key,再粘贴 `uaa-token=…` 那一项(DevTools → Application → Cookies,复制整条 `uaa-token=…` 即可,插件只取值) | 额度 + 模型 | @@ -188,7 +188,8 @@ Unofficial. One console sign-in gives both the ink credits (balance, 5-hour and Four sign-in methods produce the same `intern-inkstone` account: a **zero-prompt browser sign-in** (one click opens the plugin's own browser profile; the plugin watches for the console session — the window title on Windows, the profile's -cookie store elsewhere — and closes the window itself, then creates an `ink-*` +cookie store elsewhere, where Chrome flushes cookies on a timer, so the window +closes within about half a minute — and closes it itself, then creates an `ink-*`` API key), the same browser sign-in with an optional pasted key to reuse, API key only (models, no credit card), and API key + console token (pasted `uaa-token=…`, models and card). The sign-in window @@ -218,8 +219,9 @@ would misread (its "cumulative image bytes exceeded …" hits magpie's quota wor and would rest a healthy account), are translated to the same shape; the plan's own wording never reaches the response body. Inline images missing a `detail` get `detail: "auto"`, and the reasoning fields the plan silently ignores are -stripped from the body (checked live: `reasoning_effort`, `reasoning.effort` -and `thinking.type` all answer 200 with identical output on the free models). +stripped from the body (one live check on the free models: each of +`reasoning_effort`, `reasoning.effort` and `thinking.type` was answered 200 and +left the output unchanged — an observation, not a promise for every model). `intern-decision-4b` is a SystemOne decision model, not a chat candidate. The plan takes no reasoning-effort controls (it silently ignores them), so the diff --git a/packages/inkstone/cookie-probe.test.mjs b/packages/inkstone/cookie-probe.test.mjs index fe5388b6..c83feff8 100644 --- a/packages/inkstone/cookie-probe.test.mjs +++ b/packages/inkstone/cookie-probe.test.mjs @@ -113,10 +113,17 @@ test("off Windows the console title is never read, and a new cookie ends the wai try { const profile = fakeProfileWith([]) let probes = 0 + let titles = 0 + // a title that would end the wait at once, if this platform read it + const titleOf = () => { + titles += 1 + return "书生·端砚 Intern InkStone" + } const logs = [] const how = await waitForConsoleWindow(child, { platform: "darwin", profile, + titleOf, cookieProbe: () => { probes += 1 return probes >= 3 // the console writes the session, on poll 3 @@ -127,6 +134,8 @@ test("off Windows the console title is never read, and a new cookie ends the wai }) expect(how).toBe("console") expect(probes).toBeGreaterThanOrEqual(3) + // the title is never read off Windows (it would have ended the wait at once) + expect(titles).toBe(0) // the progress lines #840 asks for expect(logs.some((l) => l.includes("cookie store"))).toBe(true) expect(logs.some((l) => l.includes("closing the window"))).toBe(true) diff --git a/packages/inkstone/fake-browser.test.mjs b/packages/inkstone/fake-browser.test.mjs index b5b900a1..dae32cda 100644 --- a/packages/inkstone/fake-browser.test.mjs +++ b/packages/inkstone/fake-browser.test.mjs @@ -45,12 +45,21 @@ beforeAll(() => { user = user.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 16) || "u" } const key = createHash("sha256").update(readFileSync(entry)).digest("hex").slice(0, 12) - fakeDir = join(tmpdir(), `inkstone-fake-browser-${user}-${key}`) + // A tree fingerprint: both the exe path and the sweep are scoped to THIS + // checkout (import.meta.dir). Without it, a concurrent run of another tree + // with the same user and source hash lands on the same dir, and this sweep + // could delete the other tree's build mid-run (the review's point). Older + // hashes of this same tree are still removed, so a source change does not + // pile up ~60 MB directories. + const tree = createHash("sha256").update(import.meta.dir).digest("hex").slice(0, 8) + const name = `inkstone-fake-browser-${user}-${tree}-${key}` + fakeDir = join(tmpdir(), name) mkdirSync(fakeDir, { recursive: true }) - // siblings: same user, another source hash — a previous build, never the - // concurrent one (its hash is ours) + // siblings: same user AND same tree, another source hash — a previous build + // of this checkout, never a concurrent run of a different tree + const owned = `inkstone-fake-browser-${user}-${tree}-` for (const sibling of existsSync(tmpdir()) ? readdirSync(tmpdir()) : []) { - if (sibling.startsWith(`inkstone-fake-browser-${user}-`) && sibling !== `inkstone-fake-browser-${user}-${key}`) { + if (sibling.startsWith(owned) && sibling !== name) { try { rmSync(join(tmpdir(), sibling), { recursive: true, force: true }) } catch {} diff --git a/packages/inkstone/package.json b/packages/inkstone/package.json index e85226fb..4fdea709 100644 --- a/packages/inkstone/package.json +++ b/packages/inkstone/package.json @@ -1,6 +1,6 @@ { "name": "@magpie-community/opencode-inkstone-auth", - "version": "0.1.4", + "version": "0.1.5", "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", From 07e82babe6165ce983b6d5c4c527321eed9cb40a Mon Sep 17 00:00:00 2001 From: nonamelane <242740720+nonamelane@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:33:32 +0800 Subject: [PATCH 5/7] inkstone: the orphan-deleting key list is held to the shared strict fake too, so every listKeys caller is covered (blocking 2) --- packages/inkstone/keys.test.mjs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/inkstone/keys.test.mjs b/packages/inkstone/keys.test.mjs index ec494fb2..f56c65ec 100644 --- a/packages/inkstone/keys.test.mjs +++ b/packages/inkstone/keys.test.mjs @@ -29,6 +29,17 @@ function serveKeys(initial = []) { keys.push({ id: `id-${keys.length + 1}`, name, status: "active", created_at: now(keys.length + 1) }) return ok({ key: "sk-new" }) }) + // the delete path too: the refused-key flow deletes its orphan by id, and + // that call goes through the same strict /keys fake (a query added there + // would hit -15100, a bearer-only request -10002) + for (let i = 1; i <= 6; i++) { + f.route(`DELETE /api/tokenplan/v1/keys/id-${i}`, (r) => { + const id = r.url.split("/").pop() + const at = keys.findIndex((k) => k.id === id) + if (at >= 0) keys.splice(at, 1) + return ok({}) + }) + } return keys } @@ -260,3 +271,19 @@ test("a refused new key is deleted by name - the cleanup listKeys carries no que expect(keys.some((k) => k.id === "id-new")).toBe(false) expect(keys).toHaveLength(1) }) + +test("the orphan-deleting listKeys carries no query either - the shared strict fake holds it", async () => { + // #847 blocking 3 + blocking 2 together: the refused-key flow lists the keys + // to find its orphan, and that call goes through the same serveKeys() fake. + // A pagination query put back on it answers -15100, the orphan is not found, + // it survives on the account, and this goes red. + f = fakeInkStone() + const keys = serveKeys([]) + f.route("POST /api/tokenplan/v1/keys", (r) => { + keys.push({ id: `id-${keys.length + 1}`, name: r.json?.name, status: "active", created_at: now(keys.length + 1) }) + return fail(-15108, "created but hidden") + }) + await expect(ensureKey("uaa", "u", { create: true, softRetryDelays: [0, 0] })).rejects.toThrow(/plaintext/i) + // every orphan was found and deleted through the strict list + expect(keys).toHaveLength(0) +}) From 1925a258812d771affc633c5bc5cf794085b8c3e Mon Sep 17 00:00:00 2001 From: nonamelane <242740720+nonamelane@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:18:08 +0800 Subject: [PATCH 6/7] inkstone: carry the Request's method/headers/signal when re-attaching its body, drive the production cookie probe with no injection, delete the last orphan and report removal truthfully, and clean up a -15108 inside the -15109 retry (third-round review) --- packages/inkstone/fake-browser.test.mjs | 22 ++++--- packages/inkstone/index.mjs | 15 ++++- packages/inkstone/keys.test.mjs | 68 +++++++++++++++++++++ packages/inkstone/lib/keys.mjs | 80 +++++++++++++++---------- 4 files changed, 138 insertions(+), 47 deletions(-) diff --git a/packages/inkstone/fake-browser.test.mjs b/packages/inkstone/fake-browser.test.mjs index dae32cda..e1aa4519 100644 --- a/packages/inkstone/fake-browser.test.mjs +++ b/packages/inkstone/fake-browser.test.mjs @@ -236,30 +236,28 @@ test("the reuse method keeps a pasted key instead of creating one", async () => }, 60_000) test("off Windows the production wiring closes the window on a new cookie", async () => { - // B4: the real signInWithBrowser path (baseline → probe → close), with the - // sqlite probe injected so the test does not depend on where a compiled - // Chromium stand-in happens to write its profile on each OS. Removing the - // cookieProbe wiring from signInWithBrowser makes the wait ignore the - // injected probe and end on the child's exit, not "console" — this goes red. + // B4 (third-round review): the real signInWithBrowser path with NO injected + // probe — launch baseline → the real sqlite probe → "console". The stand-in + // writes its uaa-token row ~300 ms in, and the wait must see it through the + // production wiring. Replacing the wiring with `loginWait?.cookieProbe` + // alone (no probe at all off Windows) leaves this wait to the child's exit + // and it never says "console" — this goes red. f = fakeInkStone() serveKeyRoutes() + // a clean store: whatever a previous test left is in the baseline anyway, + // but starting empty makes "a new row appeared" unambiguous + try { rmSync(join(profile, "Default", "Cookies"), { force: true }) } catch {} const logs = [] - let probes = 0 const token = await signInWithBrowser({ browserPath: fakeExe, keepSignIn: true, timeoutMs: 6000, - loginWait: { - platform: "linux", - pollMs: 50, - cookieProbe: () => ++probes >= 3, - }, + loginWait: { platform: "linux", pollMs: 50 }, log: (...args) => logs.push(args.map(String).join(" ")), }) expect(token).toBe("fake-uaa-token-for-tests") expect(logs.some((l) => l.includes("login window finished (console)"))).toBe(true) expect(logs.some((l) => l.includes("cookie store"))).toBe(true) - expect(probes).toBeGreaterThanOrEqual(3) }, 30_000) test("off Windows an old cookie in the kept profile is not this sign-in", async () => { diff --git a/packages/inkstone/index.mjs b/packages/inkstone/index.mjs index 51b3661c..9eb23d6b 100644 --- a/packages/inkstone/index.mjs +++ b/packages/inkstone/index.mjs @@ -69,6 +69,14 @@ function chatFetch(log = () => {}) { else if (raw instanceof ArrayBuffer) text = new TextDecoder().decode(raw) else if (ArrayBuffer.isView(raw)) text = new TextDecoder().decode(raw) else if (isRequest) text = await input.text().catch(() => "") + if (isRequest) { + // 第三轮 review blocking 1:Request 的流已被 text() 读掉,url/method/ + // headers/signal 必须由 init 显式携带——宿主进程里可能有别的 fetch 包装 + // 层只读 (url, init)(catpaw 的泄漏,#877),一个不带 method 的重挂 + // body 会被它当成 GET 发出去("GET/HEAD cannot have body")。 + // ...init 在后:调用方显式给的设置仍然优先。 + init = { method: input.method, headers: input.headers, signal: input.signal, ...init } + } if (text) { // 解析一次,预检与改写共用同一个对象(近 40MB 的 body 解析两遍是浪费) let body @@ -87,15 +95,16 @@ function chatFetch(log = () => {}) { }) } // ②③改写:补 detail、剥无效参数。一字未动就保留原字节(review N1, - // 不再无条件重新序列化);改过才原地改写后重新序列化。 + // 不再无条件重新序列化);改过才原地改写后重新序列化。Request 的 + // method/headers 已在上面带进 init,这条路同样成立。 const rewritten = rewriteBodyObj(body) if (rewriteBodyChanged(rewritten)) init = { ...init, body: JSON.stringify(rewritten) } } } // N2: 若是 Request 且上面没重挂 body,把读到的字节原样交回去。 if (isRequest && init.body === undefined) init = { ...init, body: text } - // 真发。 - const res = await upstream(input, init) + // 真发。Request 时 url 也一起显式化(不再传被读空的对象)。 + const res = await upstream(isRequest ? input.url : input, init) // ④错误翻译:端砚的图片错误码宿主读不对,换成宿主认得的形状。 if (!res.ok && res.status !== 429) { let said = "" diff --git a/packages/inkstone/keys.test.mjs b/packages/inkstone/keys.test.mjs index f56c65ec..037bab17 100644 --- a/packages/inkstone/keys.test.mjs +++ b/packages/inkstone/keys.test.mjs @@ -213,6 +213,74 @@ test("a key created without a plaintext is a clear failure, not a silent empty k expect(keys.length).toBe(0) }) +test("a successful reply without a plaintext deletes the LAST orphan too", async () => { + // 第三轮 review:这个循环的最后一轮 continue 之后没有下一轮顶部的删除, + // 所以退出循环时必须再补删一次。若缺了这一步,keys 会剩一把,测试变红。 + f = fakeInkStone() + f.route("GET /v1/models", () => json({ object: "list", data: [] })) + const keys = [] + f.route("GET /api/tokenplan/v1/keys", () => ok({ total: keys.length, items: keys })) + f.route("POST /api/tokenplan/v1/keys", (r) => { + const id = `k${keys.length + 1}` + keys.push({ id, name: r.json?.name, status: "active" }) + f.route(`DELETE /api/tokenplan/v1/keys/${id}`, () => { + const at = keys.findIndex((k) => k.id === id) + if (at >= 0) keys.splice(at, 1) + return ok({}) + }) + // 建成了,但回复里没有明文(没有 key 字段) + return ok({}) + }) + await expect(ensureKey("uaa", "u", { create: true })).rejects.toThrow(/plaintext/i) + expect(keys.length).toBe(0) +}) + +test("-15108 inside the -15109 retry is cleaned up and reported clearly, not thrown raw", async () => { + // 第三轮 review:幂等重试里撞上 -15108 时,孤儿也要删、文案也要清楚。 + f = fakeInkStone() + f.route("GET /v1/models", () => json({ object: "list", data: [] })) + const keys = [] + f.route("GET /api/tokenplan/v1/keys", () => ok({ total: keys.length, items: keys })) + let posts = 0 + f.route("POST /api/tokenplan/v1/keys", (r) => { + posts += 1 + if (posts === 1) return fail(-15109, "name clash") + const id = `k${keys.length + 1}` + keys.push({ id, name: r.json?.name, status: "active" }) + f.route(`DELETE /api/tokenplan/v1/keys/${id}`, () => { + const at = keys.findIndex((k) => k.id === id) + if (at >= 0) keys.splice(at, 1) + return ok({}) + }) + return fail(-15108, "created but hidden") + }) + await expect(ensureKey("uaa", "u", { create: true })).rejects.toThrow(/plaintext/i) + expect(keys.length).toBe(0) +}) + +test("when the orphan list fails, the error does not claim it was removed", async () => { + // 第三轮 review:deleteKeyByName 在列表读不到时只记日志返回——对外不能 + // 说「已移除」。这里第一次 list(keyCount)成功,之后的 list 全部失败。 + f = fakeInkStone() + f.route("GET /v1/models", () => json({ object: "list", data: [] })) + let lists = 0 + f.route("GET /api/tokenplan/v1/keys", () => { + lists += 1 + if (lists === 1) return ok({ total: 0, items: [] }) + return fail(-10002, "request is not authenticated") + }) + f.route("POST /api/tokenplan/v1/keys", () => fail(-15108, "created but hidden")) + let said = "" + try { + await ensureKey("uaa", "u", { create: true, softRetryDelays: [0, 0] }) + } catch (e) { + said = String(e?.message ?? e) + } + expect(said).toMatch(/plaintext/i) + expect(said).toContain("could not be deleted") + expect(said).not.toContain("was removed") +}) + test("an idempotency clash retries with a FRESH name and returns the key string", async () => { f = fakeInkStone() f.route("GET /v1/models", () => json({ object: "list", data: [] })) diff --git a/packages/inkstone/lib/keys.mjs b/packages/inkstone/lib/keys.mjs index 2c8cf23a..0b07b094 100644 --- a/packages/inkstone/lib/keys.mjs +++ b/packages/inkstone/lib/keys.mjs @@ -79,18 +79,21 @@ export async function keyCount(token) { * 名字为 `name` 的孤儿 Key(软风控 -15100 吞了明文但 POST 实际建成): * 按名字找到就删掉,别留在账号上(#847 blocking 3——孤儿不是死胡同, * 删掉换名重试,不是让用户去粘贴)。 + * 返回是否**真的删掉了一把**:调用方只有在 true 时才能说「已移除」 + * (第三轮 review:列表读不到时这里只记日志返回,不能对外宣称删了)。 */ async function deleteKeyByName(token, name, log) { try { const listed = await listKeys(token) const match = keyItems(listed).find((k) => k.name === name) - if (match) { - await deleteKey(token, match.id) - log?.("warn", "inkstone: deleted the refused key by name to retry with a fresh one") - } + if (!match) return false + await deleteKey(token, match.id) + log?.("warn", "inkstone: deleted the refused key by name to retry with a fresh one") + return true } catch (e) { // 列表读不到(同一风控):孤儿留给下次 prune,重试继续 log?.("warn", "inkstone: could not list the keys to delete the refused one (" + (e?.message ?? e) + ")") + return false } } @@ -166,11 +169,28 @@ export async function ensureKey(token, username, { apiKey = "", create = true, p let name = keyNameFor(username) let reply + // 「建成了但明文没给」:-15108(平台明确答"创建成功、明文拿不到")与 + // -15100 软风控(吞掉明文)是同一个情形——账号上多了一把拿不到明文的 + // 孤儿 Key。重试换新名,孤儿先删;耗尽后如实说,且只有真删掉了才说 + // 「已移除」(第三轮 review:最后一轮的孤儿也要删,文案不能谎报)。 + const plaintextLost = (err) => + err?.code === -15108 || /fetch failed, refresh page/i.test(String(err?.message ?? "")) + const noPlaintextError = (removed) => + Object.assign( + new Error( + removed + ? "the console created an API key but did not return its plaintext; the orphan key was removed — please try the sign-in again." + : "the console created an API key but did not return its plaintext, and the orphan could not be deleted from here — please check the console's API key page.", + ), + { code: "no_plaintext" }, + ) + let removed = false // 最后一次删孤儿是否真的删掉了 try { reply = await createKey(token, name, randomUUID()) } catch (e) { // 幂等冲突(-15109):换一个 Idempotency-Key 和一个新名字再试(名字也 - // 可能撞),最多三次。 + // 可能撞),最多三次。这个循环里也可能撞上 -15108/软风控:同样先删 + // 孤儿再换名重试(第三轮 review:不能裸抛)。 if (e?.code === -15109) { let last = e for (let attempt = 0; attempt < 3; attempt++) { @@ -180,17 +200,23 @@ export async function ensureKey(token, username, { apiKey = "", create = true, p last = null break } catch (e2) { + if (plaintextLost(e2)) { + removed = await deleteKeyByName(token, name, log) + last = e2 + continue + } if (e2?.code !== -15109) throw e2 last = e2 } } - if (last) throw last + if (last) { + if (last?.code === -15108) throw noPlaintextError(removed) + throw last + } } - // 「建成了但明文没给」:-15108(平台明确答"创建成功、明文拿不到")与 - // -15100 软风控(吞掉明文)是同一个情形——账号上多了一把拿不到明文的 - // 孤儿 Key。blocking 3:不留死胡同,按名字删掉孤儿、换新名重试;重试 - // 期间再落进同一情形就再删再试,绝不指引用户去「粘贴一把 Key」。 - else if (e?.code === -15108 || /fetch failed, refresh page/i.test(String(e?.message ?? ""))) { + // 「建成了但明文没给」(同上):删孤儿、换新名重试,耗尽后按真实删除 + // 结果说话;-15100 的原文照旧透传(宿主认它)。 + else if (plaintextLost(e)) { await deleteKeyByName(token, name, log) let last = e for (let attempt = 0; attempt < softRetryDelays.length; attempt++) { @@ -201,47 +227,37 @@ export async function ensureKey(token, username, { apiKey = "", create = true, p last = null break } catch (e2) { - if (e2?.code === -15108 || /fetch failed, refresh page/i.test(String(e2?.message ?? ""))) { - await deleteKeyByName(token, name, log) - } + if (plaintextLost(e2)) removed = await deleteKeyByName(token, name, log) last = e2 } } if (last) { - if (last?.code === -15108) { - // 抬头就是"没拿到明文",不说"去粘贴" - throw Object.assign( - new Error( - "the console created an API key but did not return its plaintext; the orphan key was removed — please try the sign-in again.", - ), - { code: "no_plaintext" }, - ) - } + if (last?.code === -15108) throw noPlaintextError(removed) throw last } } else throw e } let created = String(reply?.data?.key ?? "").trim() - // 回复成功却没有明文(罕见):同上——删孤儿、换名重试,最多两次,耗尽后 - // 如实报错,但不说「去粘贴」。 + // 回复成功却没有明文(罕见):同上——删上一轮的孤儿、换名重试,最多两次。 + // 最后一轮的孤儿在 continue 之后没有下一轮顶部的删除,所以循环外再补删 + // 一次,并按那次删除的真实结果说话(第三轮 review)。 for (let attempt = 0; !created && attempt < 2; attempt++) { await deleteKeyByName(token, name, log) try { name = keyNameFor(username) reply = await createKey(token, name, randomUUID()) } catch (e2) { - if (e2?.code === -15108 || /fetch failed, refresh page/i.test(String(e2?.message ?? ""))) continue + if (plaintextLost(e2)) { + removed = await deleteKeyByName(token, name, log) + continue + } throw e2 } created = String(reply?.data?.key ?? "").trim() } if (!created) { - throw Object.assign( - new Error( - "the console kept creating API keys without returning their plaintext; the orphan keys were removed from the account — please try the sign-in again.", - ), - { code: "no_plaintext" }, - ) + if (!removed) removed = await deleteKeyByName(token, name, log) + throw noPlaintextError(removed) } try { await probeWithRetry(created, log, probeDelays) From d90df2628a7290e30c535df66e9a7d58da1977b5 Mon Sep 17 00:00:00 2001 From: nonamelane <242740720+nonamelane@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:39:31 +0800 Subject: [PATCH 7/7] inkstone: the no-plaintext retry resets removed per attempt, so the last orphan is deleted and the message stays true, and the re-attached Request is sent as url + full init (third-round follow-up) --- packages/inkstone/api.test.mjs | 28 +++++++++++++++++++++++++++ packages/inkstone/keys.test.mjs | 34 +++++++++++++++++++++++++++++++++ packages/inkstone/lib/keys.mjs | 5 +++++ 3 files changed, 67 insertions(+) diff --git a/packages/inkstone/api.test.mjs b/packages/inkstone/api.test.mjs index 66aaa3df..a03482b5 100644 --- a/packages/inkstone/api.test.mjs +++ b/packages/inkstone/api.test.mjs @@ -331,6 +331,34 @@ test("the fetch wiring is the request path — each piece breaks it in a way the const r4 = await loader.fetch(req) expect(r4.status).toBe(200) expect(gotRaw).toBe("not json at all") + + // ⑥b 第四轮 review 备忘:Request 的流被读掉后,交给上游的是「url 字符串 + + // 完整 init」,不是那个已读空的 Request。装一个一次性包装 fetch 钉住 + // 这一半——只把 init 原样转发的包装层(catpaw 泄漏那类)也照样能跑。 + const realFetch = globalThis.fetch + let sawSpentRequest = false + let sawUrl = "" + globalThis.fetch = (input2, init2 = {}) => { + if (String(init2?.method ?? "").toUpperCase() === "POST" && String(input2 ?? "").includes("/chat/completions")) { + if (typeof input2 !== "string") sawSpentRequest = true + else sawUrl = input2 + } + return realFetch(input2, init2) + } + try { + const r5 = await loader.fetch( + new Request(`${f.origin}/v1/chat/completions`, { + method: "POST", + body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }), + headers: { "content-type": "application/json" }, + }), + ) + expect(r5.status).toBe(200) + expect(sawSpentRequest).toBe(false) + expect(sawUrl).toBe(`${f.origin}/v1/chat/completions`) + } finally { + globalThis.fetch = realFetch + } }) test("cleanDetail masks real uaa-token, sk-, Bearer and JWT runs", async () => { diff --git a/packages/inkstone/keys.test.mjs b/packages/inkstone/keys.test.mjs index 037bab17..f0021b46 100644 --- a/packages/inkstone/keys.test.mjs +++ b/packages/inkstone/keys.test.mjs @@ -281,6 +281,40 @@ test("when the orphan list fails, the error does not claim it was removed", asyn expect(said).not.toContain("was removed") }) +test("a stale removed does not spare the last orphan (-15108 mid, no-plaintext success last)", async () => { + // 第四轮 review 的序列:①成功但没给明文 → ②-15108(删掉,removed=true) + // → ③又「成功但没给明文」。陈旧的 removed 会让循环外的补删被跳过:最后 + // 那把留在账号上,文案却声称已移除。修好后最后那把也删——账号零孤儿。 + f = fakeInkStone() + f.route("GET /v1/models", () => json({ object: "list", data: [] })) + const keys = [] + f.route("GET /api/tokenplan/v1/keys", () => ok({ total: keys.length, items: keys })) + let posts = 0 + f.route("POST /api/tokenplan/v1/keys", (r) => { + posts += 1 + const id = `k${keys.length + 1}` + keys.push({ id, name: r.json?.name, status: "active" }) + f.route(`DELETE /api/tokenplan/v1/keys/${id}`, () => { + const at = keys.findIndex((k) => k.id === id) + if (at >= 0) keys.splice(at, 1) + return ok({}) + }) + if (posts === 2) return fail(-15108, "created but hidden") + return ok({}) // 建成了,但回复里没有明文 + }) + let said = "" + try { + await ensureKey("uaa", "u", { create: true }) + } catch (e) { + said = String(e?.message ?? e) + } + expect(said).toMatch(/plaintext/i) + // 最后那把(③)也删了:账号零孤儿 + expect(keys.length).toBe(0) + // 且它确实被删掉了,所以这句是实话 + expect(said).toContain("was removed") +}) + test("an idempotency clash retries with a FRESH name and returns the key string", async () => { f = fakeInkStone() f.route("GET /v1/models", () => json({ object: "list", data: [] })) diff --git a/packages/inkstone/lib/keys.mjs b/packages/inkstone/lib/keys.mjs index 0b07b094..12ee8df3 100644 --- a/packages/inkstone/lib/keys.mjs +++ b/packages/inkstone/lib/keys.mjs @@ -243,6 +243,11 @@ export async function ensureKey(token, username, { apiKey = "", create = true, p // 一次,并按那次删除的真实结果说话(第三轮 review)。 for (let attempt = 0; !created && attempt < 2; attempt++) { await deleteKeyByName(token, name, log) + // 每轮重置:`removed` 只描述「本轮这把孤儿」的删除结果,不继承前几轮 + // 的。中间一轮 -15108 删过、最后一轮又是「成功但没明文」时,陈旧的 + // true 会让循环外的补删被跳过——孤儿留下、文案却声称已移除(第四轮 + // review:按轮设,不按整函数设)。 + removed = false try { name = keyNameFor(username) reply = await createKey(token, name, randomUUID())