From b752e116bd9f36a3d0960cf88dc9ebbf12879232 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 6 Oct 2026 20:05:19 -0700 Subject: [PATCH] fix(desktop): read tmux format output with a separator no tmux version escapes tmux 3.4 and 3.5 print control characters in -F output as octal escapes, so the 0x1f field separator arrived as the text \037 and no line split: Sim never found the tmux client in a terminal, treated it as a plain shell, and the panes operation came back empty. The separator is now printable text that no tmux escapes; a field that happened to contain it changes the line's field count, so that line is dropped rather than misread. The fake tmux in the unit tests now escapes its output the way 3.4 and 3.5 do, so this cannot pass unnoticed again. --- apps/desktop/src/main/terminal/tmux.test.ts | 47 ++++++++++++++++++++- apps/desktop/src/main/terminal/tmux.ts | 11 +++-- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/terminal/tmux.test.ts b/apps/desktop/src/main/terminal/tmux.test.ts index e2921380dca..b160a552505 100644 --- a/apps/desktop/src/main/terminal/tmux.test.ts +++ b/apps/desktop/src/main/terminal/tmux.test.ts @@ -8,14 +8,15 @@ import { isDescendantOf, parseFormatLines, pollRun, + resolveAttachment, runPaneState, startRun, stopRun, type TmuxRunHandle, } from '@/main/terminal/tmux' -/** The separator the format strings use; no tmux field can contain it. */ -const F = '\u001f' +/** The separator the format strings use. */ +const F = '|~sim~|' describe('parseFormatLines', () => { it('drops lines with the wrong field count rather than mis-assigning them', () => { @@ -104,6 +105,8 @@ interface FakeTmuxState { log: string[] /** Commands the fake fails, with the error tmux would print. */ fail?: Record + /** Attached clients, as `list-clients` reports them. */ + clients?: Array<{ pid: string; tty: string; session: string }> } const FAKE_TMUX = ` @@ -114,6 +117,12 @@ const args = process.argv.slice(2) const save = () => fs.writeFileSync(file, JSON.stringify(state)) const target = () => args[args.indexOf('-t') + 1] const fail = (message) => { process.stderr.write(message); process.exit(1) } +// Prints a format's output as tmux 3.4 and 3.5 do: a backslash doubled, and every other control +// character as its octal escape, so a control-character separator would arrive as text. +const escaped = (text) => + text + .replace(/\\\\/g, '\\\\\\\\') + .replace(/[\\x00-\\x1f]/g, (c) => '\\\\' + c.charCodeAt(0).toString(8).padStart(3, '0')) if (state.fail && state.fail[args[0]]) fail(state.fail[args[0]]) switch (args[0]) { case 'new-window': { @@ -144,6 +153,17 @@ switch (args[0]) { process.stdout.write((pane.options[name] ?? '') + '\\n') break } + case 'list-clients': { + const format = args[args.indexOf('-F') + 1] + for (const client of state.clients ?? []) { + const line = format + .replace('#{client_pid}', client.pid) + .replace('#{client_tty}', client.tty) + .replace('#{client_session}', client.session) + process.stdout.write(escaped(line) + '\\n') + } + break + } case 'send-keys': case 'kill-pane': { if (!state.panes[target()]) fail("can't find pane") @@ -191,6 +211,29 @@ function fakeTmux(options: { exec?: boolean } = {}) { } } +describe('finding the tmux session a shell runs', () => { + const dirs: string[] = [] + + afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) + }) + + it('reads the clients tmux 3.4 and 3.5 list, which escape control characters', async () => { + const tmux = fakeTmux() + dirs.push(tmux.dir) + // A client of this very process: its parent stands in for the shell the client runs in. + tmux.write({ + ...tmux.read(), + clients: [{ pid: String(process.pid), tty: '/dev/pts/3', session: 'work' }], + }) + + expect(await resolveAttachment(process.ppid, tmux.env)).toEqual({ + session: 'work', + clientTty: '/dev/pts/3', + }) + }) +}) + describe('stopping a tmux run touches only its own pane', () => { const dirs: string[] = [] diff --git a/apps/desktop/src/main/terminal/tmux.ts b/apps/desktop/src/main/terminal/tmux.ts index 109f82a2eee..8896ae3536c 100644 --- a/apps/desktop/src/main/terminal/tmux.ts +++ b/apps/desktop/src/main/terminal/tmux.ts @@ -38,8 +38,13 @@ const TMUX_TIMEOUT_MS = 5_000 /** How often the status file is checked while a tmux-run command is going. */ const RUN_POLL_INTERVAL_MS = 250 -/** Field separator for `-F` output. Chosen because no tmux field contains it. */ -const FIELD = '\u001f' +/** + * Field separator for `-F` output. Printable on purpose: tmux 3.4 and 3.5 print a control + * character as its octal escape, so a control-character separator arrived as the text `\037` and + * no line split. No tmux escapes these characters, and a field that happened to contain the + * separator would change the line's field count, so that line is dropped rather than misread. + */ +const FIELD = '|~sim~|' export interface TmuxCommandResult { ok: boolean @@ -125,7 +130,7 @@ export function runTmux(args: string[], env: NodeJS.ProcessEnv): Promise