diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4901d84f7..42b38c57e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `omp --smoke-test` recursively deleting unrelated directories in `os.tmpdir()` (tmux/ssh sockets, editor state, build trees). The smoke broker now keeps its runtime dir under a private parent, and the dead-scope reclaim refuses any root that is not the `daemons` container and only prunes entries named like a 16-hex daemon scope key ([#8721](https://github.com/can1357/oh-my-pi/issues/8721)). + ## [17.3.5] - 2026-08-16 ### Added diff --git a/packages/coding-agent/src/launch/client.ts b/packages/coding-agent/src/launch/client.ts index 07e489279..1c27a3720 100644 --- a/packages/coding-agent/src/launch/client.ts +++ b/packages/coding-agent/src/launch/client.ts @@ -516,8 +516,14 @@ export async function closeDaemonClients(): Promise { /** Exercise worker-host broker startup and authenticated RPC for distribution smoke tests. */ export async function smokeTestDaemonBroker(): Promise { - const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-project-")); - const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-run-")); + // Keep the broker's runtime dir under a private parent this process owns, so + // the broker's dead-scope sweep (pruneDeadDaemonRuntimeDirs, fired on startup) + // can only ever reclaim siblings inside it — never unrelated neighbours in + // os.tmpdir() such as tmux/ssh sockets or build trees (issue #8721). + const smokeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-")); + const projectDir = path.join(smokeRoot, "project"); + const runtimeDir = path.join(smokeRoot, "run"); + await fs.mkdir(projectDir, { recursive: true }); const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); try { const ping = await client.request({ op: "ping" }); @@ -525,7 +531,6 @@ export async function smokeTestDaemonBroker(): Promise { await client.request({ op: "shutdown" }); } finally { client.close(); - await fs.rm(projectDir, { recursive: true, force: true }); - await fs.rm(runtimeDir, { recursive: true, force: true }); + await fs.rm(smokeRoot, { recursive: true, force: true }); } } diff --git a/packages/coding-agent/src/launch/presence.ts b/packages/coding-agent/src/launch/presence.ts index 46fdf72e3..472734a48 100644 --- a/packages/coding-agent/src/launch/presence.ts +++ b/packages/coding-agent/src/launch/presence.ts @@ -6,7 +6,19 @@ import { daemonRuntimeDir } from "./paths"; const CLIENTS_DIR = "clients"; const BROKER_PID_FILE = "broker.pid"; -const GLOBAL_DAEMON_DIR = "global"; +/** + * Basename of the container holding per-project daemon scopes + * (`/run/daemons`). {@link pruneDeadDaemonRuntimeDirs} refuses to sweep + * any other root so a runtime dir passed from outside the state tree cannot + * turn the reclaim into an rm -rf of unrelated neighbours (issue #8721). + */ +const DAEMONS_DIR = "daemons"; +/** + * Name shape of a project daemon scope: the 16-hex wyhash of the project dir + * produced by `getDaemonRuntimeDir`. Only entries matching this are pruned, + * which excludes the machine-global `global` container and any foreign dir. + */ +const DAEMON_SCOPE_KEY = /^[0-9a-f]{16}$/; /** * Grace before a dead daemon runtime dir becomes prune-eligible. Guards against * deleting a scope whose owning omp process is mid-startup (token written, broker @@ -118,11 +130,14 @@ async function hasLiveDaemonBroker(runtimeDir: string): Promise { * Best-effort and non-throwing: a scope is deleted only when its `broker.pid` * is absent/dead, no live client presence remains, and it has been untouched * for {@link DAEMON_RUNTIME_STALE_GRACE_MS}. The caller's own `currentRuntimeDir` - * and the machine-global daemon container are always skipped. + * is always skipped, and the sweep runs only inside the {@link DAEMONS_DIR} + * container over entries named like a {@link DAEMON_SCOPE_KEY} — so a runtime + * dir relocated elsewhere (e.g. the smoke test under `os.tmpdir()`) never + * reclaims unrelated neighbours (issue #8721). */ export async function pruneDeadDaemonRuntimeDirs(currentRuntimeDir: string): Promise { const root = path.dirname(currentRuntimeDir); - if (path.basename(root) === GLOBAL_DAEMON_DIR) return; + if (path.basename(root) !== DAEMONS_DIR) return; const current = path.resolve(currentRuntimeDir); let entries: Dirent[]; try { @@ -138,7 +153,7 @@ export async function pruneDeadDaemonRuntimeDirs(currentRuntimeDir: string): Pro } const now = Date.now(); for (const entry of entries) { - if (!entry.isDirectory() || entry.name === GLOBAL_DAEMON_DIR) continue; + if (!entry.isDirectory() || !DAEMON_SCOPE_KEY.test(entry.name)) continue; const dir = path.join(root, entry.name); if (path.resolve(dir) === current) continue; try { diff --git a/packages/coding-agent/test/launch/daemon-prune.test.ts b/packages/coding-agent/test/launch/daemon-prune.test.ts index 7ca02286e..70742dd64 100644 --- a/packages/coding-agent/test/launch/daemon-prune.test.ts +++ b/packages/coding-agent/test/launch/daemon-prune.test.ts @@ -41,11 +41,11 @@ describe("pruneDeadDaemonRuntimeDirs", () => { const daemons = path.join(tempDir.path(), "run", "daemons"); await fs.mkdir(daemons, { recursive: true }); - const current = await scope(daemons, "current000000000", { pid: "dead", stale: true }); - await scope(daemons, "deadstale0000000", { pid: "dead", stale: true }); - await scope(daemons, "livebroker000000", { pid: process.pid, stale: true }); - await scope(daemons, "liveclient000000", { clients: [process.pid], stale: true }); - await scope(daemons, "deadfresh0000000", { pid: "dead" }); + const current = await scope(daemons, "aaaaaaaaaaaaaaaa", { pid: "dead", stale: true }); + await scope(daemons, "bbbbbbbbbbbbbbbb", { pid: "dead", stale: true }); + await scope(daemons, "cccccccccccccccc", { pid: process.pid, stale: true }); + await scope(daemons, "dddddddddddddddd", { clients: [process.pid], stale: true }); + await scope(daemons, "eeeeeeeeeeeeeeee", { pid: "dead" }); // Machine-global daemon container must never be swept as a project scope. await fs.mkdir(path.join(daemons, "global", "some-service"), { recursive: true }); await fs.utimes(path.join(daemons, "global"), STALE, STALE); @@ -53,12 +53,12 @@ describe("pruneDeadDaemonRuntimeDirs", () => { await pruneDeadDaemonRuntimeDirs(current); const remaining = new Set(await fs.readdir(daemons)); - expect(remaining.has("deadstale0000000")).toBe(false); // pruned - expect(remaining.has("current000000000")).toBe(true); // never prunes itself - expect(remaining.has("livebroker000000")).toBe(true); // live broker - expect(remaining.has("liveclient000000")).toBe(true); // live client presence - expect(remaining.has("deadfresh0000000")).toBe(true); // within stale grace - expect(remaining.has("global")).toBe(true); // global container skipped + expect(remaining.has("bbbbbbbbbbbbbbbb")).toBe(false); // pruned + expect(remaining.has("aaaaaaaaaaaaaaaa")).toBe(true); // never prunes itself + expect(remaining.has("cccccccccccccccc")).toBe(true); // live broker + expect(remaining.has("dddddddddddddddd")).toBe(true); // live client presence + expect(remaining.has("eeeeeeeeeeeeeeee")).toBe(true); // within stale grace + expect(remaining.has("global")).toBe(true); // non-scope name skipped }); it("does not sweep sibling machine-global service runtimes", async () => { @@ -72,6 +72,26 @@ describe("pruneDeadDaemonRuntimeDirs", () => { expect(await fs.exists(sibling)).toBe(true); }); + it("never sweeps outside the daemons container when a runtime dir is relocated (issue #8721)", async () => { + using tempDir = TempDir.createSync("@omp-daemon-prune-tmpdir-"); + // Simulate the smoke test relocating its runtime dir directly under a + // shared temp root full of unrelated, aged directories. + const fakeTmp = tempDir.path(); + for (const name of ["tmux-1000", "ssh-XVn1oP", "my-build-tree"]) { + await fs.mkdir(path.join(fakeTmp, name, "src"), { recursive: true }); + await fs.utimes(path.join(fakeTmp, name), STALE, STALE); + } + const runtimeDir = path.join(fakeTmp, "omp-daemon-smoke-run-xxxx"); + await fs.mkdir(runtimeDir, { recursive: true }); + + await pruneDeadDaemonRuntimeDirs(runtimeDir); + + const remaining = new Set(await fs.readdir(fakeTmp)); + expect(remaining.has("tmux-1000")).toBe(true); + expect(remaining.has("ssh-XVn1oP")).toBe(true); + expect(remaining.has("my-build-tree")).toBe(true); + }); + it("does nothing when the runtime root does not exist", async () => { using tempDir = TempDir.createSync("@omp-daemon-prune-missing-"); const current = path.join(tempDir.path(), "run", "daemons", "hash0000000000000");