COD-115 fix: scrub TMUX/TMUX_PANE so tmux-backed sessions don't crash-loop

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 <noreply@anthropic.com>
This commit is contained in:
Aamer Akhter
2026-07-09 11:47:38 -04:00
co-authored by Claude Opus 4.8
parent 1fa88cd187
commit 3c0e6286f6
3 changed files with 97 additions and 4 deletions
+12
View File
@@ -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
+19 -4
View File
@@ -124,17 +124,32 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
* Lighter than buildClaudeEnv — no PATH augmentation or Codeman vars needed
* since the mux session already has those set.
*
* @param truecolorEnabled - When true, set COLORTERM=truecolor (COD-75 opt-in);
* otherwise leave COLORTERM unset. Mirrors buildEnvExports() so both paths agree.
* @returns Environment variables object for pty.spawn
*/
export function buildMuxAttachEnv(): Record<string, string | undefined> {
return {
export function buildMuxAttachEnv(truecolorEnabled?: boolean): Record<string, string | undefined> {
const env: Record<string, string | undefined> = {
...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;
}
/**
+66
View File
@@ -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;
}
}
}
});
});