mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #110 from aakhter/cod-31-tmux-session-reliability
fix: harden tmux launch cwd
This commit is contained in:
+24
-10
@@ -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);
|
||||
|
||||
@@ -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<typeof TmuxManager> {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user