The self-hosted runner: a Managoat.Sandbox adapter whose transport is a WebSocket that a daemon on the user's own machine dials out to the platform. This package is the platform's end of that protocol: the connection process that holds one daemon's socket, the adapter that turns sandbox operations into requests over it, the sandbox-name shape that says which runner a request goes to, and a fake daemon inside the BEAM that speaks the protocol for tests.

# The platform's supervision tree, or a consumer without a cluster:
children = [Managoat.Runner.Host.Local]

# config.exs
config :managoat_runner, host: Managoat.Runner.Host.Local

config :managoat_sandbox,
  adapters: %{sprites: Managoat.Sandbox.Sprites, runner: Managoat.Runner.Adapter}

# The socket route, after authenticating the daemon however the host does:
WebSockAdapter.upgrade(
  conn,
  Managoat.Runner.Connection,
  %{runner_id: runner_id, name: "mini", meta: %{owner: account_id}},
  timeout: 120_000
)

# Then it is a sandbox like any other. The runner id rides in the name.
name = Managoat.Runner.Names.for_runner(runner_id)
{:ok, handle} = Managoat.Sandbox.create(:runner, name)
{:ok, output, 0} = Managoat.Sandbox.exec(handle, "uname", ["-a"])

The pieces

ModuleRole
Managoat.Runner.ConnectionThe WebSock handler for one daemon's socket. call/3 sends a request from anywhere the host can route from and waits for the reply; unsolicited stream frames reach the owner of the command they belong to as the standard Managoat.Sandbox owner messages. Its moduledoc is the wire protocol.
Managoat.Runner.Adapter@behaviour Managoat.Sandbox implemented as one request per callback. Advertises :suspend and :attach; every offline, disconnected or timed-out request is {:unavailable, _}, transient in the taxonomy. host_path/2 maps /home/sprite onto the sandbox's real directory.
Managoat.Runner.Namesrunner-<32 hex>-<8 hex>: the runner's UUID without dashes plus a random suffix. for_runner/1 mints, parse/1 recovers the id. Pure.
Managoat.Runner.HostThe behaviour the platform implements: what the connection and the adapter need that is not protocol.
Managoat.Runner.Host.LocalThe reference host over a plain Registry, one node, no reaction to heartbeats or presence. The library's tests run against it.
Managoat.Runner.ConfigReads config :managoat_runner, host: Module. No default: a missing host raises a message naming the key.
Managoat.Runner.FakeDaemonA daemon inside the BEAM speaking the exact protocol, in lib/ so a host's tests and a consumer's tests can drive it.

The host behaviour

The connection process and the adapter need six things from the platform that runs them, and none of them is protocol. Managoat.Runner.Host has exactly those callbacks:

CallbackWhenWhy
register(runner_id, meta)the socket opens, from the connection processone connection per runner; a second is refused and closed with 4409
unregister(runner_id)the socket closes, before presence/3a subscriber that looks the runner up on the offline notice sees the truth
whereis(runner_id)every call/3 and unsubscribe/3the connection process for a runner id, from any node the host spans
online()Adapter.list_all_names/0every connected runner with its meta; the reaper's whole view
heartbeat(runner_id)every 20 seconds while the socket is upa host that keeps a last_seen_at stamps it here
presence(runner_id, :online | :offline, meta)after register, after unregistera host with a roster to refresh broadcasts here

meta is an opaque map. The host puts what it needs in it when the connection is opened (the init map's :meta key) and gets it back on online/0 and presence/3; the library never reads a key from it. A host may send {:runner_deleted, runner_id} to the connection process, which closes the socket with 4404. That message is part of the contract between host and connection.

The host is named in config and read through Managoat.Runner.Config, not passed on each call: the adapter is instantiated through the sandbox adapter map with no arguments and has nowhere to receive one. The WebSock init map may carry host: too, which overrides the configured one for that connection and is what lets a test run two hosts side by side.

Fountain implements the behaviour in Fountain.Runners.Host over a Horde registry (cluster-wide), a runners table (the heartbeat stamps last_seen_at) and Phoenix PubSub (presence reaches the team surface). None of that is a dependency of this package.

Wire protocol

Text frames, JSON objects. Platform → daemon, one request per id:

{"id": 7, "op": "spawn", "name": "runner-…", "cmd": "…", "args": ["…"]}

Daemon → platform, one reply per request plus unsolicited stream frames:

{"id": 7, "ok": true,  "result": {"session_id": "s-…"}}
{"id": 7, "ok": false, "error": "not_found", "detail": "…"}
{"stream": "stdout", "session_id": "s-…", "data": "<base64>"}
{"stream": "exit",   "session_id": "s-…", "code": 0}
{"stream": "stdout", "session_id": "s-…", "data": "…", "replay_for": 7}

The ops are create get destroy list suspend resume write_file exec spawn stdin stdin_close detach list_sessions attach. The errors are the sandbox contract's codes: not_found, command_exited, not_supported, invalid, unavailable and write_failed; anything else becomes {:provider, :runner, {code, detail}}.

A spawn/attach request names the owner and ref its session's frames should reach; the subscription is installed before the reply is delivered so no frame can slip past it. The daemon sends the reply first, then replays the session's journal from byte zero tagged replay_for with that request's id (those frames reach only the subscriber that request installed, so a second attacher's replay never duplicates output at the first owner) and then streams live frames, which reach every subscriber of the session.

The daemon has one attached bit per session. The connection sends detach only when the last listener on this end is gone, so a second subscriber is not silenced by the first one leaving. An id of 0 marks a request the connection expects no reply to.

Failure. When the socket closes, every caller still waiting gets {:error, {:unavailable, :runner_disconnected}} and every subscribed owner gets {:error, %{ref: ref}, :runner_disconnected}: a transport failure in the contract's terms, after which no :exit follows. Detached sessions on the daemon keep running; that is what reattach is for. Close codes: 1003 for a frame that is not JSON text, 4404 when the host deletes the runner, 4409 for a second connection under the same id.

The other end

The daemon is Go, in Fountain's CLI: cli/internal/runner. conn.go is its end of this protocol and daemon_test.go pins its reply shapes. The two implementations must agree; Managoat.Runner.FakeDaemon is the executable form of the protocol on this side, and the conformance suite in test/ runs the whole Managoat.Sandbox contract through it, the connection process and the adapter, with no network and no database.

Fountain's own suite drives the same FakeDaemon against its Horde host, so a change to the protocol here is caught on both sides before the Go daemon sees it. The daemon is not published from this package; splitting the Go module out is cheap later and has not been needed.

Limitations

  • No egress policy. The adapter does not advertise :network_policy and apply_network_policy/2 answers {:error, :not_supported}: the machine is the user's and so is its network. A platform that requires a network policy before it will place a brokered conversation therefore cannot place one on a runner, which is the gap Fountain's ADR 0036 records and this extraction restates rather than closes.
  • No placement. Which runner a new sandbox should be minted on is the host's policy (Fountain picks the most recently connected online runner for the account). The library only routes a name that already carries an id.
  • One connection per runner id, held by one process. A cluster-wide host makes it reachable from every node; it does not make it redundant.

Where it comes from

Extracted from Fountain under ADR 0037; the design is ADR 0022 (the runner dials out, the runner id rides in the sandbox name, trusted mode) and ADR 0036 (the microVM backend behind the same protocol).

Not yet on hex: this package depends on managoat_sandbox, which cannot publish while its Sprites client is a git dependency. The sandbox library graduates first; this one then pins its hex version.

Licence

Apache-2.0. See LICENSE.