@@ -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+
118130describe ( "sanitizeTmuxNameSegment" , ( ) => {
119131 it ( "normalizes arbitrary path-like input" , ( ) => {
120132 expect ( sanitizeTmuxNameSegment ( "Workmux Web/Desktop" ) ) . toBe ( "workmux-web-desktop" ) ;
@@ -298,6 +310,116 @@ 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+
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+
301423 it ( "treats missing windows, sessions, and servers as already closed" , async ( ) => {
302424 const testRoot = await mkdtemp ( join ( tmpdir ( ) , "webmux-tmux-kill-window-" ) ) ;
303425 const projectRoot = join ( testRoot , "repo" ) ;
0 commit comments