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` (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 <branch>`, 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 <N>` (did the merge land a tree its reviews named)
Expand Down
13 changes: 12 additions & 1 deletion emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
83 changes: 83 additions & 0 deletions emrg/gui/renderer/src/components/SettingsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
96 changes: 95 additions & 1 deletion emrg/gui/renderer/src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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<GetSettingsResult>;
saveSettings(cfg: unknown): Promise<unknown>;
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<unknown>;
openExternal(p: { url: string }): Promise<unknown>;
taskTemplateList(): Promise<TemplateRec[]>;
taskTemplateCreate(p: { name: string; prompt: string }): Promise<unknown>;
taskTemplateUpdate(p: { name: string; prompt: string }): Promise<unknown>;
Expand Down Expand Up @@ -85,6 +99,7 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
const [theme, setTheme] = useState<string>("system");
const [github, setGithub] = useState<{ authenticated: boolean; user: string | null } | null>(null);
const [githubToken, setGithubToken] = useState("");
const [device, setDevice] = useState<DeviceFlow | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
// model 行内表单态(vanilla openModelForm/saveModelForm)
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -538,6 +612,17 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
? t("settings.githubConnectedStatus", { user: github.user || "" })
: t("settings.githubNotConnected")}
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
<button
type="button"
className="btn btn-primary"
data-testid="github-device-btn"
disabled={busy}
onClick={startDeviceLogin}
>
{t("settings.githubSignIn")}
</button>
</div>
<div style={{ display: "flex", gap: 8 }}>
<input
type="password"
Expand Down Expand Up @@ -567,10 +652,10 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
</button>
</div>
<div className="hint" style={{ marginTop: 6 }}>{t("settings.githubHint")}</div>
<div className="hint" style={{ marginTop: 6 }}>{t("settings.githubSignInHint")}</div>
</div>
</div>
)}

{/* ── 外观 ── */}
{tab === "appearance" && (
<div className="panel-tab-body" data-settings-body="appearance" data-testid="settings-body-appearance">
Expand Down Expand Up @@ -746,6 +831,15 @@ export function SettingsPanel({ version = "", evolutionCount = null }: SettingsP
</div>
</div>
<ConfirmDialog request={dialogState.confirm} onDismiss={() => dispatch({ type: "close-confirm" })} />
<GithubDeviceDialog
open={Boolean(device?.open)}
code={device?.code ?? null}
url={device?.url ?? null}
waiting={Boolean(device?.waiting)}
error={device?.error ?? null}
onOpenBrowser={openDeviceUrl}
onDismiss={dismissDevice}
/>
</section>
);
}
Expand Down
4 changes: 4 additions & 0 deletions emrg/gui/renderer/src/lib/i18n-dicts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ export const ZH_DICT: Record<string, string> = {
"settings.githubDeviceCancel": "取消",
"settings.githubDeviceWait": "等待浏览器中确认…",
"settings.githubDeviceFailed": "GitHub 连接失败:{msg}",
"settings.githubSignIn": "使用 GitHub 登录(浏览器授权)",
"settings.githubSignInHint": "无需 personal access token:点上方按钮,在浏览器里输入一次性代码即可。",
"settings.githubBannerMsg": "启用自进化需连接 GitHub →",
"settings.githubBannerConnect": "去连接",
"settings.githubBannerDismiss": "关闭",
Expand Down Expand Up @@ -610,6 +612,8 @@ export const EN_DICT: Record<string, string> = {
"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",
Expand Down
7 changes: 4 additions & 3 deletions emrg/gui/renderer/src/lib/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
Loading
Loading