Skip to content

Run agent-host Sessions in per-Session views - #360

Merged
SaladDay merged 13 commits into
feature/agent-outside-sandboxfrom
aos/agent-host
Oct 1, 2026
Merged

SaladDay merged 13 commits into
feature/agent-outside-sandboxfrom
aos/agent-host

Conversation

@SaladDay

@SaladDay SaladDay commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Lane L5a (plan row 9b): the agenthost library. It assembles the merged pieces into Sessions whose Harness runs on the agent host, in a per-Session view:

There is no CLI entry yet. oac-daemon agent-host arrives in M2, with the Core wire.

Commits

  1. Broker installed HTTP MCP credentials in the Session gateway. This is the gateway MCP extension.
    • Installed HTTP bindings (MCPHTTPServers and LocalEnvironment.MCP) carry their bearer and headers into the gateway.
    • The gateway injects them in place of the Harness's credential headers and of headers with the same names.
    • It removes response headers and trailers that contain any injected value.
    • The Harness sees only a credential-free gateway URL per binding.
    • The rule lives in contracts/agents-api/model-execution.md § Credential gateway.
  2. Serve the gateway's generic proxy only when the view declares one (gateway.Config.Proxy).
  3. Assemble agent-host Sessions in per-Session views (apps/daemon/internal/agenthost).
    • Admission runs before any effect:
      • the kind must declare a View;
      • the request must have a workspace, strict resume and enabled network;
      • it must not install Capabilities or skills, restrict domains or use stdio MCP;
      • view paths must not meet the agent host's own overlays;
      • gateway.Plan must succeed.
    • Request rewrite. The model provider becomes the gateway's loopback URL with modelprovider.Placeholder. The request carries no MCP; ViewSession.MCP and ViewSession.Proxy take their place.
    • Effects order: the Session uid, then the Session directory, then the view Executor factory. Adapter rejections and ErrViewHandoff happen before any Link effect.
    • First Launch: it opens the Link attachment, serves the world, starts the gateway in the view's network namespace and starts the process broker.
    • Link ownership: the agent host renews the lease. The Session fails when any of these happens:
      • the relay closes the attachment;
      • a Link request fails in a way that is not retryable;
      • the world is lost, or may still hold state (ErrAttachmentDirty).
    • Teardown order: Executor, view, broker, Link attachment, Session directory, uid.
    • Typed errors: every failure matches one agenthost.Err* kind.
    • Kill timeout: clirunner.DefaultKillTimeout is exported, and view launches use it for Process.Grace.
  4. Documentation: a repository-map row, and one sentence in environments.md § Effective bindings that links to the gateway rule.

Not in this PR

  • The process broker. Add the process shim and its broker #348 is being reworked, so this PR runs it behind a local processBroker seam.
    • This build's broker fails to start. A Session therefore admits and prepares its Executor, and its first launch fails with ErrProcessBroker.
    • The real broker is wired in after Add the process shim and its broker #348 merges.
    • After that, a separate Runtime–Harness protocol PR adds running a process in the live view.
  • Capability reporting of view support is M2.
  • Restart recovery. The agent host does not yet recover Sessions an earlier agent-host process left, so its state directory and uid range must not be reused across restarts. It returns after Add the process shim and its broker #348, with an owned cgroup v2 per view (cgroup.kill, then wait for the cgroup to be empty) in sessionview, as plan row 9c.

Tests

  • go build ./... and go vet ./..., plus the GOOS=darwin and GOOS=windows builds of agenthost.
  • go test -race for agenthost, clirunner and gateway.
  • Unprivileged:
    • admission rejects 10 bad requests before any dial or Session directory;
    • the Executor receives the gateway request, and the original request is unchanged;
    • a connection option is rejected with ErrViewHandoff before the adapter runs;
  • Privileged (OAC_TEST_AGENTHOST=1, in a container against a static oac-sandbox-io, with a test adapter):
    • one Session runs end to end, its lease renews across a Turn, and a clean close leaves no directories, mounts or Session-uid processes;
    • a restarted sandbox service fails the Session with ErrLink and releases everything.

Review rounds

Four blind-review rounds, each in a fresh session:

  • Round 1 found five P1 and two P2. All are fixed:
    • MCP credentials over plaintext;
    • uid reuse;
    • cleanup errors that were dropped;
    • teardown failures that were reported as success;
    • relaunch racing the view-slot release;
    • MCP URLs with a query;
    • Link validation at admission.
  • Round 2 found six P1. All are fixed:
    • query credentials;
    • the Turn driver now mirrors dispatch, so output drains from the start and Done is held until settlement and Close;
    • the forwarder is joined;
    • a failed Close keeps the directory and the uid.
  • Round 3 found three P1 and one P2. All are fixed:
    • function tools and tool search are rejected at admission;
    • the terminal emitted during Close is kept;
    • the pidfd leak is fixed.
  • Leftover processes. Ending leftover processes by matching a launcher's name could not prove ownership. The design decision is an owned cgroup per view, so Sweep and the heuristic kill code were removed (4338269). Allocation still skips any uid that a live thread holds.

@blacksmith-sh

This comment has been minimized.

