mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 08:01:03 +00:00
refactor: rename engine/ to server/
This commit is contained in:
parent
016024c04b
commit
71ab40388c
37 changed files with 917 additions and 3 deletions
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
221
sdks/typescript/src/spawn.ts
Normal file
221
sdks/typescript/src/spawn.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue