Skip to content

fix(sandbox): a command run by exec no longer inherits this process's stdin - #550

Merged
0xKT merged 2 commits into
mainfrom
fix/exec_child_stdin_devnull
Sep 21, 2026
Merged

0xKT merged 2 commits into
mainfrom
fix/exec_child_stdin_devnull

Conversation

@silverLXT

Copy link
Copy Markdown
Contributor

DirectExecutor spawned the command with stdout and stderr piped and stdin left
to inherit. Run as an ACP sub-agent, this process's stdin is the pipe the
client answers permission requests on, so a command that reads its input (ssh
without -n, cat, python3 -) consumed the frames that arrived while it ran.
Every other session sharing the process then waited out the 300 s approval
deadline on an answer the client had written within a millisecond of the
request, and reported it as "approval lapsed".

The command's stdin is now DEVNULL, which is what background_exec already
does; only the foreground path was left inheriting.

Type: fix

Verification:

  • Reproduced 2026-09-15 with four Raven-Code sessions in one process: 18 of 183
    approvals lost, each one inside another session's ssh. The same DAG with
    commands that do not read stdin lost none of 493. lsof showed the ssh child's
    fd 0 and the sub-agent's fd 0 as the same pipe.
  • New test runs "cat; echo done" and expects "done" at once; it hangs on the
    old code.
  • tests/test_sandbox_unit.py: 94 passed, 5 failed. The same 5
    (TestAgentLoopExecutorLifecycle) fail on 0464aa1 without this change, so
    this adds one passing test and no new failures.

Risk: low. One keyword argument on one subprocess call. A command that
genuinely wants piped input already has to ask for it; nothing in the tree
relies on inheriting the agent's own stdin.

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking: make the stdin regression test deterministic so it fails against the pre-fix implementation

The production change is appropriately narrow and closes the inherited descriptor on both agent exec and the RPC shell fallback. I found one blocking test issue inline.

I covered the repository rules in AGENTS.md/CLAUDE.md and CONTEXT-MAP.md, the full diff, DirectExecutor's ExecTool and RPC callers, the existing background-exec precedent, relevant history, backward compatibility, test effectiveness, and the stated architecture boundaries. I found no separate runtime or architecture defect.

Verification:

  • uv run pytest tests/test_sandbox_unit.py -x: 99 passed
  • uv run pytest tests/test_rpc_shell.py -x: 6 passed
  • uv run python scripts/check_source_language.py github/main...HEAD: passed
  • uv run ruff check raven/sandbox/direct_executor.py tests/test_sandbox_unit.py: passed
  • uv run ruff format --check raven/sandbox/direct_executor.py tests/test_sandbox_unit.py: passed
  • git diff --check github/main...HEAD: passed

make check-source-language itself could not run because make is unavailable in this environment; I ran the target's Python checker directly with the review range instead.

Comment thread tests/test_sandbox_unit.py Outdated
@silverLXT
silverLXT force-pushed the fix/exec_child_stdin_devnull branch from 4131342 to bc70c7a Compare September 20, 2026 09:55

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

The new revision fixes the prior test gap by supplying known data on fd 0 and checking that the child neither receives nor consumes it. I independently suppressed the production stdin argument and confirmed that this condition detects the pre-fix behavior. The production change remains appropriately narrow.

I covered AGENTS.md/CLAUDE.md and CONTEXT-MAP.md, the full diff and revision delta, DirectExecutor's agent and RPC callers, the background-exec precedent, relevant history, backward compatibility, test effectiveness, architecture boundaries, and interaction with the latest main. No tests were weakened, and the merge-tree check against current main is clean.

Verification:

  • uv run pytest tests/test_sandbox_unit.py tests/test_rpc_shell.py -x: 105 passed
  • pre-fix simulation with the production stdin argument suppressed: child consumed frame, parent read EOF
  • uv run python scripts/check_source_language.py github/main...HEAD: passed
  • uv run ruff check raven/sandbox/direct_executor.py tests/test_sandbox_unit.py: passed
  • uv run ruff format --check raven/sandbox/direct_executor.py tests/test_sandbox_unit.py: passed
  • git diff --check github/main...HEAD: passed
  • git merge-tree --write-tree HEAD github/main: clean

@silverLXT
silverLXT force-pushed the fix/exec_child_stdin_devnull branch from bc70c7a to fa962d5 Compare September 20, 2026 10:13

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

This revision's only substantive delta moves the controlled-stdin probe into a child interpreter instead of replacing the pytest worker's fd 0. That retains the deterministic regression check while avoiding interference with pytest capture. I found no new issue.

I covered the repository rules in AGENTS.md/CLAUDE.md and CONTEXT-MAP.md, the full diff and revision delta, DirectExecutor's callers and relevant history, backward compatibility, test effectiveness, architecture boundaries, and interaction with current main. No tests were weakened.

Verification:

  • uv run pytest tests/test_sandbox_unit.py tests/test_rpc_shell.py -x: 105 passed
  • uv run python scripts/check_source_language.py github/main...HEAD: passed
  • uv run ruff check raven/sandbox/direct_executor.py tests/test_sandbox_unit.py: passed
  • uv run ruff format --check raven/sandbox/direct_executor.py tests/test_sandbox_unit.py: passed
  • git diff --check github/main...HEAD: passed
  • git merge-tree --write-tree HEAD github/main: clean

@silverLXT
silverLXT requested a review from 0xKT September 20, 2026 10:25
@0xKT

0xKT commented Sep 20, 2026

Copy link
Copy Markdown
Member

Not a blocker -- an A stands beside this. Two sentences of prose say something the code does not
do, and the risk is that someone later makes the code match the prose.

raven/sandbox/direct_executor.py:149   # Closed rather than inherited. ...
raven/sandbox/direct_executor.py:157   stdin=asyncio.subprocess.DEVNULL,
CHANGELOG.md:102                       The command's stdin is now closed, as the
                                       background executor's already was.

DEVNULL is an open read-only fd on /dev/null, not a closed one, and a child sees a different world
under each. Measured here, same probe twice:

fd 0 = /dev/null (what the PR passes)   write(0)=EBADF  read(0)=b''      next open() -> 3
fd 0 closed      (what the prose says)  write(0)=EBADF  read(0)=EBADF    next open() -> 0

write(0) and isatty(0) answer identically in both, which is the control: the probe discriminates
rather than differing everywhere. The divergence is read(0) and, more importantly, which
descriptor the child's next open() receives
. With fd 0 genuinely closed it is fd 0 itself, and a
later read(0) in that child quietly reads the newly opened file as input. DEVNULL avoids that, so
the code is right and the sentences are what need changing.

The cross-reference is exact about parity and loose about the word: background_exec.py:127 passes
stdin=subprocess.DEVNULL too, so the two executors do now match -- neither is "closed".

Two words in two places: "pointed at /dev/null", or "reads EOF at once". Nothing else in the change
needs touching.

Everything I checked on the change itself holds, and the regression test in particular is sound. I
reverted the one production line and ran the new tests in the three environments that matter, since
a naive version of this test passes against the pre-fix code whenever the parent's stdin is already
spent:

interactive stdin      -> FAILED test_the_command_does_not_read_this_process_stdin
stdin at EOF (< /dev/null) -> FAILED   (the state that would have hidden it)
stdin closed (0<&-)        -> FAILED
unreverted, stdin at EOF   -> 3 passed

So the fix is protected in CI, not only on a terminal.

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking: resolve the CHANGELOG.md conflict against the current main branch

The code revision is unchanged, but the target branch advanced to 0cb8b7996. git merge-tree --write-tree HEAD github/main now reports a content conflict in CHANGELOG.md, where both sides inserted a new first item under ### Fixed; GitHub likewise reports this PR as dirty and unmergeable. The branch must be refreshed and that changelog conflict resolved before it can merge.

The new /dev/null wording note is explicitly nonblocking and does not change the code assessment. The previously settled test thread remains resolved.

Verification on the unchanged head: uv run pytest tests/test_sandbox_unit.py tests/test_rpc_shell.py -x passed all 105 tests.

… stdin

DirectExecutor spawned the command with stdout and stderr piped and stdin
left to inherit. Run as an ACP sub-agent, this process's stdin is the pipe
the client answers permission requests on, so a command that reads its
input (ssh without -n, cat, python3 -) consumed the frames arriving while it
ran. Every other session sharing the process then waited out the 300 s
approval deadline on an answer the client had written within a millisecond
of the request, and reported it as "approval lapsed".

Measured 2026-09-15 with four Raven-Code sessions in one process: 18 of 183
approvals lost, each inside another session's ssh; the same DAG with
commands that do not read stdin lost none of 493. lsof showed the ssh
child's fd 0 and the sub-agent's fd 0 as one pipe.

The command's stdin is now DEVNULL, as background_exec already does.

The test runs the check in a child interpreter fed a byte on its stdin.
Only running "cat" here would prove nothing -- under pytest fd 0 is already
at EOF, so the inheriting spawn reads nothing either and the assertion holds
against the implementation it is meant to catch, measured both ways -- and
swapping this process's fd 0 instead would reach into the capture pytest
installed on it. With a readable fd 0 the two diverge: inherited, the child
prints the byte and leaves its parent's stdin drained; pointed at /dev/null,
the byte is still there for the parent afterwards, which is the property that
matters.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
@silverLXT
silverLXT force-pushed the fix/exec_child_stdin_devnull branch from fa962d5 to cc92c56 Compare September 21, 2026 07:54
@silverLXT

Copy link
Copy Markdown
Contributor Author

Revision cc92c56 rebases onto main at cf3c95f and resolves the CHANGELOG.md
conflict: main's three new entries stay first under ### Fixed and this PR's
entry follows them. Nothing else was taken from either side, and
git merge-tree --write-tree HEAD github/main is clean.

The nonblocking wording note is taken too, since leaving it risks someone
later making the code match the prose. DEVNULL is an open read-only fd, not a
closed one, so three places now say what the code does rather than something
stronger: the CHANGELOG entry reads "reads EOF at once", the comment at the
spawn reads "pointed at /dev/null ... an open read-only fd, not a closed one,
and the same thing the background executor passes", and the commit message's
last paragraph matches. The production line and the test are unchanged from
the revision already reviewed.

Verification on this head:

  • pytest tests/test_sandbox_unit.py tests/test_rpc_shell.py -q: 105 passed
  • python scripts/check_source_language.py github/main...HEAD: exit 0
  • ruff check and ruff format --check on the two files: clean
  • ty check raven/sandbox/direct_executor.py: clean
  • git diff --check github/main...HEAD: clean
  • git merge-tree --write-tree HEAD github/main: clean
  • diff against main: 3 files, 67 insertions, no deletions

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

The rebase preserves the reviewed production change and deterministic regression test, keeps current main's changelog entries, and resolves the previously reported conflict. The accepted wording correction now describes the null-device behavior accurately in the production comment and changelog. GitHub reports this head mergeable, and the merge-tree check against current main is clean.

I covered the repository rules in AGENTS.md/CLAUDE.md and CONTEXT-MAP.md, the full diff and range-diff from the prior revision, callers and relevant history, backward compatibility, test effectiveness, architecture boundaries, current comments, and interaction with the target tip. No tests were weakened.

Verification:

  • uv run pytest tests/test_sandbox_unit.py tests/test_rpc_shell.py -x: 105 passed
  • uv run python scripts/check_source_language.py github/main...HEAD: passed
  • uv run ruff check raven/sandbox/direct_executor.py tests/test_sandbox_unit.py: passed
  • uv run ruff format --check raven/sandbox/direct_executor.py tests/test_sandbox_unit.py: passed
  • uv run ty check raven/sandbox/direct_executor.py: passed
  • git diff --check github/main...HEAD: passed
  • git merge-tree --write-tree HEAD github/main: clean

@0xKT

0xKT commented Sep 21, 2026

Copy link
Copy Markdown
Member

Not a blocker -- the code change is right and I am accepting it. One observation about the CHANGELOG entry's reach, and one cosmetic note.

(1) The bullet's first sentence promises more than this PR delivers, because exec names a tool with two branches and only one of them is here.

CHANGELOG.md:124 opens "A command run by exec no longer reads this process's stdin." exec is the literal tool name (raven/agent/tools/shell.py:82 return "exec"). That tool has a second branch: exec(machine=<id>) at shell.py:195-198 hands off to run_on_machine, which never reaches a sandbox executor at all -- machine_exec.py:202 -> :126 -> raven/ops/transport.py:59 subprocess.Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE, text=True, ...) with no stdin=, or transport.py:165 subprocess.run(argv, capture_output=True, text=True) where the argv built at transport.py:150-164 is ssh WITHOUT -n. capture_output sets stdout and stderr only. Back at machine_exec.py:222 the runner is called through asyncio.to_thread, a thread of this same process, so the fd 0 it hands the child is this process's -- the same pipe the bullet is about.

I reproduced the local half through the production wrapper, with a control:

ops local runner, make_local_runner(30)(look_script("cat; echo done"))
    CHILD: 'frame|done'    LEFT: ''          <- the command ate the parent's byte
DirectExecutor (what this PR fixes), same wrapper, same byte
    CHILD: 'done'          LEFT: 'frame\n'   <- the parent's stdin is intact

