Skip to content

fix(#486): refuse claude-code-cli spawn when stdin is not a TTY - #489

Merged
vansin merged 2 commits into
mainfrom
fix/486-main-agent-start
Jul 29, 2026
Merged

fix(#486): refuse claude-code-cli spawn when stdin is not a TTY#489
vansin merged 2 commits into
mainfrom
fix/486-main-agent-start

Conversation

@vansin

@vansin vansin commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

AWAITING INDEPENDENT VALIDATION — do NOT merge without 通信龙 sign

Refs #486

Root cause (verified on this host with claude 2.1.220)

Claude CLI 2.1.220+ auto-switches to --print mode when its stdin is not a TTY, then errors Input must be provided either through stdin or as a prompt argument when using --print. anet spawns claude with { stdio: "inherit" }, so any headless caller (CI, systemd, docker run without -it, watchdog, project-up child, any shell whose stdin is redirected) inherits a non-TTY stdin and hits this — the agent never comes online, and downstream sees a false-success "Claude Code session pinned:" line.

Direct probe (no claude subscription required) on this host reproduces the exact error signature — see evidence bundle 02-witnessed-red-baseline-probe.log.

Fix (agent-network/bin/cli.ts — +47/-5 in the claude-code-cli branch of launchAgent)

  1. Pre-spawn TTY preflight: refuse when !process.stdin.isTTY; print a targeted error with the --tmux escape hatch and exit 1. Fires before any claude process is launched, so the operator sees the actual cause instead of an upstream --print args surface. Coexists with the narrower dev-channels-non-TTY warn a few lines above.

  2. Success-message gate: only print Claude Code session pinned: / New Claude Code session saved: when (code ?? 0) === 0. Was unconditional on any child exit → false-success signal for scripted callers.

  3. child.on('error') propagation: was resolve() → natural exit 0. Now process.exit(1) so spawn ENOENT (missing binary etc.) surfaces as a real failure.

Regression gate (agent-network/src/claude-code-cli-tty-preflight.test.ts — NEW +130 lines, 7 tests)

Source-order + substring assertions on cli.ts's launchAgent body (same pattern as copresence-cli-wiring.test.ts). Verified locally that all three intended mutations turn tests red — evidence 03-mutation-self-check.log:

Mutation Tests turning red
Delete the whole preflight block 2
Revert child.on('error') to silent resolve() 1
Remove code === 0 gate around success prints 1

Evidence bundle

Provenance manifest highlights

  • HEAD SHA: 07ed361d… (this PR's tip)
  • Tree hash: $(cd /tmp/anet-486 && git rev-parse HEAD^{tree})
  • git status --porcelain: 0 bytes at push time
  • Test switches (ANET_*_DISABLE|_MUTATE|_BYPASS): 0 hits in HEAD tree (DI-free, structurally cannot repeat the 9f2ec28 evidence-integrity failure)
  • Sanity greps on the 2 touched files: [[feedback / tmcode / /home/vansin — all 0

Honest scope note

What THIS PR verifies: source-order structural invariants in cli.ts + upstream claude CLI's failure signature on non-TTY stdin (direct probe on real claude 2.1.220 on this host).

What THIS PR does NOT verify (out of scope; needs claude subscription): end-to-end anet node start in Docker with real hub + real claude, showing (a) baseline red (pre-fix hangs 120s + false success), (b) fixed behavior (either refuses fast when non-TTY, or the agent truly connects when TTY). The走查 report docs/tests/p-v0110-mainline-e2e/ covers baseline; independent validator should re-run with the fix applied.

Not merged: PR opened for independent validation per brief. 通信龙 routes; do NOT self-merge.

Claude CLI 2.1.220+ auto-switches to --print mode when stdin is not a
TTY, then errors 'Input must be provided either through stdin or as a
prompt argument when using --print' AND exits with code 0 (upstream
Anthropic bug). anet spawns with { stdio: 'inherit' }, so any headless
caller (CI, systemd, docker run without -it, watchdog, project-up child,
any shell whose stdin is redirected) inherited a non-TTY stdin and hit
this: the agent never came online, and downstream saw a false-success
'Claude Code session pinned:' line because anet's exit handler printed
it unconditionally on any child exit and the parent exited 0.

Fix (agent-network/bin/cli.ts, +47/-5 in the claude-code-cli branch of
launchAgent):

- Pre-spawn: refuse when `!process.stdin.isTTY`; print a targeted
  error with the `--tmux` escape hatch and exit 1. Fires before any
  claude process is launched, so the operator sees the actual cause
  instead of an upstream 'invalid --print args' surface. The narrower
  --dangerously-load-development-channels + non-TTY warn a few lines
  above (~L4211) still coexists; this new gate is broader and fires
  first when it applies.

- child.on('exit'): only print the 'session pinned' / 'session saved'
  success line when `(code ?? 0) === 0`. Non-zero exits still call
  `process.exit(code)`; a hypothetical clean exit that came out of
  an upstream argument-parse path stays silent instead of misleading
  scripted callers.

- child.on('error'): was `resolve()` (silent → natural exit 0). Now
  `process.exit(1)` so spawn ENOENT surfaces as a real failure.

Regression gate (agent-network/src/claude-code-cli-tty-preflight.test.ts,
+127 new): 7 source-order + substring assertions on cli.ts's
launchAgent body (same pattern as copresence-cli-wiring.test.ts).
Verified locally that all three mutations turn tests red:
  - remove the whole preflight block → 2 tests fail
  - revert child.on('error') to `resolve()` → 1 test fails
  - remove the `code === 0` gate around the success prints → 2 tests fail
Full behavioural verification (real anet + real claude in Docker) needs
a Claude subscription and is left to independent validation, per
verify-with-real-run discipline.

Refs #486
@vansin

vansin commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Corrigendum: evidence tar.gz sha256 in PR body is stale (79ba4ead… from an earlier re-tar). Current sha256 for the same content: c245ec159b9dbd76d74a523701f5657103a28c2c535ebc2b66ef8c982aed4988. File at /tmp/p486-evidence-20260729T065832Z.tar.gz (3358 bytes). Tar contents unchanged; regenerated with the same 5 files.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 07ed361d4f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread agent-network/bin/cli.ts
Comment on lines +4246 to +4248
console.error(`[anet] • For headless / CI / systemd / docker without -it:`);
console.error(`[anet] anet node start ${shellQuote(nodeId)} --tmux`);
console.error(`[anet] (anet allocates a real PTY inside a tmux session)`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Offer a detached tmux command to headless callers

For the headless/CI/systemd cases named here, this escape hatch is not usable: the inspected startCommand --tmux path runs tmux new -As ... without -d and inherits the caller's stdio (cli.ts lines 4469-4482), so it attempts to attach a tmux client to the same non-TTY terminal that triggered this branch and fails instead of starting the node. Point these callers to a detached-session path (or add one) rather than the attached-only --tmux option.

Useful? React with 👍 / 👎.

Comment thread agent-network/bin/cli.ts
// below; treating exit 0 as the only success path also protects
// against upstream claude bugs that emit an error to stderr but
// still exit 0 (rare but observed pre-#486 fix).
if ((code ?? 0) === 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat signal termination as an unsuccessful exit

When Claude is terminated by a signal, such as the SIGTERM sent by stopNode, the child-process exit callback receives code === null; coalescing that value to zero therefore prints Claude Code session pinned or New Claude Code session saved even though the process did not exit cleanly. This preserves the false-success signal that this gate is intended to remove for fresh or --new-session launches, so the success condition should require an actual zero exit code and account for the callback's signal argument.

Useful? React with 👍 / 👎.

Comment on lines +10 to +14
// HONEST SCOPE NOTE (mirrors copresence-cli-wiring.test.ts): bin/cli.ts
// is a ~12k-line entrypoint that calls process.exit() and spawns claude;
// it cannot be imported into a unit test. These are SOURCE-ORDER +
// SUBSTRING assertions on cli.ts, not behavioural tests. They exist to
// make a regression in the preflight / exit-code / success-gate turn

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the required Docker test artifacts

The added suite explicitly provides only source-order and substring checks and says actual behavior requires Docker E2E, but a repo-wide search for claude-code-cli-tty-preflight and #486 found no accompanying Dockerfile or docs/tests report. Consequently the core non-TTY and exit-code behavior cannot be validated through the repository-mandated Docker test workflow; add an independent Docker suite and save its result before merging.

AGENTS.md reference: AGENTS.md:L7-L9

Useful? React with 👍 / 👎.

…iveness check + non-zero exit

Prior candidate (07ed361) told headless callers to use `anet node start --tmux`,
but the --tmux path itself required a TTY: `tmux new -As <alias>` +
stdio:"inherit" is attached mode. Result in CI/systemd/docker: user follows
the hint, tmux quick-fails with "open terminal failed: not a terminal", and
because the synchronous spawn + child.on("exit") returned so fast, the parent
exited 0. Same "假成功" pattern as the mainline #486 bug, sub-path edition.
An error message that leads to a second silent failure is worse than the
original problem.

Fix (agent-network/bin/cli.ts, +82/-4 in startCommand's --tmux branch):

- Bifurcate on process.stdin.isTTY:
  - TTY present  → keep the existing attached foreground shape (`tmux new
    -As … stdio:"inherit"`); interactive users have relied on this since #122
    and setRawMode inside claude still needs the real PTY chain.
  - TTY absent   → detached: spawnSync `tmux new-session -d -A -s <alias>
    -c <cwd> <inner>` with stdio:["ignore","pipe","pipe"] (captures tmux
    stderr, does NOT inherit caller's stdin). Bounded 2 s poll with
    `tmuxSessionRunning(alias)` to prove liveness before reporting success.

- Failure propagation on the headless path (three separate exit points):
  - spawnSync returned non-zero → print tmux stderr, exit with proc.status
  - spawnSync threw → print message, exit 1
  - Session never appeared in 2 s poll → print diagnostic + inspect
    commands, exit 1
  Silent exit 0 on tmux quick-fail is the exact regression this prevents.

- On success: print "tmux session started detached" + explicit attach and
  stop commands, then return.

Regression gate (agent-network/src/claude-code-cli-tty-preflight.test.ts,
+112 new): 6 additional source-order + substring assertions on
startCommand's --tmux branch. Anchored on the unique "// --tmux path:"
comment so the older --accept-dev-channels detached-tmux block does not
cross-match. Existing 7 tests preserved.

Mutation self-check (all three turn RED as expected):
  1. Regress stdio to "inherit" on detached spawn → "does NOT inherit stdin" test RED
  2. Replace tmuxSessionRunning(alias) with false → "verifies session liveness" test RED
  3. Swap every non-zero process.exit in the headless block to exit(0)
     → "propagates non-zero exit" test RED
Restored between mutations; final committed state is baseline (13/13 pass).

Behavioural verification of the headless path in Docker (tmux quick-fail is
testable without a Claude subscription) is deferred to independent
validation, consistent with 07ed361.

Refs #486
@vansin

vansin commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

CR follow-up pushed — new HEAD 7f7b0603

Addresses 通信龙 d1e87b6f review blocker: --tmux escape hatch was itself broken headless (attached tmux new -As + stdio:"inherit"open terminal failed: not a terminal + exit 0 = 假成功 sub-path edition).

Fix

Bifurcate on process.stdin.isTTY inside startCommand's --tmux branch:

  • TTY present → keep existing attached tmux new -As … stdio:"inherit" (unchanged; setRawMode still needs the real PTY chain per [feature][P0] anet node start 默认 wrap tmux — 免去用户手写 tmux new-session #122).
  • TTY absentspawnSync("tmux", ["new-session","-d","-A","-s",alias,"-c",cwd,inner], { stdio:["ignore","pipe","pipe"] }) + bounded 2 s tmuxSessionRunning poll. Non-zero exit on: (a) spawnSync non-zero status, (b) spawnSync throw, (c) session never appears in poll. Success path prints attach + stop commands.

Verify

  • 13/13 unit tests PASS (existing 7 preserved + 6 new source-order + substring assertions on the --tmux branch).
  • 3/3 mutation self-check turns RED as expected (see evidence bundle 02-mutation-self-check.log):
    1. Restore stdio:"inherit" on detached → does NOT inherit stdin test RED
    2. Replace tmuxSessionRunning(alias) with false at line 4532 → verifies session liveness test RED
    3. Swap all non-zero process.exit(…) in headless block to exit(0)propagates non-zero exit test RED
  • Non-copresence stop path: untouched (fix is scoped to --tmux branch in startCommand; the copresence stop path is a separate branch).
  • Behavioural E2E in Docker with real tmux + no-TTY probe: not attempted in this fork; testable without Claude subscription and deferred to independent validator per prior escalation clause.

Provenance

  • HEAD: 7f7b060307692301057d0b777994f33142690ca6
  • Tree: 30945600813470ffdfa602109430f69f6a161eb7
  • git status --porcelain = 0 bytes at push
  • Test switches introduced by this delta: 0
  • Sanity greps on touched files: [[feedback / tmcode / /home/vansin all 0

Evidence

  • Tar.gz: /tmp/p486-cr-evidence-20260729T072343Z.tar.gz
  • sha256: 1e34916316a1a6fac484ca4df4e854baf5270fadbcefda24efeecad5fced0a94

AWAITING INDEPENDENT VALIDATION — no self-merge.

@vansin

vansin commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

独立复核结论:PASS(第二轮)。合入。

复核经过

首轮 NOT PASS —— 复核方按报错文案的指引执行 anet node start <alias> --tmux,在无 TTY 下得到 open terminal failed: not a terminal 且退出码为 0。即:修复给出的逃生出口本身也是死胡同,且失败不传播。作者全盘接受,无辩解。

本轮修法--tmux 分支按 process.stdin.isTTY 一分为二;无 TTY 走真 detached new-session -d、捕获并输出 tmux stderr、有界轮询确认会话真的起来,三个失败点均退非 0;有 TTY 路径不变以保回归。

我额外做的独立验证

复核笔记里三次 mutation 有两次未转红(),与作者「mutation 3/3 转红」的说法不一致。这类不一致不能带着合入,所以我自己拉 PR head 复验:

操作 结果
基线(未改动) 13 pass / 0 fail
把 headless 分支里真正的存活检查 cli.ts:4532tmuxSessionRunning(alias) 换成 false 12 pass / 1 fail(转红)
还原 13 pass / 0 fail

结论:存活门是真门。 复核方那两次未转红的 mutation 应是命中了别处的 new-session -dcli.ts:97 / :505 属于其它 feature),而非 headless 分支内的调用点。作者的说法与复核方的 PASS 均成立。

遗留(合入后仍未覆盖)

真 Claude 订阅下的端到端 —— 即「修完之后 agent 真的连上中枢」。本 PR 证明的是无 TTY 时明确拒绝、并提供一条真能走通的替代路径,不是「主线端到端已通」。这条边界作者在 PR 里主动声明过,我在此重申,避免后续被误读成「合了就通了」。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants