Skip to content

Add the process shim and its broker - #348

Merged
SaladDay merged 23 commits into
feature/agent-outside-sandboxfrom
aos/shim-broker
Oct 1, 2026
Merged

SaladDay merged 23 commits into
feature/agent-outside-sandboxfrom
aos/shim-broker

Conversation

@SaladDay

@SaladDay SaladDay commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Lane L2b of the agent-outside-sandbox workstream. A Harness in a Session view runs declared programs through oac-process-shim. The programs run in the sandbox through the Process protocol, and the shim mirrors the remote exit.

Invariant: only an unprivileged per-Session relay inside the view receives or operates on descriptors the view supplies. The root broker holds only its own transport endpoints.

Shape

  • apps/daemon/cmd/oac-process-shim is a static multi-call binary.
    • Shim mode: it connects to /.oac/run/process.sock and passes its request and fds 0–2 to the relay.
    • Relay mode (oac-process-shim relay): it is the only holder of view descriptors.
      • It reopens pipes, FIFOs and terminals itself through /proc/self/fd, checking the access mode and never changing shared descriptor flags.
      • It performs every read, write and terminal ioctl.
      • Unix-socket output goes out with its own credentials.
      • It keeps the terminal saved-mode registry, and writes terminal output only once the terminal is raw.
      • It allocates each invocation ID and sends its Open in one serialized step, so Opens reach the broker in ID order.
      • It sets PR_SET_DUMPABLE 0 and ignores signals.
  • apps/daemon/internal/processshim owns both private wires, shim↔relay and relay↔broker (ipc.go).
    • Each message type is explicit, validated and limited.
    • Each invocation has an ID.
    • Golden fixtures and a fuzz target cover decoding.
  • apps/daemon/internal/processbroker (root, one per view) keeps all Process protocol work: Start with an idempotent retry, Observe and re-attach, acks, offsets, signals, cancel, and Link loss and resume.
    • It reads the relay socket without a control buffer, so the kernel discards any descriptor the relay attaches.
    • Each descriptor has its own bounded pump, so a blocked descriptor never stalls control or another invocation.
    • Started reaches the relay, and stdin forwarding begins, only on the service's Started event, so an idempotent Start retry that attaches while the program is still starting never writes stdin early.
    • After an uncertain stdin write, the broker reads the accepted offset with Inspect and resends only the unaccepted bytes it still holds. If the leader has already exited, pipe stdin closes at that offset, so a background reader gets end of file. If the offset cannot be learned, the broker cancels the program and the shim exits 255 with the reason, unless the exit is already decided.
    • Exited is forwarded without waiting for output to drain.
    • Output is acknowledged to the Process service only after the relay reports that it wrote it.
    • If the relay is lost, the broker reports ErrRelayLost and never replays output.
    • API: processbroker.Start(Config{Relay, Executables, Environment, Scope, Dial, CancelGrace, Logger}), plus Close (idempotent, bounded, never waits on a relay syscall), Done and Err.
  • apps/daemon/internal/sessionview
    • When the Spec declares a shim, the launcher binds the socket on a read-only /.oac/run tmpfs, owned by the Session user with mode 0600.
    • After the launcher restricts itself, it starts the relay as the Session uid/gid with no capabilities, before the Harness. View.Relay() returns the broker's end.
    • The launcher creates the devpts instance.
    • The names oac-process-shim (agent.ViewRelayName) and Private run are reserved.
    • Teardown shuts down the broker connection, then stops the world through WorldServer.Stop while the view is reaped, then waits at most 30s. If cleanup is incomplete, Wait and Close return ErrCleanup.
  • internal/sandboxprocess/termios_linux.go: the PTY mode table moves out of processservice, so the service and the relay share one definition.
  • agenthost: the Private run directory and brokerConfig.RunDir are removed. The broker is still not wired; plan row 9c wires it, one broker per view.

Hardening removed, because the relay's own uid now gives it: the character-device allowlist, the devpts check, the root-only run directory, SO_PEERCRED, explicit SCM_CREDENTIALS and the cached shim pid.

Checks

  • go build ./... and go vet ./..., plus the darwin and windows builds and vet.

  • go test -race -count=1 on agenthost, agent, processshim, processbroker and sessionview.

  • A 30s FuzzDecode run.

  • Privileged container:

    Suite Result
    processbroker 25/25
    sessionview 7/7
    agenthost all pass
    worldfs view tests pass
    gateway listener test passes

    The processbroker suite includes these regression tests:

    • TestOutputWritesHaveTheSessionsAuthority: oom_score_adj.
    • TestStuckOutputDoesNotHoldTeardown and TestTeardownIsBounded: a FUSE stdout that never answers.
    • TestUnixSocketOutputNamesTheRelay.
    • TestBlockedOutputHoldsOnlyItsInvocation.
    • TestBackgroundOutputAfterShimExits.
    • TestRelayDescriptorsAreDiscarded.
    • TestViewRunsRemoteShell.
    • TestRetriedStartWaitsForStarted, TestUncertainStdinWriteResumes, TestUnresolvedStdinEndsTheInvocation and TestBackgroundReaderGetsEOFAfterUncertainWrite, against an in-process fake Process service.

    The processshim tests TestOpensReachTheBrokerInIDOrder and TestTerminalOutputWaitsForRawMode cover the relay ordering.

