refactor: rename engine/ to server/

This commit is contained in:
Nathan Flurry 2026-01-25 14:14:58 -08:00
parent 016024c04b
commit 71ab40388c
37 changed files with 917 additions and 3 deletions

View file

@ -1,4 +1,8 @@
import type { components } from "./generated/openapi.js";
import type {
SandboxDaemonSpawnHandle,
SandboxDaemonSpawnOptions,
} from "./spawn.js";
export type AgentInstallRequest = components["schemas"]["AgentInstallRequest"];
export type AgentModeInfo = components["schemas"]["AgentModeInfo"];
@ -25,6 +29,14 @@ export interface SandboxDaemonClientOptions {
headers?: HeadersInit;
}
export interface SandboxDaemonConnectOptions {
baseUrl?: string;
token?: string;
fetch?: typeof fetch;
headers?: HeadersInit;
spawn?: SandboxDaemonSpawnOptions | boolean;
}
export class SandboxDaemonError extends Error {
readonly status: number;
readonly problem?: ProblemDetails;
@ -53,6 +65,7 @@ export class SandboxDaemonClient {
private readonly token?: string;
private readonly fetcher: typeof fetch;
private readonly defaultHeaders?: HeadersInit;
private spawnHandle?: SandboxDaemonSpawnHandle;
constructor(options: SandboxDaemonClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, "");
@ -65,6 +78,32 @@ export class SandboxDaemonClient {
}
}
static async connect(options: SandboxDaemonConnectOptions): Promise<SandboxDaemonClient> {
const spawnOptions = normalizeSpawnOptions(options.spawn, !options.baseUrl);
if (!spawnOptions.enabled) {
if (!options.baseUrl) {
throw new Error("baseUrl is required when autospawn is disabled.");
}
return new SandboxDaemonClient({
baseUrl: options.baseUrl,
token: options.token,
fetch: options.fetch,
headers: options.headers,
});
}
const { spawnSandboxDaemon } = await import("./spawn.js");
const handle = await spawnSandboxDaemon(spawnOptions, options.fetch ?? globalThis.fetch);
const client = new SandboxDaemonClient({
baseUrl: handle.baseUrl,
token: handle.token,
fetch: options.fetch,
headers: options.headers,
});
client.spawnHandle = handle;
return client;
}
async listAgents(): Promise<AgentListResponse> {
return this.requestJson("GET", `${API_PREFIX}/agents`);
}
@ -171,6 +210,13 @@ export class SandboxDaemonClient {
);
}
async dispose(): Promise<void> {
if (this.spawnHandle) {
await this.spawnHandle.dispose();
this.spawnHandle = undefined;
}
}
private async requestJson<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
const response = await this.requestRaw(method, path, {
query: options.query,
@ -252,3 +298,22 @@ export class SandboxDaemonClient {
export const createSandboxDaemonClient = (options: SandboxDaemonClientOptions): SandboxDaemonClient => {
return new SandboxDaemonClient(options);
};
export const connectSandboxDaemonClient = (
options: SandboxDaemonConnectOptions,
): Promise<SandboxDaemonClient> => {
return SandboxDaemonClient.connect(options);
};
const normalizeSpawnOptions = (
spawn: SandboxDaemonSpawnOptions | boolean | undefined,
defaultEnabled: boolean,
): SandboxDaemonSpawnOptions => {
if (typeof spawn === "boolean") {
return { enabled: spawn };
}
if (spawn) {
return { enabled: spawn.enabled ?? defaultEnabled, ...spawn };
}
return { enabled: defaultEnabled };
};

View file

@ -1,4 +1,9 @@
export { SandboxDaemonClient, SandboxDaemonError, createSandboxDaemonClient } from "./client.js";
export {
SandboxDaemonClient,
SandboxDaemonError,
connectSandboxDaemonClient,
createSandboxDaemonClient,
} from "./client.js";
export type {
AgentInfo,
AgentInstallRequest,
@ -15,5 +20,8 @@ export type {
ProblemDetails,
QuestionReplyRequest,
UniversalEvent,
SandboxDaemonClientOptions,
SandboxDaemonConnectOptions,
} from "./client.js";
export type { components, paths } from "./generated/openapi.js";
export type { SandboxDaemonSpawnOptions, SandboxDaemonSpawnLogMode } from "./spawn.js";

View file

@ -0,0 +1,221 @@
import type { ChildProcess } from "node:child_process";
import type { AddressInfo } from "node:net";
import type { NodeRequire } from "node:module";
export type SandboxDaemonSpawnLogMode = "inherit" | "pipe" | "silent";
export type SandboxDaemonSpawnOptions = {
enabled?: boolean;
host?: string;
port?: number;
token?: string;
binaryPath?: string;
timeoutMs?: number;
log?: SandboxDaemonSpawnLogMode;
env?: Record<string, string>;
};
export type SandboxDaemonSpawnHandle = {
baseUrl: string;
token: string;
child: ChildProcess;
dispose: () => Promise<void>;
};
const PLATFORM_PACKAGES: Record<string, string> = {
"darwin-arm64": "@sandbox-agent/cli-darwin-arm64",
"darwin-x64": "@sandbox-agent/cli-darwin-x64",
"linux-x64": "@sandbox-agent/cli-linux-x64",
"win32-x64": "@sandbox-agent/cli-win32-x64",
};
export function isNodeRuntime(): boolean {
return typeof process !== "undefined" && !!process.versions?.node;
}
export async function spawnSandboxDaemon(
options: SandboxDaemonSpawnOptions,
fetcher?: typeof fetch,
): Promise<SandboxDaemonSpawnHandle> {
if (!isNodeRuntime()) {
throw new Error("Autospawn requires a Node.js runtime.");
}
const {
spawn,
} = await import("node:child_process");
const crypto = await import("node:crypto");
const fs = await import("node:fs");
const path = await import("node:path");
const net = await import("node:net");
const { createRequire } = await import("node:module");
const host = options.host ?? "127.0.0.1";
const port = options.port ?? (await getFreePort(net, host));
const token = options.token ?? crypto.randomBytes(24).toString("hex");
const timeoutMs = options.timeoutMs ?? 15_000;
const logMode: SandboxDaemonSpawnLogMode = options.log ?? "inherit";
const binaryPath =
options.binaryPath ??
resolveBinaryFromEnv(fs, path) ??
resolveBinaryFromCliPackage(createRequire(import.meta.url), path, fs) ??
resolveBinaryFromPath(fs, path);
if (!binaryPath) {
throw new Error("sandbox-agent binary not found. Install @sandbox-agent/cli or set SANDBOX_AGENT_BIN.");
}
const stdio = logMode === "inherit" ? "inherit" : logMode === "silent" ? "ignore" : "pipe";
const args = ["--host", host, "--port", String(port), "--token", token];
const child = spawn(binaryPath, args, {
stdio,
env: {
...process.env,
...(options.env ?? {}),
},
});
const cleanup = registerProcessCleanup(child);
const baseUrl = `http://${host}:${port}`;
const ready = waitForHealth(baseUrl, fetcher ?? globalThis.fetch, timeoutMs, child);
await ready;
const dispose = async () => {
if (child.exitCode !== null) {
cleanup.dispose();
return;
}
child.kill("SIGTERM");
const exited = await waitForExit(child, 5_000);
if (!exited) {
child.kill("SIGKILL");
}
cleanup.dispose();
};
return { baseUrl, token, child, dispose };
}
function resolveBinaryFromEnv(fs: typeof import("node:fs"), path: typeof import("node:path")): string | null {
const value = process.env.SANDBOX_AGENT_BIN;
if (!value) {
return null;
}
const resolved = path.resolve(value);
if (fs.existsSync(resolved)) {
return resolved;
}
return null;
}
function resolveBinaryFromCliPackage(
require: NodeRequire,
path: typeof import("node:path"),
fs: typeof import("node:fs"),
): string | null {
const key = `${process.platform}-${process.arch}`;
const pkg = PLATFORM_PACKAGES[key];
if (!pkg) {
return null;
}
try {
const pkgPath = require.resolve(`${pkg}/package.json`);
const bin = process.platform === "win32" ? "sandbox-agent.exe" : "sandbox-agent";
const resolved = path.join(path.dirname(pkgPath), "bin", bin);
return fs.existsSync(resolved) ? resolved : null;
} catch {
return null;
}
}
function resolveBinaryFromPath(fs: typeof import("node:fs"), path: typeof import("node:path")): string | null {
const pathEnv = process.env.PATH ?? "";
const separator = process.platform === "win32" ? ";" : ":";
const candidates = pathEnv.split(separator).filter(Boolean);
const bin = process.platform === "win32" ? "sandbox-agent.exe" : "sandbox-agent";
for (const dir of candidates) {
const resolved = path.join(dir, bin);
if (fs.existsSync(resolved)) {
return resolved;
}
}
return null;
}
async function getFreePort(net: typeof import("node:net"), host: string): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, host, () => {
const address = server.address() as AddressInfo;
server.close(() => resolve(address.port));
});
});
}
async function waitForHealth(
baseUrl: string,
fetcher: typeof fetch | undefined,
timeoutMs: number,
child: ChildProcess,
): Promise<void> {
if (!fetcher) {
throw new Error("Fetch API is not available; provide a fetch implementation.");
}
const start = Date.now();
let lastError: string | undefined;
while (Date.now() - start < timeoutMs) {
if (child.exitCode !== null) {
throw new Error("sandbox-agent exited before becoming healthy.");
}
try {
const response = await fetcher(`${baseUrl}/v1/health`);
if (response.ok) {
return;
}
lastError = `status ${response.status}`;
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
throw new Error(`Timed out waiting for sandbox-agent health (${lastError ?? "unknown error"}).`);
}
async function waitForExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
if (child.exitCode !== null) {
return true;
}
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), timeoutMs);
child.once("exit", () => {
clearTimeout(timer);
resolve(true);
});
});
}
function registerProcessCleanup(child: ChildProcess): { dispose: () => void } {
const handler = () => {
if (child.exitCode === null) {
child.kill("SIGTERM");
}
};
process.once("exit", handler);
process.once("SIGINT", handler);
process.once("SIGTERM", handler);
return {
dispose: () => {
process.off("exit", handler);
process.off("SIGINT", handler);
process.off("SIGTERM", handler);
},
};
}