Write a host plug
Give the agent and the plugs after yours a machine of your own, such as a cloud sandbox.
A host plug passes a host to next(). This guide builds one for a service that can run a command
and exposes an SSH port. For a service that has only an HTTP API, replace step 2.
1. Describe the machine, lazily
createHost takes a description and a connect function. The connection is made the first time
something uses the host, and at most once.
import { z } from "zod";
import { type PlugContext, definePlug } from "@blaiseagent/runner";
import { createHost, sshConnect, tcpStream } from "@blaiseagent/runner/host";
const inputs = z.object({ name: z.string(), hostKey: z.string() });
type DevboxContext = PlugContext<z.infer<typeof inputs>, undefined>;
function devboxHost(ctx: DevboxContext) {
ctx.agent.cwd = "/home/dev/app";
return createHost({
info: {
kind: "devbox",
os: "linux",
home: "/home/dev",
tmpdir: "/tmp",
workspace: "/home/dev/app",
persistent: true,
},
connect: async () => {
const ssh = await sshConnect(await tcpStream(`${ctx.inputs.name}.devbox.test`, 22), {
username: "dev",
privateKey: await ctx.runner.fs.promises.readFile("/keys/devbox", "utf8"),
hostKey: ctx.inputs.hostKey,
});
return {
transport: ssh.transport,
fs: ssh.sftp(),
forward: (spec) => ssh.forward(spec),
close: async () => ssh[Symbol.asyncDispose](),
};
},
});
}
export default definePlug({
name: "acme/devbox",
inputs,
async onSession(ctx, next) {
await next({ host: devboxHost(ctx) });
},
async onTeardown(ctx, next) {
await next({ host: devboxHost(ctx) });
// Destroy the machine here, if the plug created it.
},
});The runner closes the connection when the next() that introduced the host resolves. Do not close
it yourself.
2. Supply only what you have
A connection needs one thing: transport.spawn, which starts a process and returns byte streams.
The rest is derived.
| You provide | The host gives you |
|---|---|
transport only | exec, spawn, and a file system built from POSIX commands |
plus fs (a provider) | a native file system, such as SFTP |
plus forward | host.forward for ports |
plus sync | the synchronous fs methods |
For a service with an API instead of SSH, build spawn from its exec call and return the streams as
ReadableStream and WritableStream values. If it only offers a WebSocket, webSocketStream turns
it into a byte stream that sshConnect can use.
3. Set persistent honestly
persistent: true means the machine outlives the session. The runner then starts the agent under a
watchdog that ends it when its session process goes away. Leave it false for machines that are
destroyed with the session.
4. Handle teardown
Offer the host in onTeardown too, with the same description. Because hosts are lazy, nothing
connects unless an inner plug has teardown work to do on the machine.
async onTeardown(ctx, next) {
await next({ host: devboxHost(ctx) });
// Destroy the machine here, if the plug created it.
},5. Test it
Test the plug with the harness, as in Test a plug. The harness reports the host the plug offered, which is how this plug is tested without connecting:
import { describe, expect, test } from "vitest";
import { createPlugHarness } from "@blaiseagent/runner/testing";
import devbox from "./devbox";
describe("devbox", () => {
test("offers a lazy host without connecting", async () => {
const harness = createPlugHarness(devbox, {
inputs: { name: "dev1", hostKey: "SHA256:abc" },
});
const { switchedHost } = await harness.session();
expect(switchedHost?.info).toMatchObject({
kind: "devbox",
persistent: true,
});
});
test("offers the same host to the plugs of a teardown", async () => {
const harness = createPlugHarness(devbox, {
inputs: { name: "dev1", hostKey: "SHA256:abc" },
});
const { switchedHost } = await harness.teardown();
expect(switchedHost?.info.kind).toBe("devbox");
});
});For the SSH parts, startSshServer from @blaiseagent/runner/testing starts a real SSH server on a
loopback port that runs commands and serves SFTP over the local machine.