Skip to content

Commit fb7c290

Browse files
centdixclaude
andcommitted
refactor: extend .env strip to all tmux spawns and address review
- extract leaked-key stripping into shared adapters/project-env - strip project keys in terminal.ts tmux/PTY spawns (close born-clean gap) - use Bun.env; also strip WEBMUX_PROJECT_ENV_KEYS marker itself - gate global-env scrub to once per backend process - add project-env unit tests and a scrub-in-isolation integration test Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 063a817 commit fb7c290

5 files changed

Lines changed: 154 additions & 32 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { afterEach, describe, expect, it } from "bun:test";
2+
import { leakedProjectEnvKeys, stripProjectEnv } from "../adapters/project-env";
3+
4+
const original = process.env.WEBMUX_PROJECT_ENV_KEYS;
5+
6+
afterEach(() => {
7+
if (original === undefined) delete process.env.WEBMUX_PROJECT_ENV_KEYS;
8+
else process.env.WEBMUX_PROJECT_ENV_KEYS = original;
9+
});
10+
11+
describe("leakedProjectEnvKeys", () => {
12+
it("returns an empty set when no project env keys were loaded", () => {
13+
delete process.env.WEBMUX_PROJECT_ENV_KEYS;
14+
expect(leakedProjectEnvKeys().size).toBe(0);
15+
});
16+
17+
it("includes the listed keys plus the marker var itself, trimming blanks", () => {
18+
process.env.WEBMUX_PROJECT_ENV_KEYS = "SUPABASE_URL, SUPABASE_ANON_KEY ,";
19+
expect(leakedProjectEnvKeys()).toEqual(
20+
new Set(["WEBMUX_PROJECT_ENV_KEYS", "SUPABASE_URL", "SUPABASE_ANON_KEY"]),
21+
);
22+
});
23+
});
24+
25+
describe("stripProjectEnv", () => {
26+
it("removes the launch project's keys and the marker, keeping unrelated vars", () => {
27+
process.env.WEBMUX_PROJECT_ENV_KEYS = "SUPABASE_URL";
28+
const result = stripProjectEnv({
29+
SUPABASE_URL: "secret",
30+
WEBMUX_PROJECT_ENV_KEYS: "SUPABASE_URL",
31+
PATH: "/usr/bin",
32+
HOME: "/root",
33+
UNSET: undefined,
34+
});
35+
expect(result).toEqual({ PATH: "/usr/bin", HOME: "/root" });
36+
});
37+
38+
it("returns a full copy of defined vars when there is nothing to strip", () => {
39+
delete process.env.WEBMUX_PROJECT_ENV_KEYS;
40+
expect(stripProjectEnv({ A: "1", B: "2", C: undefined })).toEqual({ A: "1", B: "2" });
41+
});
42+
});

backend/src/__tests__/tmux-adapter.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,62 @@ describe("BunTmuxGateway", () => {
364364
}
365365
});
366366

