diff --git a/Agent.md b/Agent.md index 173c5e5a..4eadbf3e 100644 --- a/Agent.md +++ b/Agent.md @@ -54,8 +54,8 @@ What EMRG does today — capability statements, not a changelog. One line per command: what it answers. Run the daemon in the foreground with `python -m emrg`. - Python: `uv run pytest tests/ -v` — the number is **measured, never stored**: `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (175: 100 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 11 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash + 7 switch-session) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (765: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 21 markdown + 34 transcript + 32 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 12 dialogLists + 3 HelpDialog + 9 MemoryDialog + 8 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 60 daemonBridge + 9 DaemonBridgeProvider + 53 Shell + 25 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 8 vendorMarkdown + 6 ApprovalDialog + 16 SandboxRootsDialog) +GUI: `cd emrg/gui && npm test` (194: 100 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 11 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash + 7 switch-session + 19 atp_login) — syntax: `node --check main.js preload.js daemon_client.js` +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (774: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 21 markdown + 34 transcript + 32 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 12 dialogLists + 3 HelpDialog + 9 MemoryDialog + 8 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 60 daemonBridge + 9 DaemonBridgeProvider + 53 Shell + 25 DialogHost + 35 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 8 vendorMarkdown + 6 ApprovalDialog + 16 SandboxRootsDialog) - CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push with `scripts/re-trigger-ci.sh `, never an empty commit - Merge gates, run before merging — each asks what no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (dirt and what it lands) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (a pair, or a whole order) · `scripts/check-merge-tree-health.py` (the merged tree passes one guard) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3` - Post-merge audit: `uv run --no-sync python3 scripts/check-merge-landed.py ` (did the merge land a tree its reviews named) diff --git a/emrg/gui/atp_login.js b/emrg/gui/atp_login.js new file mode 100644 index 00000000..f9c7834e --- /dev/null +++ b/emrg/gui/atp_login.js @@ -0,0 +1,302 @@ +"use strict"; +/** + * atp_login.js — aitokenpool 的 GitHub 账号登录(issue #2774)。 + * + * 设置页要能「点一次、浏览器授权、回来就配好了」。这里只放**不讲界面、不碰文件、不碰时间** + * 的那部分:设备流的三个 HTTP 往返、服务端模型清单的读法,以及把结果并进 config.toml 文本 + * 的那一步。为什么在这里:renderer 是 sandbox + contextIsolation,没有网络权限(preload.js + * 头注释),所以网络与写盘都归 main;而 main.js 只被 `node --check` 看、不被单测跑,于是逻辑 + * 落在这个纯模块里,main 只负责把 fs 与 fetch 接上。 + * + * 三条硬约束,此处是它们唯一的实现点: + * - 探测不过就**一个字节都不写**:`completeLogin` 里 probe 在 writeConfig 之前,失败即返回; + * - `[llm]` 的其它键与其它段逐字保留:`applyAtpConfig` 只动 base_url / api_key / model 与 + * 对应那一条 `[[llm.models]]`,其余原样收发; + * - `[[llm.models]]` 已有同名条目时只更新 context_window,绝不追加第二条。 + * + * 注入式设计:`fetchImpl` 由调用方给(生产是 main 的 `fetch`,测试是假上游),所以整条链 + * 在测试里不出网 —— issue #2774 验收 5 的阳性对照就是靠这一点做的。 + */ + +const { parse: parseToml, stringify: stringifyToml } = require("smol-toml"); + +/** 默认服务地址;`altBases()` 里的第一个是另一台部署。 */ +const DEFAULT_BASE = "https://aitokenpool.args.fun"; +const ALT_BASES = ["https://atp-dev.shenbiai.net"]; + +/** 登录后要直接可用的模型名(契约里服务端已上线的那一个)。 */ +const READY_MODEL = "deepseek-flash"; + +/** + * 服务地址归一:去尾部斜杠,去用户可能已经打进来的 `/v1`。 + * 不做这件事的后果是把 `…/v1` 拼成 `…/v1/v1`,而它的表现和「key 填错」一模一样。 + */ +function normBase(base) { + let s = String(base == null ? "" : base).trim(); + if (!s) return DEFAULT_BASE; + s = s.replace(/\/+$/, ""); + s = s.replace(/\/v1$/, ""); + return s; +} + +function endpoint(base, path) { + return `${normBase(base)}${path}`; +} + +/** 两个部署,默认在前;界面直接渲染它,不另存一份常量。 */ +function bases() { + return { default: DEFAULT_BASE, alternatives: ALT_BASES.slice() }; +} + +async function bodyOf(res) { + try { + const text = await res.text(); + if (!text) return {}; + const parsed = JSON.parse(text); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +function positive(value, fallback) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +/** + * 服务端模型清单 → `[{ id, contextWindow }]`。 + * + * 形状不唯一(OpenAI 风格 `{data:[…]}`、本仓风格 `{models:[…]}`、或干脆一个数组), + * 所以三种都收;读不到就返回空数组——**空数组不等于 0**,调用方据此决定是否写 + * context_window,绝不拿一个猜出来的数覆盖用户原有配置。 + */ +function readModels(payload) { + const list = Array.isArray(payload) + ? payload + : Array.isArray(payload && payload.data) + ? payload.data + : Array.isArray(payload && payload.models) + ? payload.models + : []; + const out = []; + for (const item of list) { + if (!item || typeof item !== "object") continue; + const id = String(item.id || item.name || item.model || "").trim(); + if (!id) continue; + const raw = + item.context_window !== undefined + ? item.context_window + : item.contextWindow !== undefined + ? item.contextWindow + : item.context_length !== undefined + ? item.context_length + : item.max_context_length !== undefined + ? item.max_context_length + : item.max_input_tokens; + const n = Number(raw); + out.push({ id, contextWindow: Number.isFinite(n) && n > 0 ? Math.trunc(n) : null }); + } + return out; +} + +/** 清单里那个模型的上限;没有条目、或条目没写,都回答 null(= 不写这个键)。 */ +function contextWindowFor(models, model) { + const hit = (models || []).find((m) => m && m.id === model); + return hit && typeof hit.contextWindow === "number" ? hit.contextWindow : null; +} + +/** `POST /api/auth/github/device/code` */ +async function startDevice({ fetchImpl, base } = {}) { + if (typeof fetchImpl !== "function") return { ok: false, error: "unreachable" }; + let res; + try { + res = await fetchImpl(endpoint(base, "/api/auth/github/device/code"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + } catch { + return { ok: false, error: "unreachable" }; + } + const body = await bodyOf(res); + if (res.status === 503) return { ok: false, error: "not_enabled", status: 503 }; + if (res.status !== 200) return { ok: false, error: "bad_response", status: res.status }; + const deviceCode = String(body.device_code || ""); + const userCode = String(body.user_code || ""); + const verificationUri = String(body.verification_uri || ""); + if (!deviceCode || !userCode || !verificationUri) { + // 200 但没有可显示的东西不是成功:没有码就没有下一步可言。 + return { ok: false, error: "bad_response", status: 200 }; + } + return { + ok: true, + deviceCode, + userCode, + verificationUri, + expiresIn: positive(body.expires_in, 900), + interval: positive(body.interval, 5), + }; +} + +/** + * 一步 `POST /api/auth/github/device/token`。 + * + * 一次一问答,不在这里轮询:计时器归 renderer(它要能被取消、被界面看见),间隔归服务端 + * (`slow_down` 时按它给的数走;它没给就按规范 +5 秒,上限 60)。 + */ +async function pollToken({ fetchImpl, base, deviceCode, currentInterval = 5 } = {}) { + if (typeof fetchImpl !== "function") return { status: "unreachable" }; + if (!deviceCode) return { status: "bad_response", httpStatus: 0 }; + let res; + try { + res = await fetchImpl(endpoint(base, "/api/auth/github/device/token"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ device_code: deviceCode }), + }); + } catch { + return { status: "unreachable" }; + } + const body = await bodyOf(res); + if (res.status === 200) { + const apiKey = String(body.api_key || ""); + if (!apiKey) return { status: "bad_response", httpStatus: 200 }; + return { + status: "ok", + apiKey, + login: String(body.login || ""), + userId: body.user_id == null ? "" : String(body.user_id), + }; + } + if (res.status === 202) return { status: "pending" }; + if (res.status === 429) { + const given = positive(body.interval, 0); + return { status: "slow_down", interval: given || Math.min(positive(currentInterval, 5) + 5, 60) }; + } + if (res.status === 410) return { status: "expired" }; + if (res.status === 403) return { status: "denied" }; + return { status: "bad_response", httpStatus: res.status }; +} + +/** `GET /api/models` —— 一个新 key 唯一能证明自己可用的地方。 */ +async function probeModels({ fetchImpl, base, apiKey } = {}) { + if (typeof fetchImpl !== "function") return { ok: false, error: "unreachable" }; + let res; + try { + res = await fetchImpl(endpoint(base, "/api/models"), { + method: "GET", + headers: { authorization: `Bearer ${String(apiKey || "")}` }, + }); + } catch { + return { ok: false, error: "unreachable" }; + } + if (res.status !== 200) return { ok: false, status: res.status }; + return { ok: true, models: readModels(await bodyOf(res)) }; +} + +function entryMatches(entry, model) { + if (!entry || typeof entry !== "object") return false; + return entry.name === model || entry.model === model; +} + +/** + * config.toml 文本 → 登录后的 config.toml 文本。 + * + * 只碰四样东西:`llm.base_url`、`llm.api_key`、`llm.model`,以及模型清单里那一条。 + * 其余键与其余段原样收发(要求 7);同名条目只更新 context_window(要求 6); + * `contextWindow` 未知时不写这个键,也不拿默认值顶上去(顶上去就是把用户原来的数改坏)。 + */ +function applyAtpConfig(text, { base, apiKey, model = READY_MODEL, contextWindow = null } = {}) { + let toml; + try { + toml = text && String(text).trim() ? parseToml(String(text)) : {}; + } catch { + return { ok: false, error: "config_unreadable" }; + } + if (!toml || typeof toml !== "object" || Array.isArray(toml)) { + return { ok: false, error: "config_unreadable" }; + } + const next = { ...toml }; + const llm = { ...(next.llm || {}) }; + llm.base_url = `${normBase(base)}/v1`; + llm.api_key = String(apiKey || ""); + llm.model = model; + + const models = Array.isArray(llm.models) + ? llm.models.filter((m) => m && typeof m === "object").map((m) => ({ ...m })) + : []; + const known = typeof contextWindow === "number" && contextWindow > 0 ? Math.trunc(contextWindow) : null; + const index = models.findIndex((m) => entryMatches(m, model)); + if (index >= 0) { + if (known !== null) models[index].context_window = known; + } else { + models.push(known !== null ? { name: model, model, context_window: known } : { name: model, model }); + } + llm.models = models; + next.llm = llm; + + try { + return { ok: true, text: stringifyToml(next) }; + } catch { + return { ok: false, error: "config_unwritable" }; + } +} + +/** + * 整条落盘链,顺序本身就是要求:**先探测、后写入**。 + * + * `readConfig`/`writeConfig` 由 main 用 fs 接上,所以这条路在测试里可以是内存里的字符串, + * 也可以数「写了几次」——验收 2 与 5 要的正是这个调用次数,而不是「文件内容没变」。 + */ +async function completeLogin({ + fetchImpl, + base, + apiKey, + readConfig, + writeConfig, + model = READY_MODEL, +} = {}) { + if (typeof readConfig !== "function" || typeof writeConfig !== "function") { + return { ok: false, error: "bad_wiring" }; + } + const probe = await probeModels({ fetchImpl, base, apiKey }); + if (!probe.ok) { + return { + ok: false, + error: "probe_failed", + status: probe.status == null ? 0 : probe.status, + unreachable: probe.error === "unreachable", + }; + } + const contextWindow = contextWindowFor(probe.models, model); + let text; + try { + text = await readConfig(); + } catch { + return { ok: false, error: "config_unreadable" }; + } + const merged = applyAtpConfig(text, { base, apiKey, model, contextWindow }); + if (!merged.ok) return { ok: false, error: merged.error }; + try { + await writeConfig(merged.text); + } catch { + return { ok: false, error: "config_unwritable" }; + } + return { ok: true, model, contextWindow, baseUrl: `${normBase(base)}/v1` }; +} + +module.exports = { + DEFAULT_BASE, + ALT_BASES, + READY_MODEL, + bases, + normBase, + readModels, + contextWindowFor, + startDevice, + pollToken, + probeModels, + applyAtpConfig, + completeLogin, +}; diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 823311d3..30c232f9 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -18,6 +18,9 @@ const { ConnManager } = require("./conn-manager"); const { guiStatePath, sanitizeOpenSessions, saveGuiState, DEFAULT_CAP } = require("./gui-state"); const { externalNavPolicy } = require("./nav-policy"); const { makeConfigPrivate } = require("./config_mode"); +// issue #2774:aitokenpool 的 GitHub 账号登录。协议与 config 合并全在这个纯模块里 +// (main.js 只被 `node --check` 看、不被单测跑),这里只把 fetch 与 fs 接上去。 +const atpLogin = require("./atp_login"); const APP_VERSION = require("./package.json").version; // issue #1100 加固:导航策略只放行应用自身 renderer dist 前缀内的 file:// URL @@ -296,8 +299,13 @@ vision = false function writeConfig(toml) { // G69:mode 0o600(含明文 api_key) + writeConfigText(stringifyToml(toml)); + } + + /** 已是 TOML 文本的写入点(issue #2774:登录后的合并结果只有文本,不再回到对象)。 */ + function writeConfigText(text) { fs.mkdirSync(path.dirname(configPath()), { recursive: true }); - fs.writeFileSync(configPath(), stringifyToml(toml), { mode: 0o600 }); + fs.writeFileSync(configPath(), text, { mode: 0o600 }); // `mode` above applies only when the file is CREATED, so the rule is not stated by it for a // file an earlier install left at 0644 — measured, and issue #2148 is the reading. The // module carries the rule for both cases (creation is already covered by the option). @@ -1066,6 +1074,45 @@ vision = false return { ok: Boolean(frame.ok), error: frame.error || null }; }); + // ── aitokenpool 账号(issue #2774)──────────────────────────── + // 网络与写盘都在 main:renderer 是 sandbox + contextIsolation,没有网络权限 + // (preload.js 头注释)。协议、轮询语义与 config 合并全在 atp_login.js, + // 那里也是「先探测后写入」唯一的实现点。 + ipcMain.handle("emrg:atpBases", async () => atpLogin.bases()); + + ipcMain.handle("emrg:atpDeviceStart", async (_e, { base } = {}) => { + return atpLogin.startDevice({ fetchImpl: fetch, base }); + }); + + ipcMain.handle("emrg:atpDevicePoll", async (_e, { base, deviceCode, currentInterval } = {}) => { + if (typeof deviceCode !== "string" || !deviceCode) throw new Error("invalid device code"); + return atpLogin.pollToken({ + fetchImpl: fetch, + base, + deviceCode, + currentInterval: Number(currentInterval) || 5, + }); + }); + + ipcMain.handle("emrg:atpApply", async (_e, { base, apiKey } = {}) => { + // 要求 4:先用新 key 打一次受保护的 /api/models,非 200 则一个字节都不写。 + // 顺序住在 atp_login.completeLogin 里,所以单测数的是那里的写入次数。 + if (typeof apiKey !== "string" || !apiKey.trim()) throw new Error("invalid api key"); + const wasRunning = Boolean(connManager?.daemonConn()?.connected); + const out = await atpLogin.completeLogin({ + fetchImpl: fetch, + base, + apiKey: apiKey.trim(), + readConfig: async () => + fs.existsSync(configPath()) ? fs.readFileSync(configPath(), "utf8") : await ensureConfigTemplate(), + writeConfig: async (text) => writeConfigText(text || ""), + }); + // 跑着的 daemon 自己 2 秒内应用新配置并向客户端广播(emrg/server/config_reload.py); + // 从没起来过的,照 G123「首启保存 → 主动拉起」的老规矩。 + if (out.ok && !wasRunning) await ensureConnected(); + return out; + }); + ipcMain.handle("emrg:githubConnectWeb", async () => { // Windows GCM rant Stage 2b:device flow 启动(daemon github_connect_web) const frame = await requireConn().sendCommandAndWait("github_connect_web", {}, 15000); diff --git a/emrg/gui/package.json b/emrg/gui/package.json index a3c89f4a..2beb96db 100644 --- a/emrg/gui/package.json +++ b/emrg/gui/package.json @@ -48,6 +48,7 @@ "gui-state.js", "daemon_client.js", "nav-policy.js", + "atp_login.js", "package.json", "scripts/**" ], diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index ea5622ff..c9abc9e7 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -74,6 +74,12 @@ const api = { githubStatus: () => ipcRenderer.invoke("emrg:githubStatus"), githubConnect: (payload) => ipcRenderer.invoke("emrg:githubConnect", payload), githubDisconnect: () => ipcRenderer.invoke("emrg:githubDisconnect"), + // issue #2774:aitokenpool 用 GitHub 账号登录。出码 / 轮询 / 写配置都在 main + // (renderer 无网络权限),这里只开通道;服务地址有几处也由 main 说,界面不另存常量。 + atpBases: () => ipcRenderer.invoke("emrg:atpBases"), + atpDeviceStart: (payload) => ipcRenderer.invoke("emrg:atpDeviceStart", payload), + atpDevicePoll: (payload) => ipcRenderer.invoke("emrg:atpDevicePoll", payload), + atpApply: (payload) => ipcRenderer.invoke("emrg:atpApply", payload), githubConnectWeb: () => ipcRenderer.invoke("emrg:githubConnectWeb"), openExternal: (payload) => ipcRenderer.invoke("emrg:openExternal", payload), listModels: () => ipcRenderer.invoke("emrg:listModels"), diff --git a/emrg/gui/renderer/src/components/AtpLoginDialog.tsx b/emrg/gui/renderer/src/components/AtpLoginDialog.tsx new file mode 100644 index 00000000..e71d4a3a --- /dev/null +++ b/emrg/gui/renderer/src/components/AtpLoginDialog.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { useI18n } from "../lib/i18n"; +import { Dialog } from "./Dialog"; + +/** + * AtpLoginDialog — aitokenpool 的 GitHub 账号登录对话框(issue #2774 要求 2)。 + * + * 与 GithubDeviceDialog 同形(大号一次性码 + 「打开浏览器」+ 「取消」+ 等待提示), + * 两处不同: + * - 码可以被复制(要求 2 的「可复制」)。复制失败不挡授权:码仍然看得见、打得出来。 + * - 失败文案由调用方**译好后**传进来,因为这个组件不认识服务端状态码: + * 四种失败各自要说下一步(要求 11),而那句下一步是设置页按状态码选的。 + * + * 纯展示:轮询计时器在调用方(设置页),组件不持有它——同一个理由, + * GithubDeviceDialog 也不持有。 + */ +export interface AtpLoginDialogProps { + open: boolean; + /** 一次性授权码;null = 尚未取到 */ + code?: string | null; + /** 授权页地址(「打开浏览器」用的那个) */ + url?: string | null; + /** true = 正在等浏览器里确认 */ + waiting?: boolean; + /** 已译好的失败文案(四种失败各一句,见设置页) */ + error?: string | null; + onOpenBrowser?: () => void; + onDismiss?: () => void; +} + +export function AtpLoginDialog({ + open, + code = null, + url = null, + waiting = false, + error = null, + onOpenBrowser, + onDismiss, +}: AtpLoginDialogProps) { + const { t } = useI18n(); + const [copied, setCopied] = useState(false); + if (!open) return null; + + const copy = () => { + void (async () => { + try { + await navigator.clipboard.writeText(code ?? ""); + setCopied(true); + } catch { + /* 剪贴板不可用(权限/无 https)——码仍在屏幕上,用户可以自己选中 */ + } + })(); + }; + + return ( + +

+ {t("settings.atpDeviceDesc")} +

+
+ {code ?? "—"} +
+ {error ? ( +
+ {error} +
+ ) : null} +
+ + + +
+ {waiting ? ( +
+ {t("settings.atpDeviceWait")} +
+ ) : null} +
+ ); +} diff --git a/emrg/gui/renderer/src/components/SettingsPanel.test.tsx b/emrg/gui/renderer/src/components/SettingsPanel.test.tsx index 76f133c9..d9abe5d7 100644 --- a/emrg/gui/renderer/src/components/SettingsPanel.test.tsx +++ b/emrg/gui/renderer/src/components/SettingsPanel.test.tsx @@ -544,4 +544,205 @@ describe("SettingsPanel", () => { expect(screen.getByTestId("settings-msg").textContent).toContain("referenced by 2 tasks"), ); }); + + // ── aitokenpool 账号登录(issue #2774)──────────────────── + const ATP_BASES = { + default: "https://aitokenpool.args.fun", + alternatives: ["https://atp-dev.shenbiai.net"], + }; + const ATP_CODE = { + ok: true, + deviceCode: "dev-1", + userCode: "ABCD-1234", + verificationUri: "https://aitokenpool.args.fun/device", + expiresIn: 900, + interval: 1, + }; + /** 没有正在用的 key —— 覆盖确认不参与的那些用例用它 */ + const EMPTY_SETTINGS = { + apiKey: "", + baseUrl: "", + model: "", + theme: "system", + modelDetails: [], + }; + + async function atpReady(over: Record) { + setup({ atpBases: vi.fn().mockResolvedValue(ATP_BASES), ...over }); + await waitFor(() => + expect((screen.getByTestId("atp-base") as HTMLSelectElement).value).toBe( + "https://aitokenpool.args.fun", + ), + ); + } + + it("starts the aitokenpool login at the default address and shows a copyable code", async () => { + const atpDeviceStart = vi.fn().mockResolvedValue(ATP_CODE); + const openExternal = vi.fn().mockResolvedValue({ ok: true }); + await atpReady({ + atpDeviceStart, + openExternal, + atpDevicePoll: vi.fn().mockResolvedValue({ status: "pending" }), + }); + + fireEvent.click(screen.getByTestId("atp-signin")); + await waitFor(() => + expect(atpDeviceStart).toHaveBeenCalledWith({ base: "https://aitokenpool.args.fun" }), + ); + await waitFor(() => expect(screen.getByTestId("atp-device-code").textContent).toBe("ABCD-1234")); + expect(screen.getByTestId("atp-login-wait")).toBeTruthy(); + + fireEvent.click(screen.getByTestId("atp-open")); + await waitFor(() => + expect(openExternal).toHaveBeenCalledWith({ url: "https://aitokenpool.args.fun/device" }), + ); + + fireEvent.click(screen.getByTestId("atp-login-close")); + await waitFor(() => expect(screen.queryByTestId("atp-device-code")).toBeNull()); + }); + + it("writes the aitokenpool config once the probe passes and fills the three fields", async () => { + const atpApply = vi.fn().mockResolvedValue({ + ok: true, + model: "deepseek-flash", + contextWindow: 200000, + baseUrl: "https://aitokenpool.args.fun/v1", + }); + await atpReady({ + getSettings: vi.fn().mockResolvedValue(EMPTY_SETTINGS), + atpDeviceStart: vi.fn().mockResolvedValue(ATP_CODE), + atpDevicePoll: vi.fn().mockResolvedValue({ status: "ok", apiKey: "atp-key", login: "argszero" }), + atpApply, + }); + + fireEvent.click(screen.getByTestId("atp-signin")); + await waitFor(() => expect(atpApply).toHaveBeenCalledTimes(1), { timeout: 5000 }); + expect(atpApply).toHaveBeenCalledWith({ + base: "https://aitokenpool.args.fun", + apiKey: "atp-key", + }); + + await waitFor(() => + expect((screen.getByTestId("set-api-key") as HTMLInputElement).value).toBe("atp-key"), + ); + expect((screen.getByTestId("set-base-url") as HTMLInputElement).value).toBe( + "https://aitokenpool.args.fun/v1", + ); + // 要求 9:对话框关掉,提示带 GitHub 用户名与就绪的模型名 + await waitFor(() => expect(screen.queryByTestId("atp-device-code")).toBeNull()); + const msg = screen.getByTestId("settings-msg").textContent || ""; + expect(msg).toContain("argszero"); + expect(msg).toContain("deepseek-flash"); + // 新模型出现在模型清单里,且没有第二条同名条目 + await waitFor(() => expect(screen.getByTestId("model-list").textContent).toContain("deepseek-flash")); + }); + + it("asks before replacing the key in use, and a cancel calls the write zero times", async () => { + const atpApply = vi.fn().mockResolvedValue({ ok: true, model: "deepseek-flash", baseUrl: "x" }); + await atpReady({ + // 默认 mock 的 getSettings 带回 sk-test:正在用的 key 非空 + atpDeviceStart: vi.fn().mockResolvedValue(ATP_CODE), + atpDevicePoll: vi.fn().mockResolvedValue({ status: "ok", apiKey: "atp-key", login: "argszero" }), + atpApply, + }); + + fireEvent.click(screen.getByTestId("atp-signin")); + await waitFor(() => expect(screen.getByTestId("confirm-ok")).toBeTruthy(), { timeout: 5000 }); + fireEvent.click(screen.getByTestId("confirm-cancel")); + + // 验收 2 的原话:数调用次数,不是「文件内容没变」 + await waitFor(() => expect(screen.queryByTestId("confirm-ok")).toBeNull()); + expect(atpApply).toHaveBeenCalledTimes(0); + expect(screen.queryByTestId("atp-device-code")).toBeNull(); + }); + + it("replaces the key in use once the overwrite is confirmed", async () => { + const atpApply = vi.fn().mockResolvedValue({ + ok: true, + model: "deepseek-flash", + baseUrl: "https://aitokenpool.args.fun/v1", + }); + await atpReady({ + atpDeviceStart: vi.fn().mockResolvedValue(ATP_CODE), + atpDevicePoll: vi.fn().mockResolvedValue({ status: "ok", apiKey: "atp-key", login: "argszero" }), + atpApply, + }); + + fireEvent.click(screen.getByTestId("atp-signin")); + await waitFor(() => expect(screen.getByTestId("confirm-ok")).toBeTruthy(), { timeout: 5000 }); + fireEvent.click(screen.getByTestId("confirm-ok")); + await waitFor(() => expect(atpApply).toHaveBeenCalledTimes(1), { timeout: 5000 }); + expect(atpApply).toHaveBeenCalledWith({ + base: "https://aitokenpool.args.fun", + apiKey: "atp-key", + }); + }); + + it("a slow_down is invisible: the interval moves and the dialog keeps waiting", async () => { + let polls = 0; + const atpDevicePoll = vi.fn().mockImplementation(async () => { + polls += 1; + if (polls === 1) return { status: "slow_down", interval: 1 }; + return { status: "ok", apiKey: "atp-key", login: "argszero" }; + }); + const atpApply = vi.fn().mockResolvedValue({ + ok: true, + model: "deepseek-flash", + baseUrl: "https://aitokenpool.args.fun/v1", + }); + await atpReady({ + getSettings: vi.fn().mockResolvedValue(EMPTY_SETTINGS), + atpDeviceStart: vi.fn().mockResolvedValue(ATP_CODE), + atpDevicePoll, + atpApply, + }); + + fireEvent.click(screen.getByTestId("atp-signin")); + await waitFor(() => expect(atpDevicePoll).toHaveBeenCalled(), { timeout: 5000 }); + // 429 不给用户看:没有错误行,对话框还在等 + expect(screen.queryByTestId("atp-login-error")).toBeNull(); + await waitFor(() => expect(atpApply).toHaveBeenCalledTimes(1), { timeout: 6000 }); + expect(screen.queryByTestId("atp-login-error")).toBeNull(); + }); + + it("says GitHub login is off when the address answers 503", async () => { + await atpReady({ + atpDeviceStart: vi.fn().mockResolvedValue({ ok: false, error: "not_enabled", status: 503 }), + }); + fireEvent.click(screen.getByTestId("atp-signin")); + await waitFor(() => + expect(screen.getByTestId("settings-msg").textContent).toContain("not enabled at this address"), + ); + expect(screen.queryByTestId("atp-device-code")).toBeNull(); + }); + + it("says the code expired and what to do when the server answers 410", async () => { + await atpReady({ + atpDeviceStart: vi.fn().mockResolvedValue(ATP_CODE), + atpDevicePoll: vi.fn().mockResolvedValue({ status: "expired", httpStatus: 410 }), + }); + fireEvent.click(screen.getByTestId("atp-signin")); + await waitFor(() => expect(screen.getByTestId("atp-login-error")).toBeTruthy(), { timeout: 5000 }); + expect(screen.getByTestId("atp-login-error").textContent).toContain("expired"); + }); + + it("says the authorization was denied when the server answers 403", async () => { + await atpReady({ + atpDeviceStart: vi.fn().mockResolvedValue(ATP_CODE), + atpDevicePoll: vi.fn().mockResolvedValue({ status: "denied", httpStatus: 403 }), + }); + fireEvent.click(screen.getByTestId("atp-signin")); + await waitFor(() => expect(screen.getByTestId("atp-login-error")).toBeTruthy(), { timeout: 5000 }); + expect(screen.getByTestId("atp-login-error").textContent).toContain("denied"); + }); + + it("names the address it could not reach instead of saying it failed", async () => { + await atpReady({ + atpDeviceStart: vi.fn().mockResolvedValue({ ok: false, error: "unreachable" }), + }); + fireEvent.click(screen.getByTestId("atp-signin")); + await waitFor(() => + expect(screen.getByTestId("settings-msg").textContent).toContain("aitokenpool.args.fun"), + ); + }); }); diff --git a/emrg/gui/renderer/src/components/SettingsPanel.tsx b/emrg/gui/renderer/src/components/SettingsPanel.tsx index dff33398..1738160a 100644 --- a/emrg/gui/renderer/src/components/SettingsPanel.tsx +++ b/emrg/gui/renderer/src/components/SettingsPanel.tsx @@ -1,8 +1,9 @@ import { useEffect, useReducer, useRef, useState } from "react"; import { useI18n, setLocale, type Locale } from "../lib/i18n"; -import { dialogReducer, initialDialogState } from "../lib/dialog"; +import { dialogReducer, initialDialogState, type ConfirmRequest } from "../lib/dialog"; import { ConfirmDialog } from "./ConfirmDialog"; import { GithubDeviceDialog } from "./GithubDeviceDialog"; +import { AtpLoginDialog } from "./AtpLoginDialog"; /** * SettingsPanel — Batch 5 slice 6:设置面板接线(vanilla dialogs.js showSettings/ @@ -56,6 +57,17 @@ interface DeviceFlow { error: string | null; } +/** aitokenpool 登录对话框的状态(issue #2774):轮询间隔由服务端给,所以它在这里。 */ +interface AtpFlow { + open: boolean; + code: string | null; + url: string | null; + waiting: boolean; + error: string | null; + deviceCode: string; + interval: number; +} + /** one row of the daemon's `recent` list (`evolution_summary`) */ export interface RecentImprovement { timestamp?: string; @@ -75,6 +87,34 @@ interface EmrgBridge { githubConnect(p: { token: string }): Promise<{ ok?: boolean; user?: string; error?: string }>; githubConnectWeb(): Promise<{ ok?: boolean; code?: string | null; url?: string | null; user?: string | null; error?: string | null }>; githubDisconnect(): Promise; + /** issue #2774:服务地址有几处由 main 说,界面不另存一份常量。 */ + atpBases?(): Promise<{ default?: string; alternatives?: string[] }>; + atpDeviceStart?(p: { base: string }): Promise<{ + ok?: boolean; + deviceCode?: string; + userCode?: string; + verificationUri?: string; + expiresIn?: number; + interval?: number; + error?: string; + status?: number; + }>; + atpDevicePoll?(p: { base: string; deviceCode: string; currentInterval: number }): Promise<{ + status?: string; + apiKey?: string; + login?: string; + interval?: number; + httpStatus?: number; + }>; + atpApply?(p: { base: string; apiKey: string }): Promise<{ + ok?: boolean; + model?: string; + contextWindow?: number | null; + baseUrl?: string; + error?: string; + status?: number; + unreachable?: boolean; + }>; openExternal(p: { url: string }): Promise; taskTemplateList(): Promise; taskTemplateCreate(p: { name: string; prompt: string }): Promise; @@ -129,6 +169,13 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP const [github, setGithub] = useState<{ authenticated: boolean; user: string | null } | null>(null); const [githubToken, setGithubToken] = useState(""); const [device, setDevice] = useState(null); + // aitokenpool 账号(issue #2774):atpBase 从 main 的地址表来,atp 是对话框状态 + const [atpBase, setAtpBase] = useState(""); + const [atpBases, setAtpBases] = useState([]); + const [atp, setAtp] = useState(null); + // 覆盖确认的答案(askReplaceKey 的 resolve)。ConfirmDialog 的取消/ESC 只走 onDismiss, + // 所以取消也要能从那里回到这里——否则按钮放下去了、promise 永远不落地。 + const pendingConfirm = useRef<((ok: boolean) => void) | null>(null); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); // model 行内表单态(vanilla openModelForm/saveModelForm) @@ -427,6 +474,187 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP setDevice(null); } + // ── aitokenpool 账号(issue #2774)────────────────────── + /** 服务地址表由 main 给(atp_login.js 是唯一声明点);读不到也不用挡路—— + * 没有它照样能填 API Key,登录按钮点了会自己报「连不上」。 */ + useEffect(() => { + const b = bridge(); + if (!b?.atpBases) return; + b.atpBases() + .then((r) => { + const def = r?.default || ""; + const alts = Array.isArray(r?.alternatives) ? r.alternatives : []; + setAtpBases([...(def ? [def] : []), ...alts]); + setAtpBase((cur) => cur || def); + }) + .catch(() => { + /* a failed read is not a failure: the flow reports its own errors */ + }); + }, []); + + /** 四种失败各自说下一步(要求 11)——只显示「失败」不算回答。 */ + function atpStartError(error: string | undefined, status: number | undefined): string { + if (error === "not_enabled") return t("settings.atpNotEnabled"); + if (error === "unreachable") return t("settings.atpUnreachable", { base: atpBase }); + return t("settings.atpFailUnknown", { msg: error || String(status ?? "") }); + } + + function atpPollError(status: string): string { + if (status === "expired") return t("settings.atpExpired"); + if (status === "denied") return t("settings.atpDenied"); + if (status === "unreachable") return t("settings.atpUnreachable", { base: atpBase }); + return t("settings.atpFailUnknown", { msg: status }); + } + + async function startAtpLogin() { + const b = bridge(); + if (!b?.atpDeviceStart) return; + setBusy(true); + try { + const res = await b.atpDeviceStart({ base: atpBase }); + if (!res?.ok) { + setMsg({ kind: "err", text: atpStartError(res?.error, res?.status) }); + return; + } + setAtp({ + open: true, + code: res.userCode || null, + url: res.verificationUri || null, + waiting: true, + error: null, + deviceCode: String(res.deviceCode || ""), + interval: Number(res.interval) > 0 ? Number(res.interval) : 5, + }); + } catch { + setMsg({ kind: "err", text: t("settings.atpUnreachable", { base: atpBase }) }); + } finally { + setBusy(false); + } + } + + /** 一步一问:间隔归服务端(要求 3),429 换间隔后用户看不到任何东西。 */ + useEffect(() => { + if (!atp?.open || !atp.waiting || !atp.deviceCode) return; + const b = bridge(); + const poll = b?.atpDevicePoll; + if (!poll) return; + let stopped = false; + const timer = setInterval( + () => { + void poll({ base: atpBase, deviceCode: atp.deviceCode, currentInterval: atp.interval }) + .then((res) => { + if (stopped) return; + const status = res?.status || "bad_response"; + if (status === "pending") return; + if (status === "slow_down") { + const next = Number(res?.interval) > 0 ? Number(res.interval) : atp.interval; + setAtp((cur) => (cur ? { ...cur, interval: next } : cur)); + return; + } + if (status === "ok") { + void finishAtpLogin(res?.apiKey || "", res?.login || ""); + return; + } + setAtp((cur) => (cur ? { ...cur, waiting: false, error: atpPollError(status) } : cur)); + }) + .catch(() => { + /* one failed poll is not a failed flow: the next tick asks again */ + }); + }, + Math.max(1, atp.interval) * 1000, + ); + return () => { + stopped = true; + clearInterval(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [atp?.open, atp?.waiting, atp?.deviceCode, atp?.interval, atpBase]); + + /** 覆盖确认:resolve(false) 的路径**一次写入调用都不发**(验收 2 数的是调用次数)。 */ + function askReplaceKey(): Promise { + return new Promise((resolve) => { + pendingConfirm.current = resolve; + const request: ConfirmRequest = { + title: t("settings.atpConfirmTitle"), + message: t("settings.atpConfirmBody"), + okText: "settings.atpConfirmOk", + danger: false, + onOk: () => { + pendingConfirm.current = null; + resolve(true); + }, + }; + dispatch({ type: "open-confirm", payload: request }); + }); + } + + /** ConfirmDialog 的取消/ESC 都从这里出去,所以它也是那条 promise 的落地点。 */ + function closeConfirm() { + const answer = pendingConfirm.current; + pendingConfirm.current = null; + dispatch({ type: "close-confirm" }); + answer?.(false); + } + + async function finishAtpLogin(newKey: string, login: string) { + // 要求 8:正在用的 key 非空 → 先说清这会替换它;取消则零写入 + if (newKey.trim() && apiKey.trim()) { + const go = await askReplaceKey(); + if (!go) { + setAtp(null); + return; + } + } + const b = bridge(); + if (!b?.atpApply) return; + setBusy(true); + try { + const res = await b.atpApply({ base: atpBase, apiKey: newKey }); + if (!res?.ok) { + const text = + res?.error === "probe_failed" + ? res.unreachable + ? t("settings.atpUnreachable", { base: atpBase }) + : t("settings.atpProbeFailed", { status: res?.status ?? 0 }) + : t("settings.atpFailUnknown", { msg: res?.error || "" }); + setAtp((cur) => (cur ? { ...cur, waiting: false, error: text } : cur)); + setMsg({ kind: "err", text }); + return; + } + const model = res.model || ""; + // 要求 9:三个字段回填进现有表单。配置文件**已经写好了**,不再经 saveSettings + // 写第二遍——第二次写只会多一个出错的机会,不会多一个字节的事实。 + setApiKey(newKey); + setBaseUrl(res.baseUrl || ""); + if (model) { + setDefaultName(model); + setExtraModels((prev) => + prev.some((m) => m.name === model) + ? prev.map((m) => (m.name === model ? { ...m, model: m.model || model } : m)) + : [...prev, { name: model, model, vision: false }], + ); + } + setAtp(null); + setMsg({ kind: "ok", text: t("settings.atpReady", { user: login || "", model }) }); + } catch (e: unknown) { + const text = t("settings.atpFailUnknown", { msg: (e as Error).message }); + setAtp((cur) => (cur ? { ...cur, waiting: false, error: text } : cur)); + setMsg({ kind: "err", text }); + } finally { + setBusy(false); + } + } + + function openAtpUrl() { + const b = bridge(); + if (!b?.openExternal || !atp?.url) return; + void b.openExternal({ url: atp.url }); + } + + function dismissAtp() { + setAtp(null); + } + // ── templates tab(自定义任务类型管理,vanilla dialogs.js 语义) ── async function loadTemplates() { const b = bridge(); @@ -532,6 +760,36 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP {tab === "model" && (
+
+ {t("settings.atpTitle")} +
+
+ {t("settings.atpHint")} +
+
+ {/* 换地址只是换下一次登录打哪儿,不触发登录(要求 10) */} + + +
- dispatch({ type: "close-confirm" })} /> + + = { "settings.githubDeviceFailed": "GitHub 连接失败:{msg}", "settings.githubSignIn": "使用 GitHub 登录(浏览器授权)", "settings.githubSignInHint": "无需 personal access token:点上方按钮,在浏览器里输入一次性代码即可。", + "settings.atpTitle": "aitokenpool 账号", + "settings.atpHint": "用 GitHub 账号登录,Key、服务地址与默认模型会自动填好,可以直接开始对话。", + "settings.atpSignIn": "用 GitHub 登录", + "settings.atpBase": "服务地址", + "settings.atpDeviceTitle": "登录 aitokenpool", + "settings.atpDeviceDesc": "在浏览器中打开授权页,输入下面的一次性代码即可完成授权。", + "settings.atpDeviceCopy": "复制", + "settings.atpDeviceCopied": "已复制", + "settings.atpDeviceOpen": "打开浏览器", + "settings.atpDeviceCancel": "取消", + "settings.atpDeviceWait": "等待浏览器中授权…", + "settings.atpNotEnabled": "这个地址没有启用 GitHub 登录(503)。换一个服务地址,或直接填 API Key。", + "settings.atpExpired": "授权码已过期(410)。再点一次「用 GitHub 登录」就可以。", + "settings.atpDenied": "你在浏览器里拒绝了授权(403)。再点一次可以重试。", + "settings.atpUnreachable": "连不上 {base}。检查网络,或换一个服务地址。", + "settings.atpConfirmTitle": "替换正在使用的 Key?", + "settings.atpConfirmBody": "这会把当前的 API Key 与服务地址替换成 aitokenpool 的。取消则什么都不写。", + "settings.atpConfirmOk": "替换", + "settings.atpProbeFailed": "新 Key 没有通过校验(HTTP {status}),配置一个字都没改。", + "settings.atpReady": "已登录 aitokenpool:@{user},模型 {model} 已就绪。", + "settings.atpFailUnknown": "登录失败:{msg}", "settings.githubBannerMsg": "启用自进化需连接 GitHub →", "settings.githubBannerConnect": "去连接", "settings.githubBannerDismiss": "关闭", @@ -637,6 +658,27 @@ export const EN_DICT: Record = { "settings.githubDeviceFailed": "GitHub connect failed: {msg}", "settings.githubSignIn": "Sign in with GitHub (browser)", "settings.githubSignInHint": "No personal access token needed: press the button above and enter the one-time code in your browser.", + "settings.atpTitle": "aitokenpool account", + "settings.atpHint": "Sign in with a GitHub account and the key, service address and default model fill themselves in, ready to chat.", + "settings.atpSignIn": "Sign in with GitHub", + "settings.atpBase": "Service address", + "settings.atpDeviceTitle": "Sign in to aitokenpool", + "settings.atpDeviceDesc": "Open the authorization page in your browser and enter the one-time code below.", + "settings.atpDeviceCopy": "Copy", + "settings.atpDeviceCopied": "Copied", + "settings.atpDeviceOpen": "Open browser", + "settings.atpDeviceCancel": "Cancel", + "settings.atpDeviceWait": "Waiting for authorization in the browser…", + "settings.atpNotEnabled": "GitHub sign-in is not enabled at this address (503). Try the other service address, or enter an API key.", + "settings.atpExpired": "The code expired (410). Press sign-in again to get a new one.", + "settings.atpDenied": "Authorization was denied in the browser (403). Press sign-in to try again.", + "settings.atpUnreachable": "Could not reach {base}. Check the network, or try the other service address.", + "settings.atpConfirmTitle": "Replace the key in use?", + "settings.atpConfirmBody": "This replaces the current API key and service address with aitokenpool's. Cancelling writes nothing.", + "settings.atpConfirmOk": "Replace", + "settings.atpProbeFailed": "The new key did not pass the check (HTTP {status}); nothing was changed.", + "settings.atpReady": "Signed in to aitokenpool as @{user}; model {model} is ready.", + "settings.atpFailUnknown": "Sign-in failed: {msg}", "settings.githubBannerMsg": "Connect GitHub to enable self-evolution →", "settings.githubBannerConnect": "Connect", "settings.githubBannerDismiss": "Dismiss", diff --git a/emrg/gui/renderer/src/lib/i18n.test.ts b/emrg/gui/renderer/src/lib/i18n.test.ts index a10fd777..4d0342ce 100644 --- a/emrg/gui/renderer/src/lib/i18n.test.ts +++ b/emrg/gui/renderer/src/lib/i18n.test.ts @@ -87,11 +87,12 @@ describe("词典完整性守卫(防漂移)", () => { // 2026-10-09T09:43:39 Part 2(a)——从 413 改为 429。 // 2026-10-11 issue #2113:device flow 的两个提示串 → 431。 // 2026-10-22 issue #2490:调度窗口 9 个键(含下限插值)→ 440。 - it("zh/en 各 442 个 key 且完全对齐", () => { + // 2026-11-09 issue #2774:aitokenpool 账号登录 21 个键 → 465。 + it("zh/en 各 465 个 key 且完全对齐", () => { const zhKeys = Object.keys(ZH_DICT); const enKeys = Object.keys(EN_DICT); - expect(zhKeys.length).toBe(444); - expect(enKeys.length).toBe(444); + expect(zhKeys.length).toBe(465); + expect(enKeys.length).toBe(465); expect(zhKeys.sort()).toEqual(enKeys.sort()); // DICTS 聚合结构 expect(Object.keys(DICTS)).toEqual(["zh", "en"]); diff --git a/emrg/gui/test/atp_login.test.js b/emrg/gui/test/atp_login.test.js new file mode 100644 index 00000000..04864602 --- /dev/null +++ b/emrg/gui/test/atp_login.test.js @@ -0,0 +1,358 @@ +// atp_login.test.js — aitokenpool 的 GitHub 设备流登录(issue #2774) +// +// 全部用假上游:`fetchImpl` 是注入的,所以这些用例一个请求都不出网 +// (验收 5)。数的是**调用次数**——「写了几次」是这条链唯一的可观察事实, +// 「文件内容没变」证明不了什么(验收 2 的原话)。 +const { test } = require("node:test"); +const assert = require("node:assert"); +const { parse: parseToml } = require("smol-toml"); + +const atp = require("../atp_login.js"); + +function res(status, body) { + return { + status, + text: async () => + body === undefined ? "" : typeof body === "string" ? body : JSON.stringify(body), + }; +} + +/** 按 URL 片段分派的假上游;未预期的 URL 直接炸,免得用例悄悄少走一步。 */ +function fakeUpstream(routes) { + const calls = []; + const impl = async (url, init = {}) => { + calls.push({ url, init }); + for (const [needle, handler] of Object.entries(routes)) { + if (url.includes(needle)) return typeof handler === "function" ? handler(url, init) : handler; + } + throw new Error(`unexpected url: ${url}`); + }; + impl.calls = calls; + return impl; +} + +function unreachableFetch() { + return async () => { + throw new Error("ECONNREFUSED"); + }; +} + +const CODE_OK = res(200, { + device_code: "dev-1", + user_code: "ABCD-1234", + verification_uri: "https://aitokenpool.args.fun/device", + expires_in: 900, + interval: 5, +}); + +// ── 地址 ─────────────────────────────────────────────── +test("the two deployments are named once, default first", () => { + const { default: def, alternatives } = atp.bases(); + assert.strictEqual(def, "https://aitokenpool.args.fun"); + assert.deepStrictEqual(alternatives, ["https://atp-dev.shenbiai.net"]); + assert.strictEqual(atp.normBase(""), def, "an empty base falls back to the default"); + assert.strictEqual(atp.normBase(" https://x.test/ "), "https://x.test"); + // 用户已经打进 /v1 时不能再拼一层,否则表现为「模型不回话」 + assert.strictEqual(atp.normBase("https://x.test/v1"), "https://x.test"); + assert.strictEqual(atp.normBase("https://x.test/v1/"), "https://x.test"); +}); + +// ── 出码 ─────────────────────────────────────────────── +test("a code request answers the five fields the dialog shows", async () => { + const fetchImpl = fakeUpstream({ "/device/code": CODE_OK }); + const out = await atp.startDevice({ fetchImpl, base: "https://x.test" }); + assert.deepStrictEqual(out, { + ok: true, + deviceCode: "dev-1", + userCode: "ABCD-1234", + verificationUri: "https://aitokenpool.args.fun/device", + expiresIn: 900, + interval: 5, + }); + assert.ok(fetchImpl.calls[0].url.startsWith("https://x.test/api/auth/github/device/code")); +}); + +test("503 says the deployment has the login switched off", async () => { + const out = await atp.startDevice({ + fetchImpl: fakeUpstream({ "/device/code": res(503, { error: "github_login_not_enabled" }) }), + base: "https://x.test", + }); + assert.strictEqual(out.ok, false); + assert.strictEqual(out.error, "not_enabled"); +}); + +test("a 200 without a code is not a success", async () => { + const out = await atp.startDevice({ + fetchImpl: fakeUpstream({ "/device/code": res(200, { device_code: "dev-1" }) }), + base: "https://x.test", + }); + assert.strictEqual(out.ok, false); + assert.strictEqual(out.error, "bad_response"); +}); + +test("a dead service is reported, not thrown", async () => { + const out = await atp.startDevice({ fetchImpl: unreachableFetch(), base: "https://x.test" }); + assert.deepStrictEqual(out, { ok: false, error: "unreachable" }); +}); + +// ── 轮询的六种回答 ────────────────────────────────────── +test("poll: each server state maps to one answer, and none of them throws", async () => { + const cases = [ + [res(202, { error: "authorization_pending" }), { status: "pending" }], + [res(410, { error: "expired_token" }), { status: "expired" }], + [res(403, { error: "access_denied" }), { status: "denied" }], + ]; + for (const [response, expected] of cases) { + const out = await atp.pollToken({ + fetchImpl: fakeUpstream({ "/device/token": response }), + base: "https://x.test", + deviceCode: "dev-1", + }); + assert.deepStrictEqual(out, expected); + } +}); + +test("poll: a 429 is honoured silently, at the interval the server names", async () => { + const out = await atp.pollToken({ + fetchImpl: fakeUpstream({ "/device/token": res(429, { interval: 12 }) }), + base: "https://x.test", + deviceCode: "dev-1", + currentInterval: 5, + }); + assert.deepStrictEqual(out, { status: "slow_down", interval: 12 }); +}); + +test("poll: a 429 without an interval backs off by five seconds, capped at a minute", async () => { + const slow = await atp.pollToken({ + fetchImpl: fakeUpstream({ "/device/token": res(429, {}) }), + base: "https://x.test", + deviceCode: "dev-1", + currentInterval: 5, + }); + assert.deepStrictEqual(slow, { status: "slow_down", interval: 10 }); + const ceiling = await atp.pollToken({ + fetchImpl: fakeUpstream({ "/device/token": res(429, {}) }), + base: "https://x.test", + deviceCode: "dev-1", + currentInterval: 58, + }); + assert.strictEqual(ceiling.interval, 60); +}); + +test("poll: success carries the key and the GitHub login", async () => { + const out = await atp.pollToken({ + fetchImpl: fakeUpstream({ + "/device/token": res(200, { api_key: "atp-key", user_id: 7, login: "argszero" }), + }), + base: "https://x.test", + deviceCode: "dev-1", + }); + assert.deepStrictEqual(out, { + status: "ok", + apiKey: "atp-key", + login: "argszero", + userId: "7", + }); +}); + +test("poll: a 200 with no key is not a success", async () => { + const out = await atp.pollToken({ + fetchImpl: fakeUpstream({ "/device/token": res(200, { login: "argszero" }) }), + base: "https://x.test", + deviceCode: "dev-1", + }); + assert.strictEqual(out.status, "bad_response"); +}); + +// ── 探测 ─────────────────────────────────────────────── +test("the probe reads the model list in either shape and leaves a bad key refused", async () => { + const openai = await atp.probeModels({ + fetchImpl: fakeUpstream({ + "/api/models": res(200, { data: [{ id: "deepseek-flash", context_window: 200000 }] }), + }), + base: "https://x.test", + apiKey: "k", + }); + assert.deepStrictEqual(openai, { ok: true, models: [{ id: "deepseek-flash", contextWindow: 200000 }] }); + + const houseStyle = await atp.probeModels({ + fetchImpl: fakeUpstream({ + "/api/models": res(200, { models: [{ name: "deepseek-flash", context_length: 131072 }] }), + }), + base: "https://x.test", + apiKey: "k", + }); + assert.deepStrictEqual(houseStyle.models, [{ id: "deepseek-flash", contextWindow: 131072 }]); + + const refused = await atp.probeModels({ + fetchImpl: fakeUpstream({ "/api/models": res(401, { error: "unauthorized" }) }), + base: "https://x.test", + apiKey: "bad", + }); + assert.deepStrictEqual(refused, { ok: false, status: 401 }); + assert.ok(fakeUpstream({}).calls.length === 0); +}); + +test("a model the list does not name has no window, rather than a guessed one", () => { + assert.strictEqual(atp.contextWindowFor([], "deepseek-flash"), null); + assert.strictEqual( + atp.contextWindowFor([{ id: "other", contextWindow: 8000 }], "deepseek-flash"), + null, + ); + assert.strictEqual(atp.contextWindowFor([{ id: "deepseek-flash", contextWindow: null }], "deepseek-flash"), null); + assert.strictEqual(atp.contextWindowFor([{ id: "deepseek-flash", contextWindow: 200000 }], "deepseek-flash"), 200000); +}); + +// ── 合并进 config.toml ───────────────────────────────── +const PRESEEDED = `[llm] +base_url = "https://api.deepseek.com" +api_key = "old-key" +model = "deepseek-v3" +base_url_1 = "https://hand-added.test" +api_key3 = "hand-added-key" +context_window = 65536 +vision = false + +[[llm.models]] +name = "deepseek-v3" +model = "deepseek-v3" +context_window = 65536 +vision = false + +[gui] +theme = "dark" +lang = "zh" +`; + +test("the write sets the three fields and leaves every other key and section alone", () => { + const out = atp.applyAtpConfig(PRESEEDED, { + base: "https://aitokenpool.args.fun", + apiKey: "atp-key", + model: "deepseek-flash", + contextWindow: 200000, + }); + assert.strictEqual(out.ok, true); + const toml = parseToml(out.text); + assert.strictEqual(toml.llm.base_url, "https://aitokenpool.args.fun/v1"); + assert.strictEqual(toml.llm.api_key, "atp-key"); + assert.strictEqual(toml.llm.model, "deepseek-flash"); + // 要求 7:手加的键、以及别的段,逐字还在 + assert.strictEqual(toml.llm.base_url_1, "https://hand-added.test"); + assert.strictEqual(toml.llm.api_key3, "hand-added-key"); + assert.strictEqual(toml.llm.context_window, 65536); + assert.strictEqual(toml.llm.vision, false); + assert.strictEqual(toml.gui.theme, "dark"); + assert.strictEqual(toml.gui.lang, "zh"); + // 原先的 deepseek-v3 条目没被吞掉 + const names = toml.llm.models.map((m) => m.name); + assert.deepStrictEqual(names, ["deepseek-v3", "deepseek-flash"]); +}); + +test("an existing entry of the same name is updated in place, never duplicated", () => { + const seeded = `[llm] +model = "deepseek-flash" + +[[llm.models]] +name = "deepseek-flash" +model = "deepseek-flash" +context_window = 32768 +vision = true +`; + const out = atp.applyAtpConfig(seeded, { + base: "https://x.test", + apiKey: "atp-key", + model: "deepseek-flash", + contextWindow: 200000, + }); + const models = parseToml(out.text).llm.models; + assert.strictEqual(models.length, 1, "no second entry may appear"); + assert.strictEqual(models[0].context_window, 200000); + assert.strictEqual(models[0].vision, true, "only the window moves"); +}); + +test("an unknown window leaves the existing entry untouched and writes no key", () => { + const seeded = `[llm] +model = "deepseek-flash" + +[[llm.models]] +name = "deepseek-flash" +context_window = 32768 +`; + const same = parseToml( + atp.applyAtpConfig(seeded, { + base: "https://x.test", + apiKey: "k", + model: "deepseek-flash", + contextWindow: null, + }).text, + ).llm.models; + assert.strictEqual(same.length, 1); + assert.strictEqual(same[0].context_window, 32768); + + const fresh = parseToml( + atp.applyAtpConfig("[llm]\nmodel = \"deepseek-v3\"\n", { + base: "https://x.test", + apiKey: "k", + model: "deepseek-flash", + contextWindow: null, + }).text, + ).llm.models; + assert.strictEqual(fresh.length, 1); + assert.ok(!("context_window" in fresh[0]), "an unknown window is not invented"); +}); + +test("a config that cannot be parsed is refused rather than overwritten", () => { + const out = atp.applyAtpConfig("[llm\nmodel = ", { base: "https://x.test", apiKey: "k" }); + assert.deepStrictEqual(out, { ok: false, error: "config_unreadable" }); +}); + +// ── 整条链的顺序 ──────────────────────────────────────── +test("a probe that answers 200 writes exactly once, and the file carries the key", async () => { + const writes = []; + const out = await atp.completeLogin({ + fetchImpl: fakeUpstream({ + "/api/models": res(200, { data: [{ id: "deepseek-flash", context_window: 200000 }] }), + }), + base: "https://x.test", + apiKey: "atp-key", + readConfig: async () => PRESEEDED, + writeConfig: async (text) => writes.push(text), + }); + assert.strictEqual(out.ok, true); + assert.strictEqual(out.model, "deepseek-flash"); + assert.strictEqual(out.contextWindow, 200000); + assert.strictEqual(out.baseUrl, "https://x.test/v1"); + assert.strictEqual(writes.length, 1); + const toml = parseToml(writes[0]); + assert.strictEqual(toml.llm.api_key, "atp-key"); + assert.strictEqual(toml.llm.base_url_1, "https://hand-added.test"); +}); + +test("a probe that is not 200 writes nothing at all", async () => { + for (const response of [res(401, {}), res(500, {}), res(202, {})]) { + const writes = []; + const out = await atp.completeLogin({ + fetchImpl: fakeUpstream({ "/api/models": response }), + base: "https://x.test", + apiKey: "atp-key", + readConfig: async () => PRESEEDED, + writeConfig: async (text) => writes.push(text), + }); + assert.strictEqual(out.ok, false); + assert.strictEqual(out.error, "probe_failed"); + assert.strictEqual(writes.length, 0, `status ${response.status} must not write`); + } +}); + +test("a service that cannot be reached writes nothing either", async () => { + const writes = []; + const out = await atp.completeLogin({ + fetchImpl: unreachableFetch(), + base: "https://x.test", + apiKey: "atp-key", + readConfig: async () => PRESEEDED, + writeConfig: async (text) => writes.push(text), + }); + assert.deepStrictEqual(out, { ok: false, error: "probe_failed", status: 0, unreachable: true }); + assert.strictEqual(writes.length, 0); +}); diff --git a/emrg/gui/test/preload-api.test.js b/emrg/gui/test/preload-api.test.js index a0f600ab..554b3f84 100644 --- a/emrg/gui/test/preload-api.test.js +++ b/emrg/gui/test/preload-api.test.js @@ -77,6 +77,12 @@ const EXPECTED_INVOKE_METHODS = [ "githubConnect", "githubDisconnect", "githubConnectWeb", + // issue #2774:aitokenpool 用 GitHub 账号登录——出码、轮询、写配置都在 main + // (renderer 没有网络权限),地址表也由 main 说,界面不另存常量。 + "atpBases", + "atpDeviceStart", + "atpDevicePoll", + "atpApply", "openExternal", "listModels", "openFile", @@ -125,7 +131,7 @@ function parseApiBlock(block) { const api = parseApiBlock(extractApiBlock()); -test("window.emrg exposes the full contract (59 invoke + onEvent)", () => { +test("window.emrg exposes the full contract (64 invoke + onEvent)", () => { // 59 invoke 方法全部存在(防误删/改名) for (const name of EXPECTED_INVOKE_METHODS) { assert.ok(api.invoke.has(name), `缺少 invoke 方法 ${name}`); diff --git a/tests/test_gui_config_mode.py b/tests/test_gui_config_mode.py index 622199e7..7fec3d6a 100644 --- a/tests/test_gui_config_mode.py +++ b/tests/test_gui_config_mode.py @@ -266,9 +266,13 @@ def test_the_comment_stripper_reads_both_shapes(): def test_the_config_writer_applies_the_rule_and_the_neighbour_does_not(tmp_path): """The wire, read in both directions on the same extractor. - A module test cannot see a missing call, and `main.js::writeConfig` is the seam this whole - issue is about. `readConfig` is the control: the extractor has to be able to answer "no" for - it, or "yes" for `writeConfig` would only be evidence that the pattern matches anything. + A module test cannot see a missing call, and the function that writes `config.toml` is the + seam this whole issue is about. Since issue #2774 that function is `writeConfigText`: + `writeConfig` hands it the serialized TOML, and the aitokenpool login hands it merged text. + So the rule is pinned where the write happens, and the delegation is pinned with it — a + guard naming only `writeConfig` would pass on a body that no longer writes anything. + `readConfig` is the control: the extractor has to be able to answer "no" for it, or "yes" + for the writer would only be evidence that the pattern matches anything. Comments are stripped first, because a commented-out call is not a call. """ source = _strip_js_comments(MAIN_JS.read_text(encoding="utf-8")) @@ -276,13 +280,18 @@ def test_the_config_writer_applies_the_rule_and_the_neighbour_does_not(tmp_path) assert re.search(r'require\(["\']\./config_mode["\']\)', source), ( "main.js does not require ./config_mode.js, so the writer below has nothing to call" ) - writer = _function_body(source, "writeConfig") + writer = _function_body(source, "writeConfigText") assert re.search(r"^[ \t]+makeConfigPrivate\(", writer, re.MULTILINE), ( - "writeConfig does not call makeConfigPrivate as a statement — the module's rule is never " - "applied on the path that rewrites an existing config file (issue #2148)" + "writeConfigText does not call makeConfigPrivate as a statement — the module's rule is " + "never applied on the path that rewrites an existing config file (issue #2148)" ) assert "fs.writeFileSync(" in writer, ( - "writeConfig no longer writes the file, so this guard is watching the wrong function" + "writeConfigText no longer writes the file, so this guard is watching the wrong function" + ) + delegator = _function_body(source, "writeConfig") + assert "writeConfigText(" in delegator, ( + "writeConfig no longer goes through writeConfigText, so the permission rule is not on " + "every path that writes config.toml (issue #2148)" ) reader = _function_body(source, "readConfig") assert "makeConfigPrivate(" not in reader, (