This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Can pass env to spawnDaemon

Nathan Herald (Apr 17, 2026, 12:04 AM +0200) 313dd711 e48a59b2

+126 -1
+3
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Client API 6 + - 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. 7 + 5 8 ## 0.9.0 6 9 7 10 ### Session naming
+29
src/server.ts
··· 60 60 /** Additional `KEY=VALUE` env entries to add on top of the isolation 61 61 * allow-list. Only consulted when `isolateEnv` is true. */ 62 62 extraEnv?: Record<string, string>; 63 + /** Use this env dict verbatim for the spawned child — no inheritance from 64 + * the daemon's `process.env`, no allow-list. `PTY_SESSION` is always 65 + * injected on top so nesting detection and `pty exec` keep working. 66 + * 67 + * Mutually exclusive with `isolateEnv` / `extraEnv` — passing `env` 68 + * together with either throws. Use this when the caller wants total 69 + * control of the child environment (e.g., pty-layout's launcher shell 70 + * that injects a shim tmux on `PATH`). */ 71 + env?: Record<string, string>; 63 72 } 64 73 65 74 /** Env variables that are safe to pass through to a session child when ··· 73 82 ]); 74 83 75 84 function buildChildEnv(options: ServerOptions): Record<string, string> { 85 + // Mutual exclusion: `env` (explicit, verbatim) can't be combined with the 86 + // allow-list-based `isolateEnv`/`extraEnv` path. If you want total control 87 + // you pass `env`; if you want scrub+extras you pass `isolateEnv`. Picking 88 + // one implicitly would hide intent. 89 + if (options.env && (options.isolateEnv || options.extraEnv)) { 90 + throw new Error( 91 + "ServerOptions.env is mutually exclusive with isolateEnv/extraEnv. " + 92 + "Use env for verbatim control, or isolateEnv (+ optional extraEnv) for allow-list semantics — not both." 93 + ); 94 + } 95 + 96 + // Explicit verbatim env. No inheritance. Only PTY_SESSION is forced on 97 + // top so internal pty tooling (nesting prevention, `pty exec`) works. 98 + if (options.env) { 99 + const env = { ...options.env }; 100 + env.PTY_SESSION = options.name; 101 + return env; 102 + } 103 + 76 104 const source = process.env as Record<string, string>; 77 105 78 106 if (!options.isolateEnv) { ··· 872 900 displayName: config.displayName, 873 901 isolateEnv: config.isolateEnv === true, 874 902 extraEnv: config.extraEnv, 903 + env: config.env, 875 904 onExit: (code) => { 876 905 // Give clients a moment to receive the exit message, then shut down 877 906 setTimeout(() => cleanShutdown(code), 500);
+8
src/spawn.ts
··· 32 32 /** Additional `KEY=VALUE` env entries to add on top of the isolation 33 33 * allow-list. Ignored unless `isolateEnv` is true. */ 34 34 extraEnv?: Record<string, string>; 35 + /** Use this env dict verbatim for the spawned child — no inheritance from 36 + * the daemon's `process.env`, no allow-list. `PTY_SESSION` is always 37 + * injected on top so nesting detection and `pty exec` keep working. 38 + * 39 + * Mutually exclusive with `isolateEnv` / `extraEnv` — passing `env` 40 + * together with either will throw at daemon startup. */ 41 + env?: Record<string, string>; 35 42 /** Override the runtime used to launch the detached daemon process. 36 43 * 37 44 * By default the daemon is spawned with `process.execPath` — the same ··· 72 79 ...(options.displayName ? { displayName: options.displayName } : {}), 73 80 ...(options.isolateEnv ? { isolateEnv: true } : {}), 74 81 ...(options.extraEnv && Object.keys(options.extraEnv).length > 0 ? { extraEnv: options.extraEnv } : {}), 82 + ...(options.env ? { env: options.env } : {}), 75 83 }); 76 84 77 85 const launcherCmd = options.launcher?.command ?? process.execPath;
+86 -1
tests/spawn-options.test.ts
··· 5 5 import { fileURLToPath } from "node:url"; 6 6 import { spawn, spawnSync } from "node:child_process"; 7 7 import { queryStats } from "../src/client.ts"; 8 - import { spawnDaemon } from "../src/spawn.ts"; 8 + import { spawnDaemon, setServerModulePath } from "../src/spawn.ts"; 9 9 10 10 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 11 11 const nodeBin = process.execPath; ··· 16 16 afterAll(() => { 17 17 fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 18 18 }); 19 + 20 + // Tests that invoke spawnDaemon directly (rather than via the CLI) need to 21 + // point it at the built dist/server.js — otherwise __dirname resolves to 22 + // src/ where server.js doesn't exist. 23 + setServerModulePath(serverModule); 19 24 20 25 let bgPids: number[] = []; 21 26 let sessionDirs: string[] = []; ··· 278 283 bgPids.push(pid); 279 284 } catch {} 280 285 try { fs.unlinkSync("/tmp/pty-legacy-env.txt"); } catch {} 286 + }, 15000); 287 + 288 + it("env option replaces the child's environment verbatim (no inheritance)", async () => { 289 + const dir = makeSessionDir(); 290 + const name = uniqueName(); 291 + const dumpFile = path.join(dir, "verbatim-env.txt"); 292 + 293 + // Set a var in the caller that would normally leak through via 294 + // inheritance, then spawn with `env:` set to a minimal dict — the dump 295 + // must NOT contain the caller-side var. Child exits right after writing 296 + // so we don't leave a sleeping process behind. 297 + process.env.PTY_VERBATIM_LEAK_CANARY = "leaked"; 298 + process.env.PTY_SESSION_DIR = dir; 299 + 300 + try { 301 + await spawnDaemon({ 302 + name, 303 + command: "/bin/sh", 304 + args: ["-c", `env > ${JSON.stringify(dumpFile)}; exit 0`], 305 + displayCommand: "env dump", 306 + cwd: dir, 307 + env: { 308 + // Minimal env the shell needs to run at all 309 + PATH: "/usr/bin:/bin", 310 + // Plus a marker that only the test knows 311 + PTY_VERBATIM_MARKER: "from-verbatim-env", 312 + }, 313 + }); 314 + } finally { 315 + delete process.env.PTY_VERBATIM_LEAK_CANARY; 316 + } 317 + 318 + // Wait for child to write + exit (daemon cleans up on its own). 319 + const start = Date.now(); 320 + while (Date.now() - start < 3000) { 321 + try { 322 + if (fs.existsSync(dumpFile) && fs.statSync(dumpFile).size > 0) break; 323 + } catch {} 324 + await new Promise((r) => setTimeout(r, 50)); 325 + } 326 + const dumped = fs.readFileSync(dumpFile, "utf-8"); 327 + expect(dumped).toContain("PTY_VERBATIM_MARKER=from-verbatim-env"); 328 + expect(dumped).not.toContain("PTY_VERBATIM_LEAK_CANARY"); 329 + expect(dumped).not.toContain("leaked"); 330 + // PTY_SESSION is always injected on top so nesting/exec keep working 331 + expect(dumped).toContain(`PTY_SESSION=${name}`); 332 + }, 15000); 333 + 334 + it("env option combined with isolateEnv fails daemon startup", async () => { 335 + const dir = makeSessionDir(); 336 + const name = uniqueName(); 337 + process.env.PTY_SESSION_DIR = dir; 338 + 339 + // Daemon should exit immediately with a descriptive error; spawnDaemon 340 + // rejects once it detects the early exit. 341 + await expect(spawnDaemon({ 342 + name, 343 + command: "/bin/sh", 344 + args: ["-c", "sleep 30"], 345 + displayCommand: "sh", 346 + cwd: dir, 347 + env: { PATH: "/usr/bin:/bin" }, 348 + isolateEnv: true, 349 + })).rejects.toThrow(/mutually exclusive/); 350 + }, 15000); 351 + 352 + it("env option combined with extraEnv fails daemon startup", async () => { 353 + const dir = makeSessionDir(); 354 + const name = uniqueName(); 355 + process.env.PTY_SESSION_DIR = dir; 356 + 357 + await expect(spawnDaemon({ 358 + name, 359 + command: "/bin/sh", 360 + args: ["-c", "sleep 30"], 361 + displayCommand: "sh", 362 + cwd: dir, 363 + env: { PATH: "/usr/bin:/bin" }, 364 + extraEnv: { EXTRA: "nope" }, 365 + })).rejects.toThrow(/mutually exclusive/); 281 366 }, 15000); 282 367 283 368 it("surfaces a missing cwd explicitly instead of failing silently", async () => {