Author SHA1 Message Date
jb 5c79a7ec9b prototype: prove Pi runsc transport continuity 2026-08-22 23:04:23 -04:00
10 changed files with 1095 additions and 228 deletions
@@ -1,228 +0,0 @@
# gVisor supervisor topology for Linux deployments
## Decision
Build the POC around **one root-owned host service that invokes `runsc` directly against OCI bundles**. The service exposes a Paseo-specific Unix socket and nothing else. A host-installed Paseo daemon connects to that socket directly. The official Paseo Docker container receives only a bind mount of that socket and a deployment identifier; the host service maps that identifier to root-owned configuration containing the real host workspace paths.
Do not put `runsc` inside the Paseo container. Do not give Paseo, the Paseo container, or an agent sandbox `docker.sock`, `containerd.sock`, the runsc state directory, arbitrary OCI configuration, or the supervisor's executable environment.
This is the smallest topology that has the same sandbox owner in both deployments and does not make Docker or containerd part of Paseo's agent control plane.
```text
Host-installed deployment
paseo daemon (user) ── narrow RPC ──> /run/paseo-sandbox/supervisor.sock
│ root-owned supervisor
├─ builds validated OCI bundle
├─ runsc --root=/run/paseo-sandbox/runsc ...
└─ owns PTY/pipes and lifecycle records
Official Docker deployment
paseo container (uid 1000)
└─ bind-mounted supervisor.sock ───────> same host supervisor and runsc
│
host bind source ── supervisor mapping ────┴─> sandbox mount at /workspace
Agent sandbox
├─ versioned read-only rootfs
├─ only approved workspace/credential mounts
├─ no supervisor socket
└─ no Docker/containerd socket
```
## Primary-source findings
### `runsc` is usable as the low-level boundary
`runsc` is an OCI runtime. Its direct quick start constructs a bundle containing `config.json` and a `rootfs`, then runs that bundle with `sudo runsc run <id>`.[1] The OCI runtime specification defines durable container identity and the `create`, `start`, `state`, `kill`, and `delete` operations. Returned state includes the container ID, lifecycle status, runtime PID, and absolute bundle path.[2] This is enough for a supervisor to own container lifecycle without Docker or containerd.
The OCI configuration controls the process user, capabilities, `noNewPrivileges`, read-only root filesystem, additional mounts, namespace set, and resource limits. Bind-mount sources name paths on the runtime host, while mount destinations name paths inside the container.[3] The supervisor must therefore construct `config.json`; a daemon inside Docker cannot safely or correctly submit its own container-visible paths as host mount sources.
`runsc exec` starts a **new** process in an existing container. Its implementation supports a process JSON file, detached execution, a PID file, and a console socket that transfers the master PTY file descriptor.[4] It is not an API for attaching new stdio to an already-running process. `runsc list` and `runsc state` load containers from the selected runtime root, so a restarted supervisor can reconcile runtime state when it uses the same root directory.[5]
**Design consequence:** the supervisor, not the Paseo daemon, must retain PTY or pipe ownership and multiplex daemon connections. Daemon reconnect is feasible. A supervisor crash can recover container identity and status, but cannot be assumed to recover the original stdio file descriptors.
### gVisor narrows, but does not erase, the host boundary
gVisor's Sentry implements the application-facing system call API rather than passing application system calls directly to the host. Host filesystem access is mediated by a Gofer over a connected socket; the normal Sentry runs in an empty mount namespace and has a restricted host syscall surface.[6] The `runsc` configuration source states that `--directfs` lets the Sentry access the filesystem directly with elevated privileges, whereas the normal mode keeps the Gofer in the data path.[7]
**POC rule:** use the default Gofer-backed filesystem path and do not enable `--directfs`. A writable workspace still gives the sandbox authority to alter every file mounted there. gVisor protects the host kernel boundary; it does not turn an overly broad mount into a safe mount.
### Rootless `runsc` does not fit this lifecycle
gVisor documents three rootless patterns. The convenient `runsc --rootless` mode maps only the caller's UID and explicitly does not support `runsc create`; it also lacks save/restore and Netstack external connectivity, ignores cgroup configuration errors, and requires host networking for external access.[8] The native OCI user-namespace route can use multiple mappings through `newuidmap`/`newgidmap`, but currently lacks network namespacing; strictly unprivileged nested environments collapse to a single UID/GID mapping and cannot faithfully unpack ordinary multi-owner root filesystems.[8]
The `runsc` source also warns that defense-in-depth is weaker in rootless mode because Sentry and Gofer are root inside a user namespace mapped to the caller.[7]
**Decision:** retain the required root-owned supervisor for the POC. Rootless mode removes capabilities needed by the intended `create`/`start` lifecycle and produces different behavior between host and Docker deployments.
### The Unix socket can be a narrow authorization boundary
On Linux, pathname Unix sockets honor parent-directory permissions; connecting to a stream socket requires write permission on the socket. Ownership and mode can be changed with `chown`/`chmod`. `SO_PEERCRED` returns the credentials captured for the connected peer.[9] Abstract sockets have no meaningful filesystem permissions, so they are inappropriate here.[9]
Use a pathname socket such as `/run/paseo-sandbox/supervisor.sock`, owned by `root:paseo-sandbox` with mode `0660`, in a root-owned non-writable directory. The supervisor must also verify `SO_PEERCRED`; filesystem mode is not the whole authorization policy. For Docker, Compose must add the socket's numeric host GID as a supplementary group because Unix permission checks use numeric credentials.
The socket is mounted only into the outer Paseo container. It is never included among OCI mounts generated for an agent. Linux mount namespaces isolate mount views, and network namespaces isolate network resources.[10] A filesystem pathname that is absent from the sandbox mount namespace is not reachable merely because another process on the host can see it.
### Docker and containerd integration are valid tests, but the wrong control plane
gVisor's Docker integration registers `runsc` in `/etc/docker/daemon.json`, restarts Docker, and selects it with `docker run --runtime=runsc`.[11] Its containerd integration installs `containerd-shim-runsc-v1`, registers `io.containerd.runsc.v1` in `config.toml`, restarts containerd, and then uses `ctr` or CRI clients that communicate with containerd.[12]
Those integrations solve image management, network setup, and lifecycle orchestration. They also place the Docker/containerd API between Paseo and `runsc`. Exposing either manager socket would grant a much broader container-management authority than Paseo needs. Even if only the root supervisor held that socket, the POC would inherit daemon configuration, version, restart, plugin, and sibling-container behavior that direct OCI operation avoids.
Docker bind mounts name a source on the Docker daemon host and a destination in the outer container. They are writable by default and are tied to the host's directory structure.[13] This confirms why the Docker topology needs an explicit host-side mapping: `/workspace` inside Paseo is not a trustworthy host path.
## Alternatives
| Alternative | Host-installed daemon | Official Docker deployment | Privilege and socket consequences | Decision |
| --- | --- | --- | --- | --- |
| **Host root supervisor + direct `runsc` OCI** | Direct Unix socket | Bind-mount only the same Unix socket; host config maps deployment roots | One small root service owns bundles, runsc state, namespaces, PTYs, and mounts. No general manager socket. | **Choose for POC** |
| Root supervisor driving Docker with `--runtime=runsc` | Requires Docker even for local installs | Creates sibling gVisor containers | Paseo remains off `docker.sock`, but the supervisor holds root-equivalent Docker authority and inherits Docker lifecycle/network semantics. | Keep as a diagnostic fallback, not the POC |
| Root supervisor driving containerd's runsc shim | Adds containerd to local installs | Creates sibling containers through containerd | Same broad-manager issue; also adds shim/plugin/CNI configuration and a daemon restart prerequisite. | Reject for POC |
| Run `runsc` inside the official Paseo container | Nested only | Same container | Conflicts with the image's non-root daemon, needs substantial namespace/cgroup/device privilege, and rootless paths lack required lifecycle/network behavior. A privileged outer container would enlarge the host attack surface. | Reject |
| Daemon invokes rootless `runsc` | Different behavior and limits | Nested rootless constraints | No restricted root supervisor; `create` and useful isolated networking are not available in the simple rootless mode. | Reject |
## Recommended POC topology
### Host service and protocol
Install `paseo-sandbox-supervisor` as a system service running as root. Give it private directories:
- `/run/paseo-sandbox/` for the socket, PID/lease data, and a single explicit runsc `--root`;
- `/var/lib/paseo-sandbox/bundles/<sandbox-id>/` for generated bundles and immutable metadata;
- `/var/lib/paseo-sandbox/rootfs/<version>/` for versioned, read-only base root filesystems;
- a root-owned deployment registry mapping an opaque deployment ID to allowed host workspace and credential roots.
The RPC should expose domain operations such as `createSandbox`, `startProcess`, `writeInput`, `subscribeOutput`, `signalProcess`, `inspectSandbox`, and `deleteSandbox`. It must not expose argv for `runsc`, raw OCI JSON, arbitrary mount source/destination pairs, hooks, capabilities, host paths, or generic `exec` as root. Requests identify a registered workspace by opaque handle and a fixed provider launch profile. The supervisor resolves paths beneath configured roots using descriptor-relative operations, rejects symlinks and mount escapes, generates the bundle, and invokes a pinned `runsc` binary with fixed flags.
The supervisor records the requesting peer identity, deployment ID, Paseo sandbox ID, runsc container ID, bundle version, process status, and output cursor. Agent sandboxes receive no copy of the control socket.
### Host-installed Paseo
The installer:
1. installs signed/pinned `runsc` artifacts and the supervisor;
2. creates the supervisor group and adds the daemon user;
3. registers allowed workspace roots through an operator-owned command or root-owned config;
4. starts the root service;
5. leaves the Paseo daemon unprivileged.
The daemon connects to the pathname socket and refers to host workspaces through registered handles. Arbitrary user-selected directories require an explicit registration policy; allowing every path the daemon user can name would recreate the daemon's current authority inside a root service and invites symlink/mount confusion.
### Official Docker deployment
The outer Paseo container remains non-root. Its deployment adds only:
- a read-write bind mount of `/run/paseo-sandbox/supervisor.sock` at the same path;
- the socket's numeric group through Compose `group_add`;
- an opaque supervisor deployment ID;
- the existing workspace and home bind mounts.
The host operator registers that deployment ID with mappings such as:
```text
container /workspace -> host /srv/paseo/workspace
container /home/paseo -> host /srv/paseo/home
```
The daemon sends only the deployment ID plus container-relative workspace identity. The supervisor substitutes the preconfigured host source and mounts it at the expected sandbox destination. It rejects client-provided host paths. The outer container gets neither the bundle directory nor runsc state.
The sandbox rootfs cannot be taken implicitly from the running outer container without involving Docker's API. The POC therefore needs a separately installed versioned rootfs containing Node and one test provider CLI. A later packaging ticket must decide how official and user-derived agent images produce matching sandbox rootfs artifacts without giving Paseo an image-manager socket.
### Process I/O and reconnect
For each provider process, the supervisor creates and retains a PTY master (via runsc's console-socket support) or retained pipes, drains output continuously into a bounded sequence buffer, and accepts multiple narrow subscribers. Paseo reconnects with `(sandbox-id, process-id, last-sequence)` and receives missed output before live data.
On daemon restart, the supervisor remains alive, so the provider process and file descriptors remain owned. On supervisor restart, reconcile with `runsc list/state` and persisted records. Do not claim full reattachment: OCI state recovers lifecycle metadata, while `runsc exec` creates a new process and cannot reacquire an old PTY master. The POC should report such a process as `running-unattached` and either leave it alive for inspection or terminate/recreate it according to explicit policy. A stronger crash-recovery design would need a persistent in-sandbox I/O relay or tested checkpoint/restore and belongs in a later ticket.
### Networking
Use two POC profiles:
1. `--network=none` for filesystem, process, lifecycle, and reconnect tests. The runsc source defines this as loopback-only Netstack.[7]
2. `--network=sandbox` for networked tests, with the root supervisor creating a dedicated network namespace, veth, addressing, DNS inputs, and host NAT/firewall rules before start. gVisor describes sandbox networking as its isolated internal stack.[7]
Do not use `--network=host` as the default. gVisor says host passthrough disables the userspace stack and decreases host isolation.[14] It is acceptable only as a clearly labeled bring-up diagnostic. Agent sandboxes created for a Docker-hosted Paseo daemon are siblings at the host network layer, not members of the outer container's Compose network; reaching Compose-only services requires host-published endpoints or a separately designed bridge.
## POC prerequisites and test matrix
### Prerequisites
- Linux 5.6 or newer on `amd64` or `arm64`, matching gVisor's documented support floor.[15]
- Pinned `runsc` and required companion binaries installed on the host, not inside Paseo.
- A versioned OCI rootfs prepared without a live Docker/containerd socket in the Paseo control path.
- A root-owned system service, private state directories, dedicated group, and pathname Unix socket.
- For Docker: Linux Docker Engine, host bind-source registration, socket bind mount, and matching supplementary GID.
- POC starts with the default systrap platform; KVM and `/dev/kvm` are not prerequisites.
### Required tests
Run every applicable case once with a host-installed daemon and once with the official Docker daemon:
1. create/start a sandbox and prove gVisor is running;
2. read/write only the registered workspace; deny adjacent host paths, supervisor state, and the socket;
3. reject `..`, symlink, bind-submount, stale-handle, and deployment-ID substitution attempts;
4. prove the sandbox has no Docker/containerd/supervisor socket and cannot add mounts or capabilities;
5. stream stdin/stdout/stderr, disconnect Paseo, reconnect by output sequence, and continue the same process;
6. restart Paseo while work runs and preserve the session;
7. restart the supervisor and verify state reconciliation plus the documented unattached-I/O outcome;
8. signal, wait, delete, and garbage-collect stopped and half-created containers;
9. exercise `network=none`, then sandbox Netstack DNS and outbound HTTPS; verify inbound and host-loopback behavior explicitly;
10. verify uid/gid behavior on bind-mounted workspaces for host users other than 1000 and for Docker's default 1000:1000;
11. enforce CPU, memory, PID, and output-buffer bounds;
12. test both supported architectures in CI or dedicated Linux runners.
## Trust boundaries
- **Root supervisor:** trusted with host root, runsc lifecycle, configured source roots, network namespaces, and resource controls. Its RPC parser and path resolver are security-critical.
- **Paseo daemon:** trusted to request operations within pre-registered deployment/workspace policy, but not trusted with root, runsc flags, OCI hooks, raw host paths, or general container management.
- **Outer official Docker container:** same authority as its Paseo daemon plus mounted workspace/home; possessing the narrow socket must not expand it beyond the RPC policy.
- **gVisor Sentry/Gofer:** trusted isolation layer. Only explicitly mounted data is available; Gofer-backed mounts remain part of the host-facing trusted computing base.
- **Agent process and its output:** untrusted. It receives no control socket and cannot ask the supervisor directly.
- **Docker/containerd:** used to run the outer official deployment if the operator chooses Docker, but outside the agent sandbox control path.
## Open uncertainties
1. **Supervisor-crash I/O recovery:** confirm runsc's exact behavior when the process holding the PTY master dies. Current APIs support state reconciliation and new exec, not attaching to existing stdio.
2. **Rootfs/toolchain distribution:** decide how provider CLIs, native libraries, CA certificates, git/ssh, and user-installed custom agents enter a signed/versioned sandbox rootfs for both deployment modes.
3. **Credential mounts:** define per-provider read/write requirements. Mounting all of `/home/paseo` would defeat credential separation.
4. **Workspace identity and path safety:** choose the registration UX and prove descriptor-relative resolution across symlinks, nested mounts, renamed roots, and Docker bind-source changes.
5. **Networking policy:** choose CNI versus supervisor-owned veth/NAT, DNS handling, IPv6, egress filtering, inbound ports, and access to host or Compose services.
6. **Resource controls:** verify cgroup v2 ownership and cleanup under systemd across supported distributions.
7. **Provider compatibility:** test PTY versus pipes, subprocess trees, file watching, git credential helpers, browsers, and agents that launch their own sandboxes.
8. **Multi-user authorization:** decide whether one supervisor serves multiple Paseo users and how peer credentials partition state and registered roots.
## Later work unblocked
This decision provides the topology needed to scope later tickets for:
- the restricted supervisor RPC and authorization model;
- OCI bundle generation, mount policy, and runsc lifecycle adapter;
- daemon-side sandbox transport and reconnectable I/O;
- host installer/systemd packaging and rootfs distribution;
- official Docker Compose/socket/GID/deployment-root wiring;
- network namespace and egress policy;
- credential and workspace mount profiles;
- lifecycle garbage collection and supervisor-restart behavior;
- dual-deployment security and compatibility test harnesses.
It does **not** settle rootfs packaging, production networking, credential partitioning, or supervisor-crash stream recovery.
## Sources
1. gVisor, [OCI Quick Start](https://gvisor.dev/docs/user_guide/quick_start/oci/).
2. Open Container Initiative, [Runtime Specification: state, lifecycle, and operations](https://github.com/opencontainers/runtime-spec/blob/main/runtime.md).
3. Open Container Initiative, [Runtime Configuration Specification](https://github.com/opencontainers/runtime-spec/blob/main/config.md).
4. gVisor source, [`runsc/cmd/exec.go`](https://github.com/google/gvisor/blob/master/runsc/cmd/exec.go).
5. gVisor source, [`runsc/cmd/list.go`](https://github.com/google/gvisor/blob/master/runsc/cmd/list.go) and [`runsc/cmd/state.go`](https://github.com/google/gvisor/blob/master/runsc/cmd/state.go).
6. gVisor, [Security Model](https://gvisor.dev/docs/architecture_guide/security/).
7. gVisor source, [`runsc/config/config.go`](https://github.com/google/gvisor/blob/master/runsc/config/config.go).
8. gVisor, [Rootless](https://gvisor.dev/docs/user_guide/rootless/).
9. Linux man-pages, [`unix(7)`](https://man7.org/linux/man-pages/man7/unix.7.html).
10. Linux man-pages, [`namespaces(7)`](https://man7.org/linux/man-pages/man7/namespaces.7.html).
11. gVisor, [Docker Quick Start](https://gvisor.dev/docs/user_guide/quick_start/docker/).
12. gVisor, [Containerd Quick Start](https://gvisor.dev/docs/user_guide/containerd/quick_start/).
13. Docker, [Bind mounts](https://docs.docker.com/engine/storage/bind-mounts/).
14. gVisor, [Networking: network passthrough](https://gvisor.dev/docs/user_guide/networking/#network-passthrough).
15. gVisor, [Installation](https://gvisor.dev/docs/user_guide/install/).
@@ -0,0 +1 @@
.prototype-state/
@@ -0,0 +1,83 @@
# Active-turn continuity under runsc — throwaway prototype
> **PROTOTYPE: do not ship or import this code.** It answers whether a supervisor-owned bridge can keep one Pi RPC process and its active work alive while the Paseo-side daemon client crashes and reconnects.
## Question
Can a Pi process running inside gVisor continue a long-running RPC operation across a Paseo daemon crash, replay missed output without duplicate timeline delivery, accept cancellation from the replacement daemon, and clean up its runsc container afterward? This establishes the transport precondition for active-turn continuity; it does not execute an authenticated model turn.
## Shape
```text
daemon client generation 1 ─┐
├─ Unix JSONL bridge ─ retained pipes ─ runsc ─ Pi RPC
replacement generation 2 ───┘ │
└─ sequenced bounded replay buffer
```
The bridge is the throwaway stand-in for the proposed root-owned supervisor. It owns `runsc` and Pi's stdin/stdout for the complete runtime lifetime. Daemon clients receive a fenced writer lease and acknowledge bridge sequence numbers.
The demo:
1. builds a disposable Node/Pi rootfs with Docker;
2. launches Pi 0.84.2 inside rootless runsc with an OCI bundle;
3. proves a second live writer is rejected;
4. starts a long-running real Pi RPC `bash` command;
5. kills the first daemon client with `SIGKILL` after receiving—but before acknowledging—a streamed frame;
6. reconnects a replacement client using the last durable cursor;
7. proves the bridge and runsc state identities are unchanged;
8. replays the missed frame and suppresses its duplicate timeline delivery by bridge sequence;
9. sends `abort_bash` through the replacement connection and observes the original command cancel;
10. confirms the Pi session identity is unchanged; and
11. explicitly closes Pi and verifies the runsc container state is removed.
This is not the production supervisor. It uses rootless runsc, an in-memory replay buffer, one process, one host user, and no network. It does not prove supervisor-crash recovery, security policy, durable spool design, resource limits, or the official Docker topology.
## Prerequisites
- Linux with unprivileged user namespaces enabled
- Docker access for building/exporting the disposable rootfs
- Node 22+
- an official `runsc` binary
Download and verify the current official x86-64 binary outside the repository:
```bash
mkdir -p /tmp/paseo-runsc-bin
cd /tmp/paseo-runsc-bin
curl -fLO https://storage.googleapis.com/gvisor/releases/release/latest/x86_64/runsc
curl -fLO https://storage.googleapis.com/gvisor/releases/release/latest/x86_64/runsc.sha512
sha512sum -c runsc.sha512
chmod +x runsc
```
## Run
```bash
packages/server/src/server/agent/providers/pi/prototypes/active-turn-runsc/run.sh
```
Override locations when needed:
```bash
RUNSC=/path/to/runsc \
PASEO_RUNSC_PROTOTYPE_STATE=/tmp/paseo-active-turn-state \
packages/server/src/server/agent/providers/pi/prototypes/active-turn-runsc/run.sh
```
Generated rootfs, bundle, logs, cursors, and results live in `.prototype-state/` beside the scripts and are ignored by the prototype branch.
## Success evidence
The command exits zero and prints a final JSON verdict containing:
- `verdict: "pi_rpc_transport_continuity_proved"`
- `sameRuntime: true`
- `writerFencePassed: true`
- `sawReplayedTick: true`
- `duplicateReplaySuppressed` greater than zero
- `bashCancelled: true`
- `piSessionStable: true`
- `runscContainerRemoved: true`
The result also records bridge PID, runsc process PID, runsc state PID, container ID, generation, cursor, and buffer state so continuity is not inferred from timeline completion alone.
@@ -0,0 +1,46 @@
# Prototype result
## Verdict
A supervisor-owned reconnectable bridge can preserve Pi RPC transport across a Paseo daemon crash while Pi runs inside gVisor.
The prototype used a real Pi 0.84.2 process inside `runsc release-20260817.0`. It did not execute an authenticated model turn. The human accepted this as sufficient to settle bridge feasibility; authenticated model-turn and extension-UI behavior remain required in the later proof-of-concept verification matrix.
## Observed demonstration
The first daemon client launched a long-running Pi RPC `bash` command and was killed with `SIGKILL` after receiving bridge sequence 5 without acknowledging it. The replacement client attached at its last durable cursor.
The run reported:
```json
{
"verdict": "pi_rpc_transport_continuity_proved",
"sameRuntime": true,
"writerFencePassed": true,
"sawReplayedTick": true,
"duplicateReplaySuppressed": 1,
"bashCancelled": true,
"piSessionStable": true,
"runscContainerRemoved": true
}
```
The bridge PID, foreground `runsc` PID, runsc state PID, container ID, and Pi session ID were unchanged across client generations. The replacement client replayed the unacknowledged frame, suppressed its duplicate delivery by bridge sequence, sent `abort_bash` to the original Pi process, observed cancellation, and explicitly closed the runtime. A final `runsc state` lookup confirmed removal.
## What this settles
- Pi's pipes can live below the daemon in a longer-lived supervisor bridge.
- A bridge-owned sequence/cursor protocol can provide replay and downstream duplicate suppression.
- Exclusive writer leases can fence concurrent or stale daemon generations.
- A replacement daemon can send control commands to the original Pi process after reattachment.
- Explicit close can cleanly terminate Pi and remove its runsc container.
## What remains open
- authenticated model-turn and tool-call continuity;
- pending extension UI request replay and response fencing;
- durable on-disk spooling and overflow behavior;
- supervisor crash and restart;
- root-owned supervisor authorization and path validation;
- non-rootless runsc, network policy, credentials, resource limits, and both supported daemon topologies;
- integration with Paseo's real `AgentManager`, timeline reducer, cancellation, and persistence contracts.
@@ -0,0 +1,290 @@
#!/usr/bin/env node
// PROTOTYPE ONLY. This process stands in for the root-owned sandbox supervisor.
// It intentionally owns runsc and Pi's pipes instead of the daemon client.
import { execFileSync, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, unlinkSync } from "node:fs";
import { createServer } from "node:net";
import { resolve } from "node:path";
import { createInterface } from "node:readline";
const options = parseArgs(process.argv.slice(2));
const runsc = resolve(options.runsc);
const bundle = resolve(options.bundle);
const runtimeRoot = resolve(options.runtimeRoot);
const socketPath = resolve(options.socket);
const containerId = options.containerId;
const maxFrames = Number(options.maxFrames ?? 10_000);
if (existsSync(socketPath)) unlinkSync(socketPath);
let nextSequence = 1;
let lostBefore = 0;
let generation = 0;
let activeWriter = null;
let closing = false;
let runtimeExit = null;
const frames = [];
const clients = new Set();
const server = createServer((socket) => {
clients.add(socket);
socket.setEncoding("utf8");
socket.bridge = { lease: null, generation: null, acknowledged: 0 };
let pending = "";
socket.on("data", (chunk) => {
pending += chunk;
while (true) {
const newline = pending.indexOf("\n");
if (newline < 0) break;
const line = pending.slice(0, newline);
pending = pending.slice(newline + 1);
if (!line.trim()) continue;
try {
handleMessage(socket, JSON.parse(line));
} catch (error) {
send(socket, { type: "error", code: "invalid_message", message: String(error) });
}
}
});
socket.on("close", () => {
clients.delete(socket);
if (activeWriter === socket) activeWriter = null;
});
socket.on("error", () => {});
});
server.listen(socketPath, () => {
process.stderr.write(
`${JSON.stringify({ event: "bridge_listening", socketPath, bridgePid: process.pid })}\n`,
);
});
const runtime = spawn(
runsc,
[`--root=${runtimeRoot}`, "--rootless=true", "--network=none", "run", containerId],
{
cwd: bundle,
stdio: ["pipe", "pipe", "pipe"],
},
);
publish("bridge", {
type: "runtime_started",
bridgePid: process.pid,
runscPid: runtime.pid,
containerId,
});
createInterface({ input: runtime.stdout }).on("line", (line) => {
let payload;
try {
payload = JSON.parse(line);
} catch {
payload = { type: "unparsed_stdout", line };
}
publish("stdout", payload);
});
createInterface({ input: runtime.stderr }).on("line", (line) => {
publish("stderr", { type: "runtime_stderr", line });
});
runtime.on("exit", (code, signal) => {
runtimeExit = { code, signal };
publish("bridge", { type: "runtime_exit", code, signal, containerId });
setTimeout(() => {
for (const client of clients) client.end();
server.close(() => {
if (existsSync(socketPath)) unlinkSync(socketPath);
process.exit(code === 0 || closing ? 0 : 1);
});
}, 250).unref();
});
runtime.on("error", (error) => {
publish("bridge", { type: "runtime_spawn_error", message: String(error) });
});
process.on("SIGTERM", () => closeRuntime("SIGTERM"));
process.on("SIGINT", () => closeRuntime("SIGINT"));
const messageHandlers = {
attach: handleAttach,
command: handleCommand,
ack: handleAck,
inspect: handleInspect,
close: handleClose,
};
function handleMessage(socket, message) {
const handler = messageHandlers[message.type];
if (!handler) {
send(socket, { type: "error", code: "unknown_message", messageType: message.type });
return;
}
handler(socket, message);
}
function handleAttach(socket, message) {
const cursor = Number(message.cursor ?? 0);
if (!Number.isSafeInteger(cursor) || cursor < 0) {
send(socket, { type: "error", code: "invalid_cursor" });
return;
}
if (cursor < lostBefore) {
send(socket, { type: "error", code: "replay_gap", lostBefore });
return;
}
if (activeWriter && activeWriter !== socket) {
send(socket, { type: "error", code: "writer_already_attached" });
return;
}
generation += 1;
const lease = `generation-${generation}-${randomUUID()}`;
socket.bridge = { lease, generation, acknowledged: cursor };
activeWriter = socket;
send(socket, {
type: "attached",
lease,
generation,
cursor,
nextSequence,
runtimeExit,
});
for (const frame of frames) {
if (frame.sequence > cursor) send(socket, frame);
}
}
function handleCommand(socket, message) {
if (!ownsWriterLease(socket, message.lease)) return;
if (runtimeExit || closing || !runtime.stdin.writable) {
send(socket, { type: "error", code: "runtime_not_writable" });
return;
}
const payload = structuredClone(message.payload);
let effectiveId = null;
if (payload && typeof payload.id === "string") {
effectiveId = `g${socket.bridge.generation}:${payload.id}`;
payload.id = effectiveId;
}
runtime.stdin.write(`${JSON.stringify(payload)}\n`);
send(socket, {
type: "command_accepted",
requestId: message.requestId ?? null,
effectiveId,
generation: socket.bridge.generation,
});
}
function handleAck(socket, message) {
if (!ownsWriterLease(socket, message.lease)) return;
const sequence = Number(message.sequence);
if (!Number.isSafeInteger(sequence) || sequence < socket.bridge.acknowledged) {
send(socket, { type: "error", code: "invalid_ack" });
return;
}
socket.bridge.acknowledged = sequence;
send(socket, { type: "acknowledged", sequence });
}
function handleInspect(socket, message) {
if (!ownsWriterLease(socket, message.lease)) return;
send(socket, {
type: "inspection",
bridgePid: process.pid,
runscPid: runtime.pid,
containerId,
generation: socket.bridge.generation,
nextSequence,
bufferedFrames: frames.length,
lostBefore,
runtimeExit,
runscState: inspectRunsc(),
});
}
function handleClose(socket, message) {
if (!ownsWriterLease(socket, message.lease)) return;
send(socket, { type: "closing" });
closeRuntime("client_close");
}
function ownsWriterLease(socket, lease) {
if (activeWriter !== socket || socket.bridge.lease !== lease) {
send(socket, { type: "error", code: "stale_writer" });
return false;
}
return true;
}
function publish(channel, payload) {
const frame = {
type: "frame",
sequence: nextSequence++,
channel,
payload,
};
frames.push(frame);
if (frames.length > maxFrames) {
const removed = frames.shift();
lostBefore = removed.sequence;
}
for (const client of clients) {
if (client.bridge?.lease) send(client, frame);
}
}
function send(socket, message) {
if (!socket.destroyed) socket.write(`${JSON.stringify(message)}\n`);
}
function inspectRunsc() {
try {
return JSON.parse(
execFileSync(runsc, [`--root=${runtimeRoot}`, "state", containerId], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}),
);
} catch (error) {
return { error: error.stderr?.toString().trim() || String(error) };
}
}
function closeRuntime(reason) {
if (closing) return;
closing = true;
publish("bridge", { type: "runtime_closing", reason });
runtime.stdin.end();
const escalation = setTimeout(() => {
try {
execFileSync(runsc, [`--root=${runtimeRoot}`, "kill", containerId, "KILL"], {
stdio: "ignore",
});
} catch {}
runtime.kill("SIGKILL");
}, 5_000);
escalation.unref();
}
function parseArgs(args) {
const parsed = {};
for (let index = 0; index < args.length; index += 2) {
const key = args[index];
const value = args[index + 1];
if (!key?.startsWith("--") || value === undefined) {
throw new Error(`invalid argument near ${key ?? "<end>"}`);
}
parsed[key.slice(2)] = value;
}
for (const required of ["runsc", "bundle", "runtimeRoot", "socket", "containerId"]) {
if (!parsed[required]) throw new Error(`missing --${required}`);
}
return parsed;
}
@@ -0,0 +1,363 @@
#!/usr/bin/env node
// PROTOTYPE ONLY. Simulates the daemon-side owner before and after a crash.
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { connect } from "node:net";
import { resolve } from "node:path";
const [mode, socketInput, stateInput] = process.argv.slice(2);
if (!mode || !socketInput || !stateInput) {
process.stderr.write("usage: client.mjs <first|second> <socket> <state-dir>\n");
process.exit(2);
}
const socketPath = resolve(socketInput);
const stateDir = resolve(stateInput);
const cursorPath = resolve(stateDir, "cursor.json");
const observedPath = resolve(stateDir, "observed.jsonl");
const baselinePath = resolve(stateDir, "baseline.json");
mkdirSync(stateDir, { recursive: true });
async function runFirstClient() {
writeFileSync(cursorPath, `${JSON.stringify({ sequence: 0 })}\n`);
writeFileSync(observedPath, "");
const connection = await BridgeConnection.open(socketPath);
const attached = await connection.attach(0);
const fencePassed = await proveWriterFence();
connection.send({ type: "inspect", lease: attached.lease });
const inspection = await connection.waitFor((message) => message.type === "inspection");
connection.send({
type: "command",
lease: attached.lease,
requestId: "state-before",
payload: { id: "state-before", type: "get_state" },
});
const stateFrame = await consumeUntil(
connection,
attached.lease,
(frame) => frame.channel === "stdout" && frame.payload?.command === "get_state",
);
const baseline = {
bridgePid: inspection.bridgePid,
runscPid: inspection.runscPid,
runscStatePid: inspection.runscState?.pid,
containerId: inspection.containerId,
piSessionId:
stateFrame.payload?.data?.sessionId ?? stateFrame.payload?.data?.session_id ?? null,
writerFencePassed: fencePassed,
};
writeFileSync(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`);
connection.send({
type: "command",
lease: attached.lease,
requestId: "long-bash",
payload: {
id: "long-bash",
type: "bash",
command:
'echo "shell_pid=$$"; i=1; while [ "$i" -le 30 ]; do echo "tick-$i"; i=$((i+1)); sleep 0.4; done',
},
});
while (true) {
const message = await connection.next();
if (message.type !== "frame") continue;
recordObserved(message);
if (JSON.stringify(message.payload).includes("tick-3")) {
process.stdout.write(
`${JSON.stringify({
event: "simulated_daemon_crash",
sequenceDeliveredButNotAcknowledged: message.sequence,
baseline,
})}\n`,
);
process.kill(process.pid, "SIGKILL");
}
acknowledge(connection, attached.lease, message.sequence);
}
}
async function runSecondClient() {
const baseline = parseJson(readFileSync(baselinePath, "utf8"), baselinePath);
const cursor = parseJson(readFileSync(cursorPath, "utf8"), cursorPath).sequence;
const seen = loadObservedSequences();
const connection = await BridgeConnection.open(socketPath);
const attached = await connection.attach(cursor);
const inspection = await inspectRuntime(connection, attached.lease);
assertSameRuntime(baseline, inspection);
const progress = await continueInterruptedCommand(connection, attached.lease, seen);
const piSessionIdAfter = readSessionId(progress.stateAfter);
validateContinuity(baseline, piSessionIdAfter, progress);
const result = {
sameRuntime: true,
writerFencePassed: baseline.writerFencePassed,
sawReplayedTick: progress.sawReplayedTick,
duplicateReplaySuppressed: progress.duplicateReplaySuppressed,
bashCancelled: progress.bashCancelled,
piSessionStable: !baseline.piSessionId || piSessionIdAfter === baseline.piSessionId,
baseline,
inspection,
};
writeFileSync(resolve(stateDir, "result.json"), `${JSON.stringify(result, null, 2)}\n`);
process.stdout.write(`${JSON.stringify({ event: "continuity_proved", ...result })}\n`);
connection.send({ type: "close", lease: attached.lease });
await connection.waitFor(isRuntimeExitFrame, 15_000);
connection.socket.end();
}
async function inspectRuntime(connection, lease) {
connection.send({ type: "inspect", lease });
return connection.waitFor((message) => message.type === "inspection");
}
function assertSameRuntime(baseline, inspection) {
const sameRuntime =
inspection.bridgePid === baseline.bridgePid &&
inspection.runscPid === baseline.runscPid &&
inspection.runscState?.pid === baseline.runscStatePid &&
inspection.containerId === baseline.containerId;
if (!sameRuntime) {
throw new Error(
`runtime changed across reconnect: ${JSON.stringify({ baseline, inspection })}`,
);
}
}
async function continueInterruptedCommand(connection, lease, seen) {
const progress = {
duplicateReplaySuppressed: 0,
sawReplayedTick: false,
bashCancelled: false,
abortSent: false,
stateRequested: false,
stateAfter: null,
};
while (!progress.stateAfter) {
const message = await connection.next();
if (message.type !== "frame") continue;
observeFrame(connection, lease, seen, message, progress);
advanceRecovery(connection, lease, message, progress);
}
return progress;
}
function observeFrame(connection, lease, seen, message, progress) {
if (seen.has(message.sequence)) progress.duplicateReplaySuppressed += 1;
else {
seen.add(message.sequence);
recordObserved(message);
}
acknowledge(connection, lease, message.sequence);
if (JSON.stringify(message.payload).includes("tick-3")) progress.sawReplayedTick = true;
}
function advanceRecovery(connection, lease, message, progress) {
if (progress.sawReplayedTick && !progress.abortSent) {
progress.abortSent = true;
connection.send({
type: "command",
lease,
requestId: "abort-after-reconnect",
payload: { type: "abort_bash" },
});
}
if (message.payload?.command === "bash" && message.payload?.data?.cancelled === true) {
progress.bashCancelled = true;
}
if (progress.bashCancelled && !progress.stateRequested) {
progress.stateRequested = true;
connection.send({
type: "command",
lease,
requestId: "state-after",
payload: { id: "state-after", type: "get_state" },
});
}
if (progress.stateRequested && message.payload?.command === "get_state") {
progress.stateAfter = message.payload;
}
}
function readSessionId(state) {
return state?.data?.sessionId ?? state?.data?.session_id ?? null;
}
function validateContinuity(baseline, piSessionIdAfter, progress) {
if (baseline.piSessionId && piSessionIdAfter !== baseline.piSessionId) {
throw new Error(`Pi session changed: ${baseline.piSessionId} -> ${piSessionIdAfter}`);
}
if (!baseline.writerFencePassed) throw new Error("duplicate writer was not fenced");
if (!progress.sawReplayedTick) throw new Error("missed frame was not replayed");
if (!progress.duplicateReplaySuppressed) {
throw new Error("duplicate replay suppression was not exercised");
}
if (!progress.bashCancelled) throw new Error("bash command was not cancelled after reconnect");
}
function isRuntimeExitFrame(message) {
return (
message.type === "frame" &&
message.channel === "bridge" &&
message.payload?.type === "runtime_exit"
);
}
async function proveWriterFence() {
const second = await BridgeConnection.open(socketPath);
second.send({ type: "attach", cursor: 0 });
const response = await second.waitFor(
(message) => message.type === "error" || message.type === "attached",
);
second.socket.end();
return response.type === "error" && response.code === "writer_already_attached";
}
async function consumeUntil(connection, lease, predicate) {
while (true) {
const message = await connection.next();
if (message.type !== "frame") continue;
recordObserved(message);
acknowledge(connection, lease, message.sequence);
if (predicate(message)) return message;
}
}
function acknowledge(connection, lease, sequence) {
writeFileSync(cursorPath, `${JSON.stringify({ sequence })}\n`);
connection.send({ type: "ack", lease, sequence });
}
function recordObserved(frame) {
appendFileSync(observedPath, `${JSON.stringify(frame)}\n`);
}
function loadObservedSequences() {
if (!existsSync(observedPath)) return new Set();
return new Set(
readFileSync(observedPath, "utf8")
.split("\n")
.filter(Boolean)
.map((line) => parseJson(line, observedPath).sequence),
);
}
function parseJson(text, source) {
try {
return JSON.parse(text);
} catch (error) {
throw new Error(`invalid JSON from ${source}`, { cause: error });
}
}
class BridgeConnection {
static async open(path) {
const socket = connect(path);
await new Promise((resolvePromise, reject) => {
socket.once("connect", resolvePromise);
socket.once("error", reject);
});
return new BridgeConnection(socket);
}
constructor(socket) {
this.socket = socket;
this.queue = [];
this.waiters = [];
this.pending = "";
socket.setEncoding("utf8");
socket.on("data", (chunk) => this.#onData(chunk));
socket.on("error", (error) => this.#fail(error));
socket.on("close", () => this.#fail(new Error("bridge connection closed")));
}
send(message) {
this.socket.write(`${JSON.stringify(message)}\n`);
}
async attach(cursor) {
this.send({ type: "attach", cursor });
const response = await this.waitFor(
(message) => message.type === "attached" || message.type === "error",
);
if (response.type === "error") throw new Error(`attach failed: ${JSON.stringify(response)}`);
return response;
}
next(timeoutMs = 10_000) {
if (this.queue.length) return Promise.resolve(this.queue.shift());
return new Promise((resolvePromise, reject) => {
const timer = setTimeout(
() => reject(new Error("timed out waiting for bridge message")),
timeoutMs,
);
this.waiters.push({
resolve: (value) => {
clearTimeout(timer);
resolvePromise(value);
},
reject: (error) => {
clearTimeout(timer);
reject(error);
},
});
});
}
async waitFor(predicate, timeoutMs = 10_000) {
const deadline = Date.now() + timeoutMs;
const skipped = [];
try {
while (Date.now() < deadline) {
const message = await this.next(Math.max(1, deadline - Date.now()));
if (predicate(message)) {
this.queue.unshift(...skipped);
return message;
}
skipped.push(message);
}
throw new Error("timed out waiting for matching bridge message");
} catch (error) {
this.queue.unshift(...skipped);
throw error;
}
}
#onData(chunk) {
this.pending += chunk;
while (true) {
const newline = this.pending.indexOf("\n");
if (newline < 0) return;
const line = this.pending.slice(0, newline);
this.pending = this.pending.slice(newline + 1);
if (!line.trim()) continue;
let message;
try {
message = JSON.parse(line);
} catch (error) {
this.#fail(new Error("bridge returned invalid JSON", { cause: error }));
this.socket.destroy();
return;
}
const waiter = this.waiters.shift();
if (waiter) waiter.resolve(message);
else this.queue.push(message);
}
}
#fail(error) {
for (const waiter of this.waiters.splice(0)) waiter.reject(error);
}
}
if (mode === "first") await runFirstClient();
else if (mode === "second") await runSecondClient();
else throw new Error(`unknown client mode: ${mode}`);
@@ -0,0 +1,163 @@
#!/usr/bin/env node
// PROTOTYPE ONLY. Runs an end-to-end crash/reconnect demonstration with Pi in runsc.
import { execFileSync, spawn } from "node:child_process";
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const stateDir = resolve(
process.env.PASEO_RUNSC_PROTOTYPE_STATE ?? resolve(scriptDir, ".prototype-state"),
);
const runsc = resolve(process.env.RUNSC ?? "/tmp/paseo-runsc-bin/runsc");
const bundle = resolve(stateDir, "bundle");
const rootfs = resolve(bundle, "rootfs");
const workspace = resolve(stateDir, "workspace");
const piState = resolve(stateDir, "pi-state");
const clientState = resolve(stateDir, "client-state");
const runtimeRoot = resolve(stateDir, "runsc-root");
// Linux pathname Unix sockets are limited to roughly 108 bytes. Keep this outside the deep source path.
const socketPath = `/tmp/paseo-pi-bridge-${process.pid}.sock`;
const containerId = `paseo-pi-prototype-${process.pid}`;
const bridgeLog = resolve(stateDir, "bridge.log");
if (!existsSync(runsc)) throw new Error(`runsc not found: ${runsc}`);
if (!existsSync(resolve(rootfs, "usr/local/bin/pi"))) {
throw new Error(`prototype rootfs not prepared: run ${resolve(scriptDir, "prepare-rootfs.sh")}`);
}
for (const path of [clientState, runtimeRoot, workspace, piState])
rmSync(path, { recursive: true, force: true });
rmSync(socketPath, { force: true });
rmSync(resolve(bundle, "config.json"), { force: true });
for (const path of [clientState, runtimeRoot, workspace, piState])
mkdirSync(path, { recursive: true });
execFileSync(
process.execPath,
[resolve(scriptDir, "generate-bundle.mjs"), runsc, bundle, workspace, piState],
{ stdio: "inherit" },
);
const logFd = openSync(bridgeLog, "w");
const bridge = spawn(
process.execPath,
[
resolve(scriptDir, "bridge.mjs"),
"--runsc",
runsc,
"--bundle",
bundle,
"--runtimeRoot",
runtimeRoot,
"--socket",
socketPath,
"--containerId",
containerId,
],
{ stdio: ["ignore", logFd, logFd], detached: false },
);
closeSync(logFd);
try {
await waitForPath(socketPath, 10_000);
process.stdout.write("\n=== daemon generation one: start long Pi RPC command ===\n");
const first = spawn(
process.execPath,
[resolve(scriptDir, "client.mjs"), "first", socketPath, clientState],
{ stdio: "inherit" },
);
const firstExit = await waitForExit(first, 30_000);
if (firstExit.signal !== "SIGKILL") {
throw new Error(`first daemon did not crash as expected: ${JSON.stringify(firstExit)}`);
}
if (bridge.exitCode !== null) throw new Error("bridge exited with the first daemon client");
process.stdout.write("\n=== daemon generation two: attach, replay, cancel, close ===\n");
const second = spawn(
process.execPath,
[resolve(scriptDir, "client.mjs"), "second", socketPath, clientState],
{ stdio: "inherit" },
);
const secondExit = await waitForExit(second, 30_000);
if (secondExit.code !== 0) {
throw new Error(`replacement daemon failed: ${JSON.stringify(secondExit)}`);
}
const bridgeExit = await waitForExit(bridge, 15_000);
if (bridgeExit.code !== 0) {
throw new Error(`bridge did not cleanly exit: ${JSON.stringify(bridgeExit)}`);
}
let stateRemoved = false;
try {
execFileSync(runsc, [`--root=${runtimeRoot}`, "state", containerId], { stdio: "ignore" });
} catch {
stateRemoved = true;
}
if (!stateRemoved) throw new Error("runsc container state remained after explicit close");
let result;
try {
result = JSON.parse(readFileSync(resolve(clientState, "result.json"), "utf8"));
} catch (error) {
throw new Error("replacement daemon wrote an invalid result", { cause: error });
}
const summary = {
verdict: "pi_rpc_transport_continuity_proved",
runscContainerRemoved: stateRemoved,
bridgeLog,
...result,
};
process.stdout.write("\n=== verdict ===\n");
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
} catch (error) {
process.stderr.write("\n=== prototype failed ===\n");
process.stderr.write(`${error.stack ?? String(error)}\n`);
if (existsSync(bridgeLog)) process.stderr.write(readFileSync(bridgeLog, "utf8"));
throw error;
} finally {
if (bridge.exitCode === null) bridge.kill("SIGTERM");
try {
execFileSync(runsc, [`--root=${runtimeRoot}`, "kill", containerId, "KILL"], {
stdio: "ignore",
});
} catch {}
try {
execFileSync(runsc, [`--root=${runtimeRoot}`, "delete", "--force", containerId], {
stdio: "ignore",
});
} catch {}
}
async function waitForPath(path, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (existsSync(path)) return;
await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
}
throw new Error(`timed out waiting for ${path}`);
}
function waitForExit(child, timeoutMs) {
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve({ code: child.exitCode, signal: child.signalCode });
}
return new Promise((resolvePromise, reject) => {
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`timed out waiting for pid ${child.pid}`));
}, timeoutMs);
child.once("exit", (code, signal) => {
clearTimeout(timer);
resolvePromise({ code, signal });
});
child.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
});
}
@@ -0,0 +1,84 @@
#!/usr/bin/env node
// PROTOTYPE ONLY. Generates a rootless OCI bundle for one Pi RPC process.
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const [runscInput, bundleInput, workspaceInput, stateInput] = process.argv.slice(2);
if (!runscInput || !bundleInput || !workspaceInput || !stateInput) {
process.stderr.write("usage: generate-bundle.mjs <runsc> <bundle> <workspace> <state>\n");
process.exit(2);
}
const runsc = resolve(runscInput);
const bundle = resolve(bundleInput);
const workspace = resolve(workspaceInput);
const state = resolve(stateInput);
mkdirSync(bundle, { recursive: true });
mkdirSync(workspace, { recursive: true });
mkdirSync(state, { recursive: true });
mkdirSync(resolve(state, "home"), { recursive: true });
mkdirSync(resolve(state, "pi-agent"), { recursive: true });
mkdirSync(resolve(state, "pi-sessions"), { recursive: true });
execFileSync(runsc, ["--rootless=true", "spec"], { cwd: bundle, stdio: "inherit" });
const configPath = resolve(bundle, "config.json");
let config;
try {
config = JSON.parse(readFileSync(configPath, "utf8"));
} catch (error) {
throw new Error(`runsc generated invalid OCI config at ${configPath}`, { cause: error });
}
config.root = { path: "rootfs", readonly: true };
config.hostname = "paseo-pi-prototype";
config.process.terminal = false;
config.process.cwd = "/workspace";
config.process.args = ["/usr/local/bin/pi", "--mode", "rpc", "--no-session"];
config.process.env = [
"PATH=/usr/local/bin:/usr/bin:/bin",
"HOME=/state/home",
"PI_CODING_AGENT_DIR=/state/pi-agent",
"PI_CODING_AGENT_SESSION_DIR=/state/pi-sessions",
"PI_OFFLINE=1",
"PASEO_AGENT_ID=prototype-active-turn",
"PASEO_AGENT_CWD=/workspace",
];
config.process.noNewPrivileges = true;
config.process.rlimits = [
{ type: "RLIMIT_NOFILE", hard: 1024, soft: 1024 },
{ type: "RLIMIT_NPROC", hard: 256, soft: 256 },
];
config.mounts = [
{ destination: "/proc", type: "proc", source: "proc" },
{
destination: "/dev",
type: "tmpfs",
source: "tmpfs",
options: ["nosuid", "strictatime", "mode=755", "size=65536k"],
},
{
destination: "/tmp",
type: "tmpfs",
source: "tmpfs",
options: ["nosuid", "nodev", "mode=1777", "size=64m"],
},
{
destination: "/workspace",
type: "bind",
source: workspace,
options: ["rbind", "rw", "nosuid", "nodev"],
},
{
destination: "/state",
type: "bind",
source: state,
options: ["rbind", "rw", "nosuid", "nodev", "noexec"],
},
];
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
process.stdout.write(`${configPath}\n`);
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
# PROTOTYPE ONLY. Builds an expendable rootfs for the active-turn bridge demo.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STATE_DIR="${PASEO_RUNSC_PROTOTYPE_STATE:-$SCRIPT_DIR/.prototype-state}"
ROOTFS="$STATE_DIR/bundle/rootfs"
IMAGE="paseo-pi-runsc-prototype:0.84.2"
mkdir -p "$STATE_DIR/bundle"
if [[ -x "$ROOTFS/usr/local/bin/pi" ]]; then
printf 'prototype rootfs already prepared: %s\n' "$ROOTFS"
exit 0
fi
printf 'building disposable Pi image %s\n' "$IMAGE"
docker build --quiet --tag "$IMAGE" - <<'DOCKERFILE'
FROM node:24-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash ca-certificates git ripgrep fd-find \
&& rm -rf /var/lib/apt/lists/* \
&& npm install --global @earendil-works/pi-coding-agent@0.84.2 \
&& mkdir -p /workspace /state/home /state/pi-agent /state/pi-sessions /tmp \
&& chmod 1777 /tmp
ENV PATH=/usr/local/bin:/usr/bin:/bin
ENV HOME=/state/home
ENV PI_CODING_AGENT_DIR=/state/pi-agent
ENV PI_CODING_AGENT_SESSION_DIR=/state/pi-sessions
ENV PI_OFFLINE=1
WORKDIR /workspace
DOCKERFILE
container_id="$(docker create "$IMAGE")"
cleanup() {
docker rm -f "$container_id" >/dev/null 2>&1 || true
}
trap cleanup EXIT
rm -rf "$ROOTFS"
mkdir -p "$ROOTFS"
printf 'exporting rootfs to %s\n' "$ROOTFS"
docker export "$container_id" | tar -C "$ROOTFS" -xf -
printf 'prepared prototype rootfs: %s\n' "$ROOTFS"
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
# PROTOTYPE ONLY. Generated state stays ignored beside this script.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RUNSC="${RUNSC:-/tmp/paseo-runsc-bin/runsc}"
if [[ ! -x "$RUNSC" ]]; then
cat >&2 <<EOF
runsc was not found at $RUNSC.
Download and verify the official binary first, then set RUNSC=/path/to/runsc.
See README.md.
EOF
exit 1
fi
"$SCRIPT_DIR/prepare-rootfs.sh"
RUNSC="$RUNSC" node "$SCRIPT_DIR/demo.mjs"