mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
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:
1 parent
620e7728ec
commit
6eaaa960ff
14 files changed
+775
-28
No files matched your search
+89
-10
@@ -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')) {
|
||||
|
||||
Reference in new issue
Block a user