agent.run()
DOCUMENTATION / HARNESSES & COMPUTERS

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.launcher and BoxLauncher); the placer (from the root jevPlacer, jevClassifier, environmentFacts, PlacementsDoc, writePlacement, isPlacementRecord, PlacementError and the Placement* and Placer types); ./testing's replayClassifier and its FakeLaunch; and the home, auto and purpose config keys. To run an agent in a box, use @parcha/agentrun-serve: it uses this package's boxes, ./hands and ./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.

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 with unsupported.
  • gpu: "T4" works on the default sandbox only; a GPU with isolation: "vm" is refused with invalid. facts().gpu is the model served, read in the box with nvidia-smi (a bare H100 may 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 reads gpu: null and 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 snapshot and any isolation but gvisor or vm are refused with unsupported; a label key that is pi-transport or starts with pi-transport. is refused with invalid; every box carries pi-transport=1 as 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, or ports on a box with network: "none" is refused with invalid. The default is open.
  • ports: [8080, { port: 5432, kind: "tcp" }] exposes ports: a bare number is TLS (https), tcp is a raw socket. facts().ports names each port and its kind, and await box.endpoint(8080, context) returns the address (an https URL, or a tcp host 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/fuse is a character device that root can open (a VM's is crw-------, root-only, and that is enough: a FUSE mount there is root's, and nothing widens the device for other users). facts().fuse is true only 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. fuse with isolation: "gvisor" is refused with unsupported, and with a gpu with invalid.
  • facts().secretsVia is "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, the env and secrets parameters, 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. A close() that fails on a program still running is returned by wait() (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: a close() after the first (a launch closes it after the frame, and finish closes it again) returns the first and is no failure.
  • Attach never fails a live sandbox and never ends it. A box found again by attach is 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 reads fuse: 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 setup attach repeats 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. attach returns undefined for 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; null when it could not be read) and startedAt (an ISO 8601 UTC time; for Modal, the moment it was asked to start).
  • maxLifetimeMs is 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_ID and MODAL_TOKEN_SECRET in the environment and the SDK, which is not installed with this package: npm install --no-save modal@0.11.0, then PI_TRANSPORT_LIVE_MODAL=1 npm run test:live -w @parcha/pi-transport (test:live loads every live file, and each provider's own variable picks the ones that run). It names its Modal app and labels from PI_TRANSPORT_LIVE_PREFIX and PI_TRANSPORT_LIVE_LABELS, and PI_TRANSPORT_LIVE_MODAL_KINDS (sandbox,vm,gpu; default sandbox,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().liveProcesses is false: no program keeps running between calls, and a command's output arrives after it ended. Use it for tools.
  • A 300 s limit. facts().maxExecMs is 300 000. A timeoutMs above it is refused with invalid; 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 a timeout error; an exit code of 124, which is what ends a command at its deadline, is read that way too.
  • No stdin. stdin.write rejects with unsupported. Output is text: bytes that are not UTF-8 come back as U+FFFD, so move bytes with upload and download, 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 through upload and download, 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's timeout, 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 exec checks 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 through upload takes no checkout: it waits for this process's own commands and retries while another process holds the directory, up to queueMs.
  • destroy ends 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 next list, 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 maxLifetimeMs is 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 a list finds 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 a boxEnv environment on such a box (any name, and PATH) is refused with a spawn_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's ARCHIL_CONTAINER_ID, ARCHIL_DISK_ID and ARCHIL_REGION (measured; no key).
  • Refused before any call: a GPU, a snapshot, a workspace, an isolation other than vm, 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 calls in 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.exec of 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 maxExecMs sets 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 aborted or timeout after 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 a StoppedCommandError: its killUnconfirmed is true, and the command stays tracked, so cleanup() ends it with the rest. killUnconfirmed is absent when the group was confirmed gone, which is the normal case.
  • A host that dies leaves its commands running (ending a docker exec client does not end its program), and its cleanup() 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, PATH included, reach the command alone. The environment's own scripts keep the box's search path, so a PATH with no shell in it still runs a program named by its path.
  • Results match pi's NodeExecutionEnv on 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: false a command still has a default PATH.
  • A rename across file systems succeeds, because mv copies.
  • listDir returns a name that is not UTF-8 with the replacement character. Node fails the whole listing.
  • A watcher polls, so its mode is polling.
  • openBinaryReader and openTextLineReader read 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 above maxReaderBytes (64 MiB by default) is refused with not_supported before 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. readTextLines with maxLines brings 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 env returns a lazy environment. Until its box exists its cwd is . 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 next transport() 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; with scope: "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 exec with code unknown (pi's execution codes have no gone) and, as error.cause, the provider's BoxError with code gone; onLost fires 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. labels in 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-transport and the keys under it belong to providers, which mark their own boxes.
  • undefined means the host's own environment. env returns undefined when the config names no hands, or names a local environment and the host passed no local provider. 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 in receipt.skips, each one to onSkip), and never created. The skip of a link carries target, 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. admit decides 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.masked counts the files written that lost a bit.
  • Limits. The extraction stops cleanly past maxBytes of archive (header blocks, pax and long-name payloads and member data, each charged before it is read; default MAX_PULL_BYTES, 1 GiB) or maxEntries members, written or skipped (default MAX_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, and receipt.stopped says 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. exclude leaves paths out, readOnly sends 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 mark of 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. exclude is applied to the listing, to deletions and to every member of the archive. mirrorDeletes removes what the box no longer has: files and links unlinked, a directory only when empty. pullAgentHome never brings back learning/ and never removes it. The host reads at most maxListingBytes of the listing (default MAX_LISTING_BYTES, 64 MiB): past it the box's program is stopped and the pull is too_large. A copy that fails after something was written still returns the mark, with stopped.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 in pullAgentHome) 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 as host_changed. The mark keeps the hashes: seedDir returns 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), never tar. 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 auto and purpose config 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, ./hands and ./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 PlacementRecord whose by is jev or config. 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 in error.
  • No stand-in. jevPlacer refuses 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 when TYPESAFE_API_KEY is set, else Jev through OpenRouter when OPENROUTER_API_KEY is, else undefined. The host's Models holds pi-ai's typesafeProvider() and openrouterProvider().
  • Cost. A decision's usage counts tokens; costUsd is null when 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, with Box.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, ./hands and ./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.

  • launch twice 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 0 or in terminalExits, or restart is false. More than restartLimit.burst restarts in restartLimit.intervalMs (default 5 in 60 s) ends in gave_up with 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 restarting or exited while the launcher handles an exit, never running. It carries the launch's handle (launch), the incarnation now or last running (from 1), and restarts (the incarnation less one).
  • stopLaunch ends the program and everything in its process group: TERM, then KILL after graceMs, then it reports stopped. graceMs is 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, timeoutMs from 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 to out.log, and it never fails the stop.
  • A stop is ONE stop's. The first one claims it, and the phase is stopping until it has killed what was left and written stopped; 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 returns stopping.
    • 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 launch of 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 is failed ("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's launcher.sh are both of this version: a stop from a host that passes no generation, or for a launch an older launcher.sh runs, behaves as before, so a late stop can still act on a later launch of the name.
  • Callers that meet are safe: several launch calls 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.log in 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-launch in the box's TMPDIR (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 /tmp does): else another user could rename the spool away and put another in its place. TMPDIR is 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 is unsafe, naming the directory and why, and nothing runs from it. A local box makes its own directories (its home, tmp and pids) mode 700.
  • The program's environment is PATH, HOME, LANG (C.UTF-8), PI_TRANSPORT_INCARNATION (its incarnation) and env. Nothing of the box runtime's own environment reaches it unless inheritEnv names it (default false): the incarnation unsets every other exported name, and a name the shell cannot unset refuses the incarnation (exit 126).
  • env is 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's UNSECURE_ENVVARS: GCONV_PATH, GLIBC_TUNABLES, LOCPATH, NLSPATH and the rest, and musl's MUSL_LOCPATH; TMPDIR excepted), in env and inheritEnv: env is exported before a root launcher drops to user, 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 in PI_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 or out.log. Only a box whose facts say secretsVia: "fd" (a real stdin path; local and docker) takes them; any other refuses unsupported before 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 where printf is not a shell builtin or base64 is missing. A malformed frame is invalid, and nothing starts.
  • The in-box watchdog never brings back the supervisor of a launch that has stdin or secrets: only that supervisor held them. The launch then reads lost, and only a launch that 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, then getent). A root launcher finds how it drops to it by doing it once at the start: a setpriv that takes --reuid (util-linux; BusyBox's does not), else runuser, else the launch is unsupported (Alpine's base image). A launch with stdin or secrets runs its program as root only with a toolsUser (below); a launcher that is not root runs the program only as itself (another user is unsupported). An unknown user is invalid. The state reports it (user: { name, uid }).
  • A launch with stdin or secrets whose 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 is refused.
  • toolsUser (a name or a numeric uid) goes with a program run as root (no user on 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-as names. A launch with stdin or secrets runs 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 without CAP_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 (else refused, 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, tmpfs and devtmpfs included, 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 is refused, naming up to five; a box without getcap is unsupported. A walk ignores an entry that ends mid-walk only where find takes -ignore_readdir_race (the control runs it); on a find without it (busybox's) that stays unsupported, and every other find error is unsupported on every find.
    • the check fails closed: find and getcap must 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, is unsupported, 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 needs getcap and setcap (Debian's libcap2-bin). The program gets PI_TRANSPORT_TOOLS_USER and PI_TRANSPORT_TOOLS_UID; the state reports toolsUser: { name, uid }.
  • What toolsUser means 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 next launch of 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 calls launch again, 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.calls is every call in the order the provider saw it (seq, op, box, exact argv, 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 failed create is 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 typed fail, a delayMs, a hang that ends on abort or timeout, a lost end of an empty stdin (lose: "stdin-eof", the docker exec -i fault), a dropped or truncated output stream, a scripted exit, a box that goes gone in the middle of an exec, a refuse. 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, timeoutMs and a box's maxLifetimeMs all run on it.
  • Deprecated, removed in 0.1.0-beta.31 with the launcher: FakeLaunch and the launcher half of the fake that this bullet describes. Each fake box fills Box.launcher, so the launcher's own launch, launchState and launchStop (@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, secrets on the program's secrets channel, and a stop that drains, runs the beforeStop program, then kills. The stdin and secrets are recorded on the launch call only, so leaks(token) finds them there and nowhere else; each start is an incarnation call, never an exec. Errors are the launcher's LaunchError. A rule can fail a restart ({ op: "incarnation", when: (c) => c.incarnation === 2 }), make the state unreadable ({ op: "launchState" } with a fail), or end the program with a chosen code ({ op: "launch" } with an exit); fake.launches[i].exit(code) does it later.
  • modalLike() (a VM with FUSE and a ptrace scope a launcher can raise) and daytonaLike() (a container with no FUSE and a scope that cannot be raised) are presets; fuse, startedAt and ptraceScope are 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.