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:
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @file Tests that the server drops the variables describing the Claude Code
|
||||
* session that started it (always the case on macOS, where there is no
|
||||
* systemd unit to give it a clean env) — tmux and every lane pane inherit the
|
||||
* server's env, so a leaked CLAUDECODE made each pane look like a nested
|
||||
* session. User configuration (CLAUDE_CONFIG_DIR, ANTHROPIC_*, …) must survive.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { it, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-envscrub-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "home");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
|
||||
process.env.CLAUDECODE = "1";
|
||||
process.env.CLAUDE_CODE_ENTRYPOINT = "cli";
|
||||
process.env.CLAUDE_CODE_SESSION_ID = "abc";
|
||||
process.env.CLAUDE_CODE_MESSAGING_SOCKET = "/tmp/x.sock";
|
||||
process.env.CLAUDE_PLUGIN_ROOT = "/some/cache/dir";
|
||||
process.env.CLAUDE_CONFIG_DIR = "/Users/x/.claude-work";
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.ANTHROPIC_BASE_URL = "https://example.invalid";
|
||||
|
||||
require("../index");
|
||||
const { db } = require("../db");
|
||||
|
||||
after(() => {
|
||||
db.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("strips the parent session's variables and keeps user configuration", () => {
|
||||
for (const k of [
|
||||
"CLAUDECODE",
|
||||
"CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_CODE_SESSION_ID",
|
||||
"CLAUDE_CODE_MESSAGING_SOCKET",
|
||||
"CLAUDE_PLUGIN_ROOT",
|
||||
]) {
|
||||
assert.equal(process.env[k], undefined, k);
|
||||
}
|
||||
assert.equal(process.env.CLAUDE_CONFIG_DIR, "/Users/x/.claude-work");
|
||||
assert.equal(process.env.CLAUDE_CODE_USE_BEDROCK, "1");
|
||||
assert.equal(process.env.ANTHROPIC_BASE_URL, "https://example.invalid");
|
||||
});
|
||||
@@ -1269,3 +1269,21 @@ describe("the forward-only hold is short enough for one work session", () => {
|
||||
assert.equal(result, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLane — canonical cwd (macOS /tmp -> /private/tmp class)", () => {
|
||||
it("stores the realpath of a symlinked dir, and the given string when it does not exist yet", () => {
|
||||
const fs = require("node:fs");
|
||||
const base = fs.realpathSync(fs.mkdtempSync(pathMod.join(os.tmpdir(), "lane-realpath-")));
|
||||
const target = pathMod.join(base, "real");
|
||||
const link = pathMod.join(base, "link");
|
||||
fs.mkdirSync(target);
|
||||
fs.symlinkSync(target, link);
|
||||
try {
|
||||
assert.equal(lanes.createLane({ cwd: link + "/" }).cwd, target);
|
||||
const missing = pathMod.join(base, "not-yet");
|
||||
assert.equal(lanes.createLane({ cwd: missing }).cwd, missing);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -393,3 +393,55 @@ describe("watchdog liveness reap", () => {
|
||||
assert.equal(stmts.getSession.get(sid).status, "completed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeLiveCwds — darwin branch (ps + lsof, stubbed)", () => {
|
||||
const realPlatform = process.platform;
|
||||
const PS =
|
||||
" 101 /opt/homebrew/bin/claude --resume x\n 102 node /Users/x/claude-dashboard/index.js\n 103 claude\n";
|
||||
const LSOF = "p101\nfcwd\nn/Users/x/proj\np103\nfcwd\nn/private/tmp/other\n";
|
||||
let calls;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.DASHBOARD_LIVENESS_PROBE;
|
||||
process.env.AGENTHUB_FORCE_HOST = "1";
|
||||
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
|
||||
calls = [];
|
||||
});
|
||||
after(() => {
|
||||
Object.defineProperty(process, "platform", { value: realPlatform, configurable: true });
|
||||
liveness.__setExecImpl(null);
|
||||
delete process.env.AGENTHUB_FORCE_HOST;
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
});
|
||||
|
||||
it("reads each claude pid's cwd from lsof -Fn records", () => {
|
||||
liveness.__setExecImpl((bin, args) => {
|
||||
calls.push([bin, args]);
|
||||
return bin === "ps" ? PS : LSOF;
|
||||
});
|
||||
const r = realProbe();
|
||||
assert.equal(r.available, true);
|
||||
assert.deepEqual([...r.cwds].sort(), ["/Users/x/proj", "/private/tmp/other"]);
|
||||
assert.deepEqual(calls[1], ["lsof", ["-a", "-p", "101,103", "-d", "cwd", "-Fn"]]);
|
||||
});
|
||||
|
||||
it("keeps lsof's partial output when it exits 1 because a pid vanished", () => {
|
||||
liveness.__setExecImpl((bin) => {
|
||||
if (bin === "ps") return PS;
|
||||
const err = new Error("exit 1");
|
||||
err.stdout = "p101\nfcwd\nn/Users/x/proj\n";
|
||||
throw err;
|
||||
});
|
||||
const r = realProbe();
|
||||
assert.equal(r.available, true);
|
||||
assert.deepEqual([...r.cwds], ["/Users/x/proj"]);
|
||||
});
|
||||
|
||||
it("reports unavailable when lsof fails with no output", () => {
|
||||
liveness.__setExecImpl((bin) => {
|
||||
if (bin === "ps") return PS;
|
||||
throw Object.assign(new Error("ENOENT"), { stdout: "" });
|
||||
});
|
||||
assert.equal(realProbe().available, false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user