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
+51
View File
@@ -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");
});
+18
View File
@@ -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 });
}
});
});
+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", () => {
+52
View File
@@ -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);
});
});
+12
View File
@@ -5,6 +5,18 @@
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
// A server started from inside a Claude Code session (the bootstrap fallback,
// always the case on macOS) inherits that session's own variables, and tmux
// hands them to every lane pane — whose `claude` then believes it is nested.
// ponytail: a DENYLIST of parent-session variables only, so user config
// (CLAUDE_CONFIG_DIR, CLAUDE_CODE_USE_BEDROCK, ANTHROPIC_*) passes through;
// it must grow when Claude Code adds a new per-session variable.
const PARENT_SESSION_VARS =
/^(CLAUDECODE|CLAUDE_PID|CLAUDE_PROJECT_DIR|CLAUDE_ENV_FILE|CLAUDE_PLUGIN_(ROOT|DATA)|CLAUDE_CODE_(ENTRYPOINT|SESSION_ID|CHILD_SESSION|SESSION_ATTENDED|EXECPATH|MESSAGING_.*))$/;
for (const k of Object.keys(process.env)) {
if (PARENT_SESSION_VARS.test(k)) delete process.env[k];
}
// Load .env file (simple key=value, no external dependency needed)
(function loadDotEnv() {
const fs = require("fs");
+20 -1
View File
@@ -160,6 +160,24 @@ function normalizeTitle(value) {
return String(value).trim().slice(0, TITLE_MAX);
}
/**
* The path the OS calls this directory: macOS hands a session `/private/tmp/x`
* for a typed `/tmp/x`, and a case-insensitive volume accepts `~/Code` for
* `~/code`, so a lane stored under the typed string would never bind a
* session. A path that does not exist yet (a worktree lane is created before
* its directory) keeps the string it was given.
*/
function canonicalCwd(cwd) {
// A relative path must stay relative, so createLane still refuses it.
if (!cwd.startsWith("/")) return cwd;
const trimmed = cwd.replace(/\/+$/, "");
try {
return require("node:fs").realpathSync.native(trimmed);
} catch {
return trimmed;
}
}
function createLane({
title = "",
cwd,
@@ -184,7 +202,7 @@ function createLane({
)
.run(
normalizeTitle(title),
cwd.replace(/\/+$/, ""),
canonicalCwd(cwd),
branch,
pipeline,
kind,
@@ -842,6 +860,7 @@ module.exports = {
DEAD_SEC,
VALID_TOOLS,
detectionIsStale,
canonicalCwd,
createLane,
listLanes,
getLane,
+10 -3
View File
@@ -21,6 +21,13 @@ const fs = require("node:fs");
const path = require("node:path");
const { isInsideContainer } = require("../../scripts/install-hooks");
let execImpl = execFileSync;
/** Test seam — same pattern as tmux.js / desktop-env.js. */
function __setExecImpl(fn) {
execImpl = fn || execFileSync;
}
const UNAVAILABLE = () => ({ available: false, cwds: new Set() });
/**
@@ -62,7 +69,7 @@ function probeLiveCwds() {
let psOut;
try {
psOut = execFileSync("ps", ["-Ao", "pid=,args="], {
psOut = execImpl("ps", ["-Ao", "pid=,args="], {
encoding: "utf8",
timeout: 5_000,
maxBuffer: 16 * 1024 * 1024,
@@ -95,7 +102,7 @@ function probeLiveCwds() {
// machine format emits `p<pid>` / `f cwd` / `n<path>` records.
let lsofOut;
try {
lsofOut = execFileSync("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-Fn"], {
lsofOut = execImpl("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-Fn"], {
encoding: "utf8",
timeout: 10_000,
maxBuffer: 16 * 1024 * 1024,
@@ -113,4 +120,4 @@ function probeLiveCwds() {
return { available: true, cwds };
}
module.exports = { probeLiveCwds, isClaudeCommand };
module.exports = { probeLiveCwds, isClaudeCommand, __setExecImpl };
+1 -1
View File
@@ -116,7 +116,7 @@ const paths = {
tags: ["Lanes"],
summary: "Find or adopt the lane owning a working directory",
description:
"Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner's lane.",
"Idempotent: canonicalizes `cwd` to its real path when it exists (the same path `createLane` stores), then returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner's lane.",
operationId: "ensureLane",
requestBody: {
required: true,
+7
View File
@@ -1592,6 +1592,13 @@ function livenessReap({ ignoreIdleGate = false } = {}) {
continue;
}
if (probe.cwds.has(resolvedCwd)) continue;
// lsof reports the OS's own path (/private/tmp on macOS); a session row may
// hold the symlinked or differently-cased spelling of the same directory.
try {
if (probe.cwds.has(require("fs").realpathSync.native(resolvedCwd))) continue;
} catch {
/* directory gone — the process cannot be sitting in it either */
}
// Idle gate (watchdog ticks only — boot passes skip it, see above): the
// transcript mtime is the ground truth for "when did this session last
+4 -1
View File
@@ -319,7 +319,10 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
// specific directory (e.g. Sagi's mascot companion) get a real, existing
// path and lane uniqueness stays meaningful. Callers that do care pass one
// through unchanged.
const cwd = typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir();
// Canonical before the lookup, so the lookup and createLane's insert agree.
const cwd = lanesLib.canonicalCwd(
typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir()
);
if (body.tool !== undefined && !lanesLib.VALID_TOOLS.has(body.tool)) {
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });