rivet-dev / rivet-dev/agentos

Interactive PTY: compound commands silently produce no output (WASI EFAULT), and shell diagnostics never reach the terminal

Open
#1,893 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Rust
Stars
4.6k
Forks
251
Avg merge
2d 15h
Merged PRs (30d)
34

Description

Summary

Under an interactive PTY (terminal.open() → brush), compound commands produce no output and no error. The same constructs work through exec() and through sh -c inside the same PTY, so this is specific to brush's interactive path, not to pipelines.

Two symptoms, likely one root cause — both are writes from the interactive shell that never reach the terminal:

  1. Compound commands (;, &&, |, ( ), backticks) print nothing. Redirecting stderr shows the real failure is WASI errno 21 (EFAULT) on a write.
  2. brush's own diagnostics never reach the PTY at all, so an unknown command silently returns to the prompt instead of printing command not found.

Symptom 2 is what makes symptom 1 invisible: the shell is reporting an error, it just goes nowhere.

Reproduction

import { AgentOs } from "@rivet-dev/agentos-core";
import common from "@agentos-software/common";

const vm = await AgentOs.create({ software: [common] });
const shell = await vm.terminal.open({ cols: 80, rows: 24 });
const id = shell.shellId;
let buf = "";
vm.onShellData(id, (e) => { buf += Buffer.from(e?.data ?? e).toString("utf8"); });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await sleep(1500);

for (const c of [
  "echo one",                       // OK
  "echo b; echo c",                 // FAIL - no output
  "echo d && echo e",               // FAIL
  "echo a | cat",                   // FAIL
  "(echo sub)",                     // FAIL
  "echo `echo backtick`",           // FAIL
  "echo 'semi;colon'",              // OK  - quoted, so it is one simple command
  "sh -c 'echo b; echo c'",         // OK  - same PTY, non-interactive brush
  "nosuchcmd",                      // FAIL - no "command not found"
]) {
  buf = "";
  await vm.terminal.write(id, c + "\n");
  await sleep(3000);
  console.log(JSON.stringify(c), "=>", JSON.stringify(buf.replace(/\r/g, "")));
}

Observed:

command interactive PTY exec()
echo one one ok
uname -a correct output ok
echo 'semi;colon' semi;colon ok
sh -c 'echo a | cat' a ok
sh -c 'echo b; echo c' b c ok
echo b; echo c nothing ok
echo d && echo e nothing ok
echo a | cat nothing ok
(echo sub) nothing ok
nosuchcmd nothing (no error) prints error

Note echo here is a brush builtin, so the failing case involves no external command at all.

The hidden error

Redirecting stderr to a file makes both failures visible:

$ nosuchcmd 2>/tmp/e1
$ cat /tmp/e1
error: command not found: nosuchcmd

$ echo b; echo c 2>/tmp/e2
$ cat /tmp/e2
error: echo: i/o error: Bad address (os error 21)

Under wasm32-wasip1, errno 21 is EFAULT (WASI numbering, not Linux's EISDIR), so "Bad address" and the code agree. brush parsed and executed the list correctly — the write to stdout failed.

Ruled out

  • Pipelines / ; themselvesexec("ls /opt/agentos/bin | head -5") returns correct output.
  • The kernel PTY, fd wiring, job controlsh -c 'echo a | cat' works inside the failing interactive shell.
  • stderr routing generally — a child's stderr does reach the terminal (sh -c 'echo to-stderr 1>&2' renders). Only the interactive shell's own stderr is lost.
  • TERM — identical failure with TERM=xterm-256color and TERM=dumb.
  • The terminal client — reproduced entirely server-side via vm.terminal.write(), no browser involved.

Where to look

_fdWrite in crates/execution/assets/runners/wasi-module.js:1404 returns EFAULT only via _writeUint32 (:413) — _mapFsError (:811) defaults to EIO, not EFAULT. So the failing path is the nwritten pointer write, or a synthetic-pipe branch, rather than a generic fs error.

crates/CLAUDE.md already documents this failure mode in the WASM host-process bridge:

child_process.poll returning ECHILD after an exit event's trailing-drain pass is terminal, not a new fault … post-exit drain loops must stop on ECHILD instead of converting a successful pipeline into WASI_ERRNO_FAULT.

Debug logging: AGENTOS_WASM_WASI_DEBUG=1 enables [agentos-wasi] traces from that runner.

Per software/CLAUDE.md ("fix portability one layer down, in the sysroot … patch the real upstream tool only as a fallback"), the fix likely belongs in the WASI/fd layer rather than in brush.

Related

  • #1881 — adjacent but different: kernel-stub command resolution inside subshells via exec, where echo hi | head -n 1 still works. Shares the "silent failure / misleading exit code" theme.
  • #1657 — other brush redirection divergences (>>, < file).
  • #1746 — merged; restored interactive shell correctness and added a real Brush interactive PTY suite, which is the natural harness for a regression test here.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with _fdWrite in crates/execution/assets/runners/wasi-module.js, especially _writeUint32 and the synthetic-pipe path, then read the WASM host-process bridge guidance in crates/CLAUDE.md. Enable AGENTOS_WASM_WASI_DEBUG=1 and use the Brush interactive PTY suite added by #1746; done means compound-command output and brush diagnostics reach the terminal without EFAULT.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, rust, shell, wasm
Domain
cli, operating-systems, testing-qa
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.