Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.3] - 2026-06-09

### Fixed

- **Spawned agents now persist their conversation transcript when claudemux is
itself run from inside another Claude Code session.** A child `claude` that
inherited the parent's `CLAUDECODE` / `CLAUDE_CODE_*` / `AI_AGENT` environment
variables tripped claude's nested-session detection and wrote only an
`ai-title` record — no `user`/`assistant` turns — leaving claudemux (which
drives agents by *reading* the transcript) to see an empty conversation:
stalled gating, empty `messagesSince`, delivery never confirmed. claudemux now
scrubs those variables at the pane-launch boundary (`env -u … -- claude …`, a
true unset — `-e VAR=` only blanks, which still trips the heuristic, and the
shared persistent tmux server makes `process.env` mutation unreliable) so a
spawned agent is always a top-level session that owns its own transcript. The
existing `env` passthrough remains the escape hatch (a deliberate re-set wins).
Root-caused via A/B across claude 2.1.168/2.1.169 — not a version regression.
See [ADR 0008](docs/decisions/0008-scrub-parent-agent-env-for-transcript-persistence.md).

### Security

- **Validate `unsetEnv` names at the backend boundary** (`InvalidEnvVarName`).
Env-var names spliced into the `env -u` launch prefix must be POSIX
identifiers (`[A-Za-z_][A-Za-z0-9_]*`). Not consumer-exploitable today — the
sole producer is a trusted constant and the spawn path is shell-free
`execFile` argv — but it closes the seam against any future untrusted producer
(complete mediation at the boundary). ADR 0008 follow-up.

## [0.2.2] - 2026-06-07

### Added
Expand Down

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ record, it cites one of these repo-relative paths, which resolve on GitHub.
| [0005](0005-adopt-reuses-sessiongone.md) | `adopt()` reuses `SessionGone` for an absent session (symmetric with `create`/`SessionExists`); no new error class. |
| [0006](0006-adopt-single-writer-invariant-no-lock.md) | `adopt()` documents a single-writer invariant, adds no cross-process lock, and is a pure attach (no dialog dismissal); `state()`-after-adopt carries the safety. |
| [0007](0007-pane-dead-detection-and-signal-representation.md) | _(superseded)_ `PaneDead` detection + signal representation. `PaneDead` was removed (`remain-on-exit off` reaps the pane → `SessionGone`). |
| [0008](0008-scrub-parent-agent-env-for-transcript-persistence.md) | The spawned `claude` runs under an `env -u` scrub of the parent agent's env vars (`CLAUDECODE`, `CLAUDE_CODE_*`, `AI_AGENT`) so its transcript persists; threaded via a new optional `unsetEnv` on `buildArgv`; consumer `env` re-set wins. |
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wastedcode/claudemux",
"version": "0.2.2",
"version": "0.2.3",
"publishConfig": {
"access": "public"
},
Expand Down
22 changes: 21 additions & 1 deletion src/agents/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,22 @@ const SESSION_ID_FLAG = "--session-id";
const RESUME_FLAGS = ["--resume", "-r"];
const FORK_FLAG = "--fork-session";

/**
* Parent-agent env vars that make a spawned claude believe it is NESTED and
* SUPPRESS its own transcript persistence (writes only an async ai-title
* record). claudemux drives the agent by READING that transcript, so a
* suppressed agent is broken (ADR 0008). The SOLE file that knows these names
* (layering grep T12). A/B-proven (2026-06-09, claude 2.1.168/.169): the FULL
* set must be unset — the detection trips on any single remaining signal.
*/
const NESTED_AGENT_ENV: readonly string[] = [
"CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_SESSION_ID",
"CLAUDE_CODE_EXECPATH",
"AI_AGENT",
];

