mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
A user tmux config that leaves the prefix table empty makes unbind-key -a -T prefix fail and drop later commands in the \; chain, including send-prefix and remain-on-exit failed. Bind C-q send-prefix, clear the table, then bind it again. Also accept macOS ttys in the TUI switch-guard test without reading /proc, and describe the client_pid hook escape as defensive on tmux 3.4. Co-authored-by: DDT <1786035110@qq.com>
401 lines
17 KiB
TypeScript
401 lines
17 KiB
TypeScript
/**
|
|
* @fileoverview The tmux prefix table on Codeman's socket holds only
|
|
* `<prefix> send-prefix`, so keys typed into a browser terminal cannot move its
|
|
* attach client onto another session, without taking `codeman tui`'s way out away.
|
|
*
|
|
* Two layers here (the createSession wiring is in tmux-manager.test.ts):
|
|
* - the decision (`prefixTableNeedsReset`) on real `list-keys` shapes,
|
|
* - the effect against a REAL tmux server on a private throwaway socket (never
|
|
* Codeman's own), driven through a real attach client in a pty, the way a
|
|
* browser tab drives one.
|
|
*
|
|
* Port: N/A (no server)
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { execSync } from 'node:child_process';
|
|
import { rmSync, writeFileSync } from 'node:fs';
|
|
import * as pty from 'node-pty';
|
|
import {
|
|
nestedPrefixTableResetCommands,
|
|
prefixTableNeedsReset,
|
|
restrictTmuxPrefixTable,
|
|
} from '../src/tmux-prefix-table.js';
|
|
|
|
/** A trimmed copy of tmux 3.4's stock prefix table, as `list-keys -T prefix` prints it. */
|
|
const STOCK = [
|
|
'bind-key -T prefix C-b send-prefix',
|
|
'bind-key -T prefix C-o rotate-window',
|
|
'bind-key -T prefix C-z suspend-client',
|
|
'bind-key -T prefix ( switch-client -p',
|
|
'bind-key -T prefix ) switch-client -n',
|
|
'bind-key -T prefix L switch-client -l',
|
|
'bind-key -T prefix d detach-client',
|
|
'bind-key -T prefix s choose-tree -Zs',
|
|
'bind-key -T prefix w choose-tree -Zw',
|
|
'bind-key -r -T prefix Up select-pane -U',
|
|
].join('\n');
|
|
|
|
const RESTRICTED = 'bind-key -T prefix C-b send-prefix\n';
|
|
|
|
describe('prefixTableNeedsReset', () => {
|
|
it("resets tmux's stock table (switch-client, choose-tree, detach-client on d)", () => {
|
|
expect(prefixTableNeedsReset(STOCK)).toBe(true);
|
|
});
|
|
|
|
it('leaves a table that holds only C-b send-prefix alone', () => {
|
|
expect(prefixTableNeedsReset(RESTRICTED)).toBe(false);
|
|
});
|
|
|
|
it("leaves a codeman tui attach's held-Ctrl detach alias alone", () => {
|
|
expect(prefixTableNeedsReset(`${RESTRICTED}bind-key -T prefix C-d detach-client\n`)).toBe(
|
|
false
|
|
);
|
|
});
|
|
|
|
it('resets when a single foreign binding is back, or the stock detach key d', () => {
|
|
expect(prefixTableNeedsReset(`${RESTRICTED}bind-key -T prefix s choose-tree -Zs\n`)).toBe(
|
|
true
|
|
);
|
|
expect(prefixTableNeedsReset(`${RESTRICTED}bind-key -T prefix d detach-client\n`)).toBe(
|
|
true
|
|
);
|
|
});
|
|
|
|
it('resets an empty table or one without send-prefix, and output it cannot read', () => {
|
|
expect(prefixTableNeedsReset('')).toBe(true);
|
|
expect(prefixTableNeedsReset('bind-key -T prefix C-d detach-client\n')).toBe(true);
|
|
expect(prefixTableNeedsReset('something unexpected\n')).toBe(true);
|
|
});
|
|
|
|
it("follows the socket's configured prefix instead of assuming C-b", () => {
|
|
const ctrlA = 'bind-key -T prefix C-a send-prefix\n';
|
|
expect(prefixTableNeedsReset(ctrlA, 'C-a')).toBe(false);
|
|
// C-b send-prefix under a C-a prefix is a leftover, not the binding we want.
|
|
expect(prefixTableNeedsReset(RESTRICTED, 'C-a')).toBe(true);
|
|
expect(prefixTableNeedsReset('', 'None')).toBe(false);
|
|
});
|
|
|
|
it('reads a key tmux lists backslash-escaped as the key itself', () => {
|
|
// `prefix C-\` is printed as is by show-options, but listed as `C-\\`.
|
|
expect(prefixTableNeedsReset('bind-key -T prefix C-\\\\ send-prefix\n', 'C-\\')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('restrictTmuxPrefixTable (recorded commands)', () => {
|
|
/** A runner that answers from `replies` (a thrown Error stands for a failing command) and records every call. */
|
|
function fakeTmux(replies: Record<string, string | Error>): { run: (cmd: string) => string; calls: string[] } {
|
|
const calls: string[] = [];
|
|
const run = (cmd: string): string => {
|
|
calls.push(cmd);
|
|
const sub = cmd.replace(/^tmux -L 'codeman' /, '');
|
|
const reply = replies[sub] ?? '';
|
|
if (reply instanceof Error) throw reply;
|
|
return reply;
|
|
};
|
|
return { run, calls };
|
|
}
|
|
|
|
it('still binds send-prefix when the table is empty and unbind-key -a fails on it', () => {
|
|
// tmux 3.4 on an empty prefix table: both commands exit 1 with "table prefix doesn't exist".
|
|
const missing = new Error("table prefix doesn't exist");
|
|
const tmux = fakeTmux({
|
|
'show-options -gv prefix': 'C-b\n',
|
|
'list-keys -T prefix': missing,
|
|
'unbind-key -a -T prefix': missing,
|
|
});
|
|
expect(restrictTmuxPrefixTable(`tmux -L 'codeman'`, tmux.run)).toBe(true);
|
|
expect(tmux.calls).toContain(`tmux -L 'codeman' bind-key -T prefix 'C-b' send-prefix`);
|
|
});
|
|
|
|
it('does not reset again when the listed prefix is only escaped differently', () => {
|
|
const tmux = fakeTmux({
|
|
'show-options -gv prefix': 'C-\\\n',
|
|
'list-keys -T prefix': 'bind-key -T prefix C-\\\\ send-prefix\n',
|
|
});
|
|
expect(restrictTmuxPrefixTable(`tmux -L 'codeman'`, tmux.run)).toBe(false);
|
|
expect(tmux.calls.filter((c) => c.includes('bind-key'))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
const hasTmux = (() => {
|
|
try {
|
|
execSync('tmux -V', { stdio: 'ignore' });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
describe.skipIf(!hasTmux)('restrictTmuxPrefixTable against a real tmux (private socket)', () => {
|
|
// Throwaway server, never Codeman's socket; -f /dev/null keeps the user's config out.
|
|
const socket = `codeman-prefix-test-${process.pid}`;
|
|
const tmux = `tmux -L ${socket} -f /dev/null`;
|
|
const run = (cmd: string): string => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
const keys = (table: string) => run(`${tmux} list-keys -T ${table}`);
|
|
let client: pty.IPty | null = null;
|
|
|
|
/** The session the attach client is looking at right now. */
|
|
const clientSession = () => run(`${tmux} list-clients -F '#{client_session}'`).trim();
|
|
|
|
async function waitFor(check: () => boolean, ms = 3000): Promise<boolean> {
|
|
const deadline = Date.now() + ms;
|
|
while (Date.now() < deadline) {
|
|
if (check()) return true;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
return check();
|
|
}
|
|
|
|
/** Types keys into the attach client, then gives tmux time to act on them. */
|
|
async function type(keysToSend: string): Promise<void> {
|
|
for (const ch of keysToSend) {
|
|
client!.write(ch);
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
// `cat -v` prints a received Ctrl+B as ^B, which is how send-prefix shows up.
|
|
run(`${tmux} new-session -d -s alpha -x 80 -y 24 'cat -v'`);
|
|
run(`${tmux} new-session -d -s beta -x 80 -y 24 'cat -v'`);
|
|
const env = { ...process.env } as Record<string, string>;
|
|
delete env.TMUX; // the suite may itself run inside tmux
|
|
// What a browser tab is: a pty running `tmux attach-session` (tmux-manager getAttachArgs).
|
|
client = pty.spawn('tmux', ['-L', socket, 'attach-session', '-t', 'alpha'], {
|
|
name: 'xterm-256color',
|
|
cols: 80,
|
|
rows: 24,
|
|
env,
|
|
});
|
|
expect(await waitFor(() => clientSession() === 'alpha')).toBe(true);
|
|
});
|
|
|
|
afterAll(() => {
|
|
client?.kill();
|
|
let socketPath = '';
|
|
try {
|
|
socketPath = run(`${tmux} display-message -p '#{socket_path}'`).trim();
|
|
run(`${tmux} kill-server`); // our own throwaway socket only
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
// tmux leaves the socket file behind; remove ours (and only ours).
|
|
if (socketPath.endsWith(`/${socket}`)) rmSync(socketPath, { force: true });
|
|
});
|
|
|
|
it("with tmux's stock table, Ctrl+B ) moves the attach client onto another session", async () => {
|
|
// The bug, reproduced so the next test means something.
|
|
await type('\x02)');
|
|
expect(await waitFor(() => clientSession() === 'beta')).toBe(true);
|
|
run(`${tmux} switch-client -c $(${tmux} list-clients -F '#{client_name}') -t alpha`);
|
|
expect(await waitFor(() => clientSession() === 'alpha')).toBe(true);
|
|
});
|
|
|
|
it('after the reset, Ctrl+B ) and Ctrl+B s leave the client where it is, Ctrl+B Ctrl+B reaches the pane', async () => {
|
|
const rootBefore = keys('root');
|
|
|
|
expect(restrictTmuxPrefixTable(tmux, run)).toBe(true);
|
|
|
|
const prefix = keys('prefix')
|
|
.split('\n')
|
|
.filter((l) => l.trim());
|
|
expect(prefix).toHaveLength(1);
|
|
expect(prefix[0]).toMatch(/-T prefix C-b\s+send-prefix$/);
|
|
expect(keys('root')).toBe(rootBefore);
|
|
expect(run(`${tmux} show-options -gv prefix`).trim()).toBe('C-b');
|
|
|
|
await type('\x02)');
|
|
await type('\x02s');
|
|
expect(clientSession()).toBe('alpha');
|
|
expect(run(`${tmux} display-message -p -t alpha '#{pane_in_mode}'`).trim()).toBe('0');
|
|
|
|
await type('\x02\x02');
|
|
expect(await waitFor(() => run(`${tmux} capture-pane -p -t alpha`).includes('^B'))).toBe(true);
|
|
});
|
|
|
|
it("keeps a codeman tui attach's way out when another session is created mid-attach", () => {
|
|
// What beginAttachHandoff claims: C-d in the prefix table, F1 and M-1 in root.
|
|
run(`${tmux} bind-key -T prefix C-d detach-client`);
|
|
run(`${tmux} bind-key -T root F1 detach-client`);
|
|
run(`${tmux} bind-key -T root M-1 switch-client -t alpha`);
|
|
|
|
expect(restrictTmuxPrefixTable(tmux, run)).toBe(false);
|
|
|
|
expect(keys('prefix')).toMatch(/-T prefix C-d\s+detach-client/);
|
|
expect(keys('root')).toMatch(/-T root F1\s+detach-client/);
|
|
expect(keys('root')).toMatch(/-T root M-1\s+switch-client -t alpha/);
|
|
});
|
|
|
|
it("gives a running attach's held-Ctrl way out back when the reset clears a stock table", () => {
|
|
// A tmux server whose table is still stock while a codeman tui attach holds C-d:
|
|
// `unbind-key -a` takes C-d along with the rest.
|
|
try {
|
|
run(`${tmux} bind-key -T prefix d detach-client`);
|
|
run(`${tmux} bind-key -T prefix ')' switch-client -n`);
|
|
run(`${tmux} bind-key -T prefix C-d detach-client`);
|
|
expect(restrictTmuxPrefixTable(tmux, run)).toBe(true);
|
|
const prefix = keys('prefix')
|
|
.split('\n')
|
|
.filter((l) => l.trim());
|
|
expect(prefix).toHaveLength(2);
|
|
expect(keys('prefix')).toMatch(/-T prefix C-b\s+send-prefix/);
|
|
expect(keys('prefix')).toMatch(/-T prefix C-d\s+detach-client/);
|
|
} finally {
|
|
run(`${tmux} unbind-key -T prefix C-d`);
|
|
}
|
|
});
|
|
|
|
it('binds send-prefix to the configured prefix when the socket uses another one', () => {
|
|
run(`${tmux} set-option -g prefix C-a`);
|
|
try {
|
|
expect(restrictTmuxPrefixTable(tmux, run)).toBe(true);
|
|
const prefix = keys('prefix')
|
|
.split('\n')
|
|
.filter((l) => l.trim());
|
|
expect(prefix).toEqual([expect.stringMatching(/-T prefix C-a\s+send-prefix$/)]);
|
|
} finally {
|
|
run(`${tmux} set-option -g prefix C-b`);
|
|
}
|
|
});
|
|
|
|
it('binds send-prefix on a server whose prefix table is empty', () => {
|
|
// On an empty table tmux fails both `list-keys -T prefix` and `unbind-key -a -T prefix`.
|
|
run(`${tmux} unbind-key -a -T prefix`);
|
|
expect(() => run(`${tmux} unbind-key -a -T prefix`)).toThrow();
|
|
|
|
expect(restrictTmuxPrefixTable(tmux, run)).toBe(true);
|
|
const prefix = keys('prefix')
|
|
.split('\n')
|
|
.filter((l) => l.trim());
|
|
expect(prefix).toEqual([expect.stringMatching(/-T prefix C-b\s+send-prefix$/)]);
|
|
});
|
|
|
|
it('resets once, not on every call, for a prefix tmux lists escaped (C-\\)', () => {
|
|
run(`${tmux} set-option -g prefix 'C-\\'`);
|
|
try {
|
|
expect(restrictTmuxPrefixTable(tmux, run)).toBe(true);
|
|
expect(keys('prefix')).toMatch(/-T prefix C-\\\\\s+send-prefix/);
|
|
expect(restrictTmuxPrefixTable(tmux, run)).toBe(false);
|
|
} finally {
|
|
run(`${tmux} set-option -g prefix C-b`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!hasTmux)('nested Docker/remote tmux prefix table (private socket)', () => {
|
|
// What buildRemoteLaunchCommand / buildDockerLaunchCommand create: prefix C-q
|
|
// on a dedicated server, then the commands nestedPrefixTableResetCommands
|
|
// emits. The inner client is the docker-exec / ssh process; Ctrl+Q is its prefix.
|
|
const socket = `codeman-nested-prefix-${process.pid}`;
|
|
const tmux = `tmux -L ${socket} -f /dev/null`;
|
|
const run = (cmd: string): string => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
let client: pty.IPty | null = null;
|
|
|
|
const clientSession = () => run(`${tmux} list-clients -F '#{client_session}'`).trim();
|
|
|
|
async function waitFor(check: () => boolean, ms = 3000): Promise<boolean> {
|
|
const deadline = Date.now() + ms;
|
|
while (Date.now() < deadline) {
|
|
if (check()) return true;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
return check();
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
// `stty -ixon` in the pane so a delivered C-q is visible to cat -v as ^Q
|
|
// instead of being eaten as XON. The attach client below is what must also
|
|
// deliver C-q; node-pty opens the pty raw, which clears IXON on that side.
|
|
run(`${tmux} new-session -d -s alpha -x 80 -y 24 'stty -ixon; cat -v'`);
|
|
run(`${tmux} new-session -d -s beta -x 80 -y 24 'stty -ixon; cat -v'`);
|
|
run(`${tmux} set-option -t alpha prefix C-q`);
|
|
run(`${tmux} set-option -t beta prefix C-q`);
|
|
const env = { ...process.env } as Record<string, string>;
|
|
delete env.TMUX;
|
|
client = pty.spawn('tmux', ['-L', socket, '-f', '/dev/null', 'attach-session', '-t', 'alpha'], {
|
|
name: 'xterm-256color',
|
|
cols: 80,
|
|
rows: 24,
|
|
env,
|
|
});
|
|
expect(await waitFor(() => clientSession() === 'alpha')).toBe(true);
|
|
});
|
|
|
|
afterAll(() => {
|
|
client?.kill();
|
|
let socketPath = '';
|
|
try {
|
|
socketPath = run(`${tmux} display-message -p '#{socket_path}'`).trim();
|
|
run(`${tmux} kill-server`);
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
if (socketPath.endsWith(`/${socket}`)) rmSync(socketPath, { force: true });
|
|
});
|
|
|
|
it('with the stock table, Ctrl+Q ) moves the inner client onto the other session', async () => {
|
|
client!.write('\x11)');
|
|
expect(await waitFor(() => clientSession() === 'beta')).toBe(true);
|
|
const name = run(`${tmux} list-clients -F '#{client_name}'`).trim();
|
|
run(`${tmux} switch-client -c ${name} -t alpha`);
|
|
expect(await waitFor(() => clientSession() === 'alpha')).toBe(true);
|
|
});
|
|
|
|
it('after the nested reset, Ctrl+Q ), s and w leave the client, and Ctrl+Q Ctrl+Q reaches the pane', async () => {
|
|
for (const command of nestedPrefixTableResetCommands()) run(`${tmux} ${command}`);
|
|
|
|
const prefix = run(`${tmux} list-keys -T prefix`)
|
|
.split('\n')
|
|
.filter((line) => line.trim());
|
|
expect(prefix).toEqual([expect.stringMatching(/-T prefix C-q\s+send-prefix$/)]);
|
|
|
|
client!.write('\x11)');
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
client!.write('\x11s');
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
client!.write('\x11w');
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
expect(clientSession()).toBe('alpha');
|
|
expect(run(`${tmux} display-message -p -t alpha '#{pane_in_mode}'`).trim()).toBe('0');
|
|
|
|
client!.write('\x11\x11');
|
|
expect(await waitFor(() => run(`${tmux} capture-pane -p -t alpha`).includes('^Q'))).toBe(true);
|
|
});
|
|
|
|
it('keeps the reset and the next command when the config left the prefix table empty', () => {
|
|
// Nested servers load the user's tmux config (no -f /dev/null). A config
|
|
// that empties the prefix table makes `unbind-key -a -T prefix` fail with
|
|
// "table prefix doesn't exist", and tmux drops every later command in the
|
|
// \; chain (the bind, and remain-on-exit failed). Binding send-prefix
|
|
// first is what lets both still run. One invocation, as the create chain is.
|
|
const socket = `codeman-nested-empty-${process.pid}`;
|
|
const config = `/tmp/${socket}.conf`;
|
|
writeFileSync(config, 'unbind-key -a -T prefix\n');
|
|
const base = `tmux -L ${socket}`;
|
|
const session = 'alpha';
|
|
const chain = [
|
|
`new-session -d -s ${session} -x 80 -y 24 'sleep 30'`,
|
|
...nestedPrefixTableResetCommands(),
|
|
`set -t ${session} remain-on-exit failed`,
|
|
].join(' \\; ');
|
|
try {
|
|
run(`${base} -f ${config} ${chain}`);
|
|
const prefix = run(`${base} list-keys -T prefix`)
|
|
.split('\n')
|
|
.filter((line) => line.trim());
|
|
expect(prefix).toEqual([expect.stringMatching(/-T prefix C-q\s+send-prefix$/)]);
|
|
expect(run(`${base} show-options -wv -t ${session} remain-on-exit`).trim()).toBe('failed');
|
|
} finally {
|
|
try {
|
|
const socketPath = run(`${base} display-message -p '#{socket_path}'`).trim();
|
|
run(`${base} kill-server`);
|
|
if (socketPath.endsWith(`/${socket}`)) rmSync(socketPath, { force: true });
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
rmSync(config, { force: true });
|
|
}
|
|
});
|
|
});
|