Residual

Terminal output that arrives after the program's exit is written after the relay has restored the terminal, so the local terminal processes it a second time (a remote \r\n becomes \r\r\n). Holding the exit until the remote terminal closes would instead keep the shim alive for as long as a background job holds the terminal.

If the world stops answering while the launcher is still building the view, sessionview.Start can hang past its context. This predates the rework, and plan row 9c fixes it.

Intermediate commits before 233e71d don't build after the rebase. Squash-merge only.

The process broker reads local terminal modes with the same RFC 4254 table
the Linux process service applies, so the table moves to sandboxprocess.
oac-process-shim hands its invocation and descriptors 0-2 to the Session's
broker over /.oac/run/process.sock and exits the way the remote program did.
The broker authenticates the peer uid, resolves the declared executable and
environment, runs the program with the process protocol, pumps the passed
descriptors without changing their flags, forwards signals and PTY resizes,
survives link loss by re-attaching, and releases settled operations.

processserve runs the Linux process service in its own process for the
broker tests, so its reaper never competes with the tests' waits.
- Track each shim connection from accept, bound the handshake, and close
  the connection and its descriptors on Close.
- Reopen pipes, FIFOs and character devices through /proc/self/fd as
  independent non-blocking descriptions, use sockets with MSG_DONTWAIT and
  regular files as they are, and refuse other descriptors, so no read or
  write blocks outside a poll the invocation can end.
- Deliver output a gone reader discarded at once and close the remote
  output in the background, so settlement never waits behind it.
- Keep resolving a Start that may have taken effect with the same ID and
  spec until it is definite; otherwise exit 255 and cancel the operation if
  it exists.
- Release a settled operation right after re-attaching, and keep observing
  after scope observation is lost.
- Take the PTY's TERM from the composed environment and free a partly
  allocated invocation.
- Catch every signal a Go program can catch and document the remaining
  signal and descriptor limits.
sessionview.NewPTS creates the devpts instance in the daemon and reports its
device number, so the process broker can accept terminals only from the
view's own instance before anything in the view runs. Start passes the
detached mount to the launcher, which attaches it at /dev/pts; a nil
Spec.PTS gives the view a new instance.
- Accept as character devices only the memory devices, used as passed, and
  pty slaves of the view's devpts instance; refuse every other one with 126,
  so a reopen as root never revives a revoked description.
- Send output on an AF_UNIX socket with SCM_CREDENTIALS naming the shim's
  pid, uid and gid, then the broker's pid once the shim is gone.
- Require a run directory owned by the broker's user and writable by no one
  else, and bind, chmod, chown and unlink the socket relative to it; the
  view test mounts it read-only.
- Keep resolving an uncertain Start until a refusal proves absence; Busy and
  a failed implicit Attach of an existing operation never end it.
- Bound Start, its implicit Attach and every retry wait with one deadline.
- Retry Busy acknowledgements and releases with backoff until the
  invocation halts.
- Share a terminal's saved mode across invocations; the last to leave
  restores it.
The broker resolves shim names and refuses the private tree with
agent.ViewPrivateRoot and agent.ViewShimName. The shim keeps SocketPath
as a literal so that it does not link package agent, and a test checks
it against agent.ViewPrivateRoot and agent.ViewRunName.
…ost shim's wait for a stream

A Busy refusal no longer drops stdin, a re-Attach or a Cancel: every
request on a live operation repeats after a backoff, and a stdin write
resumes from the first byte the service did not accept. A Cancel that may
have taken effect is not sent again, so the scope gets one TERM. Until a
Start may have taken effect, losing the shim ends the wait for a stream and
releases the shim's descriptors.
oac-process-shim gains a relay mode: one relay per Session, running as the
Session user with no capabilities, receives each shim's request and
descriptors and does every read, write and terminal ioctl on them with the
Session's own authority. The broker keeps all process protocol work and
talks to the relay over a socketpair it reads without a control buffer, so
it never holds a view descriptor. Each descriptor has its own pump in the
relay; the broker sends at most OutputWindow unreported bytes per stream
and acknowledges output to the service only after the relay reports the
write. The terminal registry moves into the relay and takes its snapshot
under the registry lock.

