Skip to content

Commit 063a817

Browse files
centdixclaude
andcommitted
fix: keep launch project .env secrets out of tmux global env
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7b460cb commit 063a817

3 files changed

Lines changed: 136 additions & 4 deletions

File tree

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

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,18 @@ function parseManagedSessionResult(output: string): ManagedSessionResult {
115115
};
116116
}
117117

118+
function parseGlobalEnvResult(output: string): { hasLeaked: boolean; hasKept: boolean } {
119+
const value: unknown = JSON.parse(output);
120+
if (!value || typeof value !== "object") {
121+
throw new Error("global env result must be an object");
122+
}
123+
const { hasLeaked, hasKept } = value as { hasLeaked?: unknown; hasKept?: unknown };
124+
if (typeof hasLeaked !== "boolean" || typeof hasKept !== "boolean") {
125+
throw new Error("global env result must have boolean hasLeaked and hasKept");
126+
}
127+
return { hasLeaked, hasKept };
128+
}
129+
118130
describe("sanitizeTmuxNameSegment", () => {
119131
it("normalizes arbitrary path-like input", () => {
120132
expect(sanitizeTmuxNameSegment("Workmux Web/Desktop")).toBe("workmux-web-desktop");
@@ -298,6 +310,60 @@ describe("BunTmuxGateway", () => {
298310
}
299311
});
300312

313+
it("keeps launch-project .env keys out of the tmux global environment", async () => {
314+
const testRoot = await mkdtemp(join(tmpdir(), "webmux-tmux-env-leak-"));
315+
const projectRoot = join(testRoot, "repo");
316+
const runnerPath = join(testRoot, "ensure-session.ts");
317+
const tmuxModuleUrl = new URL("../adapters/tmux.ts", import.meta.url).href;
318+
await mkdir(projectRoot, { recursive: true });
319+
await Bun.write(
320+
runnerPath,
321+
[
322+
`import { BunTmuxGateway } from ${JSON.stringify(tmuxModuleUrl)};`,
323+
"",
324+
"function read(args: string[]): string {",
325+
' const result = Bun.spawnSync(args, { stdout: "pipe", stderr: "pipe" });',
326+
" if (result.exitCode !== 0) {",
327+
" const stderr = new TextDecoder().decode(result.stderr).trim();",
328+
' throw new Error(`${args.join(" ")} failed: ${stderr || `exit ${result.exitCode}`}`);',
329+
" }",
330+
' return new TextDecoder().decode(result.stdout).trim();',
331+
"}",
332+
"",
333+
"const projectRoot = process.argv[2];",
334+
'if (!projectRoot) throw new Error("expected projectRoot");',
335+
"const gateway = new BunTmuxGateway();",
336+
// ensureServer + ensureSession is the path that first creates a persistent
337+
// server, capturing this process's env into the tmux global environment.
338+
"gateway.ensureServer();",
339+
'gateway.ensureSession("wm-env-leak", projectRoot);',
340+
'const globalEnv = read(["tmux", "show-environment", "-g"]).split("\\n");',
341+
"console.log(JSON.stringify({",
342+
' hasLeaked: globalEnv.some((line) => line.startsWith("LEAKED_PROJECT_SECRET=")),',
343+
' hasKept: globalEnv.some((line) => line.startsWith("KEPT_SHELL_VAR=")),',
344+
"}));",
345+
].join("\n"),
346+
);
347+
348+
try {
349+
const result = parseGlobalEnvResult(readWithIsolatedTmux(
350+
["bun", runnerPath, projectRoot],
351+
buildEnv({
352+
WEBMUX_PROJECT_ENV_KEYS: "LEAKED_PROJECT_SECRET",
353+
LEAKED_PROJECT_SECRET: "service-role-key",
354+
KEPT_SHELL_VAR: "ok",
355+
}),
356+
));
357+
// The project .env key is stripped from the env used to spawn tmux, so the
358+
// server is born without it in the global environment...
359+
expect(result.hasLeaked).toBe(false);
360+
// ...while unrelated inherited vars are still passed through normally.
361+
expect(result.hasKept).toBe(true);
362+
} finally {
363+
await rm(testRoot, { recursive: true, force: true });
364+
}
365+
});
366+
301367
it("treats missing windows, sessions, and servers as already closed", async () => {
302368
const testRoot = await mkdtemp(join(tmpdir(), "webmux-tmux-kill-window-"));
303369
const projectRoot = join(testRoot, "repo");

backend/src/adapters/tmux.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,50 @@ export interface TmuxGateway {
4141
killPane(target: string): void;
4242
}
4343

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+
58+
let cachedTmuxSpawnEnv: { value: Record<string, string> | undefined } | null = null;
59+
60+
/** 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. */
65+
function tmuxSpawnEnv(): Record<string, string> | undefined {
66+
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;
78+
}
79+
4480
function runTmux(args: string[]): { stdout: string; stderr: string; exitCode: number } {
81+
// Only pass `env` when we have a stripped copy: Bun.spawnSync treats an
82+
// explicit `env: undefined` as an *empty* environment, not "inherit".
83+
const spawnEnv = tmuxSpawnEnv();
4584
const result = Bun.spawnSync(["tmux", ...args], {
4685
stdout: "pipe",
4786
stderr: "pipe",
87+
...(spawnEnv ? { env: spawnEnv } : {}),
4888
});
4989

5090
return {
@@ -123,13 +163,28 @@ export class BunTmuxGateway implements TmuxGateway {
123163
["new-session", "-d", "-s", sessionName, "-c", cwd, ";", "set-option", "-t", sessionName, "destroy-unattached", "off"],
124164
`create tmux session ${sessionName}`,
125165
);
166+
this.scrubLeakedGlobalEnv();
126167
return;
127168
}
128169

129170
assertTmuxOk(
130171
["set-option", "-t", sessionName, "destroy-unattached", "off"],
131172
`set destroy-unattached off for ${sessionName}`,
132173
);
174+
this.scrubLeakedGlobalEnv();
175+
}
176+
177+
/** Self-heal a tmux server that was already running with the launch project's
178+
* `.env` keys in its global environment (e.g. a server started by an older
179+
* webmux that predates the stripped-env spawn). Removing them from the global
180+
* environment cleans every pane created afterwards, in existing and new
181+
* sessions alike. Runs once the server is known to be up (`exit-empty` means a
182+
* server only persists after a session exists). Tolerant: unsetting a key that
183+
* is absent is a no-op. */
184+
private scrubLeakedGlobalEnv(): void {
185+
for (const key of leakedProjectEnvKeys()) {
186+
runTmux(["set-environment", "-gu", key]);
187+
}
133188
}
134189

135190
hasWindow(sessionName: string, windowName: string): boolean {

bin/src/webmux.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,13 @@ function isWorktreeCommand(command: RootCommand): command is "add" | "list" | "o
183183

184184
// ── Load env files from CWD (.env.local overrides .env) ─────────────────────
185185

186-
async function loadEnvFile(path: string) {
187-
if (!existsSync(path)) return;
186+
/** Load a `.env` file from CWD into `process.env`, returning the keys it added.
187+
* Those keys are the launch project's env: webmux tracks them so it can keep
188+
* them out of the tmux server's *global* environment (see WEBMUX_PROJECT_ENV_KEYS),
189+
* where they would otherwise leak into every project's sessions and panes. */
190+
async function loadEnvFile(path: string): Promise<string[]> {
191+
if (!existsSync(path)) return [];
192+
const added: string[] = [];
188193
const lines = (await Bun.file(path).text()).split("\n");
189194
for (const line of lines) {
190195
const trimmed = line.trim();
@@ -195,8 +200,10 @@ async function loadEnvFile(path: string) {
195200
const val = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
196201
if (!(key in process.env)) {
197202
process.env[key] = val;
203+
added.push(key);
198204
}
199205
}
206+
return added;
200207
}
201208

202209
// ── Browser app mode ─────────────────────────────────────────────────────────
@@ -338,8 +345,9 @@ async function main(args: string[] = process.argv.slice(2)): Promise<void> {
338345
process.exit(code);
339346
}
340347

341-
await loadEnvFile(resolve(process.cwd(), ".env.local"));
342-
await loadEnvFile(resolve(process.cwd(), ".env"));
348+
const projectEnvKeys = new Set<string>();
349+
for (const key of await loadEnvFile(resolve(process.cwd(), ".env.local"))) projectEnvKeys.add(key);
350+
for (const key of await loadEnvFile(resolve(process.cwd(), ".env"))) projectEnvKeys.add(key);
343351

344352
// When the user didn't pin a port, point CLI commands at the live server for
345353
// this project rather than the 5111 default. `webmux serve` walks to a free
@@ -413,6 +421,9 @@ async function main(args: string[] = process.argv.slice(2)): Promise<void> {
413421
...process.env,
414422
PORT: String(parsed.port),
415423
WEBMUX_PROJECT_DIR: process.cwd(),
424+
// Tell the backend which keys came from the launch project's `.env` so it can
425+
// strip them from the tmux server's global environment (see tmux adapter).
426+
...(projectEnvKeys.size > 0 ? { WEBMUX_PROJECT_ENV_KEYS: [...projectEnvKeys].join(",") } : {}),
416427
...(parsed.debug ? { WEBMUX_DEBUG: "1" } : {}),
417428
};
418429

0 commit comments

Comments
 (0)