diff --git a/Agent.md b/Agent.md index 7c6f9e0b..e08259b0 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` (148: 88 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 5 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (660: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 24 transcript + 23 TranscriptView + 22 history + 18 historyReplay + 38 composer + 65 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 + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 45 daemonBridge + 9 DaemonBridgeProvider + 44 Shell + 22 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog) +GUI: `cd emrg/gui && npm test` (149: 88 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 6 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (663: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 24 transcript + 23 TranscriptView + 22 history + 18 historyReplay + 38 composer + 65 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 + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 45 daemonBridge + 9 DaemonBridgeProvider + 44 Shell + 22 DialogHost + 23 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 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 answers a question 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` (two PRs together; every step of an 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/main.js b/emrg/gui/main.js index 7569ea24..e4fc2b9c 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -979,7 +979,18 @@ vision = false 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); - return { ok: Boolean(frame.ok), code: frame.code || null, url: frame.url || null, error: frame.error || null }; + // `user` is forwarded because the daemon answers an already-authenticated request with + // the login and no code/url (`github_connect_web_start`'s short-circuit), and the + // renderer's device-flow panel reads exactly that field to say "connected" instead of + // reporting a failure (#2113's wiring; the field was dropped here until 2026-10-11, + // which made an already-connected install print "already_authenticated" as an error). + return { + ok: Boolean(frame.ok), + code: frame.code || null, + url: frame.url || null, + user: frame.user || null, + error: frame.error || null, + }; }); ipcMain.handle("emrg:openExternal", async (_e, { url }) => { diff --git a/emrg/gui/renderer/src/components/SettingsPanel.test.tsx b/emrg/gui/renderer/src/components/SettingsPanel.test.tsx index 8abd66c3..0204bbeb 100644 --- a/emrg/gui/renderer/src/components/SettingsPanel.test.tsx +++ b/emrg/gui/renderer/src/components/SettingsPanel.test.tsx @@ -225,6 +225,89 @@ describe("SettingsPanel", () => { ); }); + /** + * Issue #2113 — the device flow (daemon `github_connect_web` → main.js → preload + * `githubConnectWeb`) and the dialog component were both complete, and nothing rendered the + * dialog, so a pasted PAT was the only path. These two tests live at the seam that was + * missing: they drive the panel, not the dialog with injected props, because a fake-injected + * component test cannot fail when the wire is cut. + */ + it("starts the GitHub device flow without a token and shows the code", async () => { + const githubConnectWeb = vi + .fn() + .mockResolvedValue({ ok: true, code: "ABCD-1234", url: "https://github.com/login/device" }); + const openExternal = vi.fn().mockResolvedValue({ ok: true }); + setup({ + githubConnectWeb, + openExternal, + // not authenticated, or the poller below would close the dialog under the assertions + githubStatus: vi.fn().mockResolvedValue({ authenticated: false, user: null }), + }); + + fireEvent.click(screen.getByTestId("settings-tab-github")); + fireEvent.click(screen.getByTestId("github-device-btn")); + + await waitFor(() => expect(githubConnectWeb).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(screen.getByTestId("github-device-code").textContent).toBe("ABCD-1234"), + ); + expect(screen.getByTestId("github-device-wait")).toBeTruthy(); + + fireEvent.click(screen.getByTestId("github-device-open")); + await waitFor(() => + expect(openExternal).toHaveBeenCalledWith({ url: "https://github.com/login/device" }), + ); + + // Dismiss closes it; no token was ever typed. + fireEvent.click(screen.getByTestId("github-device-close")); + await waitFor(() => expect(screen.queryByTestId("github-device-code")).toBeNull()); + }); + + it("closes the device dialog once github_status reports authenticated", async () => { + let authenticated = false; + const githubStatus = vi + .fn() + .mockImplementation(async () => ({ + authenticated, + user: authenticated ? "argszero" : null, + })); + const githubConnectWeb = vi + .fn() + .mockResolvedValue({ ok: true, code: "WXYZ-9876", url: "https://github.com/login/device" }); + setup({ githubStatus, githubConnectWeb }); + + fireEvent.click(screen.getByTestId("settings-tab-github")); + fireEvent.click(screen.getByTestId("github-device-btn")); + await waitFor(() => expect(screen.getByTestId("github-device-code")).toBeTruthy()); + + // The daemon never pushes a device-flow result: the panel has to poll. + authenticated = true; + await waitFor( + () => expect(screen.queryByTestId("github-device-code")).toBeNull(), + { timeout: 5000 }, + ); + expect(screen.getByTestId("github-status").textContent).toContain("argszero"); + }); + + it("short-circuits the device flow when the daemon reports an existing login", async () => { + const githubConnectWeb = vi + .fn() + .mockResolvedValue({ ok: true, user: "argszero", code: null, url: null, error: "already_authenticated" }); + setup({ + githubConnectWeb, + // unauthenticated on mount, so only the short-circuit can put a user in the status row + githubStatus: vi.fn().mockResolvedValue({ authenticated: false, user: null }), + }); + + fireEvent.click(screen.getByTestId("settings-tab-github")); + fireEvent.click(screen.getByTestId("github-device-btn")); + + await waitFor(() => + expect(screen.getByTestId("github-status").textContent).toContain("argszero"), + ); + expect(screen.queryByTestId("github-device-code")).toBeNull(); + }); + it("applies theme option to documentElement and persists via saveSettings", async () => { const { calls } = setup(); await screen.findByTestId("settings-tab-appearance"); diff --git a/emrg/gui/renderer/src/components/SettingsPanel.tsx b/emrg/gui/renderer/src/components/SettingsPanel.tsx index 3e5efaf2..5556c770 100644 --- a/emrg/gui/renderer/src/components/SettingsPanel.tsx +++ b/emrg/gui/renderer/src/components/SettingsPanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useReducer, useRef, useState } from "react"; import { useI18n, setLocale, type Locale } from "../lib/i18n"; import { dialogReducer, initialDialogState } from "../lib/dialog"; import { ConfirmDialog } from "./ConfirmDialog"; +import { GithubDeviceDialog } from "./GithubDeviceDialog"; /** * SettingsPanel — Batch 5 slice 6:设置面板接线(vanilla dialogs.js showSettings/ @@ -47,13 +48,26 @@ interface GetSettingsResult { lang?: string; } +interface DeviceFlow { + open: boolean; + code: string | null; + url: string | null; + waiting: boolean; + error: string | null; +} + +/** device flow 轮询间隔(daemon 侧 gh 进程 300s 超时会自行退出) */ +export const DEVICE_POLL_MS = 2000; + /** SettingsPanel 用到的 preload 桥方法(与 preload.js 通道对齐) */ interface EmrgBridge { getSettings(): Promise; saveSettings(cfg: unknown): Promise; githubStatus(): Promise<{ authenticated?: boolean; user?: string | null }>; 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; + openExternal(p: { url: string }): Promise; taskTemplateList(): Promise; taskTemplateCreate(p: { name: string; prompt: string }): Promise; taskTemplateUpdate(p: { name: string; prompt: string }): Promise; @@ -85,6 +99,7 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP const [theme, setTheme] = useState("system"); const [github, setGithub] = useState<{ authenticated: boolean; user: string | null } | null>(null); const [githubToken, setGithubToken] = useState(""); + const [device, setDevice] = useState(null); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); // model 行内表单态(vanilla openModelForm/saveModelForm) @@ -298,6 +313,65 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP } } + /** PAT-free login: the daemon starts `gh auth login --web` and hands back the one-time code + * and URL; the dialog below shows them. Issue #2113 — the whole chain existed, including + * this dialog component, and no module rendered it, so the only way in was a pasted token. */ + async function startDeviceLogin() { + const b = bridge(); + if (!b?.githubConnectWeb) return; + setBusy(true); + try { + const res = await b.githubConnectWeb(); + if (res.ok && res.code && res.url) { + setDevice({ open: true, code: res.code, url: res.url, waiting: true, error: null }); + } else if (res.ok && res.user) { + // daemon short-circuits when already authenticated + setGithub({ authenticated: true, user: res.user }); + setMsg({ kind: "ok", text: t("settings.githubConnected", { user: res.user }) }); + } else { + setMsg({ kind: "err", text: t("settings.githubConnectFailed", { msg: res.error || t("app.unknownError") }) }); + } + } catch (e: unknown) { + setMsg({ kind: "err", text: t("settings.githubConnectFailed", { msg: (e as Error).message }) }); + } finally { + setBusy(false); + } + } + + // While the dialog waits, poll github_status — the daemon never pushes a device-flow result. + useEffect(() => { + if (!device?.open || !device.waiting) return; + const b = bridge(); + if (!b?.githubStatus) return; + const timer = setInterval(() => { + void b + .githubStatus() + .then((s) => { + if (!s.authenticated) return; + setDevice(null); + setGithub({ authenticated: true, user: s.user || null }); + setMsg({ kind: "ok", text: t("settings.githubConnected", { user: s.user || "" }) }); + }) + .catch(() => { + /* a failed poll is not a failure of the flow; the next tick asks again */ + }); + }, DEVICE_POLL_MS); + return () => clearInterval(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [device?.open, device?.waiting]); + + function openDeviceUrl() { + const b = bridge(); + if (!b?.openExternal || !device?.url) return; + void b.openExternal({ url: device.url }); + } + + /** Dismissing only hides the dialog: the daemon's gh process is reclaimed by its own 300 s + * timeout, and no IPC channel exposes `github_connect_web`'s cancel. */ + function dismissDevice() { + setDevice(null); + } + // ── templates tab(自定义任务类型管理,vanilla dialogs.js 语义) ── async function loadTemplates() { const b = bridge(); @@ -538,6 +612,17 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP ? t("settings.githubConnectedStatus", { user: github.user || "" }) : t("settings.githubNotConnected")} +
+ +
{t("settings.githubHint")}
+
{t("settings.githubSignInHint")}
)} - {/* ── 外观 ── */} {tab === "appearance" && (
@@ -746,6 +831,15 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
dispatch({ type: "close-confirm" })} /> + ); } diff --git a/emrg/gui/renderer/src/lib/i18n-dicts.ts b/emrg/gui/renderer/src/lib/i18n-dicts.ts index 912d3746..e9adad46 100644 --- a/emrg/gui/renderer/src/lib/i18n-dicts.ts +++ b/emrg/gui/renderer/src/lib/i18n-dicts.ts @@ -171,6 +171,8 @@ export const ZH_DICT: Record = { "settings.githubDeviceCancel": "取消", "settings.githubDeviceWait": "等待浏览器中确认…", "settings.githubDeviceFailed": "GitHub 连接失败:{msg}", + "settings.githubSignIn": "使用 GitHub 登录(浏览器授权)", + "settings.githubSignInHint": "无需 personal access token:点上方按钮,在浏览器里输入一次性代码即可。", "settings.githubBannerMsg": "启用自进化需连接 GitHub →", "settings.githubBannerConnect": "去连接", "settings.githubBannerDismiss": "关闭", @@ -610,6 +612,8 @@ export const EN_DICT: Record = { "settings.githubDeviceCancel": "Cancel", "settings.githubDeviceWait": "Waiting for confirmation in browser…", "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.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 b201bb32..4e851af9 100644 --- a/emrg/gui/renderer/src/lib/i18n.test.ts +++ b/emrg/gui/renderer/src/lib/i18n.test.ts @@ -85,11 +85,12 @@ describe("词典完整性守卫(防漂移)", () => { // 2026-10-09 由 `composer.roots*`(3 条)+ `cmd.sandbox.hint`(1 条)+ // `sandboxRoots.*`(12 条)——会话额外可写根的 GUI 入口与对话框,rant // 2026-10-09T09:43:39 Part 2(a)——从 413 改为 429。 - it("zh/en 各 429 个 key 且完全对齐", () => { + // 2026-10-11 issue #2113:device flow 的两个提示串 → 431。 + it("zh/en 各 431 个 key 且完全对齐", () => { const zhKeys = Object.keys(ZH_DICT); const enKeys = Object.keys(EN_DICT); - expect(zhKeys.length).toBe(429); - expect(enKeys.length).toBe(429); + expect(zhKeys.length).toBe(431); + expect(enKeys.length).toBe(431); expect(zhKeys.sort()).toEqual(enKeys.sort()); // DICTS 聚合结构 expect(Object.keys(DICTS)).toEqual(["zh", "en"]); diff --git a/emrg/gui/test/boot-contract.test.js b/emrg/gui/test/boot-contract.test.js index c8d6ac94..c7f1ef0a 100644 --- a/emrg/gui/test/boot-contract.test.js +++ b/emrg/gui/test/boot-contract.test.js @@ -159,3 +159,73 @@ test("boot chain: renderer DaemonBridgeProvider must call window.emrg.init() on "App.tsx must mount DaemonBridgeProvider — unmounting it drops the entire init chain." ); }); + +/** + * The arrow function of an `ipcMain.handle("", …)` registration, read out of + * main.js by brace-walking rather than by a lazy regex: the handler's own closing brace is + * spelled `});` on the last line, so a regex that stops before that line truncates the + * function it means to extract (measured while writing this guard). + */ +function handlerArrow(source, channel) { + const at = source.indexOf(`ipcMain.handle("${channel}"`); + if (at < 0) return null; + const start = source.indexOf("async () => {", at); + if (start < 0) return null; + let depth = 0; + for (let i = source.indexOf("{", start); i < source.length; i++) { + if (source[i] === "{") depth++; + else if (source[i] === "}" && --depth === 0) return source.slice(start, i + 1); + } + return null; +} + +test("device flow: the emrg:githubConnectWeb handler forwards the frame's user", async () => { + // The renderer's device-flow panel branches on `res.user` for the daemon's + // already-authenticated short-circuit: `github_connect_web_start` answers + // {ok, user, error:"already_authenticated"} with no code and no url, and the panel answers + // "connected" from that field. What the renderer receives is what THIS handler returns — + // and no renderer test can see it, because they all mock `window.emrg`, so a field dropped + // here is invisible from both sides. It was dropped: an already-connected install that + // pressed "Sign in with GitHub (browser)" was told + // "GitHub connect failed: already_authenticated". + // + // Driven, not matched: the real arrow function is read out of main.js and called with a + // stubbed connection, so only the boundary (the daemon's frame) is faked. A regex for + // `user:` would bless the spelling of a line without asking whether the value came from the + // frame at all — which is the shape of the defect. + const arrow = handlerArrow(read("main.js"), "emrg:githubConnectWeb"); + assert.ok(arrow, "main.js must contain an emrg:githubConnectWeb ipcMain.handle arrow"); + const handler = new Function("requireConn", `return (${arrow});`); + const withFrame = (frame) => handler(() => ({ sendCommandAndWait: async () => frame }))(); + + const started = await withFrame({ + ok: true, + code: "ABCD-1234", + url: "https://github.com/login/device", + user: null, + error: null, + }); + assert.deepStrictEqual(started, { + ok: true, + code: "ABCD-1234", + url: "https://github.com/login/device", + user: null, + error: null, + }); + + const already = await withFrame({ + ok: true, + code: null, + url: null, + user: "octocat", + error: "already_authenticated", + }); + assert.strictEqual( + already.user, + "octocat", + "the daemon's short-circuit login must cross the bridge: without `user` the renderer's " + + "`res.ok && res.user` branch cannot fire, and the panel reports a connect failure for " + + "an install that is connected already." + ); + assert.strictEqual(already.code, null, "a short-circuit carries no device code"); +});