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
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 路径不受影响)
Expand Down
2 changes: 1 addition & 1 deletion README.cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 即失败

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
14 changes: 14 additions & 0 deletions emrg/gui/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,20 @@ <h2 data-i18n="skills.title">技能</h2>
</div>
</dialog>

<!-- GitHub device-flow 授权对话框(Windows GCM rant Stage 2b) -->
<dialog id="github-device-dialog">
<div class="dialog-card" style="min-width:440px;">
<h2 data-i18n="settings.githubDeviceTitle">连接 GitHub</h2>
<p style="color:var(--text-2);font-size:var(--fs-secondary);margin:0 0 var(--sp-3);" data-i18n="settings.githubDeviceDesc">在浏览器中打开 GitHub 并输入下方一次性代码以完成授权。</p>
<div id="github-device-code" style="font-size:28px;font-weight:700;letter-spacing:3px;text-align:center;padding:var(--sp-3);background:var(--bg-2, rgba(0,0,0,.04));border-radius:8px;margin-bottom:var(--sp-3);font-family:monospace;">—</div>
<div class="dialog-actions" style="justify-content:center;flex-wrap:wrap;gap:8px;">
<button type="button" id="github-device-open" class="btn btn-primary" data-i18n="settings.githubDeviceOpen">打开浏览器</button>
<button type="button" id="github-device-close" class="btn btn-ghost" data-i18n="settings.githubDeviceCancel">取消</button>
</div>
<div id="github-device-wait" class="hint" style="text-align:center;margin-top:var(--sp-2);" data-i18n="settings.githubDeviceWait">等待浏览器中确认…</div>
</div>
</dialog>

<!-- 进化对话框(/rant,GUI / 指令 P4) -->
<dialog id="rant-dialog">
<div class="dialog-card" style="min-width:460px;">
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:结果面板(⌘\ 折叠 + 窄屏自动隐藏)
Expand Down
70 changes: 68 additions & 2 deletions emrg/gui/renderer/js/dialogs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -452,6 +517,7 @@ const Dialogs = (() => {
initModelForm,
initRenameDialog,
initGithubSection,
initDeviceDialog,
refreshGithubStatus,
showRename,
submitRename,
Expand Down
14 changes: 14 additions & 0 deletions emrg/gui/renderer/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions emrg/gui/test/daemon_client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
113 changes: 113 additions & 0 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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", "")
Expand Down
Loading
Loading