mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
fix: COD-31 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) */
|
/** Default stats collection interval (2 seconds) */
|
||||||
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
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. */
|
/** Claude Code native macOS recommendation for avoiding low nofile startup failures. */
|
||||||
export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646;
|
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.)
|
// (Production uses systemd which has a clean env, but dev/test may be nested.)
|
||||||
const cleanEnv = { ...process.env };
|
const cleanEnv = { ...process.env };
|
||||||
delete cleanEnv.TMUX;
|
delete cleanEnv.TMUX;
|
||||||
execSync(`${this.tmux()} new-session -ds "${muxName}" -c "${workingDir}"`, {
|
// Start the tmux server from a stable local cwd so FUSE/rclone workspace
|
||||||
cwd: workingDir,
|
// 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,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
env: cleanEnv,
|
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.
|
// so secret values stay off the bash command line. Must run before respawn-pane.
|
||||||
this.applyEnvOverrides(muxName, envOverrides);
|
this.applyEnvOverrides(muxName, envOverrides);
|
||||||
|
|
||||||
// Replace the shell with the actual command (no echo in terminal)
|
// Replace the shell with the actual command (no echo in terminal). Keep
|
||||||
execSync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
// pane launch in /tmp, then cd inside bash against the current mount table.
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||||
stdio: 'ignore',
|
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
|
// Wait for tmux session to be queryable
|
||||||
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
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.
|
// Re-apply user env overrides before respawn so the new shell inherits them.
|
||||||
this.applyEnvOverrides(muxName, envOverrides);
|
this.applyEnvOverrides(muxName, envOverrides);
|
||||||
|
|
||||||
await execAsync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
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
|
// Wait for the respawned process to start
|
||||||
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||||
const pid = this.getPanePid(muxName);
|
const pid = this.getPanePid(muxName);
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ vi.mock('node:child_process', async () => {
|
|||||||
const actual = await vi.importActual('node:child_process');
|
const actual = await vi.importActual('node:child_process');
|
||||||
return {
|
return {
|
||||||
...actual,
|
...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(),
|
execSync: vi.fn(),
|
||||||
spawn: vi.fn(() => ({
|
spawn: vi.fn(() => ({
|
||||||
unref: 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)', () => {
|
describe('TmuxManager (unit)', () => {
|
||||||
let manager: TmuxManager;
|
let manager: TmuxManager;
|
||||||
const mockedExecSync = vi.mocked(execSync);
|
const mockedExecSync = vi.mocked(execSync);
|
||||||
@@ -369,6 +389,95 @@ describe('TmuxManager (unit)', () => {
|
|||||||
// No error thrown
|
// 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