The gateway's MCP config is now the effective agent.MCPBinding list, so
installed HTTP bindings reach it with their bearer token and HTTP headers.
The MCP relay injects the bearer and each header in place of the Harness's
credential headers and same-named headers, and withholds every injected
value from response headers and trailers. Non-HTTP bindings, invalid
headers and a header that would replace the bearer are rejected.
Config.Proxy selects the generic proxy listener. Without it nothing
listens at ProxyPort and Endpoints.Proxy is empty, so a ViewProxyNone
view has no generic proxy; the model and MCP listeners keep their fixed
ports either way.
Add apps/daemon/internal/agenthost: Run admits a Session before any
effect, allocates its uid and Session directory, points the model
provider and HTTP MCP at the Session's credential gateway and calls the
kind's view Executor factory. Each ViewSession.Launch builds one
sessionview view over the world that worldfs serves from the Session's
Link attachment, with the gateway in the view's network namespace. The
agent host owns the attachment's lease and fails the Session when the
attachment closes, a Link request fails without retry or the world is
lost. Teardown releases the Executor, the view, the process broker, the
attachment, the Session directory and the uid in that order. Sweep
removes Session directories a previous agent host left. Other
platforms return ErrUnsupported.

The process broker stays behind a local seam until the broker lands.
clirunner exports DefaultKillTimeout so a view launch uses the same
default as Start and FromHandle.

Tests: admission rejects before any dial or directory, the view
Executor receives the gateway request, Sweep, and a privileged suite
(OAC_TEST_AGENTHOST=1) that runs a Session against oac-sandbox-io.
Add the agenthost row to the repository map and state, where the effective MCP bindings are described, that an agent-host view's gateway holds HTTP binding credentials.
The gateway now rejects, before the Session starts, an HTTP MCP binding
that injects a bearer token or any HTTP header over a non-https server
URL, so no injected value crosses the sandbox's network in plaintext.

An MCP listener serves only its server URL's path. The Harness's URL
carries no query, since a query may hold a credential; the listener
relays each request to exactly the server URL, query included, and
refuses a request with a query (400) or for another path (404).
model-execution.md states both rules under Credential gateway.
- Sweep sends SIGKILL, through a pidfd after rereading the uids, to
  every process whose real, effective, saved or fs uid lies in
  Config.UIDs, rescans /proc until none remains and returns ErrTeardown
  after a bound. Allocation skips a uid any running process holds. The
  package comment states the residual races.
- A view's owning handle ends the Session's ownership inside Wait:
  the gateway stops, a lost world or a world whose Stop failed fails
  the Session as ErrWorld, and the view slot is freed before the
  clirunner.Process reports the end, so an immediate relaunch finds the
  slot free. A world Stop error after a failed sessionview.Start fails
  the Session too. Run reports every such error.
- Run tears down first and decides its result afterwards, so a failure
  recorded while Executor.Close waits counts. The link owner ignores
  what the Link reports once it begins closing the attachment.
- Admission validates the Open the Session will send with Link
  encoding, so a zero epoch, an invalid resource kind or an oversized
  grant is ErrInvalidSession before any effect.
A binding whose server URL has a query now needs https, like one that
injects a bearer token or headers, and the gateway withholds the query
and each parameter value, as sent and decoded, from response headers
and trailers. A server URL with userinfo gets its own error.
Sweep and uid allocation now scan every thread, so a process whose
leader thread is a zombie still counts. Sweep ends a task in a view by
killing its PID namespace's init, found by walking up the task's
ancestors with pidfds, so a process forked while the scan runs dies
with the namespace. A task in the agent host's own namespace is killed
directly and reported as ErrTeardown if it survives the bound.
…utor will not close

Each Turn's output consumer starts before StartTurn, and a nil Turn's
channel is closed. The Turn's Done waits for settlement and any
required Executor.Close, after an Error envelope when the Turn failed,
and nothing is published when Close fails. Teardown joins the
forwarder, so nothing reaches Output after Run returns. A failed Close
ends the views and is retried once; if it still fails, Run returns
ErrTeardown and keeps the Session directory and the uid for the next
Sweep. The driving mirrors dispatch's prepared execution.
A process in a view could fork and exit as each uid scan passed, so the
scan could miss a view entirely. sessionview.EndLeftoverViews finds each
view by its launcher, the process whose command line is exactly the
launcher's and which is PID 1 of a PID namespace directly below /proc's,
kills it through a pidfd confirmed after opening, and waits with a bound
for it to exit; the kernel kills the rest of the view first. Sweep calls
it before the uid scan, which now only ends host-namespace leftovers, and
drops the namespace-init walk. procfs.end closes its pidfd on every path.
A function call waits for a result, and a view Session's Input carries
only new Turns, so the result could never arrive. Admission now rejects
function tools and tool search with ErrUnsupported before any effect.
Close may deliver the Turn's Done, for example when StartTurn failed but
left native work running. turn now reads the forwarder's terminal again
after the forwarder has finished, so that Done is published instead of
being replaced by a synthesized one.
@SaladDay
SaladDay merged commit c87e999 into feature/agent-outside-sandbox Oct 1, 2026
2 checks passed
@SaladDay
SaladDay deleted the aos/agent-host branch October 1, 2026 05:31
Ending a previous agent host's views by launcher command line and PID
namespace cannot prove ownership, and an exiting launcher is already
invisible to the scan. Restart recovery returns with an owned cgroup per
view; until then the agent host recovers nothing an earlier agent-host
process left, and a new one must not reuse its StateDir or UIDs.

Sweep, sessionview.EndLeftoverViews and the /proc kill and wait code go.
Allocation keeps its thread scan, which only detects a uid conflict. A
Session whose Executor will not close keeps its directory, and its uid
stays in use until the agent host exits.
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