From 64c8048ddab4728054050c6052775ec837ec77b6 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 19:29:49 +0200 Subject: [PATCH] refactor: resolve the tmux socket from the instance config The socket name was computed inside tmux-manager, which the TUI cannot import just to learn which `-L` name its degraded-mode listing belongs on (that module is the server's tmux driver, not a lookup table). The resolver moves next to `dataPath()`, where the other half of the instance identity already lives, so both processes agree by construction instead of by a copied default. Behaviour is unchanged: the override still wins only when it is a name that can be passed to `tmux -L` safely, and TmuxManager keeps warning about one that cannot. Co-Authored-By: Claude Fable 5 --- src/config/instance.ts | 15 +++++++++++++++ src/tmux-manager.ts | 14 ++++++++------ test/config/instance.test.ts | 21 ++++++++++++++++++++- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/config/instance.ts b/src/config/instance.ts index 22ed7a30..471a921b 100644 --- a/src/config/instance.ts +++ b/src/config/instance.ts @@ -40,6 +40,21 @@ const INSTANCE_SUFFIX = CODEMAN_INSTANCE ? `-${CODEMAN_INSTANCE}` : ''; /** Default tmux socket for this instance. `CODEMAN_TMUX_SOCKET` still overrides. */ export const DEFAULT_TMUX_SOCKET = `codeman${INSTANCE_SUFFIX}`; +/** Characters tmux accepts in a `-L` socket name. */ +export const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/; + +/** + * This instance's tmux socket: the `CODEMAN_TMUX_SOCKET` override when it is a + * safe name, else the instance default. Every process that runs `tmux -L` has + * to resolve it through here (the server via TmuxManager, the TUI for its + * degraded-mode listing), or a beta instance ends up driving prod's sessions. + */ +export function resolveTmuxSocketName(): string { + const raw = process.env.CODEMAN_TMUX_SOCKET; + if (raw !== undefined && SAFE_TMUX_SOCKET_PATTERN.test(raw)) return raw; + return DEFAULT_TMUX_SOCKET; +} + let _ensured = false; /** diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 5ed1245c..9ab93404 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -31,7 +31,13 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs'; import { writeFile, rename } from 'node:fs/promises'; import { dirname } from 'node:path'; import { homedir } from 'node:os'; -import { dataPath, DEFAULT_TMUX_SOCKET, CODEMAN_INSTANCE } from './config/instance.js'; +import { + dataPath, + DEFAULT_TMUX_SOCKET, + CODEMAN_INSTANCE, + SAFE_TMUX_SOCKET_PATTERN, + resolveTmuxSocketName, +} from './config/instance.js'; import { ProcessStats, PersistedRespawnConfig, @@ -200,9 +206,6 @@ const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/; * `codeman` for prod, `codeman-beta` on the beta branch). */ const DEFAULT_CODEMAN_TMUX_SOCKET = DEFAULT_TMUX_SOCKET; -/** Regex to validate tmux socket names passed to `tmux -L`. */ -const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/; - /** * Separator used in `tmux list-panes -F` output between session name and pid. * @@ -597,9 +600,8 @@ function resolveConfiguredTmuxSocket(): string { const raw = process.env.CODEMAN_TMUX_SOCKET ?? DEFAULT_CODEMAN_TMUX_SOCKET; if (!SAFE_TMUX_SOCKET_PATTERN.test(raw)) { console.warn(`[TmuxManager] Ignoring invalid CODEMAN_TMUX_SOCKET: ${JSON.stringify(raw)}`); - return DEFAULT_CODEMAN_TMUX_SOCKET; } - return raw; + return resolveTmuxSocketName(); } /** Build the `tmux -L ` command prefix. Socket name is shell-escaped. */ diff --git a/test/config/instance.test.ts b/test/config/instance.test.ts index 867a526f..8f473d29 100644 --- a/test/config/instance.test.ts +++ b/test/config/instance.test.ts @@ -18,7 +18,7 @@ vi.mock('node:fs', async (orig) => { return { ...actual, mkdirSync: vi.fn() }; }); -const ENV_KEYS = ['CODEMAN_INSTANCE', 'CODEMAN_DATA_DIR'] as const; +const ENV_KEYS = ['CODEMAN_INSTANCE', 'CODEMAN_DATA_DIR', 'CODEMAN_TMUX_SOCKET'] as const; const ORIG: Record = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); async function load(env: Partial> = {}) { @@ -77,3 +77,22 @@ describe('config/instance', () => { expect(m.DEFAULT_TMUX_SOCKET).toBe('codeman-beta'); }); }); + +describe('resolveTmuxSocketName', () => { + it('is the instance socket when no override is set', async () => { + const m = await load({ CODEMAN_INSTANCE: 'beta', CODEMAN_TMUX_SOCKET: undefined }); + expect(m.resolveTmuxSocketName()).toBe('codeman-beta'); + }); + + it('honours a safe CODEMAN_TMUX_SOCKET override', async () => { + const m = await load({ CODEMAN_INSTANCE: undefined, CODEMAN_TMUX_SOCKET: 'codeman-test.1' }); + expect(m.resolveTmuxSocketName()).toBe('codeman-test.1'); + }); + + it('ignores an override that could not be passed to `tmux -L` safely', async () => { + // A socket name reaches a command line, so anything outside the pattern + // falls back to the instance default rather than being escaped. + const m = await load({ CODEMAN_INSTANCE: undefined, CODEMAN_TMUX_SOCKET: 'bad; rm -rf /' }); + expect(m.resolveTmuxSocketName()).toBe('codeman'); + }); +});