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 numberDiff line numberDiff 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` (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 路径不受影响)
Expand Down
2 changes: 1 addition & 1 deletion README.cn.md
Original file line numberDiff line numberDiff 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 # 跑测试(当前 508 项)
uv run pytest tests/ -v # 跑测试(当前 520 项)
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 numberDiff line numberDiff 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 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

Expand Down
41 changes: 40 additions & 1 deletion emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 (
Expand DownExpand Up@@ -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.

Expand DownExpand Up@@ -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", "")
Expand Down
28 changes: 22 additions & 6 deletions emrg/server/evolution_prompt.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
49 changes: 48 additions & 1 deletion emrg/server/git_utils.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@

import json
import os
import re
import shutil
import subprocess
from pathlib import Path
Expand All@@ -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.

Expand DownExpand Up@@ -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(),
)
21 changes: 15 additions & 6 deletions emrg/server/open_source_prompt.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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') %}

Expand Down
15 changes: 14 additions & 1 deletion emrg/server/scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")

Expand DownExpand Up@@ -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()
Expand DownExpand Up@@ -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
Expand All@@ -225,13 +232,15 @@ 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(
["git", "config", key, default],
cwd=repo_dir,
capture_output=True,
timeout=5,
env=no_prompt_env(),
)
except (subprocess.SubprocessError, OSError):
pass
Expand DownExpand Up@@ -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(
Expand All@@ -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",
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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
Expand Down
5 changes: 5 additions & 0 deletions emrg/tools/bash_tool.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand DownExpand Up@@ -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:
Expand Down
31 changes: 31 additions & 0 deletions tests/test_bash_tool.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
Loading
Loading