diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index d9ba90a9..e30c2592 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -73,6 +73,9 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100; /** Default stats collection interval (2 seconds) */ const DEFAULT_STATS_INTERVAL_MS = 2000; +/** Stable cwd for tmux server/pane launch; actual session cwd is reached inside the pane. */ +const TMUX_LAUNCH_CWD = '/tmp'; + /** Claude Code native macOS recommendation for avoiding low nofile startup failures. */ export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646; @@ -679,8 +682,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // (Production uses systemd which has a clean env, but dev/test may be nested.) const cleanEnv = { ...process.env }; delete cleanEnv.TMUX; - execSync(`${this.tmux()} new-session -ds "${muxName}" -c "${workingDir}"`, { - cwd: workingDir, + // Start the tmux server from a stable local cwd so FUSE/rclone workspace + // blips do not poison tmux's long-lived getcwd state. + execSync(`${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD}`, { + cwd: TMUX_LAUNCH_CWD, timeout: EXEC_TIMEOUT_MS, stdio: 'ignore', env: cleanEnv, @@ -706,11 +711,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // so secret values stay off the bash command line. Must run before respawn-pane. this.applyEnvOverrides(muxName, envOverrides); - // Replace the shell with the actual command (no echo in terminal) - execSync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { - timeout: EXEC_TIMEOUT_MS, - stdio: 'ignore', - }); + // Replace the shell with the actual command (no echo in terminal). Keep + // pane launch in /tmp, then cd inside bash against the current mount table. + const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`; + execSync( + `${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`, + { + timeout: EXEC_TIMEOUT_MS, + stdio: 'ignore', + } + ); // Wait for tmux session to be queryable await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS)); @@ -890,9 +900,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Re-apply user env overrides before respawn so the new shell inherits them. this.applyEnvOverrides(muxName, envOverrides); - await execAsync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { - timeout: EXEC_TIMEOUT_MS, - }); + const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`; + await execAsync( + `${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`, + { + timeout: EXEC_TIMEOUT_MS, + } + ); // Wait for the respawned process to start await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS)); const pid = this.getPanePid(muxName); diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index b8f840f5..d1559b96 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -20,6 +20,17 @@ vi.mock('node:child_process', async () => { const actual = await vi.importActual('node:child_process'); return { ...actual, + exec: vi.fn((_cmd: string, optionsOrCallback?: unknown, maybeCallback?: unknown) => { + const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback; + if (typeof callback === 'function') { + setImmediate(() => callback(null, '', '')); + } + return { + on: vi.fn(), + kill: vi.fn(), + pid: 12345, + }; + }), execSync: vi.fn(), spawn: vi.fn(() => ({ unref: vi.fn(), @@ -41,6 +52,15 @@ vi.mock('node:fs', async () => { }; }); +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + return { + ...actual, + writeFile: vi.fn(() => Promise.resolve()), + rename: vi.fn(() => Promise.resolve()), + }; +}); + describe('TmuxManager (unit)', () => { let manager: TmuxManager; const mockedExecSync = vi.mocked(execSync); @@ -369,6 +389,95 @@ describe('TmuxManager (unit)', () => { // No error thrown }); }); + + describe('tmux launch cwd hardening', () => { + async function importWithTmuxCommandsEnabled(): Promise { + const originalVitest = process.env.VITEST; + vi.resetModules(); + delete process.env.VITEST; + const module = await import('../src/tmux-manager.js'); + if (originalVitest === undefined) { + delete process.env.VITEST; + } else { + process.env.VITEST = originalVitest; + } + return module.TmuxManager; + } + + beforeEach(() => { + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes('which tmux')) { + return '/usr/bin/tmux\n'; + } + if (typeof cmd === 'string' && cmd.includes('display-message') && cmd.includes('#{pane_pid}')) { + return '4242\n'; + } + return ''; + }); + }); + + it('starts new tmux sessions from /tmp and cd-bounces into the requested workspace', async () => { + const NonTestTmuxManager = await importWithTmuxCommandsEnabled(); + const nonTestManager = new NonTestTmuxManager(); + + try { + const session = await nonTestManager.createSession({ + sessionId: 'abc12345-1234-5678-90ab-cdef12345678', + workingDir: '/mnt/gdrive/project with spaces', + mode: 'shell', + }); + + expect(session.workingDir).toBe('/mnt/gdrive/project with spaces'); + expect(session.pid).toBe(4242); + + const newSessionCall = mockedExecSync.mock.calls.find( + ([cmd]) => typeof cmd === 'string' && cmd.includes(' new-session ') + ); + expect(newSessionCall?.[0]).toBe(`tmux -L 'codeman' new-session -ds "codeman-abc12345" -c /tmp`); + expect(newSessionCall?.[1]).toEqual(expect.objectContaining({ cwd: '/tmp' })); + + const respawnCall = mockedExecSync.mock.calls.find( + ([cmd]) => typeof cmd === 'string' && cmd.includes(' respawn-pane ') + ); + expect(respawnCall?.[0]).toContain(`tmux -L 'codeman' respawn-pane -k -c /tmp -t "codeman-abc12345"`); + expect(respawnCall?.[0]).toContain('cd \\"/mnt/gdrive/project with spaces\\" &&'); + } finally { + nonTestManager.destroy(); + } + }); + + it('respawns existing panes from /tmp and cd-bounces into the requested workspace', async () => { + const NonTestTmuxManager = await importWithTmuxCommandsEnabled(); + const nonTestManager = new NonTestTmuxManager(); + nonTestManager.registerSession({ + sessionId: 'respawn1234', + muxName: 'codeman-abcd1234', + pid: 1000, + createdAt: Date.now(), + workingDir: '/tmp', + mode: 'shell', + attached: false, + }); + + try { + const pid = await nonTestManager.respawnPane({ + sessionId: 'respawn1234', + workingDir: '/mnt/gdrive/project', + mode: 'shell', + }); + + expect(pid).toBe(4242); + const { exec: currentExec } = await import('node:child_process'); + const respawnCall = vi + .mocked(currentExec) + .mock.calls.find(([cmd]) => typeof cmd === 'string' && cmd.includes(' respawn-pane ')); + expect(respawnCall?.[0]).toContain(`tmux -L 'codeman' respawn-pane -k -c /tmp -t "codeman-abcd1234"`); + expect(respawnCall?.[0]).toContain('cd \\"/mnt/gdrive/project\\" &&'); + } finally { + nonTestManager.destroy(); + } + }); + }); }); // ============================================================================