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

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:

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:

  1. 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.
  2. 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:

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.