fix(tmux): keep browser keys from switching a tab onto another session (#669)

Squash of PR #669. Browser terminals could be moved onto another session
by tmux's prefix keys: Ctrl+B then ), (, s or w switched the tab's tmux
client to a different session while the tab kept the original's name.
Codeman's tmux socket now keeps only Ctrl+B Ctrl+B (send-prefix) in its
prefix table, and codeman tui and scripts/tmux-manager.sh get F1 as their
way out instead of Ctrl+B d.

Merge-time fixes by the maintainer: on an empty prefix table the
unbind-key that fails no longer skips binding send-prefix; a key tmux lists
escaped (prefix C-\ shows as C-\\) compares equal, so the reset does not rerun on every create;
and reconcileSessions() reduces the table at boot when the socket has live
sessions, so sessions that survive an upgrade are covered too.

Co-authored-by: christianhaberl <116439789+christianhaberl@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
authored and Codeman maintainer committed 2026-10-22 19:23:17 +02:00
1 parent 620e7728ec
commit 6eaaa960ff
14 files changed
+775 -28

No files matched your search

+89 -10
View File
@@ -727,20 +727,99 @@ describe('TmuxManager (unit)', () => {
});
});
describe('tmux launch cwd hardening', () => {
async function importWithTmuxCommandsEnabled(): Promise<typeof TmuxManager> {
const originalVitest = process.env.VITEST;
vi.resetModules();
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;
const module = await import('../src/tmux-manager.js');
if (originalVitest === undefined) {
delete process.env.VITEST;
} else {
process.env.VITEST = originalVitest;
} else {
process.env.VITEST = originalVitest;
}
return module.TmuxManager;
}
describe('prefix table on the codeman socket', () => {
it("reduces the socket's prefix table to send-prefix before the agent starts", async () => {
// Browser keystrokes reach the attach client; with the stock table Ctrl+B ) or s
// would move a tab onto another session (the behavior itself: tmux-prefix-table.test.ts).
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';
// Not tmux's stock C-b, so a hardcoded prefix cannot pass for the one read from tmux.
if (typeof cmd === 'string' && cmd.includes(' show-options -gv prefix')) return 'C-a\n';
if (typeof cmd === 'string' && cmd.includes(' list-keys -T prefix')) {
return 'bind-key -T prefix C-a send-prefix\nbind-key -T prefix ) switch-client -n\n';
}
return '';
});
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.createSession({
sessionId: 'abc12345-1234-5678-90ab-cdef12345678',
workingDir: '/project',
mode: 'shell',
});
const commands = mockedExecSync.mock.calls.map(([cmd]) => String(cmd));
expect(commands).toContain(`tmux -L 'codeman' unbind-key -a -T prefix`);
const bind = commands.indexOf(`tmux -L 'codeman' bind-key -T prefix 'C-a' send-prefix`);
expect(bind).toBeGreaterThan(commands.indexOf(`tmux -L 'codeman' unbind-key -a -T prefix`));
expect(bind).toBeLessThan(commands.findIndex((c) => c.includes(' respawn-pane ')));
} finally {
nonTestManager.destroy();
}
return module.TmuxManager;
});
/** execSync for a boot-time reconcile: `panes` is what `list-panes -a` prints. */
function mockReconcileTmux(panes: string): void {
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('which tmux')) return '/usr/bin/tmux\n';
if (typeof cmd === 'string' && cmd.includes(' list-panes -a ')) return panes;
if (typeof cmd === 'string' && cmd.includes(' show-options -gv prefix')) return 'C-a\n';
if (typeof cmd === 'string' && cmd.includes(' list-keys -T prefix')) {
return 'bind-key -T prefix C-a send-prefix\nbind-key -T prefix ) switch-client -n\n';
}
return '';
});
}
it('reduces it at boot too, for sessions that survived an upgrade', async () => {
mockReconcileTmux('codeman-abc12345|4242|0||\n');
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.reconcileSessions();
const commands = mockedExecSync.mock.calls.map(([cmd]) => String(cmd));
const unbind = commands.indexOf(`tmux -L 'codeman' unbind-key -a -T prefix`);
expect(unbind).toBeGreaterThan(-1);
expect(commands.indexOf(`tmux -L 'codeman' bind-key -T prefix 'C-a' send-prefix`)).toBeGreaterThan(unbind);
} finally {
nonTestManager.destroy();
}
});
it('leaves the key tables alone at boot when the socket lists no pane', async () => {
// On an empty socket `list-keys` would start a throwaway tmux server.
mockReconcileTmux('');
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.reconcileSessions();
const commands = mockedExecSync.mock.calls.map(([cmd]) => String(cmd));
expect(commands.some((c) => c.includes(' list-panes -a '))).toBe(true);
expect(commands.filter((c) => /show-options -gv prefix|list-keys|unbind-key|bind-key/.test(c))).toEqual([]);
} finally {
nonTestManager.destroy();
}
});
});
describe('tmux launch cwd hardening', () => {
beforeEach(() => {
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('which tmux')) {