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