Same wrapper (machine_exec.py:175's set -m script), same single byte on the parent's stdin; the only variable is which launcher ran it. set -m does not shield it: with job control on, the backgrounded job keeps the shell's fd 0 rather than getting /dev/null.

TWO SCOPE LIMITS I want on the record rather than glossed. First, the ssh half is argued from the argv, not run: I read transport.py:150-164 and observed -n is absent, and I did not stand up an sshd to measure it -- and on that arm "inherits this process's fd 0" is loose anyway, since it is the local ssh client that inherits and forwards. Second, the sentence is OVER-BROAD rather than false: on an install with no registered machine it is true of every exec call there is, and a reader who knows the executor family can read the bullet's closing sentence ("as the background executor's already did") as scoping the whole thing to executors. The defect is an unqualified promise about a tool name, not an untruth -- but exec in backticks is what the agent and the operator both call it, and the opening sentence attaches no qualifier.

The transport.py behaviour PREDATES this PR -- transport.py is bit-identical at base 2d21c7d and at this head, and has never carried stdin=, so nothing here regressed and this does not hold up acceptance. What is new is the claim. Two ways out, either fine: qualify the sentence ("run by exec on this computer"), or close the hole in the same breath by adding stdin=subprocess.DEVNULL at transport.py:59 and -n to the ssh argv, which would make the sentence true as it stands. The second is worth its own change; the first costs four words.

One more irony worth seeing: the bullet's own worked example is "(ssh without -n, cat, python3 -)". Those are commands the AGENT runs; transport.py:150-164 is raven's own launcher invoking ssh without -n. Different actors, identical mechanism.

(2) Cosmetic: the comment block above stdin=asyncio.subprocess.DEVNULL now wraps mid-sentence. raven/sandbox/direct_executor.py:154 is # requests on. A command that at 41 columns, while the other ten comment lines of the same block measure 70-81 (149:75, 150:76, 151:79, 152:74, 153:70, 155:80, 156:80, 157:81, 158:81, 159:74) and so does the neighbouring start_new_session comment. At the previous head the same paragraph wrapped uniformly. Reflowing the paragraph is the whole fix; ruff is silent either way (E501 ignored, line-length 120).

WHAT I VERIFIED AND FOUND SOUND, so you know where I looked. The executable text of direct_executor.py is unchanged across the rewrite between the two heads -- comment lines stripped, both hash cb8cb1fbe25f77b1a27ec42677b5e7e7430b094d08ec3563938f17e60bfa57b3, and a tokenize comparison shows the only difference is three extra NL tokens; tests/test_sandbox_unit.py is the same blob at both heads (1f7a3312b088f6a9722ab0f84015ed591babb444). The comment's parity claim is true: background_exec.py:127 already passed stdin=subprocess.DEVNULL. No other sandbox launcher inherits -- direct_executor.py has exactly one, and boxlite_executor drives its own stdin writer into the VM rather than this process's fd 0. Nothing in the tree feeds input into an executed command, so DEVNULL breaks no caller. The CHANGELOG entry sits under ### Fixed in the unreleased section and no conflict residue survives anywhere in the file.

MUTATIONS, because passing tests on a fix are the weakest evidence there is. Removing stdin=asyncio.subprocess.DEVNULL -> the new test FAILS. Changing it to stdin=None, the explicit spelling of "inherit" -> FAILS. Changing it to a pipe whose write end is closed -- EOF, but not /dev/null -> PASSES, and that is correct rather than a gap: the test's docstring says the property it checks is that the parent's byte survives, which both spellings satisfy. The test also earns its own keep -- its docstring explains why the naive version (just running cat) passes against the unfixed code, because under pytest fd 0 is already at EOF.

GATES on head cc92c56 / tip 2d21c7d: GATES OK base=cf3c95fd ruff/lint-imports/commit/large/lang rc=0; pyt.sh tests/test_sandbox_unit.py -> 99 passed.

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; suggestions only, and they are marked inline.

The live head changed after this review turn began: d223191e2d15 merges current main into the previously reviewed cc92c5642311 patch without changing the PR diff. The code remains sound and merge-clean.

Nonblocking follow-ups on the record: the verifier's existing changelog-scope and cosmetic observations, and this head's merge-from-main history rather than the rebase flow prescribed by AGENTS.md. The latter is history-only, is excluded by the repository's commit-message gates, and disappears under the configured squash merge, so it does not meet the late-round blocker bar.

I covered the repository rules, full live diff and range-diff, callers and history, backward compatibility, test effectiveness, architecture boundaries, current comments, and interaction with current main. No tests were weakened.

Verification on the live head in an isolated worktree:

  • uv run pytest tests/test_sandbox_unit.py tests/test_rpc_shell.py -x: 105 passed
  • uv run python scripts/check_source_language.py github/main...HEAD: passed
  • uv run ruff check raven/sandbox/direct_executor.py tests/test_sandbox_unit.py: passed
  • uv run ruff format --check raven/sandbox/direct_executor.py tests/test_sandbox_unit.py: passed
  • uv run ty check raven/sandbox/direct_executor.py: passed
  • git diff --check github/main...HEAD: passed
  • git merge-tree --write-tree HEAD github/main: clean

@0xKT
0xKT merged commit 0f98081 into main Sep 21, 2026
23 checks passed
@0xKT
0xKT deleted the fix/exec_child_stdin_devnull branch September 21, 2026 12:27
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.

3 participants