/**
* What identity flag (if any) the caller already put in `extraArgs`, and the
* id it selects. For `session-id`/`resume`, `value === undefined` means "an id
Expand Down Expand Up @@ -312,6 +328,7 @@ function buildArgv(o: {
cmd: string;
argv: string[];
env: Record<string, string>;
unsetEnv: string[];
agentSessionId?: string;
} {
void o.cwd; // cwd is plumbed by the session/backend layer at spawn time
Expand All @@ -328,6 +345,7 @@ function buildArgv(o: {
cmd: "claude",
argv: [RESUME_FLAGS[0] as string, o.resumeFrom, ...extraArgs],
env,
unsetEnv: [...NESTED_AGENT_ENV],
agentSessionId: o.resumeFrom,
};
}
Expand All @@ -347,6 +365,7 @@ function buildArgv(o: {
cmd: "claude",
argv: extraArgs,
env,
unsetEnv: [...NESTED_AGENT_ENV],
...(surfaced === undefined ? {} : { agentSessionId: surfaced }),
};
}
Expand All @@ -358,12 +377,13 @@ function buildArgv(o: {
cmd: "claude",
argv: [SESSION_ID_FLAG, o.sessionId, ...extraArgs],
env,
unsetEnv: [...NESTED_AGENT_ENV],
agentSessionId: o.sessionId,
};
}

// No id at all (e.g. an internal caller that didn't mint one) — pass through.
return { cmd: "claude", argv: extraArgs, env };
return { cmd: "claude", argv: extraArgs, env, unsetEnv: [...NESTED_AGENT_ENV] };
}

/** The `claude` agent definition. */
Expand Down
9 changes: 9 additions & 0 deletions src/agents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ export interface AgentDef {
cmd: string;
argv: string[];
env?: Record<string, string>;
/**
* Env var NAMES to genuinely UNSET for the spawned process (a TRUE unset,
* not a blank). Applied BEFORE the session-layer env merge, so a name that
* also appears in the merged `env` ends up SET (consumer re-set wins). The
* backend emits this however its substrate truly unsets a var (tmux: an
* `env -u NAME` launch prefix — `-e VAR=` only blanks, it cannot unset). The
* agent owns the LIST; the backend owns the mechanism.
*/
unsetEnv?: string[];
agentSessionId?: string;
};

