From 34bbffdc6ff928c89a15de20da0b1bc340fd7694 Mon Sep 17 00:00:00 2001 From: Itamar Zand <133867530+ItamarZand88@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:39:15 +0300 Subject: [PATCH] feat(sdk): expose the sandbox binding to TypeScript --- Cargo.lock | 1 + crates/alien-bindings-node/Cargo.toml | 1 + crates/alien-bindings-node/src/lib.rs | 10 + crates/alien-bindings-node/src/sandbox.rs | 356 ++++++++++++++++++ .../bindings/src/__tests__/factories.test.ts | 188 +++++++++ .../bindings/src/__tests__/loader.test.ts | 3 + .../bindings/src/__tests__/remote.test.ts | 4 + packages/bindings/src/errors.ts | 21 ++ packages/bindings/src/factories.ts | 126 +++++++ packages/bindings/src/index.ts | 8 + packages/bindings/src/loader.ts | 53 +++ packages/bindings/src/types.ts | 89 +++++ packages/bindings/tests/sandbox.test.ts | 59 +++ packages/sdk/src/index.ts | 9 +- 14 files changed, 926 insertions(+), 2 deletions(-) create mode 100644 crates/alien-bindings-node/src/sandbox.rs create mode 100644 packages/bindings/tests/sandbox.test.ts diff --git a/Cargo.lock b/Cargo.lock index 8d3071edb..0cbe74dc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -272,6 +272,7 @@ name = "alien-bindings-node" version = "3.3.12" dependencies = [ "alien-bindings", + "alien-core", "alien-error", "chrono", "futures", diff --git a/crates/alien-bindings-node/Cargo.toml b/crates/alien-bindings-node/Cargo.toml index 7b7ae1563..b89934d56 100644 --- a/crates/alien-bindings-node/Cargo.toml +++ b/crates/alien-bindings-node/Cargo.toml @@ -27,6 +27,7 @@ platform-sdk = ["alien-bindings/platform-sdk"] # so the gRPC feature is never pulled in transitively; platform features are # forwarded explicitly above. alien-bindings = { workspace = true } +alien-core = { workspace = true } alien-error = { workspace = true } futures = { workspace = true } napi = { workspace = true } diff --git a/crates/alien-bindings-node/src/lib.rs b/crates/alien-bindings-node/src/lib.rs index 8254f0750..b676dd8ff 100644 --- a/crates/alien-bindings-node/src/lib.rs +++ b/crates/alien-bindings-node/src/lib.rs @@ -14,6 +14,7 @@ mod key; mod kv; mod postgres; mod queue; +mod sandbox; #[cfg(feature = "platform-sdk")] mod remote_storage; mod storage; @@ -31,6 +32,7 @@ pub use key::KeyHandle; pub use kv::KvHandle; pub use postgres::{PostgresConnectionJs, PostgresHandle}; pub use queue::QueueHandle; +pub use sandbox::{CommandFrameJs, CommandStreamHandle, SandboxHandle, SandboxSessionJs}; #[cfg(feature = "platform-sdk")] pub use remote_storage::RemoteStorageHandle; pub use storage::StorageHandle; @@ -111,6 +113,14 @@ impl BindingsHandle { Ok(VaultHandle::new(vault)) } + /// Resolve the sandbox binding named `name`. + #[napi] + pub async fn sandbox(&self, name: String) -> napi::Result { + let inner = self.inner.clone(); + let sandbox = inner.sandbox(&name).await.map_err(map_alien_error)?; + Ok(SandboxHandle::new(sandbox)) + } + /// Resolve the linked-container binding named `name`. #[napi] pub async fn container(&self, name: String) -> napi::Result { diff --git a/crates/alien-bindings-node/src/sandbox.rs b/crates/alien-bindings-node/src/sandbox.rs new file mode 100644 index 000000000..78ec9d254 --- /dev/null +++ b/crates/alien-bindings-node/src/sandbox.rs @@ -0,0 +1,356 @@ +//! Sandbox binding handle. Thin argument/error translation over the `Sandbox` trait. +//! +//! One thing here is not thin: a command's output is a stream, and every other handle in this +//! crate drains its stream before returning. Collecting frames would make the resource useless +//! for what it exists for, which is agent loops that print as they go, and a collect-only API +//! cannot be widened later without a breaking change. +//! +//! So a command returns a [`CommandStreamHandle`] whose `next()` yields one frame at a time. +//! Pull-based on purpose: nothing is produced until JavaScript asks, which is exact backpressure +//! with no callback plumbing, and it maps onto an async iterator on the TypeScript side. + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +use alien_bindings::traits::{ + CommandOutput, CreateSessionRequest, RunCommandRequest, Sandbox, SandboxSession, +}; +use futures::stream::BoxStream; +use futures::StreamExt; +use napi::bindgen_prelude::Buffer; +use napi_derive::napi; +use futures::lock::Mutex; + +use crate::error::map_alien_error; + +/// Environment as the Rust side wants it. A `BTreeMap` so the order a caller supplied does not +/// change what the sandbox sees. +fn into_env(env: Option>) -> BTreeMap { + env.map(|env| env.into_iter().collect()).unwrap_or_default() +} + +/// A live sandbox session. +#[napi(object)] +pub struct SandboxSessionJs { + /// Session id, which is what every later call addresses. + pub session_id: String, + /// Lifecycle state: `starting`, `running`, `suspended` or `terminated`. + pub state: String, + /// Increments when a session is replaced, so a stale handle is detectable. + pub generation: i64, +} + +fn session_to_js(session: SandboxSession) -> SandboxSessionJs { + SandboxSessionJs { + session_id: session.session_id, + state: match session.state { + alien_bindings::traits::SandboxSessionState::Starting => "starting", + alien_bindings::traits::SandboxSessionState::Running => "running", + alien_bindings::traits::SandboxSessionState::Suspended => "suspended", + alien_bindings::traits::SandboxSessionState::Terminated => "terminated", + } + .to_string(), + generation: session.generation as i64, + } +} + +/// One frame of a running command's output. +/// +/// Exactly one of `data` or `exitCode` is set. A frame with neither would be a frame that says +/// nothing, and the terminal frame is the one that carries the exit code. +#[napi(object)] +pub struct CommandFrameJs { + /// `stdout`, `stderr` or `exit` + pub kind: String, + /// Monotonic across both streams, so a caller can reconstruct production order. Absent on + /// the terminal frame. + pub seq: Option, + /// Raw bytes. Not a string: command output is not necessarily UTF-8. + pub data: Option, + /// Process exit code, on the terminal frame only. + pub exit_code: Option, + /// Set when output was cut short by a bound rather than by the command ending. + pub truncated: Option, +} + +fn frame_to_js(frame: CommandOutput) -> CommandFrameJs { + match frame { + CommandOutput::Stdout { seq, data } => CommandFrameJs { + kind: "stdout".to_string(), + seq: Some(seq as i64), + data: Some(Buffer::from(data)), + exit_code: None, + truncated: None, + }, + CommandOutput::Stderr { seq, data } => CommandFrameJs { + kind: "stderr".to_string(), + seq: Some(seq as i64), + data: Some(Buffer::from(data)), + exit_code: None, + truncated: None, + }, + CommandOutput::Exit { code, truncated } => CommandFrameJs { + kind: "exit".to_string(), + seq: None, + data: None, + exit_code: Some(code), + truncated: Some(truncated), + }, + } +} + +/// A running command's output, pulled one frame at a time. +/// +/// The stream is held in an `Option` so it can be dropped on demand: a caller that stops reading +/// half way through leaves a command running, and dropping the stream closes the transport +/// carrying its output, which is what tells the backend nobody is listening. +#[napi] +pub struct CommandStreamHandle { + frames: Arc>>>>, +} + +#[napi] +impl CommandStreamHandle { + /// Returns the next frame, or `null` once the command has produced its last. + /// + /// The terminal frame is delivered like any other, so a caller reads until `null` and finds + /// the exit code in the frame before it. + #[napi] + pub async fn next(&self) -> napi::Result> { + let mut frames = self.frames.lock().await; + let Some(stream) = frames.as_mut() else { + return Ok(None); + }; + + match stream.next().await { + Some(Ok(frame)) => Ok(Some(frame_to_js(frame))), + Some(Err(error)) => Err(map_alien_error(error)), + None => { + *frames = None; + Ok(None) + } + } + } + + /// Releases the stream. Idempotent, and `next()` returns `null` afterwards. + #[napi] + pub async fn close(&self) { + *self.frames.lock().await = None; + } +} + +/// Handle to a resolved sandbox binding. +#[napi] +pub struct SandboxHandle { + inner: Arc, +} + +impl SandboxHandle { + pub(crate) fn new(inner: Arc) -> Self { + Self { inner } + } +} + +#[napi] +impl SandboxHandle { + /// Which operations this platform's sandbox supports. + /// + /// Worth calling: capabilities differ per cloud, and an unsupported one raises rather than + /// silently doing nothing. + /// What this binding can actually do on the current platform. + /// + /// The point of publishing capabilities is that a caller branches on them instead of + /// discovering a gap through an error, so a capability with no method to call is worse than + /// one that is absent. `preview` and `snapshot` are true on some platforms but have no method + /// here yet, so they are not advertised until they do. + /// + /// Destructured rather than read field by field: a capability added to the set then fails to + /// compile here instead of being silently dropped, which is how `egressDeny` went missing. + #[napi] + pub fn capabilities(&self) -> Vec { + let alien_core::SandboxCapabilities { + reconnect, + preview: _, + suspend_resume, + snapshot: _, + domain_egress_rules, + egress_deny, + enforced_limits, + process_limit, + session_lifetime, + supervisor_pid_namespace, + } = self.inner.capabilities(); + + [ + (reconnect, "reconnect"), + (suspend_resume, "suspendResume"), + (domain_egress_rules, "domainEgressRules"), + (egress_deny, "egressDeny"), + (enforced_limits, "enforcedLimits"), + (process_limit, "processLimit"), + (session_lifetime, "sessionLifetime"), + (supervisor_pid_namespace, "supervisorPidNamespace"), + ] + .into_iter() + .filter(|(supported, _)| *supported) + .map(|(_, name)| name.to_string()) + .collect() + } + + /// Creates a session. + #[napi] + pub async fn create( + &self, + session_id: Option, + tenant_key: Option, + env: Option>, + ) -> napi::Result { + let sandbox = self.inner.clone(); + let session = sandbox + .create(CreateSessionRequest { + session_id, + tenant_key, + env: into_env(env), + }) + .await + .map_err(map_alien_error)?; + Ok(session_to_js(session)) + } + + /// Fetches a session, or `null` if it does not exist. + #[napi] + pub async fn get(&self, session_id: String) -> napi::Result> { + let sandbox = self.inner.clone(); + let session = sandbox.get(&session_id).await.map_err(map_alien_error)?; + Ok(session.map(session_to_js)) + } + + /// Fetches a session, creating it if absent. + #[napi] + pub async fn get_or_create( + &self, + session_id: Option, + tenant_key: Option, + env: Option>, + ) -> napi::Result { + let sandbox = self.inner.clone(); + let session = sandbox + .get_or_create(CreateSessionRequest { + session_id, + tenant_key, + env: into_env(env), + }) + .await + .map_err(map_alien_error)?; + Ok(session_to_js(session)) + } + + /// Lists this sandbox's sessions. + #[napi] + pub async fn list(&self) -> napi::Result> { + let sandbox = self.inner.clone(); + let sessions = sandbox.list().await.map_err(map_alien_error)?; + Ok(sessions.into_iter().map(session_to_js).collect()) + } + + /// Runs a command, returning a stream of output frames. + /// + /// `deadlineMs` is required rather than defaulted: a defaulted deadline is a hang waiting + /// for a slow day, in a process the caller shares with untrusted code. It bounds the command + /// rather than the call: backends with no timeout of their own end the session and return + /// once that is confirmed, others kill the process group and leave the session usable. + #[napi] + pub async fn run_command( + &self, + session_id: String, + command: Vec, + deadline_ms: u32, + working_directory: Option, + env: Option>, + ) -> napi::Result { + let sandbox = self.inner.clone(); + let frames = sandbox + .run_command( + &session_id, + RunCommandRequest { + command, + working_directory, + env: into_env(env), + deadline: Duration::from_millis(u64::from(deadline_ms)), + }, + ) + .await + .map_err(map_alien_error)?; + + Ok(CommandStreamHandle { + frames: Arc::new(Mutex::new(Some(frames))), + }) + } + + /// Reads a file out of the sandbox. + #[napi] + pub async fn read_file(&self, session_id: String, path: String) -> napi::Result { + let sandbox = self.inner.clone(); + let contents = sandbox + .read_file(&session_id, &path) + .await + .map_err(map_alien_error)?; + Ok(Buffer::from(contents)) + } + + /// Writes one file into the sandbox. + /// + /// One at a time rather than a map: napi has no natural shape for a map of buffers, and the + /// TypeScript wrapper batches on this side of the boundary. + #[napi] + pub async fn write_file( + &self, + session_id: String, + path: String, + contents: Buffer, + ) -> napi::Result<()> { + let sandbox = self.inner.clone(); + sandbox + .write_files( + &session_id, + BTreeMap::from([(path, contents.to_vec())]), + ) + .await + .map_err(map_alien_error) + } + + /// Creates a directory inside the sandbox. + #[napi] + pub async fn mkdir(&self, session_id: String, path: String) -> napi::Result<()> { + let sandbox = self.inner.clone(); + sandbox + .mkdir(&session_id, &path) + .await + .map_err(map_alien_error) + } + + /// Suspends a session, preserving its state. Requires the `suspendResume` capability. + #[napi] + pub async fn suspend(&self, session_id: String) -> napi::Result<()> { + let sandbox = self.inner.clone(); + sandbox.suspend(&session_id).await.map_err(map_alien_error) + } + + /// Resumes a suspended session. Requires the `suspendResume` capability. + #[napi] + pub async fn resume(&self, session_id: String) -> napi::Result<()> { + let sandbox = self.inner.clone(); + sandbox.resume(&session_id).await.map_err(map_alien_error) + } + + /// Destroys a session. Idempotent: a session already gone is the desired end state. + #[napi] + pub async fn terminate(&self, session_id: String) -> napi::Result<()> { + let sandbox = self.inner.clone(); + sandbox + .terminate(&session_id) + .await + .map_err(map_alien_error) + } +} diff --git a/packages/bindings/src/__tests__/factories.test.ts b/packages/bindings/src/__tests__/factories.test.ts index c83416493..6d0b8d48e 100644 --- a/packages/bindings/src/__tests__/factories.test.ts +++ b/packages/bindings/src/__tests__/factories.test.ts @@ -4,15 +4,18 @@ import { createFactories } from "../factories.js" import type { NativeAddon, RawBindingsHandle, + RawCommandFrame, RawContainerHandle, RawKeyHandle, RawKvHandle, RawPostgresConnection, RawPostgresHandle, RawQueueHandle, + RawSandboxHandle, RawStorageHandle, RawVaultHandle, } from "../loader.js" +import type { CommandFrame } from "../types.js" function unusedRemoteBindingsHandle(): NativeAddon["RemoteBindingsHandle"] { return { @@ -120,6 +123,33 @@ function fakeAddon(): { addon: NativeAddon; constructions: unknown[] } { connection: () => rawConnection("verify-full"), } + const sandboxHandle: RawSandboxHandle = { + capabilities: () => ["reconnect"], + create: async sessionId => ({ sessionId: sessionId ?? "s1", state: "running", generation: 1 }), + get: async sessionId => ({ sessionId, state: "running", generation: 1 }), + getOrCreate: async sessionId => ({ + sessionId: sessionId ?? "s1", + state: "running", + generation: 1, + }), + list: async () => [], + runCommand: async () => { + const frames: RawCommandFrame[] = [ + { kind: "stdout", seq: 0, data: Buffer.from("hello\n") }, + { kind: "stderr", seq: 1, data: Buffer.from("problem\n") }, + { kind: "exit", exitCode: 3, truncated: false }, + ] + let index = 0 + return { next: async () => frames[index++] ?? null, close: async () => {} } + }, + readFile: async () => Buffer.from("contents"), + writeFile: async () => {}, + mkdir: async () => {}, + suspend: async () => {}, + resume: async () => {}, + terminate: async () => {}, + } + const bindings: RawBindingsHandle = { storage: async () => storageHandle, key: async () => keyHandle, @@ -128,6 +158,7 @@ function fakeAddon(): { addon: NativeAddon; constructions: unknown[] } { vault: async () => vaultHandle, container: async () => containerHandle, postgres: async () => postgresHandle, + sandbox: async () => sandboxHandle, } class FakeBindingsHandle { @@ -141,6 +172,7 @@ function fakeAddon(): { addon: NativeAddon; constructions: unknown[] } { vault = bindings.vault container = bindings.container postgres = bindings.postgres + sandbox = bindings.sandbox } return { @@ -598,3 +630,159 @@ describe("createFactories postgres surface", () => { expect((error as AlienError).message).toContain("disable, verify-ca, verify-full") }) }) + +describe("sandbox streaming", () => { + // Every other binding drains its stream before returning. This one must not: the resource + // exists for agent loops that print as they go, and a caller has to see a frame before the + // command ends. + it("yields frames one at a time and ends after the terminal frame", async () => { + const { addon } = fakeAddon() + const { sandbox } = createFactories(() => addon) + + const seen: string[] = [] + for await (const frame of sandbox("sbx").runCommand("s1", ["/bin/echo"], { + deadlineMs: 10_000, + })) { + seen.push( + frame.kind === "exit" ? `exit:${frame.exitCode}` : frame.data.toString("utf8").trim(), + ) + } + + expect(seen).toEqual(["hello", "problem", "exit:3"]) + }) + + // A caller that never iterates must never start a command: the handle is opened on the first + // pull, which is also what makes the loop apply backpressure. + it("does not run the command until the iteration starts", async () => { + const { addon } = fakeAddon() + let opened = 0 + + class CountingBindingsHandle { + async sandbox(name: string): Promise { + const inner = await new addon.BindingsHandle().sandbox(name) + return { + ...inner, + runCommand: (...args: Parameters) => { + opened += 1 + return inner.runCommand(...args) + }, + } + } + } + + const { sandbox } = createFactories(() => ({ + ...addon, + BindingsHandle: CountingBindingsHandle as unknown as NativeAddon["BindingsHandle"], + })) + const iterable = sandbox("sbx").runCommand("s1", ["/bin/echo"], { deadlineMs: 1000 }) + expect(opened).toBe(0) + + for await (const _ of iterable) { + break + } + expect(opened).toBe(1) + }) + + // Leaving the loop early is the ordinary way to stop reading — "run until the first match", + // an error part way through, a caller that gives up. The command keeps running and keeps + // costing until the stream is released, so leaving that to garbage collection is a leak + // measured in sandbox minutes. + it.each([ + [ + "the loop is broken out of", + async (frames: AsyncIterable) => { + for await (const _ of frames) break + }, + ], + [ + "the loop throws", + async (frames: AsyncIterable) => { + await expect( + (async () => { + for await (const _ of frames) throw new Error("caller gave up") + })(), + ).rejects.toThrow("caller gave up") + }, + ], + [ + "the command ends on its own", + async (frames: AsyncIterable) => { + for await (const _ of frames); + }, + ], + ])("closes the stream when %s", async (_case, consume) => { + const { addon } = fakeAddon() + let closed = 0 + + class ClosingBindingsHandle { + async sandbox(name: string): Promise { + const inner = await new addon.BindingsHandle().sandbox(name) + return { + ...inner, + runCommand: async (...args: Parameters) => ({ + ...(await inner.runCommand(...args)), + close: async () => { + closed += 1 + }, + }), + } + } + } + + const { sandbox } = createFactories(() => ({ + ...addon, + BindingsHandle: ClosingBindingsHandle as unknown as NativeAddon["BindingsHandle"], + })) + + await consume(sandbox("sbx").runCommand("s1", ["/bin/echo"], { deadlineMs: 1000 })) + + expect(closed).toBe(1) + }) + + // env is the one option a caller cannot work around: without it reaching the addon there is no + // way to configure a session's environment from TypeScript at all. + it("passes env through to the addon on create and on a command", async () => { + const { addon } = fakeAddon() + const seen: Array | null | undefined> = [] + + class RecordingBindingsHandle { + async sandbox(name: string): Promise { + const inner = await new addon.BindingsHandle().sandbox(name) + return { + ...inner, + create: (sessionId, tenantKey, env) => { + seen.push(env) + return inner.create(sessionId, tenantKey, env) + }, + runCommand: (sessionId, command, deadlineMs, workingDirectory, env) => { + seen.push(env) + return inner.runCommand(sessionId, command, deadlineMs, workingDirectory, env) + }, + } + } + } + + const { sandbox } = createFactories(() => ({ + ...addon, + BindingsHandle: RecordingBindingsHandle as unknown as NativeAddon["BindingsHandle"], + })) + + await sandbox("sbx").create({ env: { TOKEN: "s3cret" } }) + for await (const _ of sandbox("sbx").runCommand("s1", ["/bin/echo"], { + deadlineMs: 1000, + env: { EXTRA: "1" }, + })); + + expect(seen).toEqual([{ TOKEN: "s3cret" }, { EXTRA: "1" }]) + }) + + it("writes files one call per path and reads them back as bytes", async () => { + const { addon } = fakeAddon() + const { sandbox } = createFactories(() => addon) + + await sandbox("sbx").writeFiles("s1", { "a.txt": "text", "b.bin": Buffer.from([1, 2]) }) + const read = await sandbox("sbx").readFile("s1", "a.txt") + + expect(Buffer.isBuffer(read)).toBe(true) + }) +}) diff --git a/packages/bindings/src/__tests__/loader.test.ts b/packages/bindings/src/__tests__/loader.test.ts index fddad8d7f..d409dbd02 100644 --- a/packages/bindings/src/__tests__/loader.test.ts +++ b/packages/bindings/src/__tests__/loader.test.ts @@ -25,6 +25,9 @@ function addonReporting(version: string): NativeAddon { postgres(): never { throw new Error("not used by version validation") } + sandbox(): never { + throw new Error("not used by version validation") + } } const RemoteBindingsHandle: NativeAddon["RemoteBindingsHandle"] = { diff --git a/packages/bindings/src/__tests__/remote.test.ts b/packages/bindings/src/__tests__/remote.test.ts index 4993002f8..aecb6f3bf 100644 --- a/packages/bindings/src/__tests__/remote.test.ts +++ b/packages/bindings/src/__tests__/remote.test.ts @@ -91,6 +91,10 @@ function fakeRemoteAddon() { async postgres(): Promise { throw new Error("unused") } + + async sandbox(): Promise { + throw new Error("unused") + } } class FakeRemoteBindingsHandle implements RawRemoteBindingsHandle { diff --git a/packages/bindings/src/errors.ts b/packages/bindings/src/errors.ts index c34b189c1..7b31f9cfd 100644 --- a/packages/bindings/src/errors.ts +++ b/packages/bindings/src/errors.ts @@ -80,6 +80,27 @@ export const InvalidPostgresTlsConfigError = defineError({ internal: false, }) +/** + * Thrown when the native addon reports a sandbox session state or output frame kind this wrapper + * does not know. + * + * Casting instead would put a value outside the declared union behind a type that says otherwise, + * so a `switch` a caller wrote against the union would fall through silently. Version skew has to + * fail loudly, and retrying cannot repair it. + */ +export const UnknownSandboxValueError = defineError({ + code: "UNKNOWN_SANDBOX_VALUE", + context: z.object({ + field: z.string(), + value: z.string(), + expected: z.array(z.string()), + }), + message: ({ field, value, expected }) => + `@alienplatform/bindings received an unknown sandbox ${field} '${value}' from the native addon; expected one of ${expected.join(", ")}.`, + retryable: false, + internal: false, +}) + /** Fallback code for napi-internal errors whose message is not an envelope. */ const GENERIC_BINDINGS_CODE = "BINDINGS_ERROR" diff --git a/packages/bindings/src/factories.ts b/packages/bindings/src/factories.ts index 858a64b53..765492aec 100644 --- a/packages/bindings/src/factories.ts +++ b/packages/bindings/src/factories.ts @@ -17,11 +17,13 @@ import { AlienError, InvalidPostgresTlsConfigError, UnknownPostgresSslModeError, + UnknownSandboxValueError, unwrapNapiError, } from "./errors.js" import type { NativeAddon, RawBindingsHandle, + RawCommandFrame, RawContainerHandle, RawKeyHandle, RawKvHandle, @@ -30,10 +32,13 @@ import type { RawQueueHandle, RawRemoteBindingsHandle, RawRemoteStorageHandle, + RawSandboxHandle, + RawSandboxSession, RawStorageHandle, RawVaultHandle, } from "./loader.js" import type { + CommandFrame, Container, Key, KeyOptions, @@ -48,6 +53,8 @@ import type { Queue, QueueMessage, RemoteStorage, + Sandbox, + SandboxSession, SignedUrlOptions, Storage, StoragePutOptions, @@ -133,6 +140,123 @@ function makeRemoteStorage(handle: () => Promise): Remot } } +/** The session states the addon and this wrapper agree on. */ +const SANDBOX_SESSION_STATES = ["starting", "running", "suspended", "terminated"] as const + +/** The output frame kinds that carry data; `exit` is handled separately. */ +const SANDBOX_STREAM_KINDS = ["stdout", "stderr"] as const + +/** + * Narrows a value the addon produced into a declared union, or throws. + * + * Casting would put a value outside the union behind a type claiming otherwise, and a caller's + * `switch` over the union would then fall through with no error. This is the same version-skew + * argument `toPostgresConnection` makes for `sslmode`. + */ +function narrow(field: string, value: string, expected: readonly T[]): T { + if ((expected as readonly string[]).includes(value)) { + return value as T + } + throw new AlienError( + UnknownSandboxValueError.create({ field, value, expected: [...expected] }).toOptions(), + ) +} + +function makeSandbox(handle: () => Promise): Sandbox { + const session = (raw: RawSandboxSession): SandboxSession => ({ + sessionId: raw.sessionId, + state: narrow("session state", raw.state, SANDBOX_SESSION_STATES), + generation: raw.generation, + }) + + const frame = (raw: RawCommandFrame): CommandFrame => + raw.kind === "exit" + ? { kind: "exit", exitCode: raw.exitCode ?? -1, truncated: raw.truncated ?? false } + : { + kind: narrow("frame kind", raw.kind, SANDBOX_STREAM_KINDS), + seq: raw.seq ?? 0, + data: raw.data ?? Buffer.alloc(0), + } + + return { + capabilities: () => guard(handle, async raw => raw.capabilities()), + create: options => + guard(handle, async raw => + session( + await raw.create( + options?.sessionId ?? null, + options?.tenantKey ?? null, + options?.env ?? null, + ), + ), + ), + get: sessionId => + guard(handle, async raw => { + const found = await raw.get(sessionId) + return found === null ? null : session(found) + }), + getOrCreate: options => + guard(handle, async raw => + session( + await raw.getOrCreate( + options?.sessionId ?? null, + options?.tenantKey ?? null, + options?.env ?? null, + ), + ), + ), + list: () => guard(handle, async raw => (await raw.list()).map(session)), + // Not `async function*` over a resolved stream: the handle is opened on the first pull, so + // a caller that never iterates never starts a command. + runCommand: (sessionId, command, options) => ({ + async *[Symbol.asyncIterator]() { + const stream = await guard(handle, raw => + raw.runCommand( + sessionId, + command, + options.deadlineMs, + options.workingDirectory ?? null, + options.env ?? null, + ), + ) + + // A caller that breaks out of the loop, returns, or throws still leaves a command + // running in the sandbox. `for await` calls the generator's `return()` on every one of + // those, so closing here is what stops paying for output nobody is reading. + try { + while (true) { + let next: RawCommandFrame | null + try { + next = await stream.next() + } catch (err) { + throw unwrapNapiError(err) + } + if (next === null) return + yield frame(next) + } + } finally { + await stream.close() + } + }, + }), + readFile: (sessionId, path) => guard(handle, raw => raw.readFile(sessionId, path)), + writeFiles: (sessionId, files) => + guard(handle, async raw => { + for (const [path, contents] of Object.entries(files)) { + await raw.writeFile( + sessionId, + path, + typeof contents === "string" ? Buffer.from(contents, "utf8") : contents, + ) + } + }), + mkdir: (sessionId, path) => guard(handle, raw => raw.mkdir(sessionId, path)), + suspend: sessionId => guard(handle, raw => raw.suspend(sessionId)), + resume: sessionId => guard(handle, raw => raw.resume(sessionId)), + terminate: sessionId => guard(handle, raw => raw.terminate(sessionId)), + } +} + function makeKv(handle: () => Promise): Kv { return { get: key => guard(handle, raw => raw.get(key)), @@ -348,6 +472,7 @@ export interface Factories { vault(name: string): Vault container(name: string): Container postgres(name: string): Postgres + sandbox(name: string): Sandbox } /** Build the factories bound to a given addon provider. */ @@ -361,6 +486,7 @@ export function createFactories(getAddon: () => NativeAddon): Factories { vault: name => makeVault(lazyHandle(async () => (await getBindings()).vault(name))), container: name => makeContainer(lazyHandle(async () => (await getBindings()).container(name))), postgres: name => makePostgres(lazyHandle(async () => (await getBindings()).postgres(name))), + sandbox: name => makeSandbox(lazyHandle(async () => (await getBindings()).sandbox(name))), } } diff --git a/packages/bindings/src/index.ts b/packages/bindings/src/index.ts index cfdc430e4..670d016ef 100644 --- a/packages/bindings/src/index.ts +++ b/packages/bindings/src/index.ts @@ -37,6 +37,9 @@ export const container = factories.container /** Resolve the Postgres binding named `name`. */ export const postgres = factories.postgres +/** Create an isolated environment for running untrusted code. */ +export const sandbox = factories.sandbox + export { AlienError, BindingNotConfiguredError, @@ -44,9 +47,11 @@ export { defineError, InvalidPostgresTlsConfigError, UnknownPostgresSslModeError, + UnknownSandboxValueError, } from "./errors.js" export type { + CommandFrame, Container, Key, KeyOptions, @@ -65,6 +70,9 @@ export type { Queue, QueueMessage, RemoteStorage, + RunCommandOptions, + Sandbox, + SandboxSession, SignedUrlMethod, SignedUrlOptions, Storage, diff --git a/packages/bindings/src/loader.ts b/packages/bindings/src/loader.ts index 71fc09868..9c90d8a85 100644 --- a/packages/bindings/src/loader.ts +++ b/packages/bindings/src/loader.ts @@ -174,6 +174,58 @@ export interface RawVaultHandle { listSecrets(): Promise } +/** One frame of a running command's output, as the addon returns it. */ +export interface RawCommandFrame { + kind: string + seq?: number + data?: Buffer + exitCode?: number + truncated?: boolean +} + +/** Raw napi command stream, pulled one frame at a time. */ +export interface RawCommandStreamHandle { + next(): Promise + close(): Promise +} + +/** A live sandbox session, as the addon returns it. */ +export interface RawSandboxSession { + sessionId: string + state: string + generation: number +} + +/** Raw napi sandbox handle. */ +export interface RawSandboxHandle { + capabilities(): string[] + create( + sessionId?: string | null, + tenantKey?: string | null, + env?: Record | null, + ): Promise + get(sessionId: string): Promise + getOrCreate( + sessionId?: string | null, + tenantKey?: string | null, + env?: Record | null, + ): Promise + list(): Promise + runCommand( + sessionId: string, + command: string[], + deadlineMs: number, + workingDirectory?: string | null, + env?: Record | null, + ): Promise + readFile(sessionId: string, path: string): Promise + writeFile(sessionId: string, path: string, contents: Buffer): Promise + mkdir(sessionId: string, path: string): Promise + suspend(sessionId: string): Promise + resume(sessionId: string): Promise + terminate(sessionId: string): Promise +} + /** Raw napi bindings entry point. Construction validates the environment. */ export interface RawBindingsHandle { storage(name: string): Promise @@ -183,6 +235,7 @@ export interface RawBindingsHandle { vault(name: string): Promise container(name: string): Promise postgres(name: string): Promise + sandbox(name: string): Promise } /** Raw napi remote bindings entry point. */ diff --git a/packages/bindings/src/types.ts b/packages/bindings/src/types.ts index 45cfea9cf..d6f2c48ee 100644 --- a/packages/bindings/src/types.ts +++ b/packages/bindings/src/types.ts @@ -374,3 +374,92 @@ export interface Vault { /** List the names of all secrets in this vault. */ list(): Promise } + +/** A live sandbox session. */ +export interface SandboxSession { + /** Session id, which is what every later call addresses. */ + sessionId: string + /** Lifecycle state. */ + state: "starting" | "running" | "suspended" | "terminated" + /** Increments when a session is replaced, so a stale handle is detectable. */ + generation: number +} + +/** One frame of a running command's output. */ +export type CommandFrame = + | { kind: "stdout" | "stderr"; seq: number; data: Buffer } + | { kind: "exit"; exitCode: number; truncated: boolean } + +/** What a command needs to run. */ +export interface RunCommandOptions { + /** + * How long the command may run, in milliseconds. Required rather than defaulted: a defaulted + * deadline is a hang waiting for a slow day, in a sandbox running code you do not control. + * + * It bounds the command, not the call. What expiry does to the session differs by backend: + * where the backend has no timeout of its own the only lever is ending the session, so the + * iterator raises once that is confirmed, somewhat after the deadline. Where the agent + * supervises the process it kills the process group and the session stays usable. Either way + * the command is stopped; only the session's fate differs. + */ + deadlineMs: number + /** Working directory inside the sandbox. */ + workingDirectory?: string + /** Environment for this command, on top of whatever the session was created with. */ + env?: Record +} + +/** + * An isolated environment for running untrusted code. + * + * Capabilities differ per platform. Call `capabilities()` and branch, or call and handle the + * error: an unsupported operation raises rather than silently doing nothing. + */ +export interface Sandbox { + /** Which operations this platform supports. */ + capabilities(): Promise + /** Creates a session. */ + create(options?: { + sessionId?: string + tenantKey?: string + /** Environment every command in the session starts with. */ + env?: Record + }): Promise + /** Fetches a session, or `null` if it does not exist. Requires `reconnect`. */ + get(sessionId: string): Promise + /** Fetches a session, creating it if absent. */ + getOrCreate(options?: { + sessionId?: string + tenantKey?: string + /** Environment every command in the session starts with. */ + env?: Record + }): Promise + /** + * Lists this sandbox's sessions. Not offered on AWS, Azure or GCP — those raise rather than + * enumerate. Reach a session whose id you hold with `get`. + */ + list(): Promise + /** + * Runs a command, yielding frames as the command produces them. + * + * The iterator is pull-based all the way down: nothing is read from the sandbox until the + * loop asks for the next frame, so a slow consumer slows the producer instead of buffering. + */ + runCommand( + sessionId: string, + command: string[], + options: RunCommandOptions, + ): AsyncIterable + /** Reads a file out of the sandbox. */ + readFile(sessionId: string, path: string): Promise + /** Writes files into the sandbox. */ + writeFiles(sessionId: string, files: Record): Promise + /** Creates a directory inside the sandbox. */ + mkdir(sessionId: string, path: string): Promise + /** Suspends a session, preserving state. Requires `suspendResume`. */ + suspend(sessionId: string): Promise + /** Resumes a suspended session. Requires `suspendResume`. */ + resume(sessionId: string): Promise + /** Destroys a session. Idempotent. */ + terminate(sessionId: string): Promise +} diff --git a/packages/bindings/tests/sandbox.test.ts b/packages/bindings/tests/sandbox.test.ts new file mode 100644 index 000000000..436d52e06 --- /dev/null +++ b/packages/bindings/tests/sandbox.test.ts @@ -0,0 +1,59 @@ +/** + * Sandbox binding tests through the REAL napi addon. + * + * Only the paths that need no cloud credentials and no running sandbox are covered here: how a + * binding is resolved from the environment, and what a caller sees when one is missing or + * malformed. Creating a session needs a backend — Local needs Docker, and the four cloud backends + * need real credentials — so session behaviour is covered by `crates/alien-local/tests/` against + * real Docker and by the e2e apps against a deployed stack. + * + * The value of this file is the boundary the other suites skip: an unconfigured or wrong-shaped + * binding must produce a typed error naming the binding, not a panic crossing the addon. + * + * Run locally with a built addon: + * `ALIEN_BINDINGS_ADDON_PATH= pnpm vitest run tests/sandbox.test.ts` + */ + +import { describe, expect, it } from "vitest" +import { AlienError, sandbox } from "../src/index.js" + +/** Puts a binding into the environment the way the runtime supplies one. */ +function setBinding(name: string, value: unknown): void { + // Resolving a binding also reads the deployment type, as the runtime would supply it. + process.env.ALIEN_DEPLOYMENT_TYPE = "local" + const variable = `ALIEN_${name.toUpperCase().replace(/-/g, "_")}_BINDING` + process.env[variable] = JSON.stringify(value) +} + +describe("sandbox binding resolution", () => { + it("names the binding and its environment variable when none is configured", async () => { + // The failure a developer actually hits first: a sandbox declared in the stack but the + // workload started without its binding. The message has to say which one. + await expect(sandbox("not-configured").capabilities()).rejects.toThrow(/not-configured/i) + }) + + it("refuses a binding whose provider is unknown rather than guessing one", async () => { + setBinding("sandbox-bad-provider", { provider: "not-a-cloud", imageArn: "arn:aws:lambda:::x" }) + + const error = await sandbox("sandbox-bad-provider") + .capabilities() + .then( + () => null, + (caught: unknown) => caught, + ) + + expect(error).toBeInstanceOf(AlienError) + }) + + it("refuses an AWS binding that is missing a required field", async () => { + // imageVersion is load-bearing: image plus version is the session identity, so a binding + // without it would enumerate the wrong scope rather than fail. + setBinding("sandbox-incomplete", { + provider: "aws", + imageArn: "arn:aws:lambda:us-west-2:123456789012:microvm-image:sbx", + region: "us-west-2", + }) + + await expect(sandbox("sandbox-incomplete").capabilities()).rejects.toBeInstanceOf(AlienError) + }) +}) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 04186a0c7..774e0517e 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -3,7 +3,8 @@ * * It provides the Worker handler APIs (`command`, `onStorageEvent`, * `onCronEvent`, `onQueueMessage`, `waitUntil`) and re-exports the app-facing - * binding factories (`storage`, `kv`, `queue`, `vault`, `container`, `postgres`) from + * binding factories (`storage`, `kv`, `queue`, `vault`, `container`, `postgres`, + * `sandbox`) from * `@alienplatform/bindings`, so a Worker author installs one package. * * Worker protocol dependencies (nice-grpc, generated Worker protocol clients) @@ -55,16 +56,20 @@ export { // ============================================================================ export type { + CommandFrame, Container, Kv, Postgres, PostgresConnection, PostgresSslMode, Queue, + RunCommandOptions, + Sandbox, + SandboxSession, Storage, Vault, } from "@alienplatform/bindings" -export { container, kv, postgres, queue, storage, vault } from "@alienplatform/bindings" +export { container, kv, postgres, queue, sandbox, storage, vault } from "@alienplatform/bindings" // ============================================================================ // AI: re-exported from @alienplatform/ai-gateway (a spawned Rust gateway process)