The broker no longer needs a root-only run directory, SO_PEERCRED, the
character-device allowlist or explicit SCM_CREDENTIALS, so Config loses
RunDir, UID and PTSDevice and gains Relay. A lost relay fails the broker:
Done closes and Err wraps ErrRelayLost.
Only the process broker needed the instance's device number before the
view, to accept terminals from that instance alone. The relay now opens
terminals as the Session user, so the launcher creates the instance itself
and Spec.PTS and NewPTS go away.

This reverts commit 152056f6461df5af8b5f8e6fc30b381467fec8d4.
A declared shim named oac-process-shim would take the relay's path, so
View.Validate rejects it and a processshim test checks RelayName against
agent.ViewRelayName.
When the spec declares a shim, the launcher binds the Session's process
socket on a read-only /.oac/run tmpfs owned by the Session user and starts
the unprivileged relay with it and its end of the broker connection, after
the launcher restricts itself and before the Harness. View.Relay is the
broker's end. sessionview owns /.oac/run, so agenthost no longer presents a
run directory from the Session directory.

Teardown shuts the relay connection down, then stops the world while it
reaps the view, because a process blocked on an unanswered world request
cannot exit until the world ends it. It waits at most 30 seconds and
reports ErrCleanup from Wait and Close past that. The launcher's drain no
longer waits for the relay, and sessionview gives the Session user the
pipes it creates.
The relay allocated an invocation's ID under its lock but wrote the Open
later under the send lock, so a handshake that finished second could
publish ID 2 before ID 1; the broker rejects a decreasing ID and fails the
Session. Allocation and the Open write are now one step under the send
lock.
The control goroutine makes the terminal raw on Started while the output
pumps run on their own, so a pump could write first and the cooked
terminal's OPOST|ONLCR turned the remote terminal's \r\n into \r\r\n.
Output writes now wait for raw mode, as stdin reads do.
The broker treated a successful Start as started. A Start retried after an
uncertain one can find the operation still Starting; its first stdin write
then got NotRunning, forwarding stopped for good, and the program waited
forever for input or end of file. The broker now tells the relay Started
and begins forwarding stdin on the Started event, and a StartFailed event
stays the typed start failure.
When a stdin write failed with an uncertain effect, such as a lost
stream, the broker stopped reading local stdin but left the remote stdin
open, so a program like cat hung once the stream was back. As the process
protocol prescribes, the broker now learns the accepted offset with
Inspect, on the next stream when the stream ended, and writes only the
bytes after it; exact-offset writes keep any byte from arriving twice.
When the offset cannot be learned or is inconsistent, the shim exits with
255 and the reason, and the program is cancelled.
@blacksmith-sh

blacksmith-sh Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Found 2 test failures on Blacksmith runners:

Failures

Test View Logs
github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/processbroker/
TestUnresolvedStdinEndsTheInvocation
View Logs
github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs/TestInterruptReturnsOutcome View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

A stdin write whose response was lost may find, through Inspect, that the
leader exited before its Exited event arrived. A background process may
still read stdin, so pipe stdin now closes at the offset the service
accepted, as it does after the exit, and the background reader gets end of
file instead of holding the output and the settlement open.
When the shim already has its Result or is gone, the path that answered or
lost it owns the program's end. stdinLost cancelled the program anyway, so a
shim lost while stdin recovery failed sent the program a second TERM.
stdinLost checked whether the exit was decided apart from reserving its
Result, so an Exited event observed between the two had its status replaced
by 255. replyBeforeExit does both under the invocation's lock.
The test paused the first handshake before its ID was allocated, so the
second took ID 1 and a relay that allocated and published apart still
passed. The seam now runs between allocation and publication: the second
handshake must wait for the publication lock, which the test observes, and
splitting the two steps publishes Open 2 before Open 1.
The raw-mode test held raw mode for 300 ms in the hope that the output
reached its pump, and the fake service ran a late start 300 ms after the
retried Start in the hope that its Attach saw Starting. A gating seam now
marks the output pump holding output before the terminal is raw, and the
test asserts nothing is written until it is raw. The fake keeps the
operation Starting until an Attach found it so and the broker observes it
without a stdin pump. Timers remain only as failure deadlines.
@SaladDay
SaladDay merged commit b014671 into feature/agent-outside-sandbox Oct 1, 2026
2 checks passed
@SaladDay
SaladDay deleted the aos/shim-broker branch October 1, 2026 06:40
start launched the control goroutine before adding the output pumps to
their WaitGroup. An End that arrived at once let finish wait on a zero
count, close the descriptors and the end flag, and leave the pumps to start
on descriptors that may already be reused.
The relay restores the terminal at the Exit and does not hold the shim's
exit for the Terminal stream, as a native shell never waits for a
background job that holds the terminal. Output that arrives after the Exit
is therefore written in the restored mode.
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.

1 participant