@@ -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" ) ;
0 commit comments