Skip to content
Merged
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
4 changes: 2 additions & 2 deletions Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>`, 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 <N>` (did the merge land a tree its reviews named)
Expand Down
302 changes: 302 additions & 0 deletions emrg/gui/atp_login.js
Original file line number Diff line number Diff line change
@@ -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,
};
49 changes: 48 additions & 1 deletion emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"gui-state.js",
"daemon_client.js",
"nav-policy.js",
"atp_login.js",
"package.json",
"scripts/**"
],
Expand Down
Loading
Loading