Skip to content

Commit 71ab403

Browse files
committed
refactor: rename engine/ to server/
1 parent 016024c commit 71ab403

File tree

37 files changed

+917
-3
lines changed

37 files changed

+917
-3
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[workspace]
22
resolver = "2"
3-
members = ["engine/packages/*"]
3+
members = ["server/packages/*"]
44

55
[workspace.package]
66
version = "0.1.0"

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ Universal API for running Claude Code, Codex, OpenCode, and Amp inside sandboxes
88
- **Supports your sandbox provider**: Daytona, E2B, Vercel Sandboxes, and more
99
- **Lightweight, portable Rust binary**: Install anywhere with 1 curl command
1010

11+
## Architecture
12+
13+
- TODO
14+
- Embedded (runs agents locally)
15+
- Sandboxed
16+
1117
## Project Goals
1218

1319
This project aims to solve 3 problems with agents:

docs/typescript-sdk.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,24 @@ await client.postMessage("my-session", { message: "Hello" });
3131
const events = await client.getEvents("my-session", { offset: 0, limit: 50 });
3232
```
3333

34+
## Autospawn (Node only)
35+
36+
```ts
37+
import { connectSandboxDaemonClient } from "sandbox-agent";
38+
39+
const client = await connectSandboxDaemonClient({
40+
spawn: { enabled: true },
41+
});
42+
43+
await client.createSession("my-session", { agent: "claude" });
44+
await client.postMessage("my-session", { message: "Hello" });
45+
46+
await client.dispose();
47+
```
48+
49+
Autospawn uses the local `sandbox-agent` binary. Install `@sandbox-agent/cli` (recommended), or
50+
set `SANDBOX_AGENT_BIN` to the binary path.
51+
3452
## Endpoint mapping
3553

3654
<details>

scripts/release/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ function publishCrates(rootDir: string, version: string) {
317317

318318
for (const crate of CRATE_ORDER) {
319319
console.log(`==> Publishing sandbox-agent-${crate}`);
320-
const crateDir = path.join(rootDir, "engine", "packages", crate);
320+
const crateDir = path.join(rootDir, "server", "packages", crate);
321321
run("cargo", ["publish", "--allow-dirty"], { cwd: crateDir });
322322
// Wait for crates.io index propagation
323323
console.log("Waiting 30s for index...");

sdks/typescript/src/client.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import type { components } from "./generated/openapi.js";
2+
import type {
3+
SandboxDaemonSpawnHandle,
4+
SandboxDaemonSpawnOptions,
5+
} from "./spawn.js";
26

37
export type AgentInstallRequest = components["schemas"]["AgentInstallRequest"];
48
export type AgentModeInfo = components["schemas"]["AgentModeInfo"];
@@ -25,6 +29,14 @@ export interface SandboxDaemonClientOptions {
2529
headers?: HeadersInit;
2630
}
2731

32+
export interface SandboxDaemonConnectOptions {
33+
baseUrl?: string;
34+
token?: string;
35+
fetch?: typeof fetch;
36+
headers?: HeadersInit;
37+
spawn?: SandboxDaemonSpawnOptions | boolean;
38+
}
39+
2840
export class SandboxDaemonError extends Error {
2941
readonly status: number;
3042
readonly problem?: ProblemDetails;
@@ -53,6 +65,7 @@ export class SandboxDaemonClient {
5365
private readonly token?: string;
5466
private readonly fetcher: typeof fetch;
5567
private readonly defaultHeaders?: HeadersInit;
68+
private spawnHandle?: SandboxDaemonSpawnHandle;
5669

5770
constructor(options: SandboxDaemonClientOptions) {
5871
this.baseUrl = options.baseUrl.replace(/\/$/, "");
@@ -65,6 +78,32 @@ export class SandboxDaemonClient {
6578
}
6679
}
6780

81+
static async connect(options: SandboxDaemonConnectOptions): Promise<SandboxDaemonClient> {
82+
const spawnOptions = normalizeSpawnOptions(options.spawn, !options.baseUrl);
83+
if (!spawnOptions.enabled) {
84+
if (!options.baseUrl) {
85+
throw new Error("baseUrl is required when autospawn is disabled.");
86+
}
87+
return new SandboxDaemonClient({
88+
baseUrl: options.baseUrl,
89+
token: options.token,
90+
fetch: options.fetch,
91+
headers: options.headers,
92+
});
93+
}
94+
95+
const { spawnSandboxDaemon } = await import("./spawn.js");
96+
const handle = await spawnSandboxDaemon(spawnOptions, options.fetch ?? globalThis.fetch);
97+
const client = new SandboxDaemonClient({
98+
baseUrl: handle.baseUrl,
99+
token: handle.token,
100+
fetch: options.fetch,
101+
headers: options.headers,
102+
});
103+
client.spawnHandle = handle;
104+
return client;
105+
}
106+
68107
async listAgents(): Promise<AgentListResponse> {
69108
return this.requestJson("GET", `${API_PREFIX}/agents`);
70109
}
@@ -171,6 +210,13 @@ export class SandboxDaemonClient {
171210
);
172211
}
173212

213+
async dispose(): Promise<void> {
214+
if (this.spawnHandle) {
215+
await this.spawnHandle.dispose();
216+
this.spawnHandle = undefined;
217+
}
218+
}
219+
174220
private async requestJson<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
175221
const response = await this.requestRaw(method, path, {
176222
query: options.query,
@@ -252,3 +298,22 @@ export class SandboxDaemonClient {
252298
export const createSandboxDaemonClient = (options: SandboxDaemonClientOptions): SandboxDaemonClient => {
253299
return new SandboxDaemonClient(options);
254300
};
301+
302+
export const connectSandboxDaemonClient = (
303+
options: SandboxDaemonConnectOptions,
304+
): Promise<SandboxDaemonClient> => {
305+
return SandboxDaemonClient.connect(options);
306+
};
307+
308+
const normalizeSpawnOptions = (
309+
spawn: SandboxDaemonSpawnOptions | boolean | undefined,
310+
defaultEnabled: boolean,
311+
): SandboxDaemonSpawnOptions => {
312+
if (typeof spawn === "boolean") {
313+
return { enabled: spawn };
314+
}
315+
if (spawn) {
316+
return { enabled: spawn.enabled ?? defaultEnabled, ...spawn };
317+
}
318+
return { enabled: defaultEnabled };
319+
};

sdks/typescript/src/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
export { SandboxDaemonClient, SandboxDaemonError, createSandboxDaemonClient } from "./client.js";
1+
export {
2+
SandboxDaemonClient,
3+
SandboxDaemonError,
4+
connectSandboxDaemonClient,
5+
createSandboxDaemonClient,
6+
} from "./client.js";
27
export type {
38
AgentInfo,
49
AgentInstallRequest,
@@ -15,5 +20,8 @@ export type {
1520
ProblemDetails,
1621
QuestionReplyRequest,
1722
UniversalEvent,
23+
SandboxDaemonClientOptions,
24+
SandboxDaemonConnectOptions,
1825
} from "./client.js";
1926
export type { components, paths } from "./generated/openapi.js";
27+
export type { SandboxDaemonSpawnOptions, SandboxDaemonSpawnLogMode } from "./spawn.js";

sdks/typescript/src/spawn.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import type { ChildProcess } from "node:child_process";
2+
import type { AddressInfo } from "node:net";
3+
import type { NodeRequire } from "node:module";
4+
5+
export type SandboxDaemonSpawnLogMode = "inherit" | "pipe" | "silent";
6+
7+
export type SandboxDaemonSpawnOptions = {
8+
enabled?: boolean;
9+
host?: string;
10+
port?: number;
11+
token?: string;
12+
binaryPath?: string;
13+
timeoutMs?: number;
14+
log?: SandboxDaemonSpawnLogMode;
15+
env?: Record<string, string>;
16+
};
17+
18+
export type SandboxDaemonSpawnHandle = {
19+
baseUrl: string;
20+
token: string;
21+
child: ChildProcess;
22+
dispose: () => Promise<void>;
23+
};
24+
25+
const PLATFORM_PACKAGES: Record<string, string> = {
26+
"darwin-arm64": "@sandbox-agent/cli-darwin-arm64",
27+
"darwin-x64": "@sandbox-agent/cli-darwin-x64",
28+
"linux-x64": "@sandbox-agent/cli-linux-x64",
29+
"win32-x64": "@sandbox-agent/cli-win32-x64",
30+
};
31+
32+
export function isNodeRuntime(): boolean {
33+
return typeof process !== "undefined" && !!process.versions?.node;
34+
}
35+
36+
export async function spawnSandboxDaemon(
37+
options: SandboxDaemonSpawnOptions,
38+
fetcher?: typeof fetch,
39+
): Promise<SandboxDaemonSpawnHandle> {
40+
if (!isNodeRuntime()) {
41+
throw new Error("Autospawn requires a Node.js runtime.");
42+
}
43+
44+
const {
45+
spawn,
46+
} = await import("node:child_process");
47+
const crypto = await import("node:crypto");
48+
const fs = await import("node:fs");
49+
const path = await import("node:path");
50+
const net = await import("node:net");
51+
const { createRequire } = await import("node:module");
52+
53+
const host = options.host ?? "127.0.0.1";
54+
const port = options.port ?? (await getFreePort(net, host));
55+
const token = options.token ?? crypto.randomBytes(24).toString("hex");
56+
const timeoutMs = options.timeoutMs ?? 15_000;
57+
const logMode: SandboxDaemonSpawnLogMode = options.log ?? "inherit";
58+
59+
const binaryPath =
60+
options.binaryPath ??
61+
resolveBinaryFromEnv(fs, path) ??
62+
resolveBinaryFromCliPackage(createRequire(import.meta.url), path, fs) ??
63+
resolveBinaryFromPath(fs, path);
64+
65+
if (!binaryPath) {
66+
throw new Error("sandbox-agent binary not found. Install @sandbox-agent/cli or set SANDBOX_AGENT_BIN.");
67+
}
68+
69+
const stdio = logMode === "inherit" ? "inherit" : logMode === "silent" ? "ignore" : "pipe";
70+
const args = ["--host", host, "--port", String(port), "--token", token];
71+
const child = spawn(binaryPath, args, {
72+
stdio,
73+
env: {
74+
...process.env,
75+
...(options.env ?? {}),
76+
},
77+
});
78+
const cleanup = registerProcessCleanup(child);
79+
80+
const baseUrl = `http://${host}:${port}`;
81+
const ready = waitForHealth(baseUrl, fetcher ?? globalThis.fetch, timeoutMs, child);
82+
83+
await ready;
84+
85+
const dispose = async () => {
86+
if (child.exitCode !== null) {
87+
cleanup.dispose();
88+
return;
89+
}
90+
child.kill("SIGTERM");
91+
const exited = await waitForExit(child, 5_000);
92+
if (!exited) {
93+
child.kill("SIGKILL");
94+
}
95+
cleanup.dispose();
96+
};
97+
98+
return { baseUrl, token, child, dispose };
99+
}
100+
101+
function resolveBinaryFromEnv(fs: typeof import("node:fs"), path: typeof import("node:path")): string | null {
102+
const value = process.env.SANDBOX_AGENT_BIN;
103+
if (!value) {
104+
return null;
105+
}
106+
const resolved = path.resolve(value);
107+
if (fs.existsSync(resolved)) {
108+
return resolved;
109+
}
110+
return null;
111+
}
112+
113+
function resolveBinaryFromCliPackage(
114+
require: NodeRequire,
115+
path: typeof import("node:path"),
116+
fs: typeof import("node:fs"),
117+
): string | null {
118+
const key = `${process.platform}-${process.arch}`;
119+
const pkg = PLATFORM_PACKAGES[key];
120+
if (!pkg) {
121+
return null;
122+
}
123+
try {
124+
const pkgPath = require.resolve(`${pkg}/package.json`);
125+
const bin = process.platform === "win32" ? "sandbox-agent.exe" : "sandbox-agent";
126+
const resolved = path.join(path.dirname(pkgPath), "bin", bin);
127+
return fs.existsSync(resolved) ? resolved : null;
128+
} catch {
129+
return null;
130+
}
131+
}
132+
133+
function resolveBinaryFromPath(fs: typeof import("node:fs"), path: typeof import("node:path")): string | null {
134+
const pathEnv = process.env.PATH ?? "";
135+
const separator = process.platform === "win32" ? ";" : ":";
136+
const candidates = pathEnv.split(separator).filter(Boolean);
137+
const bin = process.platform === "win32" ? "sandbox-agent.exe" : "sandbox-agent";
138+
for (const dir of candidates) {
139+
const resolved = path.join(dir, bin);
140+
if (fs.existsSync(resolved)) {
141+
return resolved;
142+
}
143+
}
144+
return null;
145+
}
146+
147+
async function getFreePort(net: typeof import("node:net"), host: string): Promise<number> {
148+
return new Promise((resolve, reject) => {
149+
const server = net.createServer();
150+
server.unref();
151+
server.on("error", reject);
152+
server.listen(0, host, () => {
153+
const address = server.address() as AddressInfo;
154+
server.close(() => resolve(address.port));
155+
});
156+
});
157+
}
158+
159+
async function waitForHealth(
160+
baseUrl: string,
161+
fetcher: typeof fetch | undefined,
162+
timeoutMs: number,
163+
child: ChildProcess,
164+
): Promise<void> {
165+
if (!fetcher) {
166+
throw new Error("Fetch API is not available; provide a fetch implementation.");
167+
}
168+
const start = Date.now();
169+
let lastError: string | undefined;
170+
171+
while (Date.now() - start < timeoutMs) {
172+
if (child.exitCode !== null) {
173+
throw new Error("sandbox-agent exited before becoming healthy.");
174+
}
175+
try {
176+
const response = await fetcher(`${baseUrl}/v1/health`);
177+
if (response.ok) {
178+
return;
179+
}
180+
lastError = `status ${response.status}`;
181+
} catch (err) {
182+
lastError = err instanceof Error ? err.message : String(err);
183+
}
184+
await new Promise((resolve) => setTimeout(resolve, 200));
185+
}
186+
187+
throw new Error(`Timed out waiting for sandbox-agent health (${lastError ?? "unknown error"}).`);
188+
}
189+
190+
async function waitForExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
191+
if (child.exitCode !== null) {
192+
return true;
193+
}
194+
return new Promise((resolve) => {
195+
const timer = setTimeout(() => resolve(false), timeoutMs);
196+
child.once("exit", () => {
197+
clearTimeout(timer);
198+
resolve(true);
199+
});
200+
});
201+
}
202+
203+
function registerProcessCleanup(child: ChildProcess): { dispose: () => void } {
204+
const handler = () => {
205+
if (child.exitCode === null) {
206+
child.kill("SIGTERM");
207+
}
208+
};
209+
210+
process.once("exit", handler);
211+
process.once("SIGINT", handler);
212+
process.once("SIGTERM", handler);
213+
214+
return {
215+
dispose: () => {
216+
process.off("exit", handler);
217+
process.off("SIGINT", handler);
218+
process.off("SIGTERM", handler);
219+
},
220+
};
221+
}
File renamed without changes.
File renamed without changes.
File renamed without changes.

0 commit comments

Comments
 (0)