From ca10d5307700ce888d201d82246378b17f402f08 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 7 Aug 2026 18:28:56 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20Windows=20GCM=20silent-fail=20stage=201?= =?UTF-8?q?=20=E2=80=94=20no=5Fprompt=5Fenv=20for=20all=20git/gh=20subproc?= =?UTF-8?q?ess=20+=20github=5Fstatus=20command=20+=20prompt=20platform=20g?= =?UTF-8?q?uards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 2 +- README.cn.md | 2 +- README.md | 2 +- emrg/server/daemon.py | 41 ++++++++++++++++- emrg/server/evolution_prompt.md | 28 +++++++++--- emrg/server/git_utils.py | 49 +++++++++++++++++++- emrg/server/open_source_prompt.md | 21 ++++++--- emrg/server/scheduler.py | 15 ++++++- emrg/tools/bash_tool.py | 5 +++ tests/test_bash_tool.py | 31 +++++++++++++ tests/test_daemon.py | 75 +++++++++++++++++++++++++++++++ tests/test_git_utils.py | 51 +++++++++++++++++++++ 12 files changed, 304 insertions(+), 18 deletions(-) diff --git a/Agent.md b/Agent.md index e6169d14..1b0acad7 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` (508) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (520) — 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 f0ac6103..952450a9 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 # 跑测试(当前 508 项) +uv run pytest tests/ -v # 跑测试(当前 520 项) uv run python -m emrg # 启动 TUI # CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败 diff --git a/README.md b/README.md index 133ee551..e98f1039 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 508 items) +uv run pytest tests/ -v # run tests (currently 520 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index b3f6bae8..c7529926 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -31,7 +31,12 @@ from emrg.connect import cleanup_server from emrg.server.atomic import atomic_write_bytes, atomic_write_yaml from emrg.server.llm import LlmClient -from emrg.server.git_utils import _detect_git_remote +from emrg.server.git_utils import ( + _detect_git_remote, + no_prompt_env, + parse_gh_auth_user, + resolve_git_gh, +) from emrg.server.tool_types import ToolResult from emrg.memory import ProjectMemoryStore, SessionMemoryStore from emrg.protocol import ( @@ -567,6 +572,33 @@ def _touch_project(self, cwd: str) -> None: atomic_write_yaml(entries, self._projects_log, prefix=".projects_") + async def _check_github_auth(self) -> dict: + """Detect whether GitHub auth is configured (rant 2026-08-07T10:17:27). + + Runs the bundled ``gh auth status`` with a 10s timeout in a + prompt-free environment. Returns: + {"authenticated": bool, "user": str|None, "method": "gh"|"none"} + Never raises; any failure degrades to {"authenticated": False, ...}. + """ + _, gh = resolve_git_gh() + if not gh: + return {"authenticated": False, "user": None, "method": "none"} + try: + proc = await asyncio.create_subprocess_exec( + gh, "auth", "status", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + env=no_prompt_env(), + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10) + output = stdout.decode("utf-8", errors="replace") + user = parse_gh_auth_user(output) + if user: + return {"authenticated": True, "user": user, "method": "gh"} + return {"authenticated": False, "user": None, "method": "none"} + except (asyncio.TimeoutError, OSError, ValueError): + return {"authenticated": False, "user": None, "method": "none"} + def _build_system_prompt(self, session: Session | None = None) -> str: """Build the system prompt via Jinja2 template. @@ -945,6 +977,13 @@ async def _process_message( "recent": [], }) + elif msg_type == "github_status": + # Windows GCM rant (2026-08-07T10:17:27): GUI queries whether + # GitHub auth is configured so it can show a connect banner only + # when evolution actually needs GitHub. + auth = await self._check_github_auth() + await self._send(ws, {"type": "github_status", **auth}) + elif msg_type == "clear_session": session_id = msg.get("session_id", "") cwd = msg.get("cwd", "") diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index 7f5aee44..a1d34aa1 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -42,17 +42,33 @@ gh auth status 2>&1 || { # environment — gh auth login is not possible; the host's git credentials # usually contain a valid GitHub token that can be reused as GH_TOKEN # (never persisted to disk, never printed in plaintext). - TOKEN=$(printf "protocol=https\nhost=github.com\n\n" | git credential fill 2>/dev/null | grep '^password=' | cut -d= -f2-) - if [ -n "$TOKEN" ]; then - export GH_TOKEN="$TOKEN" - echo "gh 未认证 — 已从 git 凭据提取 token (GH_TOKEN)" - gh auth status 2>&1 + # + # ⚠️ Platform guard (rant 2026-08-07T10:17:27): on Windows, `git credential + # fill` triggers Git Credential Manager GUI popups inside the non-interactive + # daemon session, and the daemon's env already forces GIT_TERMINAL_PROMPT=0 + # / GCM_INTERACTIVE=never — so credential extraction must be SKIPPED on + # Windows entirely. The host connects GitHub from the EMRG GUI settings + # page instead (device flow / PAT paste). + if [ "$(uname)" = "Darwin" ] || [ "$(uname)" = "Linux" ]; then + TOKEN=$(printf "protocol=https\nhost=github.com\n\n" | git credential fill 2>/dev/null | grep '^password=' | cut -d= -f2-) + if [ -n "$TOKEN" ]; then + export GH_TOKEN="$TOKEN" + echo "gh 未认证 — 已从 git 凭据提取 token (GH_TOKEN)" + gh auth status 2>&1 + else + echo "gh 未认证且无可用凭据 — 提示宿主执行 gh auth login" + fi else - echo "gh 未认证且无可用凭据 — 提示宿主执行 gh auth login" + echo "gh 未认证 — 请在 EMRG GUI 设置页连接 GitHub(无需终端)" fi } ``` +**If gh is still unauthenticated after the steps above**: skip all GitHub +operations for this cycle (no retries — retrying re-triggers credential +prompts on some platforms), record "awaiting gh authentication" in the +evolution record, and finish the cycle gracefully. + **Confirm GitHub identity** (first run only; afterwards read `identity-github-role.md`): ```bash diff --git a/emrg/server/git_utils.py b/emrg/server/git_utils.py index 012aec15..6ed2f764 100644 --- a/emrg/server/git_utils.py +++ b/emrg/server/git_utils.py @@ -4,6 +4,7 @@ import json import os +import re import shutil import subprocess from pathlib import Path @@ -14,6 +15,50 @@ INSTALL_INFO = config_dir() / "install-info.json" +# ── Non-interactive subprocess environment (rant 2026-08-07T10:17:27) ── +# +# Windows GCM popup storm: the daemon is a background non-interactive +# process — any git/gh subprocess that needs credentials must FAIL FAST +# and silently, never spawn GCM GUI dialogs / askpass / terminal prompts. +# These vars are applied to every git/gh subprocess the daemon spawns +# (bash_tool child processes, scheduler clone/fetch, github_status). + +def no_prompt_env() -> dict: + """Copy of the current environment with all interactive git prompts disabled. + + - ``GIT_TERMINAL_PROMPT=0`` — git never asks on the terminal + - ``GCM_INTERACTIVE=never`` — Git Credential Manager never shows its GUI + - ``GIT_ASKPASS=`` — disables askpass helper popups + + macOS/Linux are unaffected (osxkeychain / credential helpers are + non-interactive there); Windows without stored credentials now fails + with a clear git error instead of popping a window. + """ + env = os.environ.copy() + env["GIT_TERMINAL_PROMPT"] = "0" + env["GCM_INTERACTIVE"] = "never" + env["GIT_ASKPASS"] = "" + return env + + +# gh auth status user extraction — output forms seen across gh versions: +# "Logged in to github.com as octocat" +# "Logged in to github.com account octocat" +# "Logged in to github.com account octocat using token" +_GH_AUTH_USER_RE = re.compile( + r"Logged in to github\.com (?:account |as )['\"]?([A-Za-z0-9][A-Za-z0-9-]*)" +) + + +def parse_gh_auth_user(output: str) -> str | None: + """Extract the authenticated GitHub username from ``gh auth status`` output. + + Returns None when the output does not describe an authenticated session. + """ + match = _GH_AUTH_USER_RE.search(output or "") + return match.group(1) if match else None + + def _detect_git_remote(cwd: str) -> str: """Detect the origin remote (owner/repo) from a git repository. @@ -118,10 +163,12 @@ def git_cmd(*args: str, cwd: str | None = None, timeout: int = 10) -> subprocess """Run a git command using the resolved git binary. Falls back to bare ``git`` when no bundled binary is found (dev mode). + The prompt-free environment guarantees no GCM/askpass popups from a + background daemon (rant 2026-08-07T10:17:27). """ git, _ = resolve_git_gh() exe = git or "git" return subprocess.run( [exe, *args], cwd=cwd, capture_output=True, text=True, - encoding="utf-8", timeout=timeout, + encoding="utf-8", timeout=timeout, env=no_prompt_env(), ) diff --git a/emrg/server/open_source_prompt.md b/emrg/server/open_source_prompt.md index 36c938ba..844be812 100644 --- a/emrg/server/open_source_prompt.md +++ b/emrg/server/open_source_prompt.md @@ -29,17 +29,26 @@ gh auth status 2>&1 || { # environment — gh auth login is not possible; the host's git credentials # usually contain a valid GitHub token that can be reused as GH_TOKEN # (never persisted to disk, never printed in plaintext). - TOKEN=$(printf "protocol=https\nhost=github.com\n\n" | git credential fill 2>/dev/null | grep '^password=' | cut -d= -f2-) - if [ -n "$TOKEN" ]; then - export GH_TOKEN="$TOKEN" - echo "gh 未认证 — 已从 git 凭据提取 token (GH_TOKEN)" - gh auth status 2>&1 + # + # ⚠️ Platform guard (rant 2026-08-07T10:17:27): on Windows, `git credential + # fill` triggers Git Credential Manager GUI popups inside the non-interactive + # daemon session — skip credential extraction on Windows entirely; the host + # connects GitHub from the EMRG GUI settings page instead. + if [ "$(uname)" = "Darwin" ] || [ "$(uname)" = "Linux" ]; then + TOKEN=$(printf "protocol=https\nhost=github.com\n\n" | git credential fill 2>/dev/null | grep '^password=' | cut -d= -f2-) + if [ -n "$TOKEN" ]; then + export GH_TOKEN="$TOKEN" + echo "gh 未认证 — 已从 git 凭据提取 token (GH_TOKEN)" + gh auth status 2>&1 + fi + else + echo "gh 未认证 — 请在 EMRG GUI 设置页连接 GitHub(无需终端)" fi } ``` - `gh` not installed → install (`brew install gh` / `sudo apt install gh`) -- `gh` unauthenticated and credential extraction failed → **stop this cycle**, record "awaiting gh authentication" in the state file, and finish +- `gh` unauthenticated and credential extraction failed → **stop this cycle**, record "awaiting gh authentication" in the state file, and finish — do NOT retry GitHub operations (retries re-trigger credential prompts on some platforms) {% if task.get('role', '')|lower in ('committer', 'contributor') %} diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index fdf71b79..409a694a 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -28,7 +28,12 @@ from websockets.exceptions import ConnectionClosed from emrg.protocol import EvolutionLog, InstanceIdentity from emrg.server.atomic import atomic_write_yaml -from emrg.server.git_utils import INSTALL_INFO, _detect_git_remote, resolve_git_gh +from emrg.server.git_utils import ( + INSTALL_INFO, + _detect_git_remote, + no_prompt_env, + resolve_git_gh, +) logger = logging.getLogger("emrg.server.scheduler") @@ -167,6 +172,7 @@ def _get_git_head(self) -> str | None: capture_output=True, text=True, timeout=5, + env=no_prompt_env(), ) if result.returncode == 0: return result.stdout.strip() @@ -205,6 +211,7 @@ def _is_usable_git_repo(self, path: str) -> bool: text=True, encoding="utf-8", timeout=5, + env=no_prompt_env(), ) if result.returncode != 0 or result.stdout.strip() != "true": return False @@ -225,6 +232,7 @@ def _ensure_git_identity(self, repo_dir: Path) -> None: text=True, encoding="utf-8", timeout=5, + env=no_prompt_env(), ) if not result.stdout.strip(): subprocess.run( @@ -232,6 +240,7 @@ def _ensure_git_identity(self, repo_dir: Path) -> None: cwd=repo_dir, capture_output=True, timeout=5, + env=no_prompt_env(), ) except (subprocess.SubprocessError, OSError): pass @@ -263,6 +272,7 @@ def _align_to_installed_version(self, repo_dir: Path) -> None: text=True, encoding="utf-8", timeout=10, + env=no_prompt_env(), ) if result.returncode == 0 and tag in result.stdout.split(): subprocess.run( @@ -273,6 +283,7 @@ def _align_to_installed_version(self, repo_dir: Path) -> None: encoding="utf-8", timeout=30, check=True, + env=no_prompt_env(), ) logger.info( "EvolutionHandler[%s]: evolution workspace aligned to %s", @@ -357,6 +368,7 @@ def _ensure_evolution_workspace(self) -> bool: encoding="utf-8", timeout=120, check=True, + env=no_prompt_env(), ) self._align_to_installed_version(evolve_dir) self._ensure_git_identity(evolve_dir) @@ -517,6 +529,7 @@ def _remote_advanced(self) -> bool: capture_output=True, text=True, timeout=10, + env=no_prompt_env(), ) if result.returncode != 0: return False diff --git a/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index eb349ec6..c3fa99ca 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -7,6 +7,7 @@ import os import signal +from emrg.server.git_utils import no_prompt_env from emrg.server.tool_types import ToolDefinition, ToolResult from emrg.tools.base import ToolExecutor @@ -65,6 +66,10 @@ async def execute(self, arguments: dict) -> ToolResult: stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=workdir, + # Non-interactive daemon: git/gh children must fail fast + # silently, never spawn GCM/askpass popups (rant + # 2026-08-07T10:17:27). + env=no_prompt_env(), preexec_fn=os.setsid if os.name != "nt" else None, ) try: diff --git a/tests/test_bash_tool.py b/tests/test_bash_tool.py index 3a1509a6..46bd18da 100644 --- a/tests/test_bash_tool.py +++ b/tests/test_bash_tool.py @@ -60,3 +60,34 @@ def test_bash_nonexistent_workdir(): "workdir": "/nonexistent/path/xyzzy", })) assert result.error + + +# ── Non-interactive env guards (rant 2026-08-07T10:17:27, Windows GCM) ── + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell syntax") +def test_bash_child_gets_no_prompt_env(): + """Child processes inherit GIT_TERMINAL_PROMPT=0 and GCM_INTERACTIVE=never. + + These guards prevent Git Credential Manager GUI popups when the daemon + (a non-interactive background process) runs git/gh commands on Windows. + """ + tool = BashTool() + result = _run(tool.execute({ + "command": 'echo "GTP=$GIT_TERMINAL_PROMPT GCM=$GCM_INTERACTIVE ASKPASS=[$GIT_ASKPASS]"', + })) + assert not result.error + assert "GTP=0" in result.content + assert "GCM=never" in result.content + assert "ASKPASS=[]" in result.content + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell syntax") +def test_bash_child_env_overridable_by_command(): + """The command itself can still override the guards for its own children.""" + tool = BashTool() + result = _run(tool.execute({ + "command": "GIT_TERMINAL_PROMPT=1 sh -c 'echo $GIT_TERMINAL_PROMPT'", + })) + assert not result.error + assert result.content.strip().endswith("1") diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 690c3fc4..26e2cab7 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -599,3 +599,78 @@ def test_redact_string_applies_to_log_previews(): assert "ghp_" not in _redact_string("token 是 ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890 吗") # 普通内容保留 assert "帮我看看这个文件" in _redact_string("帮我看看这个文件") + + +# ── github_status command (rant 2026-08-07T10:17:27, Windows GCM) ── + + +def test_github_status_authenticated(monkeypatch): + """github_status returns authenticated user when _check_github_auth succeeds.""" + import asyncio + + server = _make_server() + writer = _FakeWriter() + + async def fake_check(): + return {"authenticated": True, "user": "octocat", "method": "gh"} + + monkeypatch.setattr(server, "_check_github_auth", fake_check) + asyncio.run(server._process_message({"type": "github_status"}, writer)) + + assert len(writer._frames) == 1 + reply = json.loads(writer._frames[0]) + assert reply["type"] == "github_status" + assert reply["authenticated"] is True + assert reply["user"] == "octocat" + assert reply["method"] == "gh" + + +def test_github_status_unauthenticated(monkeypatch): + """github_status returns not-authenticated when no gh token is present.""" + import asyncio + + server = _make_server() + writer = _FakeWriter() + + async def fake_check(): + return {"authenticated": False, "user": None, "method": "none"} + + monkeypatch.setattr(server, "_check_github_auth", fake_check) + asyncio.run(server._process_message({"type": "github_status"}, writer)) + + reply = json.loads(writer._frames[0]) + assert reply["authenticated"] is False + assert reply["user"] is None + + +def test_check_github_auth_no_gh_binary(monkeypatch): + """Missing gh binary degrades to not-authenticated, never raises.""" + import asyncio + + from emrg.server import daemon as dmod + + server = _make_server() + monkeypatch.setattr(dmod, "resolve_git_gh", lambda: ("", "")) + result = asyncio.run(server._check_github_auth()) + assert result == {"authenticated": False, "user": None, "method": "none"} + + +def test_check_github_auth_parses_gh_output(monkeypatch): + """Real gh auth status output is parsed into an authenticated user.""" + import asyncio + + from emrg.server import daemon as dmod + + server = _make_server() + monkeypatch.setattr(dmod, "resolve_git_gh", lambda: ("/usr/bin/git", "/usr/bin/gh")) + + class FakeProc: + async def communicate(self): + return (b"Logged in to github.com as octocat (keyring)\n", None) + + async def fake_exec(*args, **kwargs): + return FakeProc() + + monkeypatch.setattr(dmod.asyncio, "create_subprocess_exec", fake_exec) + result = asyncio.run(server._check_github_auth()) + assert result == {"authenticated": True, "user": "octocat", "method": "gh"} diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index 51c96c76..57a9827d 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -113,3 +113,54 @@ def test_cache_tool_paths_preserves_existing_fields(tmp_path, monkeypatch): assert data["git_path"] == "/usr/bin/git" assert data["custom"] == 1 # preserved assert data["repo"] == "https://github.com/argszero/emrg.git" + + +# ── no_prompt_env / parse_gh_auth_user (rant 2026-08-07T10:17:27) ── + + +def test_no_prompt_env_sets_all_three_guards(): + """All three interactive-prompt guards are present.""" + from emrg.server.git_utils import no_prompt_env + + env = no_prompt_env() + assert env["GIT_TERMINAL_PROMPT"] == "0" + assert env["GCM_INTERACTIVE"] == "never" + assert env["GIT_ASKPASS"] == "" + + +def test_no_prompt_env_preserves_parent_environment(): + """Parent environment variables must survive (PATH etc.).""" + import os + + from emrg.server.git_utils import no_prompt_env + + env = no_prompt_env() + assert env.get("PATH") == os.environ.get("PATH") + + +def test_parse_gh_auth_user_logged_in_as(): + from emrg.server.git_utils import parse_gh_auth_user + + out = "Logged in to github.com as octocat (keyring)\n" + assert parse_gh_auth_user(out) == "octocat" + + +def test_parse_gh_auth_user_account_form(): + from emrg.server.git_utils import parse_gh_auth_user + + out = "Logged in to github.com account argszero using token\n" + assert parse_gh_auth_user(out) == "argszero" + + +def test_parse_gh_auth_user_unauthenticated(): + from emrg.server.git_utils import parse_gh_auth_user + + out = "You are not logged into any GitHub hosts.\n" + assert parse_gh_auth_user(out) is None + + +def test_parse_gh_auth_user_empty(): + from emrg.server.git_utils import parse_gh_auth_user + + assert parse_gh_auth_user("") is None + assert parse_gh_auth_user(None) is None # type: ignore[arg-type]