From a2a53ff355068fc048974c84f92c96b9809c5981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Mon, 4 May 2026 21:59:06 +0100 Subject: [PATCH] =?UTF-8?q?feat(cli,broker):=201.34.14=20+=201.34.15=20?= =?UTF-8?q?=E2=80=94=20env-var=20fallback,=20peer=20list=20scope,=20kick?= =?UTF-8?q?=20refuses=20control-plane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups from the 1.34.x multi-session correctness train, all backwards-compatible. 1.34.14 — stale CLAUDEMESH_CONFIG_DIR falls back. The launch flow exposes CLAUDEMESH_CONFIG_DIR= to its spawned claude; if a later claudemesh invocation inherited that env (Bash tool inside Claude Code, tmux update-environment, exported var), the inherited path pointed at a tmpdir that no longer existed and readConfig() silently returned empty. paths.ts now memoizes resolution: env unset → default; env points at a real dir → trust it; env set but dir gone → TTY-only stderr warning with shell-specific unset hint, fall back to ~/.claudemesh. 1.34.15 — peer list --mesh actually scopes. peers.ts and launch.ts were calling tryListPeersViaDaemon() with no argument; the daemon's ?mesh= filter (server-side, since 1.26.0) was already correct, the CLI just wasn't passing the slug. Forwarding fixed in both sites; send.ts cross-mesh hex-prefix resolution intentionally untouched. 1.34.15 — kick refuses no-op kicks on control-plane. Pre-1.34.15 kicking a daemon's member-WS just closed the socket and triggered auto-reconnect — a no-op with a misleading "session ended" message. Broker now skips peers where peerRole === "control-plane" and surfaces them in a new additive ack field skipped_control_plane; the CLI reads it and prints a clearer hint pointing at ban / daemon down. Soft disconnect verb keeps old behavior. PeerConn gains a peerRole slot populated at both connections.set sites. Tests: 4 new for paths-stale-env, 5 for kick-control-plane-skip. CLI 87/87 green; broker 55/55 unit green (integration tests pre-existing infra failure on this machine). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/broker/src/index.ts | 50 ++++++++- .../tests/kick-control-plane-skip.test.ts | 47 ++++++++ apps/cli/CHANGELOG.md | 105 ++++++++++++++++++ apps/cli/package.json | 2 +- apps/cli/src/commands/kick.ts | 26 ++++- apps/cli/src/commands/launch.ts | 6 +- apps/cli/src/commands/peers.ts | 10 +- apps/cli/src/constants/paths.ts | 83 +++++++++++++- apps/cli/tests/unit/paths-stale-env.test.ts | 57 ++++++++++ 9 files changed, 376 insertions(+), 10 deletions(-) create mode 100644 apps/broker/tests/kick-control-plane-skip.test.ts create mode 100644 apps/cli/tests/unit/paths-stale-env.test.ts diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index 85dc6cb..c7d6bb6 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -156,6 +156,11 @@ interface PeerConn { bio?: string; capabilities?: string[]; }; + /** v2 agentic-comms presence taxonomy. Mirrors the value passed to + * `recordPresence`. Used by the kick handler to refuse no-op kicks + * on long-lived control-plane connections (daemon, dashboard) that + * would just auto-reconnect. */ + peerRole: "control-plane" | "session" | "service"; } const connections = new Map(); @@ -1797,6 +1802,7 @@ async function handleHello( groups: initialGroups, visible: saved?.visible ?? true, profile: saved?.profile ?? {}, + peerRole: "control-plane", }); incMeshCount(hello.meshId); void audit(hello.meshId, "peer_joined", member.id, effectiveDisplayName, { @@ -2022,6 +2028,7 @@ async function handleSessionHello( groups: initialGroups, visible: true, profile: {}, + peerRole: "session", }); incMeshCount(hello.meshId); void audit(hello.meshId, "peer_joined", member.id, effectiveDisplayName, { @@ -4645,11 +4652,30 @@ function handleConnection(ws: WebSocket): void { } const affected: string[] = []; + // 1.34.15 (gap #3a): kick was a no-op against long-lived + // control-plane connections (daemon, dashboard) — closing + // their WS just triggered the auto-reconnect loop, the + // kicker's CLI rendered "Their Claude Code session ended" + // (which was misleading), and the user-visible state was + // unchanged seconds later. We now refuse to close control- + // plane WSes and surface the skipped peers in a new + // additive ack field. Pre-1.34.15 CLI clients only read + // `kicked`/`affected`, so this stays back-compat. + // + // For `kick`-only: the soft `disconnect` verb still closes + // control-plane WSes intentionally — that's what users want + // when they're nudging a peer for it to re-authenticate. + const skippedControlPlane: string[] = []; + const skipControlPlane = isKick; const now = Date.now(); if (km.all) { for (const [pid, peer] of connections) { if (peer.meshId !== conn.meshId || pid === presenceId) continue; + if (skipControlPlane && peer.peerRole === "control-plane") { + skippedControlPlane.push(peer.displayName || pid); + continue; + } try { peer.ws.close(closeCode, closeReason); } catch {} connections.delete(pid); void disconnectPresence(pid); @@ -4661,6 +4687,10 @@ function handleConnection(ws: WebSocket): void { if (peer.meshId !== conn.meshId || pid === presenceId) continue; const [pres] = await db.select({ lastPingAt: presence.lastPingAt }).from(presence).where(eq(presence.id, pid)).limit(1); if (pres && pres.lastPingAt && pres.lastPingAt.getTime() < cutoff) { + if (skipControlPlane && peer.peerRole === "control-plane") { + skippedControlPlane.push(peer.displayName || pid); + continue; + } try { peer.ws.close(closeCode, `${closeReason}_stale`); } catch {} connections.delete(pid); void disconnectPresence(pid); @@ -4671,6 +4701,10 @@ function handleConnection(ws: WebSocket): void { for (const [pid, peer] of connections) { if (peer.meshId !== conn.meshId) continue; if (peer.displayName === km.target || peer.memberPubkey === km.target || peer.memberPubkey.startsWith(km.target)) { + if (skipControlPlane && peer.peerRole === "control-plane") { + skippedControlPlane.push(peer.displayName || pid); + continue; + } try { peer.ws.close(closeCode, closeReason); } catch {} connections.delete(pid); void disconnectPresence(pid); @@ -4679,8 +4713,20 @@ function handleConnection(ws: WebSocket): void { } } - conn.ws.send(JSON.stringify({ type: ackType, kicked: affected, affected, _reqId: km._reqId })); - log.info(`ws ${closeReason}`, { presence_id: presenceId, count: affected.length, target: km.target ?? km.stale ?? "all" }); + conn.ws.send(JSON.stringify({ + type: ackType, + kicked: affected, + affected, + // Additive — older CLI clients ignore this field. + ...(skippedControlPlane.length > 0 ? { skipped_control_plane: skippedControlPlane } : {}), + _reqId: km._reqId, + })); + log.info(`ws ${closeReason}`, { + presence_id: presenceId, + count: affected.length, + target: km.target ?? km.stale ?? "all", + skipped_control_plane: skippedControlPlane.length, + }); break; } diff --git a/apps/broker/tests/kick-control-plane-skip.test.ts b/apps/broker/tests/kick-control-plane-skip.test.ts new file mode 100644 index 0000000..ee0f0cd --- /dev/null +++ b/apps/broker/tests/kick-control-plane-skip.test.ts @@ -0,0 +1,47 @@ +/** + * Kick control-plane skip: 1.34.15 (gap #3a) refuses to close + * long-lived control-plane connections (claudemesh daemon, dashboard) + * via `kick`, because they auto-reconnect within seconds and the verb + * was effectively a no-op. The soft `disconnect` verb keeps the old + * behavior so users can still nudge a control-plane peer to + * re-authenticate. + * + * Pure-logic test — mirrors the branch inside handleSend's kick case + * without spinning up a broker. Same pattern as + * grants-enforcement.test.ts. + */ + +import { describe, expect, test } from "vitest"; + +type PeerRole = "control-plane" | "session" | "service"; + +/** Mirrors the predicate inserted into the kick handler. */ +function shouldSkipKick(args: { + verb: "kick" | "disconnect"; + peerRole: PeerRole; +}): boolean { + const skipControlPlane = args.verb === "kick"; + return skipControlPlane && args.peerRole === "control-plane"; +} + +describe("kick control-plane skip (gap #3a)", () => { + test("kick on control-plane → skipped (would auto-reconnect)", () => { + expect(shouldSkipKick({ verb: "kick", peerRole: "control-plane" })).toBe(true); + }); + + test("kick on session → not skipped (closes user session)", () => { + expect(shouldSkipKick({ verb: "kick", peerRole: "session" })).toBe(false); + }); + + test("kick on service → not skipped", () => { + expect(shouldSkipKick({ verb: "kick", peerRole: "service" })).toBe(false); + }); + + test("disconnect on control-plane → not skipped (intentional nudge)", () => { + expect(shouldSkipKick({ verb: "disconnect", peerRole: "control-plane" })).toBe(false); + }); + + test("disconnect on session → not skipped", () => { + expect(shouldSkipKick({ verb: "disconnect", peerRole: "session" })).toBe(false); + }); +}); diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 53040f8..7668d73 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,110 @@ # Changelog +## 1.34.15 (2026-05-04) — `peer list --mesh` actually scopes + `kick` refuses control-plane + +Two follow-ups from the 1.34.x train, both backwards-compatible. + +### `peer list --mesh ` no longer aggregates across meshes + +`apps/cli/src/commands/peers.ts:140` was calling +`tryListPeersViaDaemon()` with no argument, so a multi-mesh daemon +returned peers from EVERY attached mesh and the renderer printed +"peers on flexicar" with cross-mesh rows mixed in. The daemon's +`/v1/peers?mesh=` filter (server-side, since 1.26.0) was +already correctly scoping when the slug was passed; the CLI just +wasn't passing it. Fixed. + +`apps/cli/src/commands/launch.ts:407` (the `printBrokerWelcome` peer +count in the launch banner) had the same bug. The "N peers online" +line in the welcome now shows the count for the launched mesh only. + +`apps/cli/src/commands/send.ts` cross-mesh hex-prefix resolution is +intentionally cross-mesh (the user is targeting by hex without +specifying a mesh) and was deliberately left as-is. + +### `claudemesh kick` refuses no-op kicks on control-plane connections + +Pre-1.34.15, kicking a daemon's member-WS or a dashboard connection +just closed the socket — the daemon's WS-lifecycle reconnect loop +brought it back within seconds, the kicker's CLI rendered "Their +Claude Code session ended" (which was misleading), and the user- +visible state was unchanged. The verb was effectively a no-op, but +the user had to learn that the hard way. + +The broker's kick handler (`apps/broker/src/index.ts:4628+`) now +skips peers where `peerRole === "control-plane"` and surfaces the +skipped peers in a new additive ack field `skipped_control_plane`. +The soft `disconnect` verb keeps the old behavior — useful when +intentionally nudging a control-plane peer to re-authenticate. + +The CLI (`apps/cli/src/commands/kick.ts`) reads the new field and +prints a clearer message: refused peers are listed, with the hint +that `claudemesh ban ` is the right tool to remove a member, +or `claudemesh daemon down` to take a daemon offline locally. + +`apps/broker/src/index.ts` adds `peerRole` to the in-memory +`PeerConn` shape, populated from both connection paths +(member-keyed `hello` → `"control-plane"`, per-launch +`session_hello` → `"session"`). The DB-side role taxonomy is +unchanged. + +### Back-compat + +- Older CLI clients ignore the new `skipped_control_plane` ack + field; their kick continues to print "Kicked 0 peer(s)" against + a control-plane target as before. +- Older brokers don't emit the field at all; newer CLI handles + the absence (the new branch is only reached when the field is + present and non-empty). +- The new `peerRole` slot on `PeerConn` is filled at every + `connections.set` callsite, so older code paths never read + `undefined`. + +### Tests + +- `apps/broker/tests/kick-control-plane-skip.test.ts` — 5 cases + covering the kick/disconnect × control-plane/session/service + truth table. + +## 1.34.14 (2026-05-04) — stale `CLAUDEMESH_CONFIG_DIR` falls back + +`claudemesh launch` exports `CLAUDEMESH_CONFIG_DIR=` to its +spawned `claude` so the per-session mesh selection is isolated from +`~/.claudemesh/config.json`. The tmpdir is `rmSync`'d on launch exit +via the `process.on('exit', cleanup)` handler. + +Footgun: if a later `claudemesh` invocation INHERITED that env — a +Bash tool call inside Claude Code, a tmux pane that captured the env +via `update-environment`, an exported var the user forgot to clear — +the inherited path pointed at a tmpdir that no longer existed. +Pre-1.34.14 we silently used the dead path, `readConfig()` came back +empty, and the user saw "No meshes joined" from an otherwise-working +install. Fish users hit it harder because fish has no `unset` — +they had to discover `set -e CLAUDEMESH_CONFIG_DIR`. + +`apps/cli/src/constants/paths.ts` now resolves `CONFIG_DIR` once via +a memoized `resolveConfigDir()`: + + 1. No env var → `~/.claudemesh` (default, unchanged). + 2. Env points at a dir containing `config.json` → trust it. The + legitimate per-session-launch case is byte-identical to before. + 3. Env set but stale (dir gone) → warn once on stderr (TTY-only — + CI / MCP boot / piped scripts stay quiet) with a shell-specific + unset hint, then fall back to `~/.claudemesh`. + +The check is on the directory's existence, not on `config.json`, +because a fresh-launch tmpdir legitimately has no `config.json` until +the first write. The stale signature we catch is the outer launch's +`rmSync(tmpDir, {recursive: true})` cleanup, which removes the +directory entirely. + +The "no meshes" check from the original triage was deliberately NOT +adopted: a launched session that legitimately joins one mesh would +hit it. + +No back-compat surface affected. No other files changed. `_resetPathsForTest()` +exported for unit tests. + ## 1.34.13 (2026-05-04) — MCP forwards session token on /v1/events The 1.34.10 SSE demux + 1.34.11 inbox per-recipient column were both diff --git a/apps/cli/package.json b/apps/cli/package.json index af55a20..d226c88 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.34.13", + "version": "1.34.15", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/kick.ts b/apps/cli/src/commands/kick.ts index 6c462a3..6d7e9c6 100644 --- a/apps/cli/src/commands/kick.ts +++ b/apps/cli/src/commands/kick.ts @@ -76,12 +76,32 @@ export async function runKick( if ("error" in built) { render.err(String(built.error)); return EXIT.INVALID_ARGS; } return await withMesh({ meshSlug }, async (client) => { - const result = await client.sendAndWait(built as Record) as { affected?: string[]; kicked?: string[] }; + const result = await client.sendAndWait(built as Record) as { + affected?: string[]; + kicked?: string[]; + // 1.34.15: broker refuses to kick control-plane WSes (they'd + // just auto-reconnect). Older brokers don't emit this field. + skipped_control_plane?: string[]; + }; const peers = result?.affected ?? result?.kicked ?? []; - if (peers.length === 0) render.info("No peers matched."); - else { + const skipped = result?.skipped_control_plane ?? []; + + if (peers.length === 0 && skipped.length === 0) { + render.info("No peers matched."); + } else if (peers.length === 0 && skipped.length > 0) { + render.warn( + `${skipped.length} match(es) refused: ${skipped.join(", ")} — control-plane connections (daemon / dashboard) auto-reconnect, so kick is a no-op.`, + "To take a daemon offline locally, run `claudemesh daemon down` on that machine. To remove a member from the mesh, use `claudemesh ban `.", + ); + } else { render.ok(`Kicked ${peers.length} peer(s): ${peers.join(", ")}`); render.hint("Their Claude Code session ended. They can rejoin anytime by running `claudemesh`."); + if (skipped.length > 0) { + render.warn( + `(also refused ${skipped.length} control-plane connection(s): ${skipped.join(", ")})`, + "Daemon / dashboard connections auto-reconnect; kick is a no-op against them. Use `claudemesh ban ` to remove a member entirely.", + ); + } } return EXIT.SUCCESS; }); diff --git a/apps/cli/src/commands/launch.ts b/apps/cli/src/commands/launch.ts index 5f9bfc3..c1bfaff 100644 --- a/apps/cli/src/commands/launch.ts +++ b/apps/cli/src/commands/launch.ts @@ -400,11 +400,13 @@ async function printBrokerWelcome(meshSlug: string): Promise { } } catch { /* daemon unreachable — not fatal */ } - // Peer count (best-effort). + // Peer count (best-effort). 1.34.15: scope to the launched mesh so + // multi-mesh daemons don't inflate the welcome banner with peers + // from other meshes the user didn't just attach to. let peerCount = -1; try { const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js"); - const peers = (await tryListPeersViaDaemon()) ?? []; + const peers = (await tryListPeersViaDaemon(meshSlug)) ?? []; peerCount = peers.filter((p) => (p as { channel?: string }).channel !== "claudemesh-daemon", ).length; diff --git a/apps/cli/src/commands/peers.ts b/apps/cli/src/commands/peers.ts index 94cb94a..33022a4 100644 --- a/apps/cli/src/commands/peers.ts +++ b/apps/cli/src/commands/peers.ts @@ -135,9 +135,17 @@ async function listPeersForMesh(slug: string): Promise { // lifecycle helper inside tryListPeersViaDaemon auto-spawns the // daemon if it's down and probes it for liveness — no separate bridge // tier is needed any more (1.28.0). + // + // 1.34.15: forward `slug` to the daemon as `?mesh=` so the + // server-side aggregator narrows to the requested mesh. Pre-1.34.15 + // we called this with no argument, so a multi-mesh daemon returned + // peers from every attached mesh and the renderer printed "peers on + // flexicar" with cross-mesh rows mixed in. The daemon's + // `meshFromCtx` already does the right scoping when the slug is + // passed; the CLI just wasn't passing it. try { const { tryListPeersViaDaemon } = await import("~/services/bridge/daemon-route.js"); - const dr = await tryListPeersViaDaemon(); + const dr = await tryListPeersViaDaemon(slug); if (dr !== null) { return dr.map((p) => annotateSelf(p as PeerRecord, selfMemberPubkey, selfSessionPubkey)); } diff --git a/apps/cli/src/constants/paths.ts b/apps/cli/src/constants/paths.ts index 61f998f..9b7072a 100644 --- a/apps/cli/src/constants/paths.ts +++ b/apps/cli/src/constants/paths.ts @@ -1,10 +1,82 @@ +import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; const home = homedir(); +const DEFAULT_CONFIG_DIR = join(home, ".claudemesh"); + +/** + * Resolve `CONFIG_DIR` once, with stale-env detection. + * + * `claudemesh launch` exposes `CLAUDEMESH_CONFIG_DIR=` to its + * spawned `claude` so the per-session mesh selection is isolated from + * `~/.claudemesh/config.json`. The tmpdir is rmSync'd on launch exit. + * + * Footgun: if a `claudemesh` invocation INHERITS that env from an + * already-launched (or previously-launched) session — e.g. a Bash tool + * call inside Claude Code, or a tmux pane that captured the env via + * `update-environment` — the inherited path may point at a tmpdir that + * no longer exists. Pre-1.34.14 we silently used the dead path, + * `readConfig()` came back empty, and the user saw "No meshes joined" + * from an otherwise-working install. + * + * Resolution rules: + * 1. No env var → `~/.claudemesh` (default). + * 2. Env points at a dir containing `config.json` → trust it + * (the legitimate per-session-launch case). + * 3. Env set but stale (dir missing or no `config.json`) → warn + * once on stderr (TTY-only) and fall back to `~/.claudemesh`. + * + * Memoized: resolves once on first access. Mid-process env mutations + * are intentionally ignored — paths must stay stable across one CLI + * invocation. + */ +let _resolvedConfigDir: string | null = null; +let _warnedStaleEnv = false; + +function resolveConfigDir(): string { + if (_resolvedConfigDir !== null) return _resolvedConfigDir; + const envDir = process.env.CLAUDEMESH_CONFIG_DIR; + if (!envDir) { + _resolvedConfigDir = DEFAULT_CONFIG_DIR; + return DEFAULT_CONFIG_DIR; + } + // Trust the env when it resolves to a real directory. We check + // the DIR (not `config.json`) because the legitimate "fresh launch + // before any write" case has the dir but no config.json yet. + // The stale signature we want to catch is `rmSync(tmpDir, + // {recursive: true})` from the outer launch's cleanup — that + // removes the directory entirely, so a missing dir is the + // unambiguous "stale" signal. + if (existsSync(envDir)) { + _resolvedConfigDir = envDir; + return envDir; + } + // Stale: env set but the dir is gone. Most likely the outer + // launch's cleanup ran and we inherited its (now-dead) tmpdir + // path. Fall back to default and warn the user once on stderr — + // only when attached to a TTY, so non-interactive callers (CI, + // MCP boot, scripts piping stdout) stay quiet. + if (!_warnedStaleEnv && process.stderr.isTTY) { + _warnedStaleEnv = true; + const unsetHint = + process.env.SHELL?.endsWith("fish") + ? "set -e CLAUDEMESH_CONFIG_DIR CLAUDEMESH_IPC_TOKEN_FILE" + : "unset CLAUDEMESH_CONFIG_DIR CLAUDEMESH_IPC_TOKEN_FILE"; + process.stderr.write( + `claudemesh: ignoring stale CLAUDEMESH_CONFIG_DIR=${envDir} (no config.json there); using ${DEFAULT_CONFIG_DIR}.\n` + + ` Hint: this is usually a leftover env from a previous \`claudemesh launch\`. Clean it with:\n` + + ` ${unsetHint}\n`, + ); + } + _resolvedConfigDir = DEFAULT_CONFIG_DIR; + return DEFAULT_CONFIG_DIR; +} export const PATHS = { - CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || join(home, ".claudemesh"), + get CONFIG_DIR() { + return resolveConfigDir(); + }, get CONFIG_FILE() { return join(this.CONFIG_DIR, "config.json"); }, @@ -20,3 +92,12 @@ export const PATHS = { CLAUDE_JSON: join(home, ".claude.json"), CLAUDE_SETTINGS: join(home, ".claude", "settings.json"), } as const; + +/** + * Test-only: reset the memoized resolution. Not exported from the + * package barrel; reach in via the relative path from a test file. + */ +export function _resetPathsForTest(): void { + _resolvedConfigDir = null; + _warnedStaleEnv = false; +} diff --git a/apps/cli/tests/unit/paths-stale-env.test.ts b/apps/cli/tests/unit/paths-stale-env.test.ts new file mode 100644 index 0000000..6483490 --- /dev/null +++ b/apps/cli/tests/unit/paths-stale-env.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdirSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir, homedir } from "node:os"; + +/** Each test imports a fresh copy of paths.ts via dynamic import + + * `_resetPathsForTest()` so memoization doesn't leak across cases. */ + +const TEST_DIR = join(tmpdir(), "claudemesh-paths-test-" + Date.now()); + +describe("paths CONFIG_DIR resolution", () => { + beforeEach(() => { + delete process.env.CLAUDEMESH_CONFIG_DIR; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); + }); + afterEach(() => { + delete process.env.CLAUDEMESH_CONFIG_DIR; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + it("falls back to ~/.claudemesh when env var is unset", async () => { + const mod = await import("~/constants/paths.js"); + mod._resetPathsForTest(); + expect(mod.PATHS.CONFIG_DIR).toBe(join(homedir(), ".claudemesh")); + }); + + it("honors CLAUDEMESH_CONFIG_DIR when the dir exists, even without config.json", async () => { + mkdirSync(TEST_DIR, { recursive: true }); + process.env.CLAUDEMESH_CONFIG_DIR = TEST_DIR; + const mod = await import("~/constants/paths.js"); + mod._resetPathsForTest(); + expect(mod.PATHS.CONFIG_DIR).toBe(TEST_DIR); + }); + + it("falls back to default when env points at a missing dir (stale-tmpdir case)", async () => { + process.env.CLAUDEMESH_CONFIG_DIR = "/var/folders/_nonexistent_claudemesh_dir_xyz123"; + const mod = await import("~/constants/paths.js"); + mod._resetPathsForTest(); + // Suppress the stderr warning to keep test output clean + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + expect(mod.PATHS.CONFIG_DIR).toBe(join(homedir(), ".claudemesh")); + } finally { + stderr.mockRestore(); + } + }); + + it("memoizes — second access returns the same path even if env changes mid-process", async () => { + mkdirSync(TEST_DIR, { recursive: true }); + process.env.CLAUDEMESH_CONFIG_DIR = TEST_DIR; + const mod = await import("~/constants/paths.js"); + mod._resetPathsForTest(); + const first = mod.PATHS.CONFIG_DIR; + process.env.CLAUDEMESH_CONFIG_DIR = "/something/else"; + expect(mod.PATHS.CONFIG_DIR).toBe(first); + }); +});