Expand Down
4 changes: 3 additions & 1 deletion src/backends/tmux/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { InvalidSessionName } from "../../errors.js";
import { validateNamePart } from "../../session/validate.js";
import { validateEnvVarName, validateNamePart } from "../../session/validate.js";
import {
type Backend,
type BackendEvent,
Expand Down Expand Up @@ -62,11 +62,13 @@ export function tmuxBackend(opts: { socket: string }): Backend {
spawn: async (o) => {
validateNamePart("namespace", o.namespace);
validateNamePart("name", o.name);
for (const name of o.unsetEnv ?? []) validateEnvVarName(name); // ADR 0008 follow-up
await newSession(exec, {
namespace: o.namespace,
name: o.name,
cwd: o.cwd,
...(o.env ? { env: o.env } : {}),
...(o.unsetEnv ? { unsetEnv: o.unsetEnv } : {}),
cmd: o.cmd,
argv: o.argv,
label: formatSessionLabel({ namespace: o.namespace, name: o.name }),
Expand Down
25 changes: 23 additions & 2 deletions src/backends/tmux/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ export function targetOf(namespace: string, name: string): string {
* substrate's supported floor (see README §Compatibility / details.md
* §Quality). `set-environment` after `new-session` is not an option — it
* doesn't affect the already-spawned pane process.
*
* `o.unsetEnv` names env vars to genuinely UNSET for the pane process. tmux's
* `-e` can only set (`-e VAR=` blanks, it cannot unset), so the launch command
* is wrapped in an `env -u NAME … --` prefix — the only mechanism that yields a
* genuinely-absent variable in the pane process, and one immune to the
* shared/baked-server trap because it acts at exec time on the pane command
* itself (ADR 0008). A name that also appears in the set `env` keeps its `-e`
* and drops its `-u` (explicit set wins).
*/
export async function newSession(
exec: TmuxExec,
Expand All @@ -37,6 +45,8 @@ export async function newSession(
env?: Record<string, string>;
cmd: string;
argv: string[];
/** Env var names to genuinely unset for the pane process (an `env -u` prefix). */
unsetEnv?: string[];
/** User-facing label for error messages (defaults to tmux target encoding). */
label?: string;
},
Expand All @@ -49,6 +59,18 @@ export async function newSession(
// `env: { LC_ALL }` (claude does) doesn't produce a duplicate `-e` flag.
const env: Record<string, string> = { LC_ALL: "C.UTF-8", ...o.env };
const envFlags = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]);

// Resolve the unset∩set overlap HERE: drop `-u` for any name also `-e` set,
// so the explicit set wins and `env` never both unsets and re-sets a name.
// The `--` terminator stops `env` from parsing cmd/argv as its own options.
// Empty/absent unsetEnv → `launch = [cmd, ...argv]`, byte-identical to before.
const setKeys = new Set(Object.keys(env));
const unsetNames = (o.unsetEnv ?? []).filter((n) => !setKeys.has(n));
const launch =
unsetNames.length > 0
? ["env", ...unsetNames.flatMap((n) => ["-u", n]), "--", o.cmd, ...o.argv]
: [o.cmd, ...o.argv];

const newSessionCmd = [
"new-session",
"-d",
Expand All @@ -62,8 +84,7 @@ export async function newSession(
...envFlags,
"-c",
o.cwd,
o.cmd,
...o.argv,
...launch,
];

// Combine server-options + new-session into ONE tmux invocation so the
Expand Down
6 changes: 6 additions & 0 deletions src/backends/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ export interface Backend {
env?: Record<string, string>;
cmd: string;
argv: string[];
/**
* Env var names to genuinely UNSET for the spawned process — see
* {@link AgentDef.buildArgv}'s `unsetEnv`. The backend emits this however
* its substrate truly unsets a var (a name also in `env` stays SET).
*/
unsetEnv?: string[];
},
): Promise<void>;

Expand Down
27 changes: 27 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,33 @@ export class InvalidAgentSessionId extends ClaudemuxError {
}
}

/**
* Thrown at the backend boundary when an `unsetEnv` name is not a well-formed
* POSIX environment-variable name (`[A-Za-z_][A-Za-z0-9_]*`). Thrown *before
* spawn*, at the substrate boundary.
*
* @remarks
* Defense-in-depth (ADR 0008 follow-up). The name is spliced into the backend's
* `env -u NAME …` pane-launch prefix. It always stays a single argv element — no
* shell, so no shell-escape and no second command — but a malformed name (a
* leading `-`, an embedded `=`/space, the empty string) could mis-instruct `env`
* for that one spawn. The sole current producer is a trusted constant, so this
* never fires today; the guard protects the seam against a future untrusted
* producer. Do not relax to a loose check — the allow-list shape is the point.
*/
export class InvalidEnvVarName extends ClaudemuxError {
/** The malformed env-var name the caller passed. */
readonly value: string;

constructor(value: string) {
super(
`invalid unsetEnv name ${JSON.stringify(value)}: must be a POSIX env var name ([A-Za-z_][A-Za-z0-9_]*)`,
"<invalid-unsetEnv>",
);
this.value = value;
}
}

/**
* Thrown by {@link create} when the caller passes an explicit
* `agentSessionId` **and** an identity flag in `extraArgs` that also selects a
Expand Down
3 changes: 3 additions & 0 deletions src/session/spawn-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ export async function spawnBootHandle(o: SpawnBootInput): Promise<SessionHandle>
env: mergedEnv,
cmd: argvBuild.cmd,
argv: argvBuild.argv,
...(argvBuild.unsetEnv && argvBuild.unsetEnv.length > 0
? { unsetEnv: argvBuild.unsetEnv }
: {}),
});

// The caller-known id to attribute a boot-death to: the resume id, or an
Expand Down
Binary file modified src/session/validate.ts
Binary file not shown.
16 changes: 15 additions & 1 deletion test/backends/tmux/hasSession-edge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TmuxExec } from "../../../src/backends/tmux/exec.js";
import { tmuxBackend } from "../../../src/backends/tmux/index.js";
import { hasSession, newSession, targetOf } from "../../../src/backends/tmux/sessions.js";
import { InvalidSessionName } from "../../../src/errors.js";
import { InvalidEnvVarName, InvalidSessionName } from "../../../src/errors.js";
import { Harness } from "../../harness/index.js";

/**
Expand Down Expand Up @@ -106,4 +106,18 @@ describe("tmuxBackend wrapper — exists/kill total for reserved-char names (QA
}),
).rejects.toBeInstanceOf(InvalidSessionName);
});

it("spawn() rejects a malformed unsetEnv name with InvalidEnvVarName", async () => {
const backend = tmuxBackend({ socket: h.socket });
await expect(
backend.spawn({
namespace: "ns",
name: "ok",
cwd: h.sandbox.home,
cmd: "sleep",
argv: ["60"],
unsetEnv: ["-X"],
}),
).rejects.toBeInstanceOf(InvalidEnvVarName);
});
});
73 changes: 73 additions & 0 deletions test/backends/tmux/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,79 @@ describe("sessions module — namespace-isolated CRUD", () => {
expect(await hasSession(exec, target)).toBe(false);
});

// ── unsetEnv → `env -u` launch prefix (ADR 0008) ─────────────────────────
// Assert the EMITTED new-session argv via onCommand (no dependence on the
// wrapped command's runtime behavior): the scrub is a command-assembly
// concern, so inspecting the issued argv is the right unit.
describe("unsetEnv emits an `env -u … --` launch prefix", () => {
/** Capture the new-session argv issued for one spawn. */
async function spawnAndCapture(o: {
name: string;
cmd: string;
argv: string[];
env?: Record<string, string>;
unsetEnv?: string[];
}): Promise<string[]> {
let issued: string[] | undefined;
const off = exec.onCommand((e) => {
if (e.argv.includes("new-session")) issued = e.argv;
});
await newSession(exec, {
namespace: NS,
name: o.name,
cwd: h.sandbox.home,
cmd: o.cmd,
argv: o.argv,
...(o.env ? { env: o.env } : {}),
...(o.unsetEnv ? { unsetEnv: o.unsetEnv } : {}),
});
off();
expect(issued).toBeDefined();
return issued ?? [];
}

it("wraps the launch in `env -u NAME … -- <cmd> <argv>` for each unset name", async () => {
const argv = await spawnAndCapture({
name: "scrub",
cmd: "sleep",
argv: ["60"],
unsetEnv: ["CLAUDECODE", "AI_AGENT"],
});
// The pane command must read: env -u CLAUDECODE -u AI_AGENT -- sleep 60
const launch = argv.slice(argv.indexOf("env"));
expect(launch).toEqual(["env", "-u", "CLAUDECODE", "-u", "AI_AGENT", "--", "sleep", "60"]);
});

it("drops `-u NAME` for a name also in the set env — explicit set wins, emits `-e`", async () => {
const argv = await spawnAndCapture({
name: "overlap",
cmd: "sleep",
argv: ["60"],
env: { CLAUDECODE: "1" },
unsetEnv: ["CLAUDECODE"],
});
// The set value rides as `-e CLAUDECODE=1` …
expect(argv).toContain("-e");
expect(argv).toContain("CLAUDECODE=1");
// … and the `-u CLAUDECODE` is dropped, so no `env` wrapper at all
// (it was the only unset name).
expect(argv).not.toContain("-u");
expect(argv).not.toContain("env");
});

it("empty/absent unsetEnv → exactly `<cmd> <argv>` with no `env` wrapper", async () => {
const argv = await spawnAndCapture({
name: "noscrub",
cmd: "sleep",
argv: ["60"],
});
expect(argv).not.toContain("env");
expect(argv).not.toContain("-u");
// The command tail is the bare cmd + argv, byte-identical to before.
expect(argv.slice(argv.length - 2)).toEqual(["sleep", "60"]);
});
});

it("applies the five per-session options (verifies escape-time + history-limit at minimum)", async () => {
const NAME = "opts";
const target = targetOf(NS, NAME);
Expand Down
Loading
Loading