367+
it("scrubs launch-project .env keys left in the global env by an already-running server", async () => {
368+
const testRoot = await mkdtemp(join(tmpdir(), "webmux-tmux-env-scrub-"));
369+
const projectRoot = join(testRoot, "repo");
370+
const runnerPath = join(testRoot, "scrub.ts");
371+
const tmuxModuleUrl = new URL("../adapters/tmux.ts", import.meta.url).href;
372+
await mkdir(projectRoot, { recursive: true });
373+
await Bun.write(
374+
runnerPath,
375+
[
376+
`import { BunTmuxGateway } from ${JSON.stringify(tmuxModuleUrl)};`,
377+
"",
378+
"function run(args: string[], env?: Record<string, string>): void {",
379+
' const result = Bun.spawnSync(args, { stdout: "pipe", stderr: "pipe", ...(env ? { env } : {}) });',
380+
" if (result.exitCode !== 0) {",
381+
" const stderr = new TextDecoder().decode(result.stderr).trim();",
382+
' throw new Error(`${args.join(" ")} failed: ${stderr || `exit ${result.exitCode}`}`);',
383+
" }",
384+
"}",
385+
"",
386+
"function globalHasLeaked(): boolean {",
387+
' const result = Bun.spawnSync(["tmux", "show-environment", "-g"], { stdout: "pipe", stderr: "pipe" });',
388+
' return new TextDecoder().decode(result.stdout).split("\\n").some((line) => line.startsWith("LEAKED_PROJECT_SECRET="));',
389+
"}",
390+
"",
391+
"const projectRoot = process.argv[2];",
392+
'if (!projectRoot) throw new Error("expected projectRoot");',
393+
// Simulate a server started before the stripped-env fix: its global env
394+
// captured the leaked key. gateway commands never spawn with it set, so
395+
// only the scrub can remove it. destroy-unattached off keeps this
396+
// detached session (and thus the server + its global env) alive even when
397+
// the tmux config enables destroy-unattached.
398+
'run(["tmux", "new-session", "-d", "-s", "preexisting", "-c", projectRoot, ";", "set-option", "-t", "preexisting", "destroy-unattached", "off"], { ...process.env, LEAKED_PROJECT_SECRET: "service-role-key" } as Record<string, string>);',
399+
"const before = globalHasLeaked();",
400+
"const gateway = new BunTmuxGateway();",
401+
"gateway.ensureServer();",
402+
'gateway.ensureSession("wm-scrub", projectRoot);',
403+
"console.log(JSON.stringify({ before, after: globalHasLeaked() }));",
404+
].join("\n"),
405+
);
406+
407+
try {
408+
const output = readWithIsolatedTmux(
409+
["bun", runnerPath, projectRoot],
410+
buildEnv({ WEBMUX_PROJECT_ENV_KEYS: "LEAKED_PROJECT_SECRET" }),
411+
);
412+
const value: unknown = JSON.parse(output);
413+
const { before, after } = value as { before?: unknown; after?: unknown };
414+
// The pre-existing server really did leak the key into the global env...
415+
expect(before).toBe(true);
416+
// ...and ensureSession's self-heal scrub removed it.
417+
expect(after).toBe(false);
418+
} finally {
419+
await rm(testRoot, { recursive: true, force: true });
420+
}
421+
});
422+
367423
it("treats missing windows, sessions, and servers as already closed", async () => {
368424
const testRoot = await mkdtemp(join(tmpdir(), "webmux-tmux-kill-window-"));
369425
const projectRoot = join(testRoot, "repo");
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/** The launch project's `.env`/`.env.local` keys that webmux's CLI loaded into
2+
* its own process env, passed down to the backend as a comma-separated list in
3+
* WEBMUX_PROJECT_ENV_KEYS. These are application secrets webmux does not need in
4+
* the tmux server: if the tmux *global* environment ever captures them (the
5+
* server inherits webmux's env from whatever process first starts it) they leak
6+
* into every session and pane of every project. WEBMUX_PROJECT_ENV_KEYS itself
7+
* is stripped too — it only holds key *names*, not values, but there is no
8+
* reason to let a webmux-internal marker reach the global env either. */
9+
export function leakedProjectEnvKeys(): Set<string> {
10+
const raw = Bun.env.WEBMUX_PROJECT_ENV_KEYS;
11+
if (!raw) return new Set();
12+
const keys = new Set<string>(["WEBMUX_PROJECT_ENV_KEYS"]);
13+
for (const key of raw.split(",").map((entry) => entry.trim()).filter(Boolean)) {
14+
keys.add(key);
15+
}
16+
return keys;
17+
}
18+
19+
/** Copy `base` (typically `Bun.env`) with the launch project's `.env` keys
20+
* removed, so a tmux server or client spawned by webmux never carries — nor
21+
* captures into its global environment — another project's secrets. Snapshots
22+
* `base` at call time; the leaked keys are fixed at launch, so a later mutation
23+
* to one of those vars is intentionally not reflected. */
24+
export function stripProjectEnv(base: Record<string, string | undefined>): Record<string, string> {
25+
const keys = leakedProjectEnvKeys();
26+
const env: Record<string, string> = {};
27+
for (const [key, val] of Object.entries(base)) {
28+
if (val !== undefined && !keys.has(key)) env[key] = val;
29+
}
30+
return env;
31+
}

backend/src/adapters/terminal.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { log } from "../lib/log";
2+
import { stripProjectEnv } from "./project-env";
23

34
interface PtyProcess {
45
pid: number;
@@ -77,11 +78,15 @@ const defaultSpawnTmuxProcess: SpawnTmuxProcess = (args, opts = {}) =>
7778
stdin: opts.stdin ?? "ignore",
7879
stdout: "ignore",
7980
stderr: "pipe",
81+
// Strip the launch project's `.env` keys: if one of these commands is ever
82+
// the first to reach a not-yet-running server it must not birth it with
83+
// another project's secrets in the global env (see project-env.ts).
84+
env: stripProjectEnv(Bun.env),
8085
});
8186
const defaultSleep: Sleep = (ms) => Bun.sleep(ms);
8287

8388
const defaultSpawnSyncCommand: SpawnSyncCommand = (args, opts = {}) => {
84-
const result = Bun.spawnSync(args, opts);
89+
const result = Bun.spawnSync(args, { ...opts, env: opts.env ?? stripProjectEnv(Bun.env) });
8590
return {
8691
exitCode: result.exitCode,
8792
stdout: result.stdout ?? new Uint8Array(),
@@ -242,7 +247,7 @@ export async function attach(
242247
initialPane,
243248
});
244249

245-
const proc = spawnPtyProcess(buildPtyArgs(cmd), { ...Bun.env, TERM: "xterm-256color" });
250+
const proc = spawnPtyProcess(buildPtyArgs(cmd), { ...stripProjectEnv(Bun.env), TERM: "xterm-256color" });
246251

247252
const session: TerminalSession = {
248253
proc,

backend/src/adapters/tmux.ts

Lines changed: 18 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createHash } from "node:crypto";
22
import { basename, resolve } from "node:path";
33
import type { PaneSplit } from "../domain/config";
4+
import { leakedProjectEnvKeys, stripProjectEnv } from "./project-env";
45

56
export interface TmuxWindowSummary {
67
sessionName: string;
@@ -41,40 +42,21 @@ export interface TmuxGateway {
4142
killPane(target: string): void;
4243
}
4344

44-
/** Keys webmux's launcher loaded from the serve directory's `.env`/`.env.local`
45-
* into its own process env (comma-separated in WEBMUX_PROJECT_ENV_KEYS). These
46-
* are the launch project's application secrets; webmux does not need them in the
47-
* tmux server. When webmux starts the tmux server it inherits webmux's env, and
48-
* the server captures that as its *global* environment — which every session and
49-
* pane of every project then inherits. Stripping these keys from the env used to
50-
* spawn tmux keeps a webmux-created server from ever being born with one
51-
* project's secrets visible to all the others. */
52-
function leakedProjectEnvKeys(): Set<string> {
53-
const raw = Bun.env.WEBMUX_PROJECT_ENV_KEYS;
54-
if (!raw) return new Set();
55-
return new Set(raw.split(",").map((key) => key.trim()).filter(Boolean));
56-
}
57-
5845
let cachedTmuxSpawnEnv: { value: Record<string, string> | undefined } | null = null;
46+
let globalEnvScrubbed = false;
5947

6048
/** Environment for spawning tmux control commands, stripped of the launch
61-
* project's `.env` keys. Whichever tmux command first starts the server fixes
62-
* the global environment for the server's lifetime, so every tmux invocation
63-
* must use the stripped env. Returns `undefined` (inherit the process env
64-
* unchanged) when there is nothing to strip. */
49+
* project's `.env` keys (see {@link stripProjectEnv}). Whichever tmux command
50+
* first starts the server fixes the global environment for the server's
51+
* lifetime, so every tmux invocation must use the stripped env. Returns
52+
* `undefined` (inherit the process env unchanged) on the common path where no
53+
* project keys were loaded, so tmux spawns don't copy the whole env for
54+
* nothing. */
6555
function tmuxSpawnEnv(): Record<string, string> | undefined {
6656
if (cachedTmuxSpawnEnv) return cachedTmuxSpawnEnv.value;
67-
const keys = leakedProjectEnvKeys();
68-
if (keys.size === 0) {
69-
cachedTmuxSpawnEnv = { value: undefined };
70-
return undefined;
71-
}
72-
const env: Record<string, string> = {};
73-
for (const [key, val] of Object.entries(process.env)) {
74-
if (val !== undefined && !keys.has(key)) env[key] = val;
75-
}
76-
cachedTmuxSpawnEnv = { value: env };
77-
return env;
57+
const value = Bun.env.WEBMUX_PROJECT_ENV_KEYS ? stripProjectEnv(Bun.env) : undefined;
58+
cachedTmuxSpawnEnv = { value };
59+
return value;
7860
}
7961

8062
function runTmux(args: string[]): { stdout: string; stderr: string; exitCode: number } {
@@ -180,8 +162,14 @@ export class BunTmuxGateway implements TmuxGateway {
180162
* environment cleans every pane created afterwards, in existing and new
181163
* sessions alike. Runs once the server is known to be up (`exit-empty` means a
182164
* server only persists after a session exists). Tolerant: unsetting a key that
183-
* is absent is a no-op. */
165+
* is absent is a no-op.
166+
*
167+
* Runs at most once per backend process: once the global env is scrubbed,
168+
* stripped-env spawns keep it clean, so re-scrubbing on every session-ensure /
169+
* reconciliation pass would be pure overhead (a tmux spawn per leaked key). */
184170
private scrubLeakedGlobalEnv(): void {
171+
if (globalEnvScrubbed) return;
172+
globalEnvScrubbed = true;
185173
for (const key of leakedProjectEnvKeys()) {
186174
runTmux(["set-environment", "-gu", key]);
187175
}

0 commit comments

Comments
 (0)