fix(macos): scrub parent-session env, survive node upgrades and reboots, store lanes by real path (#22)

What broke (code audit, untested on a Mac):
- Without systemd the server is always spawned from inside a Claude Code
  session, so it inherited CLAUDECODE, CLAUDE_CODE_SESSION_ID,
  CLAUDE_PLUGIN_ROOT, ... and tmux handed them to every lane pane (a codex
  pane even resolved as `claude` in `agenthub`'s harness check).
- The ~/.local/bin/agenthub launcher pinned a versioned node path that
  `brew upgrade`/nvm deletes, leaving the CLI dead until the next bootstrap.
- A discovery entry left over from before a reboot could name a recycled pid,
  so the bootstrap's fast path believed a dashboard was live and
  stopDashboard could signal an unrelated process.
- Lanes were stored under the typed cwd; macOS reports /private/tmp and the
  on-disk case, so sessions never bound to a /tmp or differently-cased lane.

What changed:
- server/index.js deletes a DENYLIST of parent-session variables at startup;
  user config (CLAUDE_CONFIG_DIR, CLAUDE_CODE_USE_BEDROCK, ANTHROPIC_*) stays.
- linkCli's launcher falls back to `node` on PATH when the pinned one is gone.
- liveServers ignores entries whose startedAt predates this boot (60 s margin).
- createLane and POST /api/lanes/ensure store/look up fs.realpathSync.native
  of an existing cwd; the liveness reaper also tries the realpath. Existing
  rows are untouched.
- session-liveness gains an exec seam so the darwin ps+lsof branch is tested.
- plugin-doctor names ~/.zshrc on darwin; INSTALL.md gains a macOS section
  with the Mac verification checklist, mirrored in both READMEs and PLUGINS.md.
No launchd, no macOptionIsMeta, engines stays >=20.
This commit is contained in:
2026-09-23 12:09:28 +07:00
parent aff79a787c
commit 478418f602
18 changed files with 288 additions and 15 deletions
+30
View File
@@ -61,6 +61,16 @@ describe("serverIsLive", () => {
assert.equal(boot.serverIsLive(), true);
assert.deepEqual(boot.liveServers(), [{ port: 4820, pid: process.pid, startedAt: "x" }]);
});
it("ignores an entry started before this boot, even when its pid is alive (pid reuse)", () => {
const beforeBoot = new Date(Date.now() - os.uptime() * 1000 - 3600_000).toISOString();
fs.writeFileSync(
infoPath,
JSON.stringify({ servers: [{ port: 4820, pid: process.pid, startedAt: beforeBoot }] })
);
assert.equal(boot.serverIsLive(), false);
assert.deepEqual(boot.liveServers(), []);
});
});
describe("runtime location", () => {
@@ -419,6 +429,26 @@ describe("agenthub CLI launcher", () => {
assert.equal(boot.linkCli(bin), "foreign");
assert.match(fs.readFileSync(target, "utf8"), /my\/checkout/);
});
it(
"falls back to `node` on PATH when the pinned node binary is gone (brew upgrade)",
{ skip: process.platform === "win32" },
() => {
const root = path.join(TMP_HOME, "fake-root");
fs.mkdirSync(path.join(root, "bin"), { recursive: true });
fs.writeFileSync(
path.join(root, "bin", "agenthub.js"),
"console.log('ran:' + process.argv.slice(2).join(','));\n"
);
assert.equal(boot.linkCli(bin, root, "/nonexistent/Cellar/node/0.0.0/bin/node"), "written");
assert.match(fs.readFileSync(target, "utf8"), /\/nonexistent\/Cellar/);
const out = require("child_process").execFileSync("sh", [target, "a", "b c"], {
encoding: "utf8",
env: { ...process.env, PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin` },
});
assert.equal(out.trim(), "ran:a,b c");
}
);
});
describe("plugin-open / plugin-bootstrap require cycle", () => {