Running an actor elsewhere
Run an actor of yours somewhere else — on another machine, in a container, or in a sandbox on this very machine — and keep spawning, sending and waiting on it the way you do at home.
import { remoteClient } from "posipaki/remote";
import { sshBootstrap } from "posipaki-remote-ssh";
const spawner = sshBootstrap<ToolArgs>({
host: "toolbox",
app: { name: "my-app", version: "1.4.0" },
payload: "build/env-payload.js",
});
const env = await root.fork(remoteClient("tools", spawner), { env: "agent" });
env.send({ type: "SEARCH", query: "vanilla" });
const hits = await env.wait();
That snippet is the whole change: a spawner for the way out, the remoteClient you
already have, and your actor's code landing over there on its own. Remote Actors
covers the wire, the proxy and the version handshake underneath; this page is the
three packages that build on it.
npm install posipaki posipaki-remote-ssh # another machine
npm install posipaki posipaki-remote-podman # a container
npm install posipaki posipaki-remote-bwrap # this machine, with less of it in reach
All three ship in lockstep with the core: a payload's only claim is which posipaki
it speaks, so the package and the core it builds against go out together.
LIB_VERSION from posipaki is that version.
What you get
- Your actor, unchanged. Its
setup, handlers, methods and storage run on the far side; you drive it through a proxy that takes the samespawn/send/wait/emitcalls as a local one, and state comes back the same way. - The code gets there by itself. You hand over the bundle you built; staging
writes it into a directory per build, finds a runtime that exists on that side
(
node,nodejs,bun), probes before it writes, and reports what it found. Spawning the same build again costs one command. - The far side's own words when something breaks, and
onGonethe moment its process is gone. - Cleanup that is structural. An ssh process and a sandbox last exactly as long as their command, so they leave with you; a container you started goes when the last consumer lets go.
Pick a way in
| Package | Gets you | Reach for it when |
|---|---|---|
posipaki-remote-ssh |
another machine over ssh | the work belongs on a box you can already reach |
posipaki-remote-podman |
a container, started and held for you | the work needs an image, its own filesystem, and a name |
posipaki-remote-bwrap |
a sandbox of this machine | the work is risky, noisy or nosy, and the box is this one |
Over ssh
Your actor runs on a host you can already ssh into. What the host needs: ssh
reaching it, a shell, and one of the runtimes.
import { remoteClient } from "posipaki/remote";
import { sshBootstrap } from "posipaki-remote-ssh";
const spawner = sshBootstrap<ToolArgs>(
{
host: "toolbox", // whatever ssh accepts
app: { name: "my-app", version: "1.4.0" }, // who stages, and which build
payload: "build/env-payload.js", // the bundle to put up there
},
{
// The payload's own arguments — this package does not know what they mean.
args: (boot) => [`--env=${boot.env}`],
onGone: () => console.warn("toolbox is gone"),
},
);
const env = await root.fork(remoteClient("tools", spawner), { env: "agent" });
Two connections, both ssh <host> …: the first feeds the bootstrap script on stdin,
the second runs the staged payload with its own stdin/stdout as the wire.
ssh toolbox sh -s # the bootstrap, script on stdin
ssh toolbox /usr/bin/node ~/bin/posipaki/<kit>/payload.js --env=agent
The kit lands in $HOME/bin/posipaki/<app>-<version>-posipaki-<core version>-<hash>/
on the far side, one directory per build, so two consumers or two releases land next
to each other.
relay: true (with gateway) runs the payload behind posipaki's gateway, which
creates a fifo inside the host and relays to it. Reach for it when the payload
may print — its own stdout would otherwise be the wire — or when the host is
reached as another uid, which could not open a fifo you created here.
In a container
Your actor runs inside a podman container. The container can be one you keep, one that several actors share, or one made for this actor alone.
import { containerActor, podmanBootstrap, podmanEnvironment } from "posipaki-remote-podman";
// one actor, one container: started for it, let go when it is done
const alone = podmanEnvironment<ToolArgs>(
{
image: "toolbox:1",
container: "env-agent",
app: { name: "my-app", version: "1.4.0" },
payload: "build/env-payload.js",
},
{ args: (boot) => [`--pool-size=${boot.poolSize}`] },
);
// or a container you keep, shared by the actors that hold it
const container = await containerActor.spawn({ image: "toolbox:1", container: "env-agent" });
container.send({ type: "RETAIN" });
const shared = podmanBootstrap<ToolArgs>({
container: "env-agent", // the name it is running under
app: { name: "my-app", version: "1.4.0" },
payload: "build/env-payload.js",
});
container.send({ type: "RELEASE" }); // the last one out takes it down
A container by that name that is already running, and that this process is not
holding, belongs to somebody else's process — and their lifetime is yours to end
only if you say so. onConflict says it: fail reports what was found and stops,
reuse runs in theirs and holds nothing, replace removes what is there and
starts your own. podmanEnvironment defaults to replace, since its container was
started for this actor; containerActor defaults to fail. A taken name is a
question about whose container it is.
How the container lives: it is started by an attached podman run whose main
process only reads its stdin, so while you live podman exec can open as many
channels into it as you like, and when you go — clean exit, SIGTERM, SIGKILL — the
write end closes and --rm takes the container with it. Nothing has to remember to
clean up, though the container goes a moment after you do, once podman has reaped
it. Sharing runs on the same trick: containerActor counts the consumers that
retain it, and the last release takes it down.
const container = await containerActor.spawn({ image: "toolbox:1", container: "env-agent" });
container.send({ type: "RETAIN" }); // UP, RETAINED and STATUS come back
container.send({ type: "RELEASE" }); // the last one out takes the container down
In a sandbox
Your actor runs in a bubblewrap sandbox: the same machine, with less of it in reach. A sandbox here is a policy plus a command, and it lasts exactly as long as the command — nothing to start, hold or clean up.
import { remoteClient } from "posipaki/remote";
import { bwrapBootstrap, sandboxArgs } from "posipaki-remote-bwrap";
const spawner = bwrapBootstrap<ToolArgs>(
{
name: "tools", // what errors and output are tagged with
args: sandboxArgs({ home: "/srv/agent" }), // the policy: what it may see and touch
app: { name: "my-app", version: "1.4.0" },
payload: "build/env-payload.js",
},
{ args: (boot) => [`--env=${boot.env}`] },
);
const env = await root.fork(remoteClient("tools", spawner), { env: "agent" });
sandboxArgs() is the policy this package suggests, and every part of it is a knob:
--ro-bind / /— the whole machine, read-only. Narrower binds come after it, and come out on top;--bind <home> <home>with--setenv HOME <home>— the one directory the payload may write to, and what it calls home. A staged kit lands there, and since bwrap binds rather than copies, the file inside is the file outside;--dev /dev,--proc /proc,--tmpfs /tmp— the few things a runtime needs to believe it is on a machine, all of them its own;--unshare-pid --unshare-uts --unshare-ipc,--die-with-parent,--new-session— its own namespaces, no process outliving ours, no terminal handed back;- the network stays reachable, and
network: falsetakes it away.
args is bwrap's own argument list, so a policy that wants the host's network gone,
a socket mounted, or a read-only home simply says so.
When something fails
A way in that fails fails loudly and quickly, and the reason is what the host said.
ssh env.invalid is not available: the host exited with code 255: ssh: connect to host env.invalid port 22: Connection refused
staging into container env-agent failed: no runtime among: node nodejs bun
sandbox tools is not available: the sandbox exited with code 1: bwrap: Can't find source path /nope: No such file or directory
The command's own stderr is not the wire, so it is collected, forwarded to a sink
(onOutput, our stderr by default, tagged with the host) and its tail becomes the
reason. A channel that closes tells the caller through onGone, so a slot can stop
pointing at a process that is gone.
Under the hood
Under all three packages is one shape, and it comes from two properties of an environment that is not your process:
- The only channel every way in gives you is the stdio of the command that gets you in. A path created here does not exist there, and a path created there is not yours to open. The wire is a pipe: your child's stdin and stdout.
- Your code has to arrive before it can run. The step that delivers it cannot carry the wire, because its own stdin is the script.
So a spawn is two stages: a command that puts your code there and reports what it
found, then a command whose own stdin/stdout are the wire. What travels is a
kit — your bundles, a manifest with one hash per file, and a version.json
naming the build — and the far side writes it into one directory per build. The
vocabulary is the core's (posipaki/remote/node); each package knows which files
belong in a kit and under which names. The second stage is the same everywhere: a
run whose stderr is output and whose stdin/stdout are the protocol.
Two words carry the shape, and every package ships both:
- a connector is the way in: an optional
preparestep and thecommandto run, both on the run's own stdin/stdout; - a bootstrap is that connector with the kit staged first:
preparewrites the kit and reports where it landed,commandruns the payload with the runtime the staging found.
The names carry their package, so sshConnector / sshBootstrap,
bwrapConnector / bwrapBootstrap, podmanConnector / podmanBootstrap. Reach
for a connector when the far side already has what you want to run, and a bootstrap
when it has to be put there first.
| Package | Pieces |
|---|---|
posipaki-remote-ssh |
sshConnector, sshBootstrap — the ssh process is the lifetime, so ssh starts nothing and holds nothing |
posipaki-remote-bwrap |
bwrapConnector, bwrapBootstrap, sandboxArgs — bwrap makes the sandbox for as long as the command runs |
posipaki-remote-podman |
containerActor (a container, held and counted), podmanConnector, podmanBootstrap, podmanEnvironment (both together) — a container has a life to hand over |
Every package also exposes what it builds on, for pre-warming or your own wiring:
sshStage / podmanStage / bwrapStage put the kit there and report where,
sshKit / podmanKit / bwrapKit build a kit from your bundles, and podman adds
startContainer / stopContainer / removeContainer / containerExists.
Testing your own way in
Every host interaction is injected — runHost (one command), startHost (the
container's process), spawnChild (the wire's process) — so a suite stages and
spawns with nothing installed, asserting the commands and what travels on stdin.
Each package tests itself that way; a real host is an opt-in integration test
(podman's behind POSIPAKI_PODMAN_IMAGE, bwrap's wherever bwrap is present).
Reasons for the shape itself — two stages, the kit, the connector-and-bootstrap split, and why the three packages duplicate instead of sharing — live with the proposals.