Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions packages/inkstone/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ API Key 是可选的:不填,账号仍显示额度,只是不服务模型。

| 方式 | 做什么 | 产出 |
|---|---|---|
| **浏览器登录(推荐)** | **零提示**:点一下就直接开窗。用**插件自己的专用浏览器 profile** 打开控制台,你在里面登录(密码 / 短信 / 扫码),登录完成后自动创建 `ink-*` API Key。**Windows** 上插件用窗口标题判断登录完成、**自己关窗**;**macOS / Linux** 读不了窗口标题,登录完成后请**自己关掉窗口**,插件接着自动收尾。之后的续期/重登都静默完成 | 额度 + 模型 |
| **浏览器登录(推荐)** | **零提示**:点一下就直接开窗。用**插件自己的专用浏览器 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=…` 即可,插件只取值) | 额度 + 模型 |
Expand Down Expand Up @@ -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 忽略):**

Expand Down Expand Up @@ -185,8 +187,10 @@ 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, 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
carries no debugging port, so the console's captcha sees an ordinary browser.
Expand Down Expand Up @@ -215,7 +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.
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
Expand Down
100 changes: 95 additions & 5 deletions packages/inkstone/api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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=")
Expand All @@ -295,11 +311,54 @@ 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])

// ⑥ 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")

// ⑥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 () => {
Expand All @@ -312,3 +371,34 @@ test("cleanDetail masks real uaa-token, sk-, Bearer and JWT runs", async () => {
// diagnostic words must stay
expect(cleanDetail("error: token expired")).toBe("error: token expired")
})

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) => {
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: () => {}, softRetryDelays: [0, 0] })
} catch (e) {
said = String(e?.message ?? e)
}
// 三次尝试(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")
})
159 changes: 159 additions & 0 deletions packages/inkstone/cookie-probe.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// #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 { chromiumMicros, cookieBaseline, hostMatches, profileHasNewCookie } 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 库:<profile>/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 (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
}

const DOMAIN = "intern-ai.org.cn"

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(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(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 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([])
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
},
timeoutMs: 5000,
pollMs: 20,
log: (...args) => logs.push(args.map(String).join(" ")),
})
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)
} finally {
try { child.kill() } catch {}
}
})

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 {
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 {}
}
})
Loading
Loading