Based on AgentRun release · Synced .
@parcha/pi-transport
Experimental. The API changes without notice between releases.
Removed: the session home (
./home), the box place (./place), and the move, restore and store checks of./testing. Deprecated, to be removed: the launcher (./launch,Box.launcherandBoxLauncher); the placer (from the rootjevPlacer,jevClassifier,environmentFacts,PlacementsDoc,writePlacement,isPlacementRecord,PlacementErrorand thePlacement*andPlacertypes);./testing'sreplayClassifierand itsFakeLaunch; and thehome,autoandpurposeconfig keys. To run an agent in a box, use@parcha/agentrun-serve: it uses this package's boxes,./handsand./sync, which stay.
A pi agent's tool calls go to Daytona and Modal sandboxes, and Jev places each one on the sandbox that fits it. Jev is TypeSafe's typed-decision model: it answers a question about a step with a choice and a probability, and writes no prose.
Tool calls can also run as serverless calls on an Archil disk (archilExecBoxes).
boxEnv(box) gives a pi agent's read, write, edit and bash a box: it
is pi's ExecutionEnv over the box, for a pi-durable Harness. transport(config, host) does it for each conversation,
from a config that names the environments. The session, its store and its model calls stay with the host; only the
tool calls go to the box. Every provider is behind one Box interface:
| Where | Provider | Isolation |
|---|---|---|
| This machine | localBoxes, from @parcha/pi-transport/box/local |
none: for development and tests |
| Docker | dockerBoxes, from @parcha/pi-transport/box/docker |
a container per box |
| Daytona | daytonaBoxes, from @parcha/pi-transport/box/daytona |
a container per box on the host kernel; a linux-vm snapshot is a VM |
| Modal | modalBoxes, from @parcha/pi-transport/box/modal |
a gVisor sandbox, or a VM (isolation: "vm") |
| Archil exec | archilExecBoxes, from @parcha/pi-transport/box/archil-exec |
a directory of the agent's work disk; each exec is one serverless call in a fresh VM container, and boxes on one disk share it |
Jev places a call from each environment's one-line purpose: the host asks jevPlacer, Jev reads the step and those
lines and says where it should run, and the host points its tools there.
The placer is deprecated and removed in 0.1.0-beta.31.
The reference below takes each part in turn: a box, tools in a box, where tools run, a box's archive, a run's files, Jev's placement, receipts, a program kept alive in a box and writing a provider.
A box
A Box is one running machine: a directory on this machine, a container, a sandbox, a VM. A BoxProvider creates
boxes, finds them again by id and lists them by label.
Install the package with its pi peers, pinned. The pi family is supported at 1.5.0 exactly.
npm install @parcha/pi-transport@latest @earendil-works/pi-durable@1.5.0 @earendil-works/pi-ai@1.5.0 @earendil-works/chord@1.5.0
The example below needs Docker; localBoxes({ root: "./boxes" }) from @parcha/pi-transport/box/local runs it with
no Docker, in a directory on this machine.
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { finish } from "@parcha/pi-transport";
import { localBoxes } from "@parcha/pi-transport/box/local";
const provider = localBoxes({ root: "./boxes" });
const created = await provider.create({ maxLifetimeMs: 10 * 60_000 }, BACKGROUND_CONTEXT);
if (!created.ok) throw created.error;
const box = created.value;
const started = await box.exec(["uname", "-a"], undefined, BACKGROUND_CONTEXT);
if (!started.ok) throw started.error;
const done = await finish(started.value);
if (done.ok) console.log(done.value.exitCode, new TextDecoder().decode(done.value.stdout));
await box.destroy(BACKGROUND_CONTEXT);
That needs no key and no daemon: a box here is a directory under ./boxes. The same calls on Docker, which runs a container per box and needs a Docker daemon:
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { finish } from "@parcha/pi-transport";
import { dockerBoxes } from "@parcha/pi-transport/box/docker";
const provider = dockerBoxes();
const created = await provider.create({ maxLifetimeMs: 10 * 60_000 }, BACKGROUND_CONTEXT);
if (!created.ok) throw created.error;
const box = created.value;
const started = await box.exec(["uname", "-a"], undefined, BACKGROUND_CONTEXT);
if (!started.ok) throw started.error;
const done = await finish(started.value);
if (done.ok) console.log(done.value.exitCode, new TextDecoder().decode(done.value.stdout));
await box.destroy(BACKGROUND_CONTEXT);
The examples are ES modules: save them as .mjs (or set "type": "module"); the transport() example below has TypeScript types, so save that one as .mts (Node runs it as it is). Install the peers, @earendil-works/chord and @earendil-works/pi-durable, beside @parcha/pi-transport. Node 22.19 or later.
Expected failures are returned as Result values with a BoxError and a code. Every box has a maximum lifetime
that its provider enforces with no call from the host, so a host that dies does not leave boxes behind. A program in
a box never receives the host process's environment. A provider refuses a spec field it cannot honour instead of
ignoring it. The table above lists the providers; the sections below say what each one does with a spec.
Daytona
daytonaBoxes({ apiKey, snapshot }) talks to the Daytona REST API with fetch; there is no SDK, and the key stays in
the host. Daytona sets a box's size with its snapshot, so a spec that names cpus, memoryMb or gpu is refused:
choose the size by naming a snapshot. A box that outlives maxLifetimeMs is ended by Daytona (ttlMinutes, measured
at about 65 to 70 s for a one-minute lifetime). Daytona's label index trails a create and answers each query shape on
its own schedule, so list always asks Daytona for the pi-transport label alone and filters the other labels itself,
and create returns only once that listing shows the box. A listing therefore pages through every box this package
made in the organization. The default workspace is /tmp/workspace; one the sandbox's user may not create (below /run) is
made with sudo -n and given to that user, and a snapshot without a passwordless sudo fails the create naming the path. Programs run through the toolbox with their input
and output spooled in /tmp/pi-transport, polled about every 150 ms. A default sandbox is a container on the host
kernel (not gVisor); the vm class is mapped from a linux-vm snapshot but was not verified live in this run (a VM
snapshot could not be created in the test organization), so treat it as untested.
Modal
modalBoxes(sdk, { appName }) takes the host's own Modal client, so this package depends on no Modal SDK. The
client is typed structurally (ModalSdk); the ModalClient of the modal package, version 0.11.0, fits it.
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { ModalClient } from "modal";
import { modalBoxes } from "@parcha/pi-transport/box/modal";
const provider = modalBoxes(new ModalClient(), { appName: "my-boxes" });
await provider.create({ maxLifetimeMs: 10 * 60_000, isolation: "vm" }, BACKGROUND_CONTEXT);
isolation: "vm"starts a VM with its own kernel."gvisor"or no value starts Modal's default sandbox. Any other value is refused withunsupported.gpu: "T4"works on the default sandbox only; a GPU withisolation: "vm"is refused withinvalid.facts().gpuis the model served, read in the box withnvidia-smi(a bareH100may be served an H200;H100!pins the type): one name for identical cards, every model, in the order first seen and joined by+, for a mixed box (NVIDIA A100 + NVIDIA H100 80GB HBM3). The request stays what the caller passed. A box that asked for a GPU and reports none fails the create and is ended. On attach the read runs again and the box is handed back whatever it says (see "Attach never fails a live sandbox"): a model named is the fact, none named readsgpu: nulland the caller ends the box through the handle, and when every try errs the handle keeps the model measured at create (from the tag), or the attach is an error for a box whose tag has none.- A
snapshotand anyisolationbutgvisororvmare refused withunsupported; a label key that ispi-transportor starts withpi-transport.is refused withinvalid; every box carriespi-transport=1as its own tag. No other field of the spec is ignored. network: "none"blocks all traffic from the box.network: { allow: ["api.example.com", "*.example.com", "10.0.0.0/8"] }allows only those domains and IPv4 CIDRs. A malformed entry, an empty list, orportson a box withnetwork: "none"is refused withinvalid. The default is open.ports: [8080, { port: 5432, kind: "tcp" }]exposes ports: a bare number is TLS (https),tcpis a raw socket.facts().portsnames each port and its kind, andawait box.endpoint(8080, context)returns the address (anhttpsURL, or atcphost and port), never Modal's id for the sandbox.capabilities: ["fuse"]starts a VM (isolation: "vm"is implied) and checks, as root in the box, that/dev/fuseis a character device that root can open (a VM's iscrw-------, root-only, and that is enough: a FUSE mount there is root's, and nothing widens the device for other users).facts().fuseistrueonly after that check; a box that fails it is ended and the create fails, naming what is missing. It does not prove a given FUSE program mounts, only that the device is there for root.fusewithisolation: "gvisor"is refused withunsupported, and with agpuwithinvalid.facts().secretsViais"fd": a launch's stdin and secrets (the launch frame) ride the stdin of the one exec that starts the launcher. A Modal exec's stdin is a stream with an explicit end (close()sends the EOF, also with no byte written), written in 512 KiB chunks.secretsVia: "fd"means: no client-controlled place (argv, theenvandsecretsparameters, tags, facts, errors, echoed output) and no place in the box but the program's pipes holds the secret. The provider is trusted with the box, and whether it keeps an exec's stdin is undocumented, so no claim is made about that. Aclose()that fails on a program still running is returned bywait()(the program would otherwise wait for an EOF that never comes); on a program that already exited it is ignored. The input is closed once: aclose()after the first (a launch closes it after the frame, andfinishcloses it again) returns the first and is no failure.- Attach never fails a live sandbox and never ends it. A box found again by
attachis always handed back, and the caller ends it through the handle if its facts do not fit. The facts are read in the box again (a failed read is tried three times, with a pause). A read that answers gives the facts as measured, so a box with no usable FUSE readsfuse: false. If every try errs, the handle keeps the hardware the create measured (the disk, whether FUSE works), which is kept in a tag set through Modal's API: nothing inside the box can write it. A security fact (the Yama setting, the network, the ports) is never taken from a tag on a read error: it is left out, unknown. A box made before the tag existed has nothing measured to fall back on: if every try errs, the attach is an error (the box keeps running; try again), never a guess given as measured. The workspace setupattachrepeats can fail too, and then the attach is an error as well (never an ok handle on a box that may have no workspace); nothing is ended or created.attachreturnsundefinedfor a box that does not exist or has ended. facts()also reports what the box has, read in it:diskGb(the workspace's filesystem, whole GiB; left out when unreadable or over 1 PiB, which is what gVisor's filesystem claims),ptraceScope(kernel.yama.ptrace_scope; a box without the file reads 0;nullwhen it could not be read) andstartedAt(an ISO 8601 UTC time; for Modal, the moment it was asked to start).maxLifetimeMsis Modal's own timeout, rounded up to whole seconds and capped at 24 hours. Modal ends the box when it passes, whatever happens to the host.- The live suite spends money and runs only when asked. It needs
MODAL_TOKEN_IDandMODAL_TOKEN_SECRETin the environment and the SDK, which is not installed with this package:npm install --no-save modal@0.11.0, thenPI_TRANSPORT_LIVE_MODAL=1 npm run test:live -w @parcha/pi-transport(test:liveloads every live file, and each provider's own variable picks the ones that run). It names its Modal app and labels fromPI_TRANSPORT_LIVE_PREFIXandPI_TRANSPORT_LIVE_LABELS, andPI_TRANSPORT_LIVE_MODAL_KINDS(sandbox,vm,gpu; defaultsandbox,vm) picks the kinds.
The new facts are optional, and an absent one never grants anything: readFacts(facts) reads them the way that fails closed. fuse
absent is false; network absent is unknown (a caller that needs none or an allowlist must refuse the box); ports absent is
unknown, not none (a caller that must be sure no port is exposed must refuse; noPortExposed is true only for a list that is
empty); ptraceScope absent is unknown (do not assume 1 or more); diskGb and startedAt absent are unknown (a receipt falls back to
the time the create call answered). Only Modal reports all of them and can restrict a network to an allowlist, expose ports or give
fuse. Docker takes network: "none" (--network none), reports ports: [] and refuses the rest. Daytona takes network: "none"
(networkBlockAll) and reports network: "none" only once the sandbox's own read says Daytona blocked it; a sandbox it did not block
(an organization on tier 1 or 2, whose own network policy holds) is deleted and the create refused as unsupported. It takes an
allowlist of domains (a *. wildcard allowed) as Daytona's domain list (domainAllowList, which Daytona's egress proxy enforces,
re-signing TLS with its own CA) and reports network: "allowlist", with the hosts in allow, only once the sandbox's own read
carries that list (comma-separated, or a list of hosts); a sandbox without it, or with a value that is neither, is deleted and
the create refused as unsupported the same way. A CIDR in the list is refused. Daytona also
reports diskGb and startedAt. Local reports network: "open" and startedAt, the Archil exec box startedAt, and those two
and Daytona refuse every other network, ports and capabilities as unsupported.
localBoxes gives a program a fixed PATH (/usr/local/bin:/usr/bin:/bin, or the path option), a private HOME
and TMPDIR, and the spec's env. A detached watchdog ends a box when its lifetime has passed. On Linux, destroy
and the watchdog end every program of the box, also one that went to the background or left its process group. Where
/proc is missing they end only the programs the calling process started, and the watchdog removes the directory only.
It refuses image, snapshot, cpus, memoryMb, gpu, region and any isolation but none.
dockerBoxes runs sleep for the lifetime in place of the image's own entrypoint, so Docker ends the box. It refuses
snapshot, region and any isolation but container. Docker cannot choose a GPU model: any gpu gives the
container every GPU of the host. dockerBoxes({ user: "1000:1000" }) runs every program of its boxes as that uid:gid
(--user), so what a box writes in a bound directory stays the host user's. dockerBoxes({ cpus: 2, memory: "4g", pidsLimit: 1024 }) bounds every box: --cpu-period 100000 --cpu-quota 200000, --memory 4g with --memory-swap 4g (so
swap never adds to it) and --pids-limit 1024. CPUs, the option's or a spec's, are the CFS quota --cpus would set,
which Docker never checks against its own CPU count (a VM's, under Docker Desktop) and drops with a warning where it has no
CPU controller; --cpus fails in both. cpus is at least 0.01, memory is bytes or Docker's size (512m, 4g), and
pidsLimit is at most 4194304. A spec's own cpus or memoryMb takes the option's place, and facts() says what the box
got. With none of them a box has no limit.
It alone binds a spec's mounts ([{ source: "/data/run", target: "/workspace/run", readOnly: true }], canonical paths used as given, at most 8) with --mount type=bind, never -v, and refuses a source this machine lacks with not_found; provider.mounts says whether a provider binds them, and every other one refuses them as unsupported.
dockerBoxes({ volumes: [{ name: "v", target: "/run/x", readOnly: true }] }) mounts a named Docker volume on every box
(--mount type=volume, after the binds): it lives on the daemon's own disk, so it carries what a file-sharing layer to a
VM does not, such as a Unix socket another container made there. A name Docker would not take, or a target that is not an
absolute path in normal form, throws at once; a spec's bind on a volume's target is refused invalid.
dockerBoxes({ dropCapabilities: true }) runs every box with --cap-drop ALL and --security-opt no-new-privileges, for a
host whose boxes' programs never change user: as root a box then cannot make a device node in a directory it binds. With
it, attach ends a box that was made without it (by an older version) instead of taking it, so the caller makes a new
one. A launcher that drops from root to another user (setpriv, runuser) needs Docker's default set, so it stays the
default. The daemon is the one the docker CLI reaches: when DOCKER_HOST, or the docker context, names another machine
(tcp://, ssh://) rather than a unix socket, provider.mounts is false and a spec with mounts is refused as unsupported,
since that daemon would bind its own paths, not this machine's. With both DOCKER_HOST and DOCKER_CONTEXT set, both
must be this machine, and every create checks again, so a docker context use after the provider was made refuses the
next spec with mounts.
Archil exec
archilExecBoxes({ client, disk }) makes a box of a directory of an agent's Archil WORK disk (never the disk of its
store), and runs each exec as one Archil serverless exec: a fresh container with the disk mounted, one request that
answers when the command ends. It takes the host's own SDK, so this package depends on none: client is new Archil({ apiKey, region }) and disk is a disk of it, from the disk package (version 1.9.0 was measured; npm install --no-save disk@1.9.0).
import * as archil from "disk";
import { archilExecBoxes } from "@parcha/pi-transport/box/archil-exec";
const client = new archil.Archil({ apiKey, region: "aws-us-east-1" });
const { disk } = await client.disks.create({ name: "agent-work" });
const provider = archilExecBoxes({ client, disk });
The box is the directory boxes/<id>/ of the disk. Its labels, facts and end time are the file
boxes.records/<id>.json, outside every directory a command can hold, so attach and list find the box after a
restart and a box can be ended while a command runs in it. These are the limits, each one in the facts or refused with
a typed error:
- One request per command.
facts().liveProcessesisfalse: no program keeps running between calls, and a command's output arrives after it ended. Use it for tools. - A 300 s limit.
facts().maxExecMsis 300 000. AtimeoutMsabove it is refused withinvalid; a command with none still gets a deadline of 295 s of its own inside the container, so nothing runs past the point where the platform cuts a call and loses its later writes. A command ended by its deadline is atimeouterror; an exit code of 124, which is what ends a command at its deadline, is read that way too. - No stdin.
stdin.writerejects withunsupported. Output is text: bytes that are not UTF-8 come back as U+FFFD, so move bytes withuploadanddownload, which use the disk's object API and carry every byte value (8 MiB measured in about 0.3 s each way). - Aborting the context ends the wait at once. The SDK has no signal to stop the command in the container, so the command ends at its own deadline (an abort of the raw HTTP request was measured to stop a command at the platform, but not through this SDK).
- Through
boxEnv(the file and bash tools). An environment on such a box reads and writes files throughuploadanddownload, so a write keeps every byte and a binary read is exact. Each transfer runs under the operation deadline and is cancelled with its call, so one still queued behind a command is not run after a timeout or an abort. An append is the bytes uploaded to a file of their own and one command that appends it, so a command queued beside it keeps its change. A binary reader refuses a file over its limit before moving a byte. A path outside the box's workspace can be read as text only, and not written. A command's own deadline is the caller'stimeout, and a caller that gives up never has its command run later. A command stopped by a timeout or an abort cannot be killed: nothing reaches a running call, so it ends at its own deadline and the next call to the box waits for it. Temporary and spill files go under the workspace. A watcher bills one call per interval. - One command at a time per box, across processes. Every
execchecks out the box's directory with a queue (queueMs, default 60 s), so a second process that attached the same box (a restore, another host) waits for the first instead of being refused (a plain write without a checkout loses writes: 3 of 5 parallel appends were refused), and this process also runs a box's execs in the order they were asked. Boxes do not wait for each other. A file written throughuploadtakes no checkout: it waits for this process's own commands and retries while another process holds the directory, up toqueueMs. destroyends the box for every client at once, whatever still runs in it, by deleting its record; the directory is removed through its parent (destroyWaitMs, default 5 s) and a directory a running command held is removed by the nextlist, which sweeps every directory with no record.- A lifetime is checked, not enforced by a timer. Archil has no timer that ends a directory: a box past its
maxLifetimeMsis ended, and its directory removed, by the first call that looks at it (attach,list,exec,status,upload,download). A box nobody calls again stays on the disk until alistfinds it. - The isolation is one VM per call, and the disk is shared. Each call runs in its own fresh VM container, but boxes on one disk share it: a command can read and write every box's directory, and the records, on that disk. Give each agent its own work disk, never one disk to two agents.
- A box has no env. Archil's exec carries only the command, so a value could only ride in the command or in the box's
record on the disk, where any command on that disk can read it. A spec with an env (any name, any value) is refused with
unsupported, a per-call env of aboxEnvenvironment on such a box (any name, andPATH) is refused with aspawn_error, and the record and every call's command carry none. A program gets what it is given as arguments or as files. Each exec container's own environment holds the platform'sARCHIL_CONTAINER_ID,ARCHIL_DISK_IDandARCHIL_REGION(measured; no key). - Refused before any call: a GPU, a snapshot, a
workspace, an isolation other thanvm, more than 2 vCPU or 3938 MiB (unsupported); a label in the providers' namespace, an env of any kind, a lifetime that is not positive (invalid). - A box billed per call gets its receipt with
callsin place of seconds (see receipts).
The live suite spends money and runs only when asked. It needs the key in ARCHIL_API_KEY and the SDK:
PI_TRANSPORT_LIVE_ARCHIL=1 npm run test:live -w @parcha/pi-transport (PI_TRANSPORT_LIVE_ARCHIL_SDK names the SDK's
entry file when disk here is another version). It makes its own new disk in aws-us-east-1, deletes it at the end and
checks that none is left.
Hands: tools run in a box
boxEnv(box) is pi's ExecutionEnv over a box. Hand it to a pi-durable Harness and the session's read, write,
edit and bash tools run in the box, while the session, its store and its model calls stay with the host.
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { boxEnv } from "@parcha/pi-transport/hands";
// `box` is a box made as in "A box" above, before its destroy.
const env = boxEnv(box);
const written = await env.writeFile("notes/a.txt", "hello", BACKGROUND_CONTEXT);
const ran = await env.exec("wc -c notes/a.txt", { timeout: 10 }, BACKGROUND_CONTEXT);
- Every file operation and every command is one
Box.execof a fixed script. Paths are arguments, never part of the script. - Every file operation has a deadline, and so has the start of a command. A box that ended or stopped answering is
a tool error on that call and on every later one, with the reason in the message. A command runs as long as its
caller's timeout allows; with none it has no deadline, as in pi's local environment, unless
maxExecMssets one. - A command runs as a process group of its own. A timeout or an abort kills the whole group with a second exec, also on a box whose provider cannot stop a program it started.
- The kill is confirmed, not assumed. The kill exec reads back that no live process of the group is left (a zombie does not
count: a container whose first process does not reap would otherwise keep a killed group "alive" for ever) and is sent
again, at 1, 2 and 4 seconds, when it fails, times out or finds the group alive. The whole confirmation has 10 seconds of
wall time, a call in flight included, so a stop answers
abortedortimeoutafter at most 10 seconds more than before, and only when a kill call failed. When the group could not be confirmed gone, the error is aStoppedCommandError: itskillUnconfirmedistrue, and the command stays tracked, socleanup()ends it with the rest.killUnconfirmedis absent when the group was confirmed gone, which is the normal case. - A host that dies leaves its commands running (ending a
docker execclient does not end its program), and itscleanup()with it.endLeftCommands(box, context), from@parcha/pi-transport/hands, kills every command group an environment wrote down in the box: the host that takes a box over calls it once, before its first command. - The variables a caller asks for,
PATHincluded, reach the command alone. The environment's own scripts keep the box's search path, so aPATHwith no shell in it still runs a program named by its path. - Results match pi's
NodeExecutionEnvon the same files: the same values and the same error codes. Messages differ. The suite checks this with pi's own environment conformance cases and with random operation sequences run against both. - The environment's id is
pi-transport:<provider>:<box id>.~is the box's home directory.
What a box must have: a POSIX sh, and the GNU versions of dd, find, mv, mktemp, realpath, sleep and
truncate with setsid (coreutils, findutils and util-linux, as in Debian and Ubuntu images). An image built on
BusyBox does not have them. A string command runs in bash when the box has it, else in sh. The environment keeps its
commands' process groups in .pi-transport under the box's temporary directory (TMPDIR, else /tmp).
Where it differs from NodeExecutionEnv, on purpose:
- A command that is stopped is asked to end first and killed a second later. Node kills at once.
- With
inheritEnv: falsea command still has a defaultPATH. - A rename across file systems succeeds, because
mvcopies. listDirreturns a name that is not UTF-8 with the replacement character. Node fails the whole listing.- A watcher polls, so its
modeispolling. openBinaryReaderandopenTextLineReaderread the file whole when they open it, in one round trip to the box, and serve every later read from that copy on the host. A file abovemaxReaderBytes(64 MiB by default) is refused withnot_supportedbefore a byte of it leaves the box; a command (head,tail,sed) reads such a file in parts. Node reads the ranges it is asked for.readTextLineswithmaxLinesbrings only those lines out of the box.
Not covered by hands: MCP servers, code mode and model calls stay with the Harness.
Where tools run is a line of config
transport(config, host) is a Harness's env. It reads the transport block of an agent's config and runs each
conversation's tools in the environment the block's hands names.
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { TranscriptContext } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { createRegistry, Harness, MemoryStorage } from "@earendil-works/pi-durable";
import { CodingTools } from "@earendil-works/pi-durable/tools";
import { parseTransportConfig } from "@parcha/pi-transport";
import { localBoxes } from "@parcha/pi-transport/box/local";
import { transport } from "@parcha/pi-transport/hands";
// The model is scripted, so this costs nothing: it runs `hostname` with pi's bash tool, then prints the result.
const faux = fauxProvider();
const models = createModels();
models.setProvider(faux.provider);
faux.setResponses([
fauxAssistantMessage(fauxToolCall("bash", { command: "hostname" }), { stopReason: "toolUse" }),
(transcript: TranscriptContext) => {
const result = transcript.messages.at(-1);
if (result?.role === "toolResult") {
console.log(result.content.map((part) => (part.type === "text" ? part.text : "")).join("").trim());
}
return fauxAssistantMessage("Done.");
},
]);
const registry = createRegistry();
registry.install(CodingTools);
const storage = new MemoryStorage();
const config = parseTransportConfig({
environments: { sandbox: { provider: "local", maxLifetime: "6h" } },
hands: "sandbox",
});
if (!config.ok) throw config.error;
// The transport is the Harness's `env`, so it is built first and reaches the Harness through this binding.
let harness: Harness;
const hands = transport(config.value, {
// For Docker: `provider: "docker"` above, and `docker: dockerBoxes()` here.
providers: { local: localBoxes({ root: join(mkdtempSync(join(tmpdir(), "pi-transport-")), "boxes") }) },
commit: (change, context) => harness.commit(change, context),
});
harness = await Harness.open(storage, { models, registry, env: hands.env }, BACKGROUND_CONTEXT);
const model = { provider: "faux", modelId: faux.getModel().id };
const conversation = await harness.createConversation(
{ ownership: { kind: "ownerless" }, agent: { model } },
BACKGROUND_CONTEXT,
);
await (await conversation.submit({ type: "input", content: "run hostname" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
// The session is over: end its boxes (`release()` leaves them running for a host that opens the session again).
await hands.destroy(BACKGROUND_CONTEXT);
await harness.close(BACKGROUND_CONTEXT);
- A box is created at the first file operation or command, not before. pi asks for a conversation's environment
at every prompt it prepares as well as at every tool call, so
envreturns a lazy environment. Until its box exists itscwdis.and its id ends in:unopened. A run that calls no tool creates no box. - A host that opens the session again finds its boxes. A box's id is written to a session document
(
transport.boxes) before a tool runs in it, and the nexttransport()on the same store asks the provider for that box. Concurrent first uses share one creation. - One box for the session, or one for each conversation.
scope: "session"is the default; withscope: "conversation"every conversation gets its own. - Roles.
hands: { default: "sandbox", build: "gpu" }names a role.hands.assign(conversation, "build", context)points a conversation's tools at it, or at any declared environment, from its next tool call. The assignment is a conversation document (transport.hands). A fork starts without one, on the default. - A box that is gone is one failed tool call. A box that cannot be created, or that ended behind the session's
back, is that call's error result with the provider's code in the message. The next call asks for a box again.
A box the environment found lost answers
execwith codeunknown(pi's execution codes have nogone) and, aserror.cause, the provider'sBoxErrorwith codegone;onLostfires once, however many calls find it out. - Two ways to let boxes go.
release()forgets them and leaves them running, for a host that will open the session again; a box nobody comes back to ends at its lifetime.destroy()ends every box of the session, including those only the session's documents still name. - Labels are the host's.
labelsin the host go on every box the transport creates, for example a session id, so the host can list what it left behind. The transport adds none of its own:pi-transportand the keys under it belong to providers, which mark their own boxes. undefinedmeans the host's own environment.envreturnsundefinedwhen the config names nohands, or names alocalenvironment and the host passed nolocalprovider. A host puts its own environment behind it:(await hands.env(target, context)) ?? new NodeExecutionEnv({ cwd }).
transport() throws a TransportError at start for a config it cannot serve: an environment whose provider the
host did not pass, hands: "auto", and expose.
expose is not available yet: no secret is passed into a box. When it is, a secret will travel with each command
over the exec's input and be set in the box by the environment's launcher. It will never be in BoxSpec.env, which
a provider may store and return, and never in a command line.
The repository's examples/01-hands.ts runs one scripted session whose tools go where a config file says. It is not in the
npm package; from a checkout, in packages/pi-transport:
node --experimental-strip-types examples/01-hands.ts examples/hands.local.json 'hostname; pwd'
A box's archive, unpacked on this machine (./sync)
A box is not trusted, so neither is a byte of a tar archive it makes. The host never unpacks one with tar:
@parcha/pi-transport/sync reads it with its own reader as it arrives and writes it under one directory.
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { readStream } from "@parcha/pi-transport";
import { extractTar } from "@parcha/pi-transport/sync";
const packed = await box.exec(["tar", "-c", "-f", "-", "-C", "/work", "."], undefined, BACKGROUND_CONTEXT);
if (!packed.ok) throw packed.error;
const tar = packed.value;
// Both streams are read: a program whose error pipe fills stops writing its output.
const [extracted, errors] = await Promise.all([
extractTar(tar.stdout, "/data/runs/r1/work", {
onSkip: (skip) => console.warn(skip.reason, skip.path, skip.target ?? ""),
}),
readStream(tar.stderr),
]);
const exited = await tar.wait();
if (!exited.ok || exited.value !== 0) console.warn("tar failed:", new TextDecoder().decode(errors));
if (extracted.ok && extracted.value.stopped !== undefined) console.warn(extracted.value.stopped.message);
- Regular files and directories only. A symbolic or hard link, a device, a pipe or any other member is skipped,
counted by reason (
receipt.skipped, the first 100 by path inreceipt.skips, each one toonSkip), and never created. The skip of a link carriestarget, what the link names, so a caller that keeps links keeps them as data. - Safe names, and only the ones asked for. A name is refused when it is absolute, holds
.,.., an empty segment or a NUL, or passes 255 bytes a segment or 4095 a path.admitdecides by path and kind what may be written; a second member of a path is skipped, and a pax global header is not honoured. - Nothing outside the destination. Every directory is opened from a held handle of the one above with
O_NOFOLLOW, so a link already in the destination is never followed (link_in_destination), and a file never replaces a directory or the other way round (occupied). Each file is written under a temporary name and renamed into place; one cut off halfway is removed. - Modes capped. A file gets at most 0755 (0644 without an execute bit), never setuid, setgid, group or world write;
a directory at most 0755.
receipt.maskedcounts the files written that lost a bit. - Limits. The extraction stops cleanly past
maxBytesof archive (header blocks, pax and long-name payloads and member data, each charged before it is read; defaultMAX_PULL_BYTES, 1 GiB) ormaxEntriesmembers, written or skipped (defaultMAX_PULL_ENTRIES, 100,000). One pax header or long name holds at most 64 KiB. A bad checksum, a lone zero block or an archive cut short stops it too (malformed). What was written before a stop stays, every file whole, andreceipt.stoppedsays why.
openExtractor is the same reader fed a chunk at a time: write until it returns false, then finish, which waits
for a write still running. keep: "newer" keeps a host file newer than the member. removeEntries removes paths
under a directory by the same walk: a file or a link is unlinked (never what a link names), a directory only when
empty. A failure of the host's own file system is transfer_failed; everything the archive does wrong is in the
receipt.
A run's files between this machine and a box (./sync)
A run whose box shares no disk with the host keeps its files on the host and moves them: in when the box starts, back
after each turn. @parcha/pi-transport/sync does both through any Box, with the transport of the movers above (one
tar, streamed or as files by facts().bulk, the same stall limit, deadline and clean-up).
import { pullAgentHome, pullDir, seedAgentHome, seedDir } from "@parcha/pi-transport/sync";
await seedAgentHome(box, { from: "/data/homes/a1", to: "/run/agentrun/home" }, ctx); // learning/ goes read-only
const seeded = await seedDir(box, { from: "/data/runs/r1/work", to: "/workspace" }, ctx);
let mark = seeded.ok ? seeded.value.mark : undefined; // the box's listing, with the SHA-256 of each file sent
const pulled = await pullDir(box, { from: "/workspace", to: "/data/runs/r1/work", since: mark, mirrorDeletes: true }, ctx);
if (pulled.ok) mark = pulled.value.mark; // kept for the next pull
- Seed. Regular files and directories go. A link on the host is never followed (it could carry a file from outside
the directory into the box): it is skipped and counted with pipes, sockets and devices.
excludeleaves paths out,readOnlysends them without a write bit. Modes are capped at 0755. - Pull. Incremental by listing: the box lists the directory (kind, size, mode, time to the nanosecond, every path
NUL-separated) and a path comes back when it is new or listed differently from the
markof the seed or the last pull. The box's clock is never compared with the host's: a listing sees a file restored with an old time, and deletions. Dotfiles come back like any file.excludeis applied to the listing, to deletions and to every member of the archive.mirrorDeletesremoves what the box no longer has: files and links unlinked, a directory only when empty.pullAgentHomenever brings backlearning/and never removes it. The host reads at mostmaxListingBytesof the listing (defaultMAX_LISTING_BYTES, 64 MiB): past it the box's program is stopped and the pull istoo_large. A copy that fails after something was written still returns the mark, withstopped.reason: "copy_failed"and the copy's error; a file asked for and not written keeps its old entry. - A home file another run wrote stays.
overwrite: "if_unchanged"(always on inpullAgentHome) lets a pulled file replace, or a mirrored deletion remove, the host's copy only while that copy is still the bytes the mark records: the SHA-256 of what was seeded into the box, or of what the last pull wrote. A host copy that changed since (another run's pull, a host edit) wins; the box's file is not fetched, and the skip is counted and named ashost_changed. The mark keeps the hashes:seedDirreturns the first one (what it sent; what the box already held comes on the first pull), each pull the next. Pulls into one directory run one at a time in a process, from the first check to the mark, so two runs' pulls into one home never both pass the check against the same bytes. - The archive is not trusted. Every pulled member goes through
extractTar(above), nevertar. Two names of one inode in the box both come back as files: the box packs with--hard-dereference, and a hard link member is still refused.
The box needs sh, GNU tar and find, and split, sha256sum, cut, wc and cat when it moves files, as the
movers do. No name is ever put in a shell string: the box reads the list on its standard input.
Where a session runs: the placer
Deprecated: The placer, with the
autoandpurposeconfig keys, is removed in 0.1.0-beta.31. To run an agent in a box, use@parcha/agentrun-serve: it uses this package's boxes,./handsand./sync, which stay.
A config whose home is auto lets Jev place the session. Each environment then has a purpose, the author's one
line on what it is for, which is what Jev reads for it (required when auto places the session, never made up from the
facts); auto.rest is where the session goes back when nothing needs it elsewhere (default the first declared
environment) and auto.threshold how sure Jev must be (default 0.5).
jevPlacer(models, auto.classifier, { rest, threshold }) asks Jev through pi-ai's Models.classify (provider
typesafe, model jev-latest, on TYPESAFE_API_KEY; or Jev through OpenRouter's System One, provider openrouter,
model ~typesafe/jev-latest, on OPENROUTER_API_KEY): one call a decision, two questions. need is whether the
step needs something rest cannot do, wherever the session is now; where is a choice among the environments, each
by its purpose. Jev reads the step and each environment's purpose and facts (GPU, CPUs, memory, capabilities,
isolation, region; not the provider, which names how a box is made). The session
moves only for a need: need at least the threshold and where elsewhere. A session away from rest stays while its
next step needs that place, and goes back to rest at a turn's end when nothing does, by the config. Code checks only
structure: the judgment is Jev's.
- Jev or the config, nothing else. Every decision is a
PlacementRecordwhosebyisjevorconfig. A decision Jev does not give (an error, an answer that is not one, the placer's own deadline, 15 s by default) keeps the session where it is and says why inerror. - No stand-in.
jevPlacerrefuses to build without the classifier model (no_classifier) or its provider's key (no_key). - On the key you have.
jevClassifier(process.env)picks the classifier as serve's decisions do: TypeSafe's own API whenTYPESAFE_API_KEYis set, else Jev through OpenRouter whenOPENROUTER_API_KEYis, elseundefined. The host'sModelsholds pi-ai'stypesafeProvider()andopenrouterProvider(). - Cost. A decision's usage counts tokens;
costUsdisnullwhen the classifier has no price (pi-ai's catalog gives TypeSafe's own API none, and prices Jev through OpenRouter): unknown, not 0.
./testing has replayClassifier, TypeSafe as a replay set keyed by the canonical question (an unknown question is
an error, never an answer), and recordingClassifications to record one. This package's set, 40 decisions recorded
against Jev, replays in its tests offline.
What a box cost: receipts
boxReceipts names each box by a label (box-1, box-2, never the provider's id) and writes its receipt when it
ends: the provider, the kind of box, when it started and ended, and its cost at the rates the host passes. This
package holds no price table; a box the host gives no rate gets no receipt.
boxReceipts({ prices, write }) keeps a ledger of the boxes its providers make: ledger.wrap(provider) is the same
provider, and each box it makes gets one receipt when it ends, destroyed through it, found ended by status, or found
gone by attach (a lifetime that ran out, or a sweep). The host writes each receipt as a live line, source
transport, kind box.receipt: { box, provider, kind, from, to, seconds, usd_per_second, cost_usd, source, priced };
a box its provider bills per call instead (archil-exec) carries calls and usd_per_call in place of usd_per_second,
which is then absent, never 0 (RECORDING.md draft 40).
box is a label, box-1, box-2 as the boxes are made, never the provider's id. Hand the wrapped providers to
everything that makes boxes: a box place, and the transport's hands pools (TransportHost.providers).
A host that labels boxes of its own passes label(made): it is called once when a provider has made a box, with that
box's own request and facts ({ provider, kind, spec, facts }, its id left out), so a host reads its label from the
request it made (spec.labels), never from the order it asked in: boxes asked for together, from providers that answer
at different speeds, are made in any order. After a restart, taken passes the labels the recording already holds (its
box.receipt lines), and the default labeler numbers on past them. A box the killed process made or was making is not
the new ledger's: no receipt is written for it, so its cost stays unknown.
The prices are the host's (prices: [{ provider, kind, usdPerSecond, source, priced }], with source the page a price
was read from and priced the date it was read): this package holds no price table. A box's kind is found from its
facts (gpu:<gpu> for a box with a GPU, else its isolation), or by the host's kindOf. A box with no price writes no
receipt, so its cost stays unknown, never 0. cost_usd is an estimate at list price, seconds times the price per
second: not the provider's invoice, which bills by its own rounding, minimums and discounts. A price with usdPerCall
(and no usdPerSecond) is for a box billed per call: its receipt counts the exec calls the box started, one aborted or
given up on included, and cost_usd is calls times usd_per_call rounded to 9 decimals; seconds stays its lifetime,
for display only. A call refused before it is made is not counted. A box another process or host made is its maker's to receipt.
Launch: a program kept alive in a box
Deprecated:
@parcha/pi-transport/launch, withBox.launcher, is removed in 0.1.0-beta.31. To run an agent in a box, use@parcha/agentrun-serve: it uses this package's boxes,./handsand./sync, which stay.
A box has no init system, and a host's exec ends when the host goes away. launch keeps one program running in a box
whatever happens to the host. It uploads a small sh supervisor and starts it in its own session, so the program
outlives the call, the exec and the host process. The launcher is @parcha/pi-transport/launch.
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { launch, launchState, launchStop } from "@parcha/pi-transport/launch";
// `box` is a box made as in "A box" above.
const started = await launch(box, "runner", { argv: ["node", "runner.js"], env: { APP_MODE: "home" } }, BACKGROUND_CONTEXT);
if (!started.ok) throw started.error; // a LaunchError: invalid, unsupported, refused, unsafe, box, timeout or failed
const handle = started.value.launch; // { v: 1, name: "runner" }: JSON, no secret
const state = await launchState(box, handle, BACKGROUND_CONTEXT); // read from a file in the box
await launchStop(box, handle, { graceMs: 5_000 }, BACKGROUND_CONTEXT);
launchStatus(box, name, ctx) and stopLaunch(box, name, options, ctx) do the same by name. launchSpecError(name, spec) is
the spec's own check, pure (no box, no I/O): the same invalid error launch would give, or undefined, for a host that asks
before it makes a box to launch in.
launchtwice with the same name starts nothing and returns the first one's state. A launch that has ended (exited,gave_up,stopped) starts again.- The program restarts when it exits, unless the exit code is
0or interminalExits, orrestartisfalse. More thanrestartLimit.burstrestarts inrestartLimit.intervalMs(default 5 in 60 s) ends ingave_upwith the last exit code. Numbers must be finite and within bounds. - The state is read from the launcher's own state file, never guessed from whether a process lives. It is
restartingorexitedwhile the launcher handles an exit, neverrunning. It carries the launch's handle (launch), theincarnationnow or last running (from 1), andrestarts(the incarnation less one). stopLaunchends the program and everything in its process group: TERM, then KILL aftergraceMs, then it reportsstopped.graceMsis a number from 0 to 600000 and the call to the box is given that long on top of its usual time. The saved process id is signalled only while it still names the process the launch started; the id of a process that ended and was given to another job is left alone.beforeStop(argv,timeoutMsfrom 100 to 120000, default 30000) runs ONCE per launch, after the drain and before the final kill, as the launcher's own user, in a session of its own. One past its bound is killed (137). Its exit goes toout.log, and it never fails the stop.- A stop is ONE stop's. The first one claims it, and the phase is
stoppinguntil it has killed what was left and writtenstopped; nothing else writes the state meanwhile.- A second stop at the same time never kills: it waits for the first one's end and returns its state. One whose own
bound (its
graceMs, the hook at its longest, and 3 s) ends first returnsstopping. - A stop whose first one died (past its deadline: that stop's grace, its hook's bound and 3 s) is taken over by the next stop.
- A
launchof the name while it is stopped waits for the stop's end, then starts anew, never beside the hook. Past the stop's deadline it isfailed("stop it again"). - What a stop returns, the second stop's included, is the state of the incarnation it stopped, as the launcher
recorded it before it released the name: never that launch of the name, and never text the program or its hook
printed. The record rests in the spool as
.stopped.<name>.<generation>until a later reclaim of the name finds it older than any stop that could wait for it. - A stop is for the launch the call found: one whose script reaches the box only after that launch has ended and
another of the name took the directory returns the launch it was for, from that record (or is
invalid, as for a launch that does not exist, when there is none), and leaves the new one alone. This holds only when the host and the launch'slauncher.share both of this version: a stop from a host that passes no generation, or for a launch an olderlauncher.shruns, behaves as before, so a late stop can still act on a later launch of the name.
- A second stop at the same time never kills: it waits for the first one's end and returns its state. One whose own
bound (its
- Callers that meet are safe: several
launchcalls for one name start one program, a claim whose caller died before filling it is taken over after a minute, and a launch that ended is replaced by one caller only. - The program's output goes to
out.login the launcher's directory, bounded to its tail (logMaxBytes), also after the program has exited. The launcher holds no pipe to the host. - The launcher keeps its files in a spool,
.pi-transport-launchin the box'sTMPDIR(or/tmp), one directory per launch. The spool and each launch directory must be the launcher's uid's alone: a directory, never a symbolic link (read without following it), owned by that uid, with no group- or other-write bit. Every directory above the spool, up to/, must be root's or that uid's, never a symbolic link, with no group- or other-write bit unless it has the sticky bit (as/tmpdoes): else another user could rename the spool away and put another in its place.TMPDIRis read as the directory it names (pwd -P). Every call checks all of them before it uses them, a spool that already existed like a new one, and the launcher checks them again before it starts a process from them (a restart, its watchdog, the tools user's check) and before a stop's hook. A failed check isunsafe, naming the directory and why, and nothing runs from it. A local box makes its own directories (itshome,tmpandpids) mode 700. - The program's environment is PATH, HOME, LANG (
C.UTF-8),PI_TRANSPORT_INCARNATION(its incarnation) andenv. Nothing of the box runtime's own environment reaches it unlessinheritEnvnames it (defaultfalse): the incarnation unsets every other exported name, and a name the shell cannot unset refuses the incarnation (exit 126). envis plain settings kept in a file of the box. Never put a secret in it: a key-shaped name (a_-separated segment KEY, TOKEN, SECRET, PASSWORD, PASSWD, CREDENTIAL or CREDENTIALS) is refused by name, as are PATH, HOME, LANG and the names the launcher sets. So are the shell's, the loader's (LD_*) and every variable the C library itself strips in secure mode (glibc'sUNSECURE_ENVVARS:GCONV_PATH,GLIBC_TUNABLES,LOCPATH,NLSPATHand the rest, and musl'sMUSL_LOCPATH;TMPDIRexcepted), inenvandinheritEnv:envis exported before a root launcher drops touser, so they would act on that switch. The launcher clears them at every exec that starts its shell, and reads only/proc.- A box may launch natively (
Box.launcher, a test's fake box): the three calls then use it, read on every call. A provider leaves it undefined. stdin(bytes, at most 64 KiB; disk's mount token) is written to EVERY incarnation's stdin and then closed.secrets(names to values, at most 64 KiB as JSON) reach every incarnation on one inherited fd, whose NUMBER is inPI_TRANSPORT_SECRETS_FD(3): one JSON object, then EOF. Both travel on ONE path, the launch frame below, and rest nowhere: never in a file of the box, an environment, an argv, the state orout.log. Only a box whose facts saysecretsVia: "fd"(a real stdin path;localanddocker) takes them; any other refusesunsupportedbefore anything runs.- The launch frame: the exec that starts the launcher gets, on its stdin, the line
PI-TRANSPORT-LAUNCH/1, then the secrets as JSON and the stdin, each base64 (standard alphabet, padded, unwrapped) on a line of its own (empty when absent), then EOF. The launcher keeps the two parts in unexported shell variables and decodes them, for each incarnation, straight into pipes; it refuses whereprintfis not a shell builtin orbase64is missing. A malformed frame isinvalid, and nothing starts. - The in-box watchdog never brings back the supervisor of a launch that has
stdinorsecrets: only that supervisor held them. The launch then readslost, and only alaunchthat passes them again starts it again. user(a name or a numeric uid) is who the program runs as, resolved in the box (/etc/passwd, thengetent). A root launcher finds how it drops to it by doing it once at the start: asetprivthat takes--reuid(util-linux; BusyBox's does not), elserunuser, else the launch isunsupported(Alpine's base image). A launch withstdinorsecretsruns its program as root only with atoolsUser(below); a launcher that is not root runs the program only as itself (another user isunsupported). An unknown user isinvalid. The state reports it (user: { name, uid }).- A launch with
stdinorsecretswhose program does not run as root needs the box's ptrace scope (kernel.yama.ptrace_scope) at 1 or more, or a process of the same user could read them from the program's memory. It is measured in the box before the frame is read: a missing file counts as 0, a 0 on a box with its own kernel (vm) is raised to 1 and read back, and any other 0 isrefused. toolsUser(a name or a numeric uid) goes with a program run as root (nouseron a root launcher,"root"or uid 0). It is the user that program runs everything it does not trust as: the user disk's--run-asnames. A launch withstdinorsecretsruns its program as root only with one, and then needs no ptrace scope: a process of the tools user cannot attach to a root one withoutCAP_SYS_PTRACE. The launcher checks it in the box, before it reads the frame, in at most 60 s (the start's call to the box, a revive's too, is given that and its usual 30 s on top):- it exists, is not root and has not the root group (else
invalid); - where the box has
sudo,sudo -l -U, run with no environment but PATH, answers exactly its sentence for a user who may run nothing (elserefused, with sudo's first line); - on every mount without
nosuid, it can run no setuid or setgid root file and no file with capabilities. The mounts are chosen by filesystem type from the mount table, never by mount point: the kernel's own pseudo filesystems (proc,sysfs,cgroup,cgroup2,devpts,mqueue,securityfs,debugfs,tracefs,bpf,pstore,configfs,fusectl,binfmt_misc,efivarfs) are skipped, and every other type, an unknown one,tmpfsanddevtmpfsincluded, is walked. Each file is found as root and tested as the tools user, so a directory it can pass through but not list hides nothing. Else the launch isrefused, naming up to five; a box withoutgetcapisunsupported. A walk ignores an entry that ends mid-walk only wherefindtakes-ignore_readdir_race(the control runs it); on afindwithout it (busybox's) that staysunsupported, and every otherfinderror isunsupportedon everyfind. - the check fails closed:
findandgetcapmust each report a probe it plants (empty, root's, in the launch's own root-only directory, removed before anything runs as the tools user), the tools user's test must pass on its own shell, and a walk that fails on a mount, or finds no mount to walk, isunsupported, as is a getcap line that names no file (a line is read to its file whatever its form: several capability groups,path = caps, a path with spaces). The image needsgetcapandsetcap(Debian'slibcap2-bin). The program getsPI_TRANSPORT_TOOLS_USERandPI_TRANSPORT_TOOLS_UID; the state reportstoolsUser: { name, uid }.
- it exists, is not root and has not the root group (else
- What
toolsUsermeans is the HOST'S guarantee: the program runs everything it does not trust as that user, with no capabilities, no supplementary groups and none of its own descriptors (fd 3 and its stdin included), as disk's own spawn does. The launcher cannot see the program's children, and it does not measure, so the host and its image keep:- no root-run code the tools user can write (the program's files, a PATH directory, a cron or service unit);
- no root service the tools user can reach on a socket;
- no later change by root: a sudo rule, a setuid file, a capability, or a mount without
nosuid. The residual: root in the box can still trace and read the program, so the image is trusted. A program run as root shares root with every root process in the box (the launcher, any other root launch, a root daemon), so its secrets are only as safe as the least trusted of them.
- If both launcher processes die, the state reads
lost: the program may still run, unsupervised. The nextlaunchof the name adopts it and starts a supervisor again. - On Daytona a stopped and started sandbox keeps its disk and loses its processes. The launcher then reads
lost, the program is gone, and nothing restarts by itself: the host callslaunchagain, and the old state and output are still there.
The box needs sh, setsid, sed, cut, awk, wc, tail, date, sleep, id and ls, and for a launch with
stdin or secrets a builtin printf and base64. It is tested on Debian (dash) and Alpine (BusyBox).
probeAcknowledgedWrites(box) in @parcha/pi-transport/testing checks that a write a program was told was durable
survives that program's death: a writer appends ten records, each followed by an fsync, then it is killed and a second
process counts them. A runtime is fit to hold a session's disk only when every acknowledged record is kept.
Testing a host against a fake provider
fakeBoxes(options) (@parcha/pi-transport/testing) is a BoxProvider that does what a test scripts and writes down every call it
was asked, so a test of a host (anything built on a Box) asserts what the host asked and how it behaves when a provider misbehaves,
with no machine, key or network. It follows the Box contract (typed errors as values, stable ids, labels, facts), so a test written
against it holds on a real provider. It runs no real program: a program in a fake box is a function a test registers with
provider.program(match, run), and a box's files are in memory (box.files).
import { fakeBoxes, fakeClock, modalLike } from "@parcha/pi-transport/testing";
const provider = fakeBoxes(modalLike({ clock: fakeClock() }));
provider.program("inst", async ({ stdin }) => { await stdin.readAll(); return 0; });
provider.script.on({ op: "create" }, { fail: "unknown", leakBox: true }); // fails, but the box exists and `list` finds it
provider.callsis every call in the order the provider saw it (seq,op,box, exactargv, env names never values, the stdin bytes written, file paths and sizes, the spec, the typed result, and the label of the script rule that decided it). A failedcreateis in it.provider.leaks(marker)says where a value appears in anything recorded (argv, stdin, an upload's bytes or path, env, a label, the spec): the check that a secret travelled only on the channel it was meant for.provider.script.on(match, outcome, { times, label })decides a call: a typedfail, adelayMs, ahangthat ends on abort or timeout, a lost end of an empty stdin (lose: "stdin-eof", thedocker exec -ifault), a dropped or truncated output stream, a scriptedexit, a box that goesgonein the middle of an exec, arefuse. A match names the op, a box, the nth call, a command or any predicate over the call.fakeClock()moves time only when a test says (advance): delays,timeoutMsand a box'smaxLifetimeMsall run on it.- Deprecated, removed in 0.1.0-beta.31 with the launcher:
FakeLaunchand the launcher half of the fake that this bullet describes. Each fake box fillsBox.launcher, so the launcher's ownlaunch,launchStateandlaunchStop(@parcha/pi-transport/launch) run over it with no exec of a launcher program. A launch has a scriptable state (phase,incarnation,restarts,lastExit), restarts under the spec's policy (restart,restartDelayMs,restartLimit,terminalExits), the stdin replayed to every incarnation,secretson the program's secrets channel, and a stop that drains, runs thebeforeStopprogram, then kills. The stdin and secrets are recorded on thelaunchcall only, soleaks(token)finds them there and nowhere else; each start is anincarnationcall, never anexec. Errors are the launcher'sLaunchError. A rule can fail a restart ({ op: "incarnation", when: (c) => c.incarnation === 2 }), make the state unreadable ({ op: "launchState" }with afail), or end the program with a chosen code ({ op: "launch" }with anexit);fake.launches[i].exit(code)does it later. modalLike()(a VM with FUSE and a ptrace scope a launcher can raise) anddaytonaLike()(a container with no FUSE and a scope that cannot be raised) are presets;fuse,startedAtandptraceScopeare extra facts until the box contract carries them.
The fake does not run a shell, a real process or a mount: those are the real providers' and their live suites'.
Writing a provider
Fill BoxProvider and Box, then run the conformance suite against real boxes:
import { describe, expect, it } from "vitest";
import { registerBoxConformance } from "@parcha/pi-transport/testing";
registerBoxConformance({ describe, expect, it }, "my boxes", (use) => use(myBoxes()), {
// One box is left alone for its lifetime. `ended` must ask the vendor's own API, never the provider under test.
lifetime: { maxLifetimeMs: 60_000, graceMs: 120_000, ended: (box) => myVendorSaysGone(box.id) },
});
A box whose exec carries input and streams output can take uploadByExec and downloadByExec as its file calls.
A provider whose boxes keep no program running between calls and take no input (facts().liveProcesses is false) passes
withoutLiveProcesses: "<why>": the suite then leaves out the cases that need a live program or stdin, and one case in
their place requires the facts to say so and stdin to be refused with unsupported; the reason is in its name.
A provider whose boxes have no env passes withoutEnv: "<why>": the suite creates its boxes with no env, leaves out the case that
reads the spec's env, and adds one that requires a spec with an env to be refused with unsupported; the reason is in its name.