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 @@ -54,7 +54,7 @@ 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` (156: 95 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`
GUI: `cd emrg/gui && npm test` (157: 96 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` (684: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 28 transcript + 26 TranscriptView + 22 history + 20 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 + 50 daemonBridge + 9 DaemonBridgeProvider + 44 Shell + 22 DialogHost + 26 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`
Expand Down
23 changes: 17 additions & 6 deletions emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,22 @@ const NO_LOG_MARK = Object.freeze({ size: 0, ino: null });
// (测试与将来别的调用者)时的默认值,与"子进程有 pid"同义。
const NO_SPAWN_STATE = Object.freeze({ err: null, neverStarted: false });

// 会话 ID 允许多种形态:
// - 交互会话:s_<6位日期>_<4位时间>_<hex id>(generateSessionId 产物)
// - 固定任务会话:emrg-evolution-<task-name>(首尾均连字符,无下划线分隔)
// 两者都可能经 GUI 的 switchSession/打开会话 传入,需都接受。
const SESSION_ID_RE = /^(s_\d{6}_\d{4}_[0-9a-f]{4,8}|emrg-evolution-[A-Za-z0-9_-]+)$/;
// 会话 ID 的**形态不做检查**(宿主 2026-10-17:定时任务的会话 id 就去掉前缀,叫任务名)。
// 原先这里是一张白名单 `/^(s_\d{6}_\d{4}_[0-9a-f]{4,8}|emrg-evolution-[...])$/`,
// 它挡的是"命名方案"而不是危险输入:交互会话叫 `s_260808_0857_387c852f`、任务会话现在
// 叫任务自己的名字,白名单每多一种命名就要改一次,漏改的后果是这个会话在 GUI 里**打不开**
// (main.js 的每个 IPC 都先问这个谓词),而错误信息只说 `invalid session_id`。
//
// 留下的只有路径安全,那不是命名规则:一个 id 会拼进 `<cwd>/.emrg/sessions/<id>`
// (`Session.__init__`),所以空串、分隔符与 `..` 段必须拒绝——它们会逃出目录。
const MAX_SESSION_ID_LENGTH = 128;

function isSessionId(id) {
if (typeof id !== "string") return false;
if (id.length === 0 || id.length > MAX_SESSION_ID_LENGTH) return false;
if (id === "." || id === "..") return false;
return !id.includes("/") && !id.includes("\\") && !id.includes("\u0000");
}

// G93:命令类型 → 响应帧类型映射(daemon 协议,daemon.py _process_message)
const RESPONSE_TYPES = {
Expand Down Expand Up @@ -1271,4 +1282,4 @@ function generateSessionId(seed) {
return sid;
}

module.exports = { DaemonClient, generateSessionId, TOKEN_FILE, SESSION_ID_RE, MAX_PAYLOAD, EMRGD_PORT, EMRGD_START_ERR };
module.exports = { DaemonClient, generateSessionId, isSessionId, TOKEN_FILE, MAX_PAYLOAD, EMRGD_PORT, EMRGD_START_ERR };
6 changes: 4 additions & 2 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const { pathToFileURL } = require("url");
const { spawn } = require("child_process");
const crypto = require("crypto");
const { parse: parseToml, stringify: stringifyToml } = require("smol-toml");
const { generateSessionId, SESSION_ID_RE } = require("./daemon_client");
const { generateSessionId, isSessionId } = require("./daemon_client");
const { ConnManager } = require("./conn-manager");
const { guiStatePath, sanitizeOpenSessions, saveGuiState, DEFAULT_CAP } = require("./gui-state");
const { externalNavPolicy } = require("./nav-policy");
Expand Down Expand Up @@ -283,8 +283,10 @@ vision = false

// ── IPC(G102/G114 输入校验)────────────────────────────

// 宿主 2026-10-17:不再按"命名方案"校验会话 id,只保留路径安全(见 daemon_client.js
// 的 `isSessionId`)——任务会话的 id 就是任务名,白名单会把它挡在门外。
function validateSessionId(id) {
return typeof id === "string" && SESSION_ID_RE.test(id);
return isSessionId(id);
}

function validateText(t) {
Expand Down
4 changes: 2 additions & 2 deletions emrg/gui/renderer/src/components/Shell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function mockEmrg() {
const clearSession = vi.fn().mockResolvedValue({ ok: true });
const compactSession = vi.fn().mockResolvedValue({ ok: true });
const listProjects = vi.fn().mockResolvedValue([{ name: "emrg", path: "/p/emrg" }]);
const listTasks = vi.fn().mockResolvedValue([{ name: "evo", type: "evolution", enabled: true, session_id: "emrg-evolution-evo", project_path: "/p/emrg" }]);
const listTasks = vi.fn().mockResolvedValue([{ name: "evo", type: "evolution", enabled: true, session_id: "evo", project_path: "/p/emrg" }]);
const listRants = vi.fn().mockResolvedValue([{ timestamp: "2026-08-26T12:00:00+08:00", project: "emrg", status: "pending", message: "x" }]);
const listMemories = vi.fn().mockResolvedValue([]);
const listSkills = vi.fn().mockResolvedValue([]);
Expand Down Expand Up @@ -615,7 +615,7 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => {
expect(openBtn).toBeEnabled();
fireEvent.click(openBtn);
await waitFor(() =>
expect(m.switchSession).toHaveBeenCalledWith({ sessionId: "emrg-evolution-evo", projectPath: "/p/emrg" }),
expect(m.switchSession).toHaveBeenCalledWith({ sessionId: "evo", projectPath: "/p/emrg" }),
);
// 切回会话视图(transcript + composer 可见)
await waitFor(() => expect(screen.getByTestId("composer")).toBeInTheDocument());
Expand Down
2 changes: 1 addition & 1 deletion emrg/gui/renderer/src/components/WorkspaceView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ describe("WorkspaceView", () => {
const startedAt = Math.floor(Date.now() / 1000) - 100; // 100s 前启动
const tasks = [
task("daily-report", { running: true, started_at: startedAt }),
task("evolve", { next_run_in_seconds: 83, session_id: "emrg-evolution-evolve", project_path: "/proj/evolve" }),
task("evolve", { next_run_in_seconds: 83, session_id: "evolve", project_path: "/proj/evolve" }),
task("idle", {}),
];
setup({ activeView: "tasks", tasks, onTriggerTask: onTrigger, onOpenSessionTask: onOpenSession, onEditTask: onEdit, onDeleteTask: onDelete });
Expand Down
18 changes: 17 additions & 1 deletion emrg/gui/test/daemon_client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Module._load = function (request, parent, isMain) {
if (request === "ws") return MockWs;
return origLoad.apply(this, arguments);
};
const { DaemonClient, generateSessionId, TOKEN_FILE, EMRGD_PORT, EMRGD_START_ERR } = require("../daemon_client.js");
const { DaemonClient, generateSessionId, isSessionId, TOKEN_FILE, EMRGD_PORT, EMRGD_START_ERR } = require("../daemon_client.js");
let tmpHome = null;
let origHome = null;
let origUserProfile = null;
Expand Down Expand Up @@ -1278,6 +1278,22 @@ test("generateSessionId 格式(G28+G81)", () => {
assert.strictEqual(seen.size, 100);
});

test("isSessionId 不查命名方案,只查路径安全(宿主 2026-10-17)", () => {
// 定时任务的会话 id 现在就是任务名,所以凡是"看起来像名字"的都必须放行——
// 原先的白名单只认 s_<...>_<hex> 与 emrg-evolution-<...>,裸任务名会被挡在
// GUI 门外,而报错只说 `invalid session_id`(main.js 每个 IPC 都先问这个谓词)。
for (const id of ["emrg-task", "competition-task", "silicon-science-cs-journal-task",
"OfficeCLI-opensource-task", "s_260808_0857_387c852f"]) {
assert.strictEqual(isSessionId(id), true, `${id} must be accepted`);
}
// 剩下的只有路径安全:id 会拼进 `<cwd>/.emrg/sessions/<id>`,逃出目录的必须拒绝。
for (const id of ["", ".", "..", "../etc", "a/b", "a\\b", "cwd\u0000x", "x".repeat(129)]) {
assert.strictEqual(isSessionId(id), false, `${JSON.stringify(id)} must be refused`);
}
assert.strictEqual(isSessionId(42), false, "a non-string is not an id");
assert.strictEqual(isSessionId(null), false, "null is not an id");
});

test("isRunning:TCP 探测(G43/G90)", async () => {
const client = new DaemonClient();
// mock net.connect:成功 → true
Expand Down
4 changes: 2 additions & 2 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -2255,7 +2255,7 @@ async def _task_vibe_check(self, task_name: str, session_id: str, cwd: str,

Rant 2026-08-19T10:15:43 (host-finalized architecture): the PRIMARY
evidence is the task's OWN session history, loaded here by the task's
fixed ``session_id`` (``emrg-evolution-{name}``) + working dir —
fixed ``session_id`` (the task's own name) + working dir —
the full tool loop / analysis / memory writes are in the session,
far richer than the transported prompt/completion_summary. Those are
kept only as auxiliary context. System message lives in
Expand Down Expand Up @@ -3769,7 +3769,7 @@ async def _run_tool_loop_locked(
# Rant 2026-09-02T10:36:26:权威 turn 生命周期广播——started_at 为任务
# 实际开始执行时刻(队列等待之后)。TUI/GUI 据此对齐各自计时(排队请求
# 不再从发送时刻起算);覆盖所有 turn 来源:TUI/GUI 请求、演化任务
# (emrg-evolution-*)、upgrade 会话(都经此 locked 包装执行)。
# (定时任务会话)、upgrade 会话(都经此 locked 包装执行)。
started_at = time.time()
self._session_turn_started[session_id] = started_at
# A new turn speaks for itself: the previous turn's terminal frame (or
Expand Down
12 changes: 6 additions & 6 deletions emrg/server/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ def recovery_recipe(stash_message: str) -> str:
#
# 这条规则是**路径安全**规则,不是命名风格规则。任务名同时是文件名与会话 id:
# `~/.emrg/logs/task-runs/<name>.jsonl`、`<name>.heartbeat.json`、`~/.emrg/next-run/
# <name>.json`、会话 id `emrg-evolution-{name}`。所以它必须挡住分隔符、跳转、空格与
# <name>.json`、会话 id `{name}`。所以它必须挡住分隔符、跳转、空格与
# 超长——那些会逃出目录或造出非法文件名。大小写不在其中。
#
# 原先只收小写,于是一条**已经在 tasks.yml 里**的记录被加载器每个 tick 拒绝:
Expand Down Expand Up @@ -930,7 +930,7 @@ def _apply_record(self, record: dict) -> None:
"repo": repo,
"repo_url": repo_url,
"repo_configured": repo_configured,
"session_id": f"emrg-evolution-{self.name}",
"session_id": self.name,
"source_dir": path or self.name,
"sandbox": sandbox,
# The template follows the record's type, so changing a task's type in
Expand Down Expand Up @@ -976,7 +976,7 @@ def _repo_configured(self) -> bool:

@property
def _session_id(self) -> str:
return self._derived.get("session_id", f"emrg-evolution-{self.name}")
return self._derived.get("session_id", self.name)

@property
def _source_dir(self) -> str:
Expand Down Expand Up @@ -2513,7 +2513,7 @@ def status(self) -> dict:
# rant 2026-08-21T17:46:12: expose the task→session link so the GUI
# tasks panel can add an "open session" action. All in-memory —
# project name (config.project), resolved project path, and the
# fixed task session_id (emrg-evolution-{name}).
# fixed task session_id (the task name).
"project": self._project_name,
"project_path": self.project_path,
"session_id": self._session_id,
Expand Down Expand Up @@ -3306,7 +3306,7 @@ def _idle_status(self, record: dict) -> dict:
"saturation": {},
"project": project,
"project_path": _resolve_project_path(project) if project else "",
"session_id": f"emrg-evolution-{record.get('name')}",
"session_id": record.get("name"),
}

def total_evolutions(self) -> int:
Expand Down Expand Up @@ -3427,7 +3427,7 @@ def task_create(
return False, f"invalid sandbox {sandbox!r} (expected one of {', '.join(SANDBOX_MODES)})"
tasks = self._read_table()
# 名字即文件名与会话 id,而 macOS/Windows 的文件系统不区分大小写:`Foo` 与
# `foo` 会写进同一个 `<name>.jsonl`、同一个 `emrg-evolution-{name}` 会话。
# `foo` 会写进同一个 `<name>.jsonl`、同一个 `{name}` 会话。
# 允许大写之后这种重名才可能出现,所以这道唯一性比较必须忽略大小写。
if any(str(t.get("name", "")).lower() == name.lower() for t in tasks):
return False, (
Expand Down
4 changes: 2 additions & 2 deletions emrg/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ def _parse_instant(value: object) -> datetime | None:
def records_since(records: list[dict], since: str) -> list[dict]:
"""The records from `since` (an ISO-8601 instant) onward — the run's own.

A task session is **one session for every run of that task**: `emrg-evolution-<name>`
is reused cycle after cycle, so its history holds all of them. Anything reading
A task session is **one session for every run of that task**: the task's own name is
reused cycle after cycle, so its history holds all of them. Anything reading
"the recent history" as evidence about *this* run therefore reads the previous
run's work as if it were this one's, unless it knows where the run began. This
function is that knowledge, applied: `since` is the instant the run was dispatched,
Expand Down
2 changes: 1 addition & 1 deletion scripts/llm-cost-report.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
Usage examples::

scripts/llm-cost-report.py # all indexed sessions
scripts/llm-cost-report.py --session-id emrg-evolution-emrg-task
scripts/llm-cost-report.py --session-id emrg-task
scripts/llm-cost-report.py --root ~/.emrg/.emrg/sessions --json
scripts/llm-cost-report.py --pricing my-model:0.5:1.5
"""
Expand Down
12 changes: 6 additions & 6 deletions tests/test_app_task_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def make_task(name: str = "t", **extra) -> dict:
"name": name,
"project": "emrg",
"project_path": "/Users/x/projects/emrg",
"session_id": "emrg-evolution-emrg",
"session_id": "emrg",
"running": False,
"interval": 300,
}
Expand Down Expand Up @@ -111,7 +111,7 @@ def test_selected_task_is_none_when_nothing_is_selected():
def test_a_real_row_yields_the_tasks_project_and_session():
assert _task_session_target(make_task()) == (
"/Users/x/projects/emrg",
"emrg-evolution-emrg",
"emrg",
)


Expand All @@ -129,7 +129,7 @@ def test_a_real_row_yields_the_tasks_project_and_session():
# replay somewhere that does not exist.
make_task(project_path=" "),
# Not a dict at all: the row comes off the wire.
"emrg-evolution-emrg",
"emrg",
],
)
def test_a_row_that_cannot_name_a_session_yields_nothing(row):
Expand All @@ -143,8 +143,8 @@ def test_a_row_that_cannot_name_a_session_yields_nothing(row):


def test_the_cwd_moves_only_for_the_session_the_open_requested():
pending = ("/Users/x/projects/emrg", "emrg-evolution-emrg")
assert _task_open_switch(pending, "emrg-evolution-emrg") == "/Users/x/projects/emrg"
pending = ("/Users/x/projects/emrg", "emrg")
assert _task_open_switch(pending, "emrg") == "/Users/x/projects/emrg"


def test_no_open_means_no_switch():
Expand All @@ -157,7 +157,7 @@ def test_a_stale_open_does_not_ride_along_on_an_unrelated_resume():
(connection dropped) is still pending when a different session resumes, and
moving the host into that stale project would put every later message and the
history replay in the wrong directory."""
stale = ("/Users/x/projects/other", "emrg-evolution-other")
stale = ("/Users/x/projects/other", "other")
assert _task_open_switch(stale, "s_260917_1200_abcd") is None


Expand Down
4 changes: 2 additions & 2 deletions tests/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def test_build_prompt_emrg_self():

# Core template variables must be present
assert "argszero/emrg" in prompt
assert "emrg-evolution" in prompt
assert "- Session ID: `emrg`" in prompt, "the prompt must carry the task's own session id"
assert "https://github.com/argszero/emrg.git" in prompt
# …and the per-daemon identity must NOT be, even though the builder still passes it
# (it is there for the host's own custom templates in `~/.emrg/task-templates/`).
Expand Down Expand Up @@ -80,7 +80,7 @@ def test_build_prompt_with_project():
prompt = handler._build_evolution_prompt()

assert "/home/user/src/myproject" in prompt
assert "emrg-evolution-myproject" in prompt
assert "- Session ID: `myproject`" in prompt
assert "https://github.com/user/myproject.git" in prompt
assert "<<<<<<<" not in prompt
assert ">>>>>>>" not in prompt
Expand Down
6 changes: 3 additions & 3 deletions tests/test_local_exclude.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ def _exclude_file(repo: Path) -> Path:

def _runtime_dir(repo: Path) -> None:
"""What this instance writes into a directory it works in."""
(repo / ".emrg" / "sessions" / "emrg-evolution-emrg-task").mkdir(parents=True)
(repo / ".emrg" / "sessions" / "emrg-evolution-emrg-task" / "history.jsonl").write_text(
(repo / ".emrg" / "sessions" / "emrg-task").mkdir(parents=True)
(repo / ".emrg" / "sessions" / "emrg-task" / "history.jsonl").write_text(
"{}\n", encoding="utf-8"
)

Expand All @@ -77,7 +77,7 @@ def _runtime_file(repo: Path) -> str:
A file rather than the bare directory: git ignores what it can see, and an
empty directory is invisible to it either way.
"""
return str(repo / ".emrg" / "sessions" / "emrg-evolution-emrg-task" / "history.jsonl")
return str(repo / ".emrg" / "sessions" / "emrg-task" / "history.jsonl")


def _ignored(repo: Path, path: str) -> bool:
Expand Down
Loading
Loading