From 3c0e6286f6caf626ff08f01c6c8dc51e3fc11f36 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Mon, 15 Jun 2026 13:50:40 -0400 Subject: [PATCH] COD-115 fix: scrub TMUX/TMUX_PANE so tmux-backed sessions don't crash-loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the web server is launched from inside a tmux pane it inherits TMUX/ TMUX_PANE. tmux's nesting guard then makes every new attach-bridge PTY (`tmux attach-session`, used by codex/opencode/gemini and mux-wrapped claude) exit code 1; the respawn controller recreates the dead bridge → infinite loop. The existing guard in buildMuxAttachEnv() used `TMUX: undefined` on a {...process.env} spread, which leaves the KEY present with value undefined — node-pty serializes it as the literal string "TMUX=undefined", still tripping the guard. (The working create path in tmux-manager.ts uses `delete`.) Fix: - Primary: delete process.env.TMUX / TMUX_PANE at web bootstrap (src/index.ts) so every downstream {...process.env} spread is clean regardless of launch context. `delete`, not `= undefined`. - buildMuxAttachEnv(): build a copy and `delete` TMUX/TMUX_PANE/CLAUDECODE (and COLORTERM when not truecolor) instead of `: undefined` — same node-pty quirk affected all of them. - Test: assert the keys are genuinely ABSENT (`'TMUX' in env === false`), not merely undefined — the prior test only checked `toBeUndefined()`, which is why the bug slipped through. Red→green confirmed. Verified on isolated beta launched from inside tmux (inherited the poisonous TMUX=codeman,980,7): created a codex session + triggered interactive attach — the bridge `tmux -L codeman-beta attach-session` spawned with NO TMUX in its env, attached successfully, zero "exited with code: 1", server healthy. Circuit-breaker for repeated non-zero bridge exits (AC bullet 4, optional) split to a follow-up. Deploy-pending (substrate): never auto-deployed. Co-Authored-By: Claude Opus 4.8 --- src/index.ts | 12 ++++++ src/session-cli-builder.ts | 23 +++++++++-- test/session-cli-builder.test.ts | 66 ++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 test/session-cli-builder.test.ts diff --git a/src/index.ts b/src/index.ts index 494d6944..9f94d378 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,18 @@ import { program } from './cli.js'; // In web mode, we should NOT exit on transient errors — log and continue const isWebMode = process.argv.includes('web'); +// COD-115: Codeman IS a tmux controller; it must never present as a tmux *client*. +// If the web server is launched from inside a tmux pane it inherits TMUX/TMUX_PANE, +// and tmux's nesting guard then kills every new attach-bridge PTY (exit 1 → respawn +// loop, crash-looping any new tmux-backed session). Scrub at the root so every +// downstream `{...process.env}` spread (attach, send-keys, create) is clean regardless +// of launch context. `delete` (not `= undefined`, which node-pty serializes as the +// literal string "undefined" and fails to clear). +if (isWebMode) { + delete process.env.TMUX; + delete process.env.TMUX_PANE; +} + import { MAX_CONSECUTIVE_ERRORS, ERROR_RESET_MS } from './config/server-timing.js'; // Track consecutive unhandled errors in web mode — restart after too many diff --git a/src/session-cli-builder.ts b/src/session-cli-builder.ts index ad6ed85f..72ee3f96 100644 --- a/src/session-cli-builder.ts +++ b/src/session-cli-builder.ts @@ -124,17 +124,32 @@ export function buildClaudeEnv(sessionId: string): Record { - return { +export function buildMuxAttachEnv(truecolorEnabled?: boolean): Record { + const env: Record = { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', TERM: 'xterm-256color', - COLORTERM: undefined, - CLAUDECODE: undefined, }; + // COD-115: keys to UNSET must be `delete`d, NOT set to `undefined`. On a + // `{...process.env}` spread the key stays present with value undefined, and node-pty + // serializes it as the literal string "TMUX=undefined" — a non-empty value that still + // trips tmux's nesting guard, killing the attach-bridge PTY (exit 1 → respawn loop). + // The server can be launched from inside tmux; attach clients must never inherit that + // parent tmux context. (Same fix the working create path uses in tmux-manager.ts.) + delete env.TMUX; + delete env.TMUX_PANE; + delete env.CLAUDECODE; + if (truecolorEnabled) { + env.COLORTERM = 'truecolor'; + } else { + delete env.COLORTERM; // COD-75: unset for non-truecolor (was `: undefined`, same node-pty quirk) + } + return env; } /** diff --git a/test/session-cli-builder.test.ts b/test/session-cli-builder.test.ts new file mode 100644 index 00000000..f853d0ee --- /dev/null +++ b/test/session-cli-builder.test.ts @@ -0,0 +1,66 @@ +/** + * @fileoverview Tests for CLI environment builders. + * + * Port: N/A (no server needed) + */ + +import { describe, it, expect } from 'vitest'; +import { buildMuxAttachEnv } from '../src/session-cli-builder.js'; + +describe('buildMuxAttachEnv', () => { + it('does not pass an inherited tmux context into tmux attach clients', () => { + const originalTmux = process.env.TMUX; + const originalTmuxPane = process.env.TMUX_PANE; + process.env.TMUX = '/tmp/tmux-1000/codeman,1169416,9'; + process.env.TMUX_PANE = '%9'; + + try { + const env = buildMuxAttachEnv(); + + expect(env.TMUX).toBeUndefined(); + expect(env.TMUX_PANE).toBeUndefined(); + } finally { + if (originalTmux === undefined) { + delete process.env.TMUX; + } else { + process.env.TMUX = originalTmux; + } + if (originalTmuxPane === undefined) { + delete process.env.TMUX_PANE; + } else { + process.env.TMUX_PANE = originalTmuxPane; + } + } + }); + + // COD-115: `{...process.env, TMUX: undefined}` leaves the KEY present with value + // undefined; node-pty serializes that as the literal string "TMUX=undefined", which + // still trips tmux's nesting guard and kills the attach-bridge PTY (exit 1 → respawn + // loop). The keys must be genuinely ABSENT, which only `delete` achieves. + it('deletes tmux/claude context keys entirely (absent, not present-with-undefined) (COD-115)', () => { + const saved = { + TMUX: process.env.TMUX, + TMUX_PANE: process.env.TMUX_PANE, + CLAUDECODE: process.env.CLAUDECODE, + }; + process.env.TMUX = '/tmp/tmux-1000/codeman,1169416,9'; + process.env.TMUX_PANE = '%9'; + process.env.CLAUDECODE = '1'; + + try { + const env = buildMuxAttachEnv(); + + expect('TMUX' in env).toBe(false); + expect('TMUX_PANE' in env).toBe(false); + expect('CLAUDECODE' in env).toBe(false); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } + } + }); +});