From c318ea69343b8c1aa8853e15444748560e9537b1 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 7 Aug 2026 20:06:29 +0800 Subject: [PATCH] emrg: GitHub device-flow auth in GUI settings (GCM rant Stage 2b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preferred auth path from the Windows GCM rant (2026-08-07T10:17:27): device flow — no terminal, no GCM, no PAT needed. Daemon (emrg/server/daemon.py): - github_connect_web command: spawns `gh auth login --web` (stdin closed, prompt-free env), parses the one-time code + device URL from its output, returns them to the GUI, and keeps the process alive in a background task until the host authorizes in the browser (300s timeout-kill). - github_connect_web_cancel kills any pending flow; the proc is stored on self so a cancel racing the task start still kills the process (edge found in review: task.cancel() on a never-started task never runs the waiter). GUI: - Settings Connect button now prefers device flow when no PAT is entered (PAT remains the fallback for restricted environments). - New device-flow dialog: one-time code display, Open-browser button (shell.openExternal), 3s github_status polling until authorized. - daemon_client RESPONSE_TYPES + main IPC (emrg:githubConnectWeb / emrg:openExternal) + preload exposure; zh/en i18n. Tests: +6 Python (code/URL parsing, already-authenticated short-circuit, gh-missing degrade, no-code kill, dispatch frame, cancel-kills-pending); GUI RESPONSE_TYPES extended. Docs synced 542 -> 548 (doc-count guard). --- Agent.md | 2 +- README.cn.md | 2 +- README.md | 2 +- emrg/gui/daemon_client.js | 1 + emrg/gui/main.js | 13 ++ emrg/gui/preload.js | 2 + emrg/gui/renderer/index.html | 14 ++ emrg/gui/renderer/js/app.js | 1 + emrg/gui/renderer/js/dialogs.js | 70 +++++++++- emrg/gui/renderer/js/i18n.js | 14 ++ emrg/gui/test/daemon_client.test.js | 7 + emrg/server/daemon.py | 113 ++++++++++++++++ tests/test_daemon.py | 195 ++++++++++++++++++++++++++++ 13 files changed, 431 insertions(+), 5 deletions(-) diff --git a/Agent.md b/Agent.md index d82b0022..3685e5b2 100644 --- a/Agent.md +++ b/Agent.md @@ -93,7 +93,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (542) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (548) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/README.cn.md b/README.cn.md index 39828c69..ed5fb055 100644 --- a/README.cn.md +++ b/README.cn.md @@ -274,7 +274,7 @@ EMRG 不只是追赶——它自己追上来。 git clone https://github.com/argszero/emrg.git cd emrg uv sync # 安装依赖 -uv run pytest tests/ -v # 跑测试(当前 542 项) +uv run pytest tests/ -v # 跑测试(当前 548 项) uv run python -m emrg # 启动 TUI # CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败 diff --git a/README.md b/README.md index 6d8fe2c7..cdd78b9f 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ EMRG doesn't just keep up — it catches up on its own. git clone https://github.com/argszero/emrg.git cd emrg uv sync # install deps -uv run pytest tests/ -v # run tests (currently 542 items) +uv run pytest tests/ -v # run tests (currently 548 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index dd27983a..7528221a 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -47,6 +47,7 @@ const RESPONSE_TYPES = { evolution_summary: "evolution_summary", // WorkBuddy P3:自进化可见化 github_connect: "github_connect_result", // Windows GCM rant Stage 2:PAT 授权(daemon.py github_connect) github_disconnect: "github_disconnect_result", // Windows GCM rant Stage 2:断开(daemon.py github_disconnect) + github_connect_web: "github_connect_web_result", // Stage 2b:device flow(daemon.py github_connect_web) }; class DaemonClient { diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 06404e5a..d6fb7c7e 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -460,6 +460,19 @@ vision = false return { ok: Boolean(frame.ok), error: frame.error || null }; }); + ipcMain.handle("emrg:githubConnectWeb", async () => { + // Windows GCM rant Stage 2b:device flow 启动(daemon github_connect_web) + const frame = await client.sendCommandAndWait("github_connect_web", {}, 15000); + return { ok: Boolean(frame.ok), code: frame.code || null, url: frame.url || null, error: frame.error || null }; + }); + + ipcMain.handle("emrg:openExternal", async (_e, { url }) => { + // Windows GCM rant Stage 2b:打开 device flow 授权页(默认浏览器) + if (typeof url !== "string" || !/^https:\/\//.test(url)) return { ok: false }; + await shell.openExternal(url); + return { ok: true }; + }); + ipcMain.handle("emrg:setModel", async (_e, { model }) => { await client.sendCommandAndWait("set_model", { model }, 5000); return { ok: true }; diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index 945225e8..1ccc6678 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -30,6 +30,8 @@ const api = { githubStatus: () => ipcRenderer.invoke("emrg:githubStatus"), githubConnect: (payload) => ipcRenderer.invoke("emrg:githubConnect", payload), githubDisconnect: () => ipcRenderer.invoke("emrg:githubDisconnect"), + githubConnectWeb: () => ipcRenderer.invoke("emrg:githubConnectWeb"), + openExternal: (payload) => ipcRenderer.invoke("emrg:openExternal", payload), listModels: () => ipcRenderer.invoke("emrg:listModels"), openFile: (payload) => ipcRenderer.invoke("emrg:openFile", payload), saveSettings: (payload) => ipcRenderer.invoke("emrg:saveSettings", payload), diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index 166739ea..a19c6b83 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -294,6 +294,20 @@

技能

+ + +
+

连接 GitHub

+

在浏览器中打开 GitHub 并输入下方一次性代码以完成授权。

+
+
+ + +
+
等待浏览器中确认…
+
+
+
diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index dd40c6f2..4adc7b3f 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -1185,6 +1185,7 @@ const App = (() => { Dialogs.initModelForm(); Dialogs.initRenameDialog(); Dialogs.initGithubSection(); // Windows GCM rant Stage 2:设置页 GitHub 连接 + Dialogs.initDeviceDialog(); // Windows GCM rant Stage 2b:device flow 对话框 initModelSwitcher(); initModeSwitcher(); // WorkBuddy P2:Ask/Auto 工作模式 ResultPanel.init(); // WorkBuddy P1:结果面板(⌘\ 折叠 + 窄屏自动隐藏) diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js index 23534f3c..f9c759b1 100644 --- a/emrg/gui/renderer/js/dialogs.js +++ b/emrg/gui/renderer/js/dialogs.js @@ -359,12 +359,13 @@ const Dialogs = (() => { }); } - // ── GitHub 连接(Windows GCM rant Stage 2:设置页 PAT 授权) ──── + // ── GitHub 连接(Windows GCM rant Stage 2:设置页授权) ──── function initGithubSection() { $("github-connect-btn").addEventListener("click", async () => { const token = $("set-github-token").value.trim(); if (!token) { - Chat.addSystemMessage(_t("settings.githubTokenEmpty")); + // 首选 device flow(不开终端、不弹 GCM);PAT 为受限环境兜底 + await startDeviceFlow(); return; } $("github-connect-btn").disabled = true; @@ -400,6 +401,70 @@ const Dialogs = (() => { }); } + // ── GitHub device flow(Stage 2b:gh auth login --web) ──── + let _devicePollTimer = null; + function stopDevicePolling() { + if (_devicePollTimer) { + clearInterval(_devicePollTimer); + _devicePollTimer = null; + } + } + + async function startDeviceFlow() { + const dlg = $("github-device-dialog"); + const codeEl = $("github-device-code"); + if (!dlg || !codeEl) return; // 元素缺失(测试桩)时忽略 + stopDevicePolling(); + codeEl.textContent = "…"; + dlg.showModal(); + try { + const res = await window.emrg.githubConnectWeb(); + if (!res || !res.ok) { + dlg.close(); + Chat.addSystemMessage(_t("settings.githubDeviceFailed", { msg: (res && res.error) || _t("app.unknownError") })); + return; + } + if (res.code && res.url) { + codeEl.textContent = res.code; + const openBtn = $("github-device-open"); + if (openBtn) { + openBtn.onclick = () => window.emrg.openExternal({ url: res.url }); + } + } else { + // already authenticated + dlg.close(); + Chat.addSystemMessage(_t("settings.githubConnected", { user: res.user || "" })); + await refreshGithubStatus(); + return; + } + } catch (e) { + dlg.close(); + Chat.addSystemMessage(_t("settings.githubDeviceFailed", { msg: e.message })); + return; + } + // 轮询 github_status 直到授权完成(daemon 侧 300s 超时兜底) + _devicePollTimer = setInterval(async () => { + try { + const s = await window.emrg.githubStatus(); + if (s && s.authenticated) { + stopDevicePolling(); + if (dlg.open) dlg.close(); + Chat.addSystemMessage(_t("settings.githubConnected", { user: s.user || "" })); + await refreshGithubStatus(); + } + } catch { /* 网络抖动忽略,下一轮再试 */ } + }, 3000); + } + + function initDeviceDialog() { + const dlg = $("github-device-dialog"); + if (!dlg) return; + $("github-device-close").addEventListener("click", () => { + stopDevicePolling(); + dlg.close(); + }); + } + async function refreshGithubStatus() { const statusEl = $("github-status"); const authRow = $("github-auth-row"); @@ -452,6 +517,7 @@ const Dialogs = (() => { initModelForm, initRenameDialog, initGithubSection, + initDeviceDialog, refreshGithubStatus, showRename, submitRename, diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index b2281e1c..68803af5 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -95,6 +95,13 @@ const I18N = (() => { "settings.githubConnectFailed": "GitHub 连接失败:{msg}", "settings.githubDisconnected": "已断开 GitHub 连接", "settings.githubDisconnectFailed": "断开失败:{msg}", + // Windows GCM rant Stage 2b:device flow + "settings.githubDeviceTitle": "连接 GitHub", + "settings.githubDeviceDesc": "在浏览器中打开 GitHub 并输入下方一次性代码以完成授权。", + "settings.githubDeviceOpen": "打开浏览器", + "settings.githubDeviceCancel": "取消", + "settings.githubDeviceWait": "等待浏览器中确认…", + "settings.githubDeviceFailed": "GitHub 连接失败:{msg}", // 首启引导 "welcome.title": "欢迎使用 EMRG", @@ -374,6 +381,13 @@ const I18N = (() => { "settings.githubConnectFailed": "GitHub connect failed: {msg}", "settings.githubDisconnected": "Disconnected from GitHub", "settings.githubDisconnectFailed": "Disconnect failed: {msg}", + // Windows GCM rant Stage 2b: device flow + "settings.githubDeviceTitle": "Connect GitHub", + "settings.githubDeviceDesc": "Open GitHub in your browser and enter the one-time code below to finish authorization.", + "settings.githubDeviceOpen": "Open browser", + "settings.githubDeviceCancel": "Cancel", + "settings.githubDeviceWait": "Waiting for confirmation in browser…", + "settings.githubDeviceFailed": "GitHub connect failed: {msg}", // Welcome / onboarding "welcome.title": "Welcome to EMRG", diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index 57046aa4..54465b6f 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -427,6 +427,13 @@ test("RESPONSE_TYPES 映射表与 daemon 命令名一致(修正 clear/rename/t const r7 = await p7; assert.strictEqual(r7.type, "github_disconnect_result"); assert.strictEqual(r7.ok, true); + // github_connect_web → github_connect_web_result(Windows GCM rant Stage 2b) + const p8 = client.sendCommandAndWait("github_connect_web", {}, 2000); + await new Promise((r) => setTimeout(r, 10)); + currentMockWs.emit("message", Buffer.from(JSON.stringify({ type: "github_connect_web_result", ok: true, code: "ABCD-1234", url: "https://github.com/login/device", error: null }))); + const r8 = await p8; + assert.strictEqual(r8.type, "github_connect_web_result"); + assert.strictEqual(r8.code, "ABCD-1234"); }); test("命令-响应配对超时 → reject(G93)", async () => { diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index ead0d791..c0580e67 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -171,6 +171,10 @@ def __init__(self, llm_config: LlmConfig) -> None: self._session_busy: dict[str, bool] = {} # session_id → active task? self._all_connections: set = set() # all authenticated connections + # Device-flow auth (rant 10:17 Stage 2b): background gh auth login --web task + self._pending_web_auth: Optional[asyncio.Task] = None + self._pending_web_auth_proc: Optional[asyncio.subprocess.Process] = None + # Build tool registry self.tools = ToolRegistry() self.tools.register(BashTool()) @@ -686,6 +690,108 @@ async def _github_disconnect(self) -> dict: except (asyncio.TimeoutError, OSError, ValueError): return {"ok": False, "error": "gh auth logout failed"} + # ── Device-flow auth (rant 2026-08-07T10:17:27 Stage 2b) ──────────── + # gh auth login --web prints a one-time code + device URL even with + # stdin closed (probe-verified). We return those to the GUI, keep the + # gh process alive in the background until the host authorizes in the + # browser (or a timeout kills it), and the GUI discovers completion by + # polling github_status. + _GH_DEVICE_CODE_RE = re.compile(r"one-time code:\s*([A-Z0-9]{4}-[A-Z0-9]{4})") + _GH_DEVICE_URL_RE = re.compile(r"(https://github\.com/login/device)") + _GH_DEVICE_TIMEOUT = 300 + + async def _github_connect_web_start(self) -> dict: + """Start a device-flow login. Returns {ok, code, url, user?, error?}. + + Cancels any previously pending device flow (only one may run). + If already authenticated, short-circuits with ok=True + user so the + GUI can just reflect the connected state. + """ + status = await self._check_github_auth() + if status.get("authenticated"): + return {"ok": True, "code": None, "url": None, + "user": status.get("user"), "error": "already_authenticated"} + _, gh = resolve_git_gh() + if not gh: + return {"ok": False, "code": None, "url": None, + "user": None, "error": "gh binary not found"} + await self._github_connect_web_cancel() + try: + proc = await asyncio.create_subprocess_exec( + gh, "auth", "login", "--web", + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + env=no_prompt_env(), + ) + except (OSError, ValueError): + return {"ok": False, "code": None, "url": None, + "user": None, "error": "failed to start gh auth login --web"} + code = None + url = None + try: + async for raw in proc.stdout: + line = raw.decode("utf-8", errors="replace") + if code is None: + m = self._GH_DEVICE_CODE_RE.search(line) + if m: + code = m.group(1) + if url is None: + m = self._GH_DEVICE_URL_RE.search(line) + if m: + url = m.group(1) + if code and url: + break + if not (code and url): + proc.kill() + return {"ok": False, "code": None, "url": None, + "user": None, "error": "gh auth login --web produced no device code"} + except (asyncio.CancelledError, OSError, ValueError): + return {"ok": False, "code": None, "url": None, + "user": None, "error": "failed reading device code"} + # Keep the process alive until the host authorizes; the GUI polls + # github_status. On success gh writes its config and exits 0; on + # timeout we kill it so a stale flow never lingers. The proc is also + # kept on self so a cancel racing the task start still kills it. + self._pending_web_auth_proc = proc + self._pending_web_auth = asyncio.create_task( + self._gh_web_auth_wait(proc) + ) + return {"ok": True, "code": code, "url": url, "user": None, "error": None} + + async def _gh_web_auth_wait(self, proc) -> None: + """Background: await gh auth login --web completion, timeout-kill.""" + try: + await asyncio.wait_for(proc.communicate(), timeout=self._GH_DEVICE_TIMEOUT) + except (asyncio.TimeoutError, asyncio.CancelledError, OSError, ValueError): + try: + proc.kill() + except (OSError, ValueError): + pass + finally: + if self._pending_web_auth is not None: + self._pending_web_auth = None + if getattr(self, "_pending_web_auth_proc", None) is proc: + self._pending_web_auth_proc = None + + async def _github_connect_web_cancel(self) -> None: + """Kill any pending device-flow gh process.""" + task = getattr(self, "_pending_web_auth", None) + proc = getattr(self, "_pending_web_auth_proc", None) + self._pending_web_auth = None + self._pending_web_auth_proc = None + if task is not None: + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + if proc is not None: + try: + proc.kill() + except (OSError, ValueError): + pass + def _build_system_prompt(self, session: Session | None = None) -> str: """Build the system prompt via Jinja2 template. @@ -1083,6 +1189,13 @@ async def _process_message( result = await self._github_disconnect() await self._send(ws, {"type": "github_disconnect_result", **result}) + elif msg_type == "github_connect_web": + # Windows GCM rant Stage 2b: device-flow login — gh auth login + # --web; the GUI shows the one-time code and polls github_status + # until the host authorizes in the browser. + result = await self._github_connect_web_start() + await self._send(ws, {"type": "github_connect_web_result", **result}) + elif msg_type == "clear_session": session_id = msg.get("session_id", "") cwd = msg.get("cwd", "") diff --git a/tests/test_daemon.py b/tests/test_daemon.py index c3d3f10f..920756b5 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -7,6 +7,7 @@ from __future__ import annotations +import asyncio import json import tempfile from pathlib import Path @@ -860,6 +861,200 @@ def test_github_disconnect_gh_missing(monkeypatch): assert reply["error"] == "gh binary not found" +# ── github_connect_web (device flow) command (rant 10:17:27 Stage 2b) ── + + +class _FakeWebStream: + """Async-iterable of stdout lines for the device-flow fake proc.""" + + def __init__(self, lines): + self._lines = list(lines) + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._lines: + raise StopAsyncIteration + return self._lines.pop(0) + + +class _FakeWebProc: + """Fake gh auth login --web subprocess.""" + + def __init__(self, stream, blocking=False): + self.stdout = stream + self.killed = False + self.returncode = None + self._blocking = blocking + + async def communicate(self, data=None): + if self._blocking: + while True: + await asyncio.sleep(3600) + return (b"", None) + + def kill(self): + self.killed = True + + +def test_github_connect_web_parses_device_code(monkeypatch): + """Device flow parses the one-time code + URL and starts a background task.""" + import asyncio + + from emrg.server import daemon as dmod + + server = _make_server() + monkeypatch.setattr(server, "_check_github_auth", + lambda: _async_value({"authenticated": False, "user": None, "method": "none"})) + monkeypatch.setattr(dmod, "resolve_git_gh", lambda: ("/usr/bin/git", "/usr/bin/gh")) + procs = [] + + async def fake_exec(*args, **kwargs): + p = _FakeWebProc(_FakeWebStream([ + b"\n", + b"! First copy your one-time code: ABCD-1234\n", + b"Open this URL to continue in your web browser: https://github.com/login/device\n", + ])) + procs.append(p) + return p + + monkeypatch.setattr(dmod.asyncio, "create_subprocess_exec", fake_exec) + result = asyncio.run(server._github_connect_web_start()) + + assert result["ok"] is True + assert result["code"] == "ABCD-1234" + assert result["url"] == "https://github.com/login/device" + assert result["error"] is None + assert len(procs) == 1 + assert procs[0].killed is False + + +def test_github_connect_web_already_authenticated(monkeypatch): + """Already-authenticated short-circuits without spawning a subprocess.""" + import asyncio + + from emrg.server import daemon as dmod + + server = _make_server() + monkeypatch.setattr(server, "_check_github_auth", + lambda: _async_value({"authenticated": True, "user": "octocat", "method": "gh"})) + spawned = [] + monkeypatch.setattr(dmod, "resolve_git_gh", lambda: ("/usr/bin/git", "/usr/bin/gh")) + + async def fake_exec(*args, **kwargs): + spawned.append(args) + return _FakeWebProc(_FakeWebStream([])) + + monkeypatch.setattr(dmod.asyncio, "create_subprocess_exec", fake_exec) + result = asyncio.run(server._github_connect_web_start()) + + assert result["ok"] is True + assert result["user"] == "octocat" + assert result["error"] == "already_authenticated" + assert spawned == [] # no gh process started + + +def test_github_connect_web_gh_missing(monkeypatch): + """Missing gh binary degrades to ok=False, never raises.""" + import asyncio + + from emrg.server import daemon as dmod + + server = _make_server() + monkeypatch.setattr(server, "_check_github_auth", + lambda: _async_value({"authenticated": False, "user": None, "method": "none"})) + monkeypatch.setattr(dmod, "resolve_git_gh", lambda: ("", "")) + result = asyncio.run(server._github_connect_web_start()) + + assert result["ok"] is False + assert result["error"] == "gh binary not found" + + +def test_github_connect_web_no_device_code_kills_proc(monkeypatch): + """gh produces no code → ok=False and the process is killed.""" + import asyncio + + from emrg.server import daemon as dmod + + server = _make_server() + monkeypatch.setattr(server, "_check_github_auth", + lambda: _async_value({"authenticated": False, "user": None, "method": "none"})) + monkeypatch.setattr(dmod, "resolve_git_gh", lambda: ("/usr/bin/git", "/usr/bin/gh")) + proc = _FakeWebProc(_FakeWebStream([b"unexpected output\n"])) + + async def fake_exec(*args, **kwargs): + return proc + + monkeypatch.setattr(dmod.asyncio, "create_subprocess_exec", fake_exec) + result = asyncio.run(server._github_connect_web_start()) + + assert result["ok"] is False + assert result["error"] == "gh auth login --web produced no device code" + assert proc.killed is True + + +def test_github_connect_web_dispatch(monkeypatch): + """github_connect_web returns github_connect_web_result frame.""" + import asyncio + + server = _make_server() + writer = _FakeWriter() + + async def fake_start(): + return {"ok": True, "code": "WXYZ-9876", "url": "https://github.com/login/device", + "user": None, "error": None} + + monkeypatch.setattr(server, "_github_connect_web_start", fake_start) + asyncio.run(server._process_message({"type": "github_connect_web"}, writer)) + + assert len(writer._frames) == 1 + reply = json.loads(writer._frames[0]) + assert reply["type"] == "github_connect_web_result" + assert reply["ok"] is True + assert reply["code"] == "WXYZ-9876" + + +def test_github_connect_web_cancel_kills_pending(monkeypatch): + """Cancelling a pending device flow kills the gh process.""" + import asyncio + + from emrg.server import daemon as dmod + + server = _make_server() + monkeypatch.setattr(server, "_check_github_auth", + lambda: _async_value({"authenticated": False, "user": None, "method": "none"})) + monkeypatch.setattr(dmod, "resolve_git_gh", lambda: ("/usr/bin/git", "/usr/bin/gh")) + proc = _FakeWebProc( + _FakeWebStream([b"one-time code: WXYZ-9876\nhttps://github.com/login/device\n"]), + blocking=True, + ) + + async def fake_exec(*args, **kwargs): + return proc + + monkeypatch.setattr(dmod.asyncio, "create_subprocess_exec", fake_exec) + + async def run(): + r = await server._github_connect_web_start() + assert r["ok"] is True + assert server._pending_web_auth is not None + await server._github_connect_web_cancel() + assert server._pending_web_auth is None + return r + + result = asyncio.run(run()) + assert result["code"] == "WXYZ-9876" + assert proc.killed is True + + +def _async_value(v): + """Return an awaitable that yields v (for monkeypatched async fakes).""" + async def _wrap(): + return v + return _wrap() + + # ── /rant project list shows evolution-workspace entries (rant 10:48:00) ──