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@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg
```

Python: `uv run pytest tests/ -v` (1064) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (1078) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 20 conn-manager + 22 app-commands + 129 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + 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
137 changes: 76 additions & 61 deletions bin/emrg-uninstall
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,15 +44,19 @@ from pathlib import Path
EMRG_DIR = Path.home() / ".emrg"
LOG_DIR = EMRG_DIR / "logs"
GRAVEYARD_DIR = EMRG_DIR / "graveyard"
PORT_FILE = EMRG_DIR / "emrgd.port"
PID_FILE = EMRG_DIR / "emrgd.pid"
INSTALL_DIR = EMRG_DIR / "install"

# Rant 2026-08-25T18:46(宿主):emrgd.pid / emrgd.port 已彻底废弃——不写不读。
# 固定端口(EMRGD_PORT=56031)是 daemon 存活唯一 ground truth(rant
# 2026-08-19T08:05:21);token 只由 emrgd.token 单行承载(rant 2026-08-20T14:32:52)。
# WHITELIST 里仍保留这两个文件名,仅为卸载时清理老安装的历史残留(不创建不读取)。

# R101 whitelist — known EMRG files. Anything else in ~/.emrg is user data
# and is preserved (listed in the report instead of deleted).
# R121: 补全运行时文件 — emrgd.log / emrg-gui.log / gui-window.json 为 daemon/GUI
# 运行日志与窗口状态;skills/ 是 daemon 启动时 mkdir 的运行时骨架(非用户数据),
# 用户自定义 skills 先入 graveyard 快照再删除(rant 2026-08-05T15:35:17)。
# emrgd.pid / emrgd.port:历史遗留(已废弃),仅卸载时清理。
WHITELIST = [
"install", "versions", "config.toml", "sessions", "memory", "logs",
"projects.yml", "tasks.yml", "rants.jsonl", "saturation",
Expand DownExpand Up@@ -99,33 +103,37 @@ def stop_gui() -> dict:


def stop_daemon() -> dict:
"""Step 1b — stop the daemon. Returns {method, ok}."""
"""Step 1b — stop the daemon. Returns {method, ok}.

Rant 2026-08-25T18:46(宿主):不读 emrgd.pid / emrgd.port(已废弃)。
顺序:① ws 协议关闭(固定端口 56031 + emrgd.token 单行 token)
② 命令行扫描 ``-m emrg.server`` 进程 SIGTERM/taskkill(与
emrg._stop_all.stop_daemon 同语义,仅剩的可靠身份标记)
"""
result = {"method": "none", "ok": False}
# Try protocol shutdown when websockets is importable (PYTHONPATH has lib/).
EMRGD_PORT = 56031 # 固定端口(connect.py EMRGD_PORT,rant 2026-08-19T08:05:21)
token_path = EMRG_DIR / "emrgd.token"
try:
import asyncio
import json as _json

from websockets.asyncio.client import connect # type: ignore
from websockets.exceptions import ConnectionClosed # type: ignore

port = None
token = None
if PORT_FILE.exists():
lines = PORT_FILE.read_text(encoding="utf-8").splitlines()
if lines:
port = lines[0].strip()
if len(lines) > 1:
token = lines[1].strip()
token = ""
try:
token = token_path.read_text(encoding="utf-8").strip()
except OSError:
pass

async def _shutdown() -> bool:
if not port:
if not token:
return False
try:
ws = await asyncio.wait_for(connect(f"ws://127.0.0.1:{port}", open_timeout=3), timeout=4)
ws = await asyncio.wait_for(connect(f"ws://127.0.0.1:{EMRGD_PORT}", open_timeout=3), timeout=4)
try:
# Auth handshake (mirrors connect.py connect_to_server).
await ws.send(_json.dumps({"type": "auth", "token": token or ""}, ensure_ascii=False))
await ws.send(_json.dumps({"type": "auth", "token": token}, ensure_ascii=False))
ack = _json.loads(await asyncio.wait_for(ws.recv(), timeout=10))
if ack.get("type") != "auth_ok":
await ws.close()
Expand All@@ -141,61 +149,68 @@ def stop_daemon() -> dict:
except (ConnectionClosed, OSError, asyncio.TimeoutError, _json.JSONDecodeError):
return False

if port:
if token:
ok = asyncio.run(_shutdown())
if ok:
result = {"method": "protocol-shutdown", "ok": True}
print(" [1] daemon stopped (protocol shutdown)")
return result
except Exception:
pass # degrade to pid-based stop
pass # degrade to cmdline-scan stop

# Fallback: SIGTERM via pid file (POSIX) / taskkill (Windows).
pid = None
if PID_FILE.exists():
try:
pid = int(PID_FILE.read_text(encoding="utf-8").strip())
except ValueError:
pid = None
if not pid and PORT_FILE.exists():
# port file second line is the token, not pid — skip
pass
if pid:
try:
if os.name == "nt":
subprocess.run(
["taskkill", "/PID", str(pid), "/F"],
capture_output=True, timeout=10,
)
# R121: 轮询确认进程退出(≤5s),避免 pid 文件被并发重建
for _ in range(35):
chk = subprocess.run(
["tasklist", "/FI", f"PID eq {pid}"],
capture_output=True, text=True, timeout=5,
)
if "No tasks" in chk.stdout:
break
time.sleep(0.15)
else:
os.kill(pid, signal.SIGTERM)
for _ in range(20):
try:
os.kill(pid, 0)
# Fallback: kill by command-line identity (``-m emrg.server``) — the only
# reliable marker on Windows (rant 2026-08-17T17:03:38; emrg._stop_all
# same approach). No pid file involved.
import subprocess as _sp
try:
if os.name == "nt":
out = _sp.run(
["wmic", "process", "where", "name='pythonw.exe'", "get", "processid,commandline"],
capture_output=True, text=True, timeout=10,
).stdout or ""
# PowerShell CIM fallback for Win11 24H2+ (no wmic).
if not out.strip() or "emrg.server" not in out:
ps_cmd = ("Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match "
"'emrg.server' } | ForEach-Object { $_.ProcessId }")
out = _sp.run(
["powershell", "-NoProfile", "-Command", ps_cmd],
capture_output=True, text=True, timeout=10,
).stdout or ""
pids = [int(p) for p in re.findall(r"\d+", out) if p.strip()]
for pid in pids:
_sp.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True, timeout=10)
else:
out = _sp.run(
["ps", "-axww", "-o", "pid=,command="],
capture_output=True, text=True, timeout=10,
).stdout or ""
pids = [int(m.group(1)) for m in re.finditer(r"^\s*(\d+)\s+.*-m emrg\.server\b", out, re.M)]
for pid in pids:
try:
os.kill(pid, signal.SIGTERM)
except OSError:
pass
if pids:
# Poll ≤5s for the port to close (ground truth).
import socket as _socket
for _ in range(35):
try:
with _socket.create_connection(("127.0.0.1", EMRGD_PORT), timeout=0.3):
time.sleep(0.15)
except OSError:
break
result = {"method": "pid-sigterm", "ok": True}
print(f" [1] daemon stopped (pid {pid})")
except OSError:
break
result = {"method": "cmdline-scan", "ok": True}
print(f" [1] daemon stopped (cmdline scan, {len(pids)} pid(s))")
return result
except (OSError, subprocess.SubprocessError):
pass
# Fallback: remove stale runtime files.
for f in (PORT_FILE, PID_FILE):
try:
f.unlink()
except FileNotFoundError:
pass
print(" [1] daemon not running (or already stopped); removed stale port/pid files")
except (OSError, subprocess.SubprocessError, _sp.SubprocessError):
pass
# Token cleanup once port is confirmed closed (daemon removes it itself on
# graceful shutdown; a force-killed daemon cannot).
try:
token_path.unlink()
except OSError:
pass
print(" [1] daemon not running (or already stopped)")
return result


Expand Down
6 changes: 6 additions & 0 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2734,6 +2734,12 @@ async def _run_tool_loop(
# agent cannot choose it per call.
if tc_name == "bash" and req.sandbox:
args["sandbox"] = req.sandbox
# write/edit get the tier + workspace boundary too
# (community issue #979): under read-only the tools must
# not clobber the host's tree — workspace = session cwd.
elif tc_name in ("write", "edit") and req.sandbox:
args["sandbox"] = req.sandbox
args["workspace"] = str(session.cwd)

# Execute
tool = self.tools.get(tc_name)
Expand Down
64 changes: 63 additions & 1 deletion emrg/server/scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -336,6 +336,65 @@ def _resolve_sandbox(config: dict, explicit: str | None) -> str:
return cand
return "workspace-write"

@staticmethod
def _is_dirty_tree_sync(source_dir: str) -> bool:
"""Sync core of the dirty-tree probe (community issue #979).

Local ``subprocess`` import on purpose: the module must keep its
no-subprocess attribute invariant (rant 2026-08-19T14:20:52 — the
self-heal git machinery was deleted). Run via ``asyncio.to_thread``
so the event loop is never blocked by the git call.

Fail-open by design: a non-git source dir, or a failing git call,
returns False — there is no uncommitted state to protect, and the
guard itself must never block a cycle.
"""
import subprocess as _sp # noqa: PLC0415 — local import keeps the module invariant

if not os.path.isdir(os.path.join(source_dir, ".git")):
return False
try:
out = _sp.run(
["git", "-C", source_dir, "status", "--porcelain"],
capture_output=True, text=True, timeout=10,
)
except (OSError, _sp.SubprocessError):
return False
return bool(out.stdout.strip()) if out.returncode == 0 else False

async def _effective_sandbox(self, dirty: bool | None = None) -> str:
"""Per-cycle effective bash sandbox tier for the task message.

Structural dirty-tree guard (community issue #979 — heinrichneb's
"audited override" pattern: inconvenient by default, possible on
explicit human override, every exception is a receipt). When the
source dir has uncommitted changes the cycle runs read-only
regardless of configuration — the host's live edits are out of
reach structurally, not by prompt aspiration. A human may override
with the env var ``EMRG_TASK_DIRTY_OVERRIDE`` (comma-separated task
names, or ``*`` for all); every override is logged as a receipt.
"""
if dirty is None:
dirty = await asyncio.to_thread(
self._is_dirty_tree_sync, str(self._source_dir)
)
if not dirty:
return self._sandbox
override = os.environ.get("EMRG_TASK_DIRTY_OVERRIDE", "")
names = [n.strip() for n in override.split(",") if n.strip()]
if "*" in names or self.name in names:
self._logger.warning(
"TaskHandler[%s]: dirty tree + EMRG_TASK_DIRTY_OVERRIDE — "
"read-only guard overridden (sandbox=%s, audited receipt)",
self.name, self._sandbox,
)
return self._sandbox
self._logger.warning(
"TaskHandler[%s]: dirty working tree — cycle forced read-only "
"(structural guard, community issue #979)", self.name,
)
return "read-only"

# ── Saturation state (restored from disk across daemon restarts) ──

def _load_saturation_state(self) -> bool:
Expand DownExpand Up@@ -895,7 +954,10 @@ async def _run_evolution_cycle(self) -> None:
"prompt": prompt,
"stream": True,
"timestamp": cycle_time.isoformat(),
"sandbox": self._sandbox,
# Structural dirty-tree guard (community issue #979): effective
# sandbox per cycle — dirty tree forces read-only unless a
# human set EMRG_TASK_DIRTY_OVERRIDE (audited receipt).
"sandbox": await self._effective_sandbox(),
},
ensure_ascii=False,
)
Expand Down
58 changes: 56 additions & 2 deletions emrg/tools/bash_tool.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,8 +99,11 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]:
# Three tiers (default danger-full-access = current, un-sandboxed behavior):
# danger-full-access — no checks at all (existing behavior)
# read-only — no writes allowed: destructive commands (rm -r /
# rmdir / mv / cp -r) and shell redirects (> / >>)
# to any non-/dev/null target are blocked
# rmdir / mv / cp -r), git mutating commands (stash /
# checkout / restore / clean / reset / commit / push /
# pull / merge / rebase — community issue #979) and
# shell redirects (> / >>) to any non-/dev/null target
# are blocked
# workspace-write — writes inside the workspace root (and the OS temp
# area) are allowed; destructive writes to protected
# daemon state files and to absolute paths outside
Expand All@@ -125,6 +128,20 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]:
"~/.emrg/rants.jsonl",
)

# Git mutating commands — blocked under read-only (community issue #979,
# heinrichneb dev.to comment on the 2026-08-20 data-loss postmortem): the
# incident's actual killers (`git stash`, `git checkout .`, `git reset --hard`,
# `git clean`) were NOT caught by the rm/rmdir/mv/cp checks. Under read-only
# these must be structurally impossible, not merely discouraged by a prompt
# rule — "rules can regress; topology can't". Read-only git reads (status /
# fetch / log / diff / remote) stay allowed.
_GIT_MUTATOR_RE = re.compile(
r"\bgit\s+(?:stash|checkout|restore|clean|reset|commit|push|pull|merge|"
r"rebase|cherry-pick|cherry_pick|revert|rm|mv|switch|apply|am|archive|"
r"submodule|worktree)\b"
)
_GIT_DELETE_RE = re.compile(r"\bgit\s+(?:branch|tag)\s+-[dD]\b")


def _extract_write_targets(cmd: str) -> list[str]:
"""Heuristic extraction of write targets from a command line.
Expand DownExpand Up@@ -188,6 +205,33 @@ def _is_within(path: str, root: str) -> bool:
return False


def check_read_only_file_write(file_path: str, workspace: str | None = None) -> str | None:
"""Read-only sandbox check for the write/edit tools (community issue #979).

Returns a block reason when the target file is inside the task's workspace
(the host's working tree — protected by the structural dirty-tree guard) or
is a protected daemon state file; returns None when allowed.

Writes OUTSIDE the workspace (memory dir, logs, OS temp) stay allowed so a
read-only cycle can still record state and write its own artifacts — the
guard protects the host's uncommitted work, not the agent's own scratch
space. Mirrors the bash tool's read-only semantics for file tools.
"""
path = os.path.realpath(os.path.expanduser(file_path))
if workspace:
ws = os.path.realpath(os.path.expanduser(workspace))
if _is_within(path, ws):
return (
f"read-only sandbox: blocked file write inside workspace {path!r} "
"(dirty-tree guard, community issue #979)"
)
if path in _protected_paths():
return (
f"read-only sandbox: blocked write to protected daemon file {path!r}"
)
return None


def _check_sandbox(cmd: str, mode: str, workdir: str | None = None) -> tuple[bool, str | None, str]:
"""Static sandbox check for a bash command (rant 2026-08-20T15:46:50).

Expand DownExpand Up@@ -215,6 +259,16 @@ def _check_sandbox(cmd: str, mode: str, workdir: str | None = None) -> tuple[boo
return False, (
f"read-only sandbox: blocked destructive write targeting {t!r}"
), "partial"
# Git mutators are blocked too — the 2026-08-20 data-loss commands
# (stash / checkout . / reset --hard / clean) write no file targets
# and escaped the target scan (community issue #979). Also blocks
# working-tree writers: apply / am / archive / submodule / worktree.
m = _GIT_MUTATOR_RE.search(cmd) or _GIT_DELETE_RE.search(cmd)
if m:
return False, (
f"read-only sandbox: blocked git mutating command {m.group(0)!r} "
"(dirty-tree guard, community issue #979)"
), "partial"
return True, None, "partial"

# workspace-write
Expand Down
10 changes: 10 additions & 0 deletions emrg/tools/edit_tool.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@

from emrg.server.tool_types import ToolDefinition, ToolResult
from emrg.tools.base import ToolExecutor
from emrg.tools.bash_tool import check_read_only_file_write

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -77,6 +78,15 @@ async def execute(self, arguments: dict) -> ToolResult:

path = Path(file_path).expanduser().resolve()

# Read-only sandbox (community issue #979): the edit tool must not
# modify the host's uncommitted work in the task source tree when the
# dirty-tree guard forced read-only. Workspace boundary injected by the
# daemon (session cwd); None in non-daemon use → fail-open.
if arguments.get("sandbox") == "read-only":
reason = check_read_only_file_write(str(path), arguments.get("workspace"))
if reason:
return ToolResult(name="edit", content=reason, error=True)

if not path.exists():
return ToolResult(
name="edit", content=f"Error: file not found: {path}", error=True
Expand Down
Loading
Loading