···2233## Unreleased
4455+### Client API
66+- Add `env?: Record<string, string>` to `SpawnDaemonOptions` and `ServerOptions` — use this to spawn a session child with a verbatim environment (no inheritance from the daemon's `process.env`, no allow-list). `PTY_SESSION` is always injected on top so nesting detection and `pty exec` keep working. Mutually exclusive with `isolateEnv` / `extraEnv` (throws at daemon startup if both are passed). Requested by the pty-layout dev to spawn a launcher shell with a shim `tmux` on `PATH`, a custom `TMUX` marker, and a filter-tag env var.
77+58## 0.9.0
69710### Session naming
+29
src/server.ts
···6060 /** Additional `KEY=VALUE` env entries to add on top of the isolation
6161 * allow-list. Only consulted when `isolateEnv` is true. */
6262 extraEnv?: Record<string, string>;
6363+ /** Use this env dict verbatim for the spawned child — no inheritance from
6464+ * the daemon's `process.env`, no allow-list. `PTY_SESSION` is always
6565+ * injected on top so nesting detection and `pty exec` keep working.
6666+ *
6767+ * Mutually exclusive with `isolateEnv` / `extraEnv` — passing `env`
6868+ * together with either throws. Use this when the caller wants total
6969+ * control of the child environment (e.g., pty-layout's launcher shell
7070+ * that injects a shim tmux on `PATH`). */
7171+ env?: Record<string, string>;
6372}
64736574/** Env variables that are safe to pass through to a session child when
···7382]);
74837584function buildChildEnv(options: ServerOptions): Record<string, string> {
8585+ // Mutual exclusion: `env` (explicit, verbatim) can't be combined with the
8686+ // allow-list-based `isolateEnv`/`extraEnv` path. If you want total control
8787+ // you pass `env`; if you want scrub+extras you pass `isolateEnv`. Picking
8888+ // one implicitly would hide intent.
8989+ if (options.env && (options.isolateEnv || options.extraEnv)) {
9090+ throw new Error(
9191+ "ServerOptions.env is mutually exclusive with isolateEnv/extraEnv. " +
9292+ "Use env for verbatim control, or isolateEnv (+ optional extraEnv) for allow-list semantics — not both."
9393+ );
9494+ }
9595+9696+ // Explicit verbatim env. No inheritance. Only PTY_SESSION is forced on
9797+ // top so internal pty tooling (nesting prevention, `pty exec`) works.
9898+ if (options.env) {
9999+ const env = { ...options.env };
100100+ env.PTY_SESSION = options.name;
101101+ return env;
102102+ }
103103+76104 const source = process.env as Record<string, string>;
7710578106 if (!options.isolateEnv) {
···872900 displayName: config.displayName,
873901 isolateEnv: config.isolateEnv === true,
874902 extraEnv: config.extraEnv,
903903+ env: config.env,
875904 onExit: (code) => {
876905 // Give clients a moment to receive the exit message, then shut down
877906 setTimeout(() => cleanShutdown(code), 500);
+8
src/spawn.ts
···3232 /** Additional `KEY=VALUE` env entries to add on top of the isolation
3333 * allow-list. Ignored unless `isolateEnv` is true. */
3434 extraEnv?: Record<string, string>;
3535+ /** Use this env dict verbatim for the spawned child — no inheritance from
3636+ * the daemon's `process.env`, no allow-list. `PTY_SESSION` is always
3737+ * injected on top so nesting detection and `pty exec` keep working.
3838+ *
3939+ * Mutually exclusive with `isolateEnv` / `extraEnv` — passing `env`
4040+ * together with either will throw at daemon startup. */
4141+ env?: Record<string, string>;
3542 /** Override the runtime used to launch the detached daemon process.
3643 *
3744 * By default the daemon is spawned with `process.execPath` — the same
···7279 ...(options.displayName ? { displayName: options.displayName } : {}),
7380 ...(options.isolateEnv ? { isolateEnv: true } : {}),
7481 ...(options.extraEnv && Object.keys(options.extraEnv).length > 0 ? { extraEnv: options.extraEnv } : {}),
8282+ ...(options.env ? { env: options.env } : {}),
7583 });
76847785 const launcherCmd = options.launcher?.command ?? process.execPath;
+86-1
tests/spawn-options.test.ts
···55import { fileURLToPath } from "node:url";
66import { spawn, spawnSync } from "node:child_process";
77import { queryStats } from "../src/client.ts";
88-import { spawnDaemon } from "../src/spawn.ts";
88+import { spawnDaemon, setServerModulePath } from "../src/spawn.ts";
991010const __dirname = path.dirname(fileURLToPath(import.meta.url));
1111const nodeBin = process.execPath;
···1616afterAll(() => {
1717 fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
1818});
1919+2020+// Tests that invoke spawnDaemon directly (rather than via the CLI) need to
2121+// point it at the built dist/server.js — otherwise __dirname resolves to
2222+// src/ where server.js doesn't exist.
2323+setServerModulePath(serverModule);
19242025let bgPids: number[] = [];
2126let sessionDirs: string[] = [];
···278283 bgPids.push(pid);
279284 } catch {}
280285 try { fs.unlinkSync("/tmp/pty-legacy-env.txt"); } catch {}
286286+ }, 15000);
287287+288288+ it("env option replaces the child's environment verbatim (no inheritance)", async () => {
289289+ const dir = makeSessionDir();
290290+ const name = uniqueName();
291291+ const dumpFile = path.join(dir, "verbatim-env.txt");
292292+293293+ // Set a var in the caller that would normally leak through via
294294+ // inheritance, then spawn with `env:` set to a minimal dict — the dump
295295+ // must NOT contain the caller-side var. Child exits right after writing
296296+ // so we don't leave a sleeping process behind.
297297+ process.env.PTY_VERBATIM_LEAK_CANARY = "leaked";
298298+ process.env.PTY_SESSION_DIR = dir;
299299+300300+ try {
301301+ await spawnDaemon({
302302+ name,
303303+ command: "/bin/sh",
304304+ args: ["-c", `env > ${JSON.stringify(dumpFile)}; exit 0`],
305305+ displayCommand: "env dump",
306306+ cwd: dir,
307307+ env: {
308308+ // Minimal env the shell needs to run at all
309309+ PATH: "/usr/bin:/bin",
310310+ // Plus a marker that only the test knows
311311+ PTY_VERBATIM_MARKER: "from-verbatim-env",
312312+ },
313313+ });
314314+ } finally {
315315+ delete process.env.PTY_VERBATIM_LEAK_CANARY;
316316+ }
317317+318318+ // Wait for child to write + exit (daemon cleans up on its own).
319319+ const start = Date.now();
320320+ while (Date.now() - start < 3000) {
321321+ try {
322322+ if (fs.existsSync(dumpFile) && fs.statSync(dumpFile).size > 0) break;
323323+ } catch {}
324324+ await new Promise((r) => setTimeout(r, 50));
325325+ }
326326+ const dumped = fs.readFileSync(dumpFile, "utf-8");
327327+ expect(dumped).toContain("PTY_VERBATIM_MARKER=from-verbatim-env");
328328+ expect(dumped).not.toContain("PTY_VERBATIM_LEAK_CANARY");
329329+ expect(dumped).not.toContain("leaked");
330330+ // PTY_SESSION is always injected on top so nesting/exec keep working
331331+ expect(dumped).toContain(`PTY_SESSION=${name}`);
332332+ }, 15000);
333333+334334+ it("env option combined with isolateEnv fails daemon startup", async () => {
335335+ const dir = makeSessionDir();
336336+ const name = uniqueName();
337337+ process.env.PTY_SESSION_DIR = dir;
338338+339339+ // Daemon should exit immediately with a descriptive error; spawnDaemon
340340+ // rejects once it detects the early exit.
341341+ await expect(spawnDaemon({
342342+ name,
343343+ command: "/bin/sh",
344344+ args: ["-c", "sleep 30"],
345345+ displayCommand: "sh",
346346+ cwd: dir,
347347+ env: { PATH: "/usr/bin:/bin" },
348348+ isolateEnv: true,
349349+ })).rejects.toThrow(/mutually exclusive/);
350350+ }, 15000);
351351+352352+ it("env option combined with extraEnv fails daemon startup", async () => {
353353+ const dir = makeSessionDir();
354354+ const name = uniqueName();
355355+ process.env.PTY_SESSION_DIR = dir;
356356+357357+ await expect(spawnDaemon({
358358+ name,
359359+ command: "/bin/sh",
360360+ args: ["-c", "sleep 30"],
361361+ displayCommand: "sh",
362362+ cwd: dir,
363363+ env: { PATH: "/usr/bin:/bin" },
364364+ extraEnv: { EXTRA: "nope" },
365365+ })).rejects.toThrow(/mutually exclusive/);
281366 }, 15000);
282367283368 it("surfaces a missing cwd explicitly instead of failing silently", async () => {