From ab41e25e1cd728b5be4c03b13a1cd5c89865f721 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Fri, 8 May 2026 16:53:22 -0400 Subject: [PATCH 1/7] feat(sandbox-blaxel): add Blaxel sandbox provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships @voltagent/sandbox-blaxel implementing WorkspaceSandbox over @blaxel/core. Uses the SDK-native pattern (process.exec → process.wait → process.logs/get) with native env/streaming/timeout support — no manual polling, no shell-prefix env hacks. Public API: - BlaxelSandbox class (execute, getSandbox, destroy, getInfo) - BlaxelSandboxOptions with apiKey/workspace + a config bag extending SandboxCreateConfiguration with cwd, env, defaultTimeoutMs, maxOutputBytes, pollIntervalMs - BlaxelSandboxConfig, BlaxelSandboxInstance type re-exports - SandboxCreateConfiguration re-export from @blaxel/core for full SDK type access without a separate import Tests cover 53 cases at 100% coverage on lines/branches/funcs/stmts. Docs updated in website/docs/workspaces/sandbox.md with import, getSandbox, and tenant-router examples. --- .changeset/sandbox-blaxel.md | 5 + .gitignore | 3 + packages/sandbox-blaxel/package.json | 51 ++ packages/sandbox-blaxel/src/constants.ts | 20 + packages/sandbox-blaxel/src/index.spec.ts | 752 ++++++++++++++++++++++ packages/sandbox-blaxel/src/index.ts | 7 + packages/sandbox-blaxel/src/output.ts | 21 + packages/sandbox-blaxel/src/sandbox.ts | 249 +++++++ packages/sandbox-blaxel/src/shell.ts | 52 ++ packages/sandbox-blaxel/src/types.ts | 56 ++ packages/sandbox-blaxel/tsconfig.json | 30 + packages/sandbox-blaxel/tsup.config.ts | 18 + packages/sandbox-blaxel/vitest.config.ts | 23 + pnpm-lock.yaml | 560 +++++++++++++--- website/docs/workspaces/sandbox.md | 79 ++- 15 files changed, 1848 insertions(+), 78 deletions(-) create mode 100644 .changeset/sandbox-blaxel.md create mode 100644 packages/sandbox-blaxel/package.json create mode 100644 packages/sandbox-blaxel/src/constants.ts create mode 100644 packages/sandbox-blaxel/src/index.spec.ts create mode 100644 packages/sandbox-blaxel/src/index.ts create mode 100644 packages/sandbox-blaxel/src/output.ts create mode 100644 packages/sandbox-blaxel/src/sandbox.ts create mode 100644 packages/sandbox-blaxel/src/shell.ts create mode 100644 packages/sandbox-blaxel/src/types.ts create mode 100644 packages/sandbox-blaxel/tsconfig.json create mode 100644 packages/sandbox-blaxel/tsup.config.ts create mode 100644 packages/sandbox-blaxel/vitest.config.ts diff --git a/.changeset/sandbox-blaxel.md b/.changeset/sandbox-blaxel.md new file mode 100644 index 000000000..1d79115f0 --- /dev/null +++ b/.changeset/sandbox-blaxel.md @@ -0,0 +1,5 @@ +--- +"@voltagent/sandbox-blaxel": minor +--- + +Add `@voltagent/sandbox-blaxel`, a new VoltAgent workspace sandbox provider built on `@blaxel/core`. Implements `execute()` with polling-based stdout/stderr streaming, timeout and `AbortSignal` enforcement via `sandbox.process.kill()`, output truncation, and `destroy()` via `sandbox.delete()`. Also exposes `getSandbox()` for direct access to the underlying Blaxel SDK. diff --git a/.gitignore b/.gitignore index fe0d10b5d..60ad947c8 100644 --- a/.gitignore +++ b/.gitignore @@ -182,6 +182,9 @@ node_modules/ # serena .serena +# joggr +.joggr + # example skills !examples/with-workspace/workspace/skills diff --git a/packages/sandbox-blaxel/package.json b/packages/sandbox-blaxel/package.json new file mode 100644 index 000000000..16791ff8c --- /dev/null +++ b/packages/sandbox-blaxel/package.json @@ -0,0 +1,51 @@ +{ + "name": "@voltagent/sandbox-blaxel", + "description": "VoltAgent Blaxel sandbox provider", + "version": "2.0.0", + "dependencies": { + "@blaxel/core": "^0.2.0", + "es-toolkit": "^1.46.1" + }, + "devDependencies": { + "@types/node": "^24.2.1", + "@vitest/coverage-v8": "^3.2.4", + "@voltagent/core": "^2.4.1", + "tsup": "^8.5.0", + "typescript": "^5.8.2", + "vitest": "^3.2.4" + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "files": [ + "dist" + ], + "license": "MIT", + "main": "dist/index.js", + "module": "dist/index.mjs", + "peerDependencies": { + "@voltagent/core": "^2.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/VoltAgent/voltagent.git", + "directory": "packages/sandbox-blaxel" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "types": "dist/index.d.ts" +} diff --git a/packages/sandbox-blaxel/src/constants.ts b/packages/sandbox-blaxel/src/constants.ts new file mode 100644 index 000000000..9b8eff078 --- /dev/null +++ b/packages/sandbox-blaxel/src/constants.ts @@ -0,0 +1,20 @@ +/** + * Default `defaultTimeoutMs` (60 seconds). + */ +export const DEFAULT_TIMEOUT_MS = 60_000; + +/** + * Default `maxOutputBytes` (5 MiB). + */ +export const DEFAULT_MAX_OUTPUT_BYTES = 5 * 1024 * 1024; + +/** + * Default `pollIntervalMs` (250 ms). + */ +export const DEFAULT_POLL_INTERVAL_MS = 250; + +/** + * Safety cap on `process.wait()` `maxWait` when `timeoutMs: 0` is set. + * 24h — prevents runaway poll loops if a process never terminates. + */ +export const NO_TIMEOUT_MAX_WAIT_MS = 24 * 60 * 60 * 1000; diff --git a/packages/sandbox-blaxel/src/index.spec.ts b/packages/sandbox-blaxel/src/index.spec.ts new file mode 100644 index 000000000..210cdd5b8 --- /dev/null +++ b/packages/sandbox-blaxel/src/index.spec.ts @@ -0,0 +1,752 @@ +import * as blaxelCore from "@blaxel/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BlaxelSandbox, type BlaxelSandboxInstance } from "./index"; +import { truncateOutput } from "./output"; +import { applyEnvBindings } from "./shell"; + +interface ExecRequest { + name: string; + command: string; + workingDir?: string; + env?: Record; + timeout?: number; + onStdout?: (chunk: string) => void; + onStderr?: (chunk: string) => void; +} + +interface GetResponse { + status: "running" | "completed" | "failed" | "killed" | "stopped"; + exitCode?: number; +} + +interface Mock { + execCalls: ExecRequest[]; + waitCalls: Array<{ name: string; maxWait?: number; interval?: number }>; + killed: string[]; + deleteCalls: number; + closeCalls: number; + stdoutLog: string; + stderrLog: string; + /** + * Final state returned by `get()`. Defaults to completed/exitCode 0. + */ + finalState: GetResponse; + /** + * When set, `wait()` rejects with this error to simulate timeout/failure. + */ + waitError?: Error; + /** + * When set, the next `get()` call rejects with this error. + */ + getError?: Error; + /** + * When set, the next `logs()` call rejects with this error. + */ + logsError?: Error; + /** + * When true, calling `started.close()` throws — exercises the swallow guard. + */ + closeShouldThrow?: boolean; + /** + * When set, `kill()` rejects after recording the call — exercises the silent-kill swallow. + */ + killError?: Error; + instance: BlaxelSandboxInstance; +} + +function makeMock(): Mock { + const state: Mock = { + execCalls: [], + waitCalls: [], + killed: [], + deleteCalls: 0, + closeCalls: 0, + stdoutLog: "", + stderrLog: "", + finalState: { status: "completed", exitCode: 0 }, + instance: null as unknown as BlaxelSandboxInstance, + }; + + async function exec(request: ExecRequest) { + state.execCalls.push(request); + // Mirror the SDK behavior: when callbacks are passed, attach a `close()` handle. + if (request.onStdout || request.onStderr) { + return { + name: request.name, + close: () => { + state.closeCalls += 1; + if (state.closeShouldThrow) throw new Error("boom"); + }, + }; + } + return { name: request.name }; + } + + async function wait(name: string, opts?: { maxWait?: number; interval?: number }) { + state.waitCalls.push({ name, maxWait: opts?.maxWait, interval: opts?.interval }); + if (state.waitError) throw state.waitError; + return state.finalState; + } + + async function get(_name: string) { + if (state.getError) { + const err = state.getError; + state.getError = undefined; + throw err; + } + return state.finalState; + } + + async function logs(_name: string, type: "stdout" | "stderr" | "all") { + if (state.logsError) { + const err = state.logsError; + state.logsError = undefined; + throw err; + } + if (type === "stdout") return state.stdoutLog; + if (type === "stderr") return state.stderrLog; + return `${state.stdoutLog}${state.stderrLog}`; + } + + async function kill(name: string) { + state.killed.push(name); + if (state.killError) { + throw state.killError; + } + } + + async function deleteSandbox() { + state.deleteCalls += 1; + } + + state.instance = { + process: { exec, wait, get, logs, kill }, + delete: deleteSandbox, + } as unknown as BlaxelSandboxInstance; + + return state; +} + +function spyCreate(instance: BlaxelSandboxInstance) { + return vi + .spyOn(blaxelCore.SandboxInstance, "createIfNotExists") + .mockResolvedValue(instance as unknown as InstanceType); +} + +beforeEach(() => { + vi.restoreAllMocks(); + // biome-ignore lint/performance/noDelete: removing the property; assigning undefined coerces to string "undefined". + delete process.env.BL_API_KEY; + // biome-ignore lint/performance/noDelete: removing the property; assigning undefined coerces to string "undefined". + delete process.env.BL_WORKSPACE; + // biome-ignore lint/performance/noDelete: removing the property; assigning undefined coerces to string "undefined". + delete process.env.BL_REGION; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("BlaxelSandbox constructor", () => { + it("forwards apiKey and workspace to process.env", () => { + new BlaxelSandbox({ apiKey: "k", workspace: "w" }); + expect(process.env.BL_API_KEY).toBe("k"); + expect(process.env.BL_WORKSPACE).toBe("w"); + }); + + it("leaves process.env untouched when those options are absent", () => { + new BlaxelSandbox(); + expect(process.env.BL_API_KEY).toBeUndefined(); + expect(process.env.BL_WORKSPACE).toBeUndefined(); + }); + + it("normalizes per-call env, dropping undefined and null entries", async () => { + const mock = makeMock(); + mock.stdoutLog = "ok"; + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + await sandbox.execute({ + command: "ls", + env: { KEEP: "yes", DROP: undefined, NULLISH: null } as unknown as Record, + }); + expect(mock.execCalls[0].env).toEqual({ KEEP: "yes" }); + }); +}); + +describe("BlaxelSandbox.execute (happy path)", () => { + it("forwards command, env, and workingDir natively to exec()", async () => { + const mock = makeMock(); + mock.stdoutLog = "hello\n"; + const sandbox = new BlaxelSandbox({ + sandbox: mock.instance, + config: { cwd: "/tmp" }, + }); + + const result = await sandbox.execute({ + command: "echo", + args: ["hi"], + env: { FOO: "bar" }, + }); + + expect(mock.execCalls).toHaveLength(1); + expect(mock.execCalls[0].command).toBe("echo hi"); + expect(mock.execCalls[0].workingDir).toBe("/tmp"); + expect(mock.execCalls[0].env).toEqual({ FOO: "bar" }); + expect(mock.execCalls[0].timeout).toBe(0); + expect(mock.execCalls[0].name).toMatch(/^voltagent-/); + + expect(result.stdout).toBe("hello\n"); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + expect(result.timedOut).toBe(false); + expect(result.aborted).toBe(false); + expect(result.stdoutTruncated).toBe(false); + expect(result.stderrTruncated).toBe(false); + }); + + it("escapes unsafe shell characters in command/args", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + await sandbox.execute({ command: "echo", args: ["hello world", "$DANGER"] }); + expect(mock.execCalls[0].command).toBe("echo 'hello world' '$DANGER'"); + }); + + it("renders empty-string args as the empty quoted token", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + await sandbox.execute({ command: "echo", args: ["", "tail"] }); + expect(mock.execCalls[0].command).toBe("echo '' tail"); + }); + + it("forwards per-call env to exec(), dropping undefined entries", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + await sandbox.execute({ + command: "ls", + env: { A: "one", B: "two", DROP: undefined } as unknown as Record, + }); + expect(mock.execCalls[0].env).toEqual({ A: "one", B: "two" }); + }); + + it("passes env as undefined when no env vars are set", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + await sandbox.execute({ command: "ls" }); + expect(mock.execCalls[0].env).toBeUndefined(); + }); + + it("falls back to constructor cwd when per-call cwd is omitted", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance, config: { cwd: "/home" } }); + await sandbox.execute({ command: "ls" }); + expect(mock.execCalls[0].workingDir).toBe("/home"); + }); + + it("per-call cwd overrides constructor cwd", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance, config: { cwd: "/home" } }); + await sandbox.execute({ command: "ls", cwd: "/var" }); + expect(mock.execCalls[0].workingDir).toBe("/var"); + }); + + it("passes workingDir as undefined when neither constructor nor per-call cwd is set", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + await sandbox.execute({ command: "ls" }); + expect(mock.execCalls[0].workingDir).toBeUndefined(); + }); + + it("forwards onStdout and onStderr callbacks to the SDK", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + const onStdout = vi.fn(); + const onStderr = vi.fn(); + + await sandbox.execute({ command: "ls", onStdout, onStderr }); + + expect(mock.execCalls[0].onStdout).toBe(onStdout); + expect(mock.execCalls[0].onStderr).toBe(onStderr); + // Callbacks attached → SDK returned a `close()` handle which we must invoke. + expect(mock.closeCalls).toBe(1); + }); + + it("does not attach close() when no streaming callbacks are passed", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + await sandbox.execute({ command: "ls" }); + expect(mock.closeCalls).toBe(0); + }); + + it("swallows errors thrown by close() so cleanup never escapes execute()", async () => { + const mock = makeMock(); + mock.closeShouldThrow = true; + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + await expect(sandbox.execute({ command: "ls", onStdout: () => {} })).resolves.toMatchObject({ + exitCode: 0, + }); + expect(mock.closeCalls).toBe(1); + }); + + it("returns null exitCode when terminal status payload has no numeric exit code", async () => { + const mock = makeMock(); + mock.finalState = { status: "completed" }; + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + const result = await sandbox.execute({ command: "ls" }); + expect(result.exitCode).toBe(null); + }); + + it("uses the configured pollIntervalMs as wait() interval", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ + sandbox: mock.instance, + config: { pollIntervalMs: 75, defaultTimeoutMs: 5000 }, + }); + await sandbox.execute({ command: "ls" }); + expect(mock.waitCalls[0].interval).toBe(75); + expect(mock.waitCalls[0].maxWait).toBe(5000); + }); +}); + +describe("BlaxelSandbox.execute (timeout, abort, truncation)", () => { + it("flips timedOut and kills the process when wait() rejects", async () => { + const mock = makeMock(); + mock.waitError = new Error("Process did not finish in time"); + mock.stdoutLog = "partial"; + const sandbox = new BlaxelSandbox({ + sandbox: mock.instance, + config: { defaultTimeoutMs: 100 }, + }); + + const result = await sandbox.execute({ command: "tail" }); + + expect(result.timedOut).toBe(true); + expect(result.aborted).toBe(false); + expect(mock.killed).toContain(mock.execCalls[0].name); + expect(result.stdout).toBe("partial"); + }); + + it("swallows errors thrown by kill() during the timeout path", async () => { + const mock = makeMock(); + mock.waitError = new Error("Process did not finish in time"); + mock.killError = new Error("kill failed"); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance, config: { defaultTimeoutMs: 50 } }); + + const result = await sandbox.execute({ command: "tail" }); + + expect(result.timedOut).toBe(true); + expect(mock.killed).toContain(mock.execCalls[0].name); + }); + + it("uses the 24h safety cap when timeoutMs is 0", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance, config: { defaultTimeoutMs: 0 } }); + await sandbox.execute({ command: "ls" }); + expect(mock.waitCalls[0].maxWait).toBe(24 * 60 * 60 * 1000); + }); + + it("clamps a negative per-call timeoutMs to zero (uses safety cap)", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + await sandbox.execute({ command: "ls", timeoutMs: -50 }); + expect(mock.waitCalls[0].maxWait).toBe(24 * 60 * 60 * 1000); + }); + + it("returns immediately on a pre-aborted AbortSignal without calling exec()", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + const result = await sandbox.execute({ command: "ls", signal: AbortSignal.abort() }); + expect(result.aborted).toBe(true); + expect(result.timedOut).toBe(false); + expect(mock.execCalls).toHaveLength(0); + }); + + it("kills the process when the AbortSignal fires mid-flight", async () => { + const mock = makeMock(); + const controller = new AbortController(); + let waitResolve: (value: GetResponse) => void = () => {}; + mock.instance.process.wait = ((name: string) => { + mock.waitCalls.push({ name }); + return new Promise((resolve) => { + waitResolve = resolve; + }); + }) as unknown as BlaxelSandboxInstance["process"]["wait"]; + + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + const promise = sandbox.execute({ command: "tail", signal: controller.signal }); + // Let exec + wait start + await Promise.resolve(); + await Promise.resolve(); + controller.abort(); + waitResolve(mock.finalState); + const result = await promise; + + expect(result.aborted).toBe(true); + expect(mock.killed).toContain(mock.execCalls[0].name); + }); + + it("removes the abort listener in finally", async () => { + const mock = makeMock(); + const controller = new AbortController(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + await sandbox.execute({ command: "ls", signal: controller.signal }); + // After execute() returns, aborting should not call kill again. + controller.abort(); + expect(mock.killed).toHaveLength(0); + }); + + it("truncates stdout when output exceeds maxOutputBytes", async () => { + const mock = makeMock(); + mock.stdoutLog = "x".repeat(50); + const sandbox = new BlaxelSandbox({ + sandbox: mock.instance, + config: { maxOutputBytes: 10 }, + }); + const result = await sandbox.execute({ command: "ls" }); + expect(result.stdout).toBe("x".repeat(10)); + expect(result.stdoutTruncated).toBe(true); + }); + + it("truncates stderr when output exceeds per-call maxOutputBytes", async () => { + const mock = makeMock(); + mock.stderrLog = "y".repeat(20); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + const result = await sandbox.execute({ command: "ls", maxOutputBytes: 5 }); + expect(result.stderr).toBe("y".repeat(5)); + expect(result.stderrTruncated).toBe(true); + }); + + it("flags truncation immediately when maxOutputBytes is 0", async () => { + const mock = makeMock(); + mock.stdoutLog = "anything"; + const sandbox = new BlaxelSandbox({ sandbox: mock.instance, config: { maxOutputBytes: 0 } }); + const result = await sandbox.execute({ command: "ls" }); + expect(result.stdout).toBe(""); + expect(result.stdoutTruncated).toBe(true); + }); + + it("clamps a negative maxOutputBytes to zero", async () => { + const mock = makeMock(); + mock.stdoutLog = "anything"; + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + const result = await sandbox.execute({ command: "ls", maxOutputBytes: -10 }); + expect(result.stdout).toBe(""); + expect(result.stdoutTruncated).toBe(true); + }); + + it("returns null exitCode in the zero-output path when get() throws", async () => { + const mock = makeMock(); + mock.getError = new Error("gone"); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance, config: { maxOutputBytes: 0 } }); + const result = await sandbox.execute({ command: "ls" }); + expect(result.exitCode).toBe(null); + expect(result.stdoutTruncated).toBe(true); + }); + + it("returns empty output without truncation flag when log is empty", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + const result = await sandbox.execute({ command: "ls" }); + expect(result.stdout).toBe(""); + expect(result.stdoutTruncated).toBe(false); + }); +}); + +describe("BlaxelSandbox.execute (validation and post-state robustness)", () => { + it("rejects when stdin is provided", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + await expect(sandbox.execute({ command: "cat", stdin: "input" })).rejects.toThrow( + "Workspace sandbox does not support stdin for this command.", + ); + }); + + it("rejects when the command is empty/whitespace", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + await expect(sandbox.execute({ command: " " })).rejects.toThrow( + "Sandbox command is required", + ); + }); + + it("rejects when no command is provided at all", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + await expect( + sandbox.execute({} as unknown as Parameters[0]), + ).rejects.toThrow("Sandbox command is required"); + }); + + it("returns null exitCode + empty output when get() throws", async () => { + const mock = makeMock(); + mock.getError = new Error("gone"); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + const result = await sandbox.execute({ command: "ls" }); + expect(result.exitCode).toBe(null); + expect(result.stdout).toBe(""); + }); + + it("falls through to empty stdout/stderr when logs() throws", async () => { + const mock = makeMock(); + mock.logsError = new Error("logs gone"); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + const result = await sandbox.execute({ command: "ls" }); + expect(result.stdout).toBe(""); + }); + + it("falls through to empty stderr when the stderr logs() call fails", async () => { + const mock = makeMock(); + mock.stdoutLog = "ok"; + // Throw only on the stderr fetch (second logs() call). + let calls = 0; + mock.instance.process.logs = (async (_name: string, type: "stdout" | "stderr" | "all") => { + calls += 1; + if (calls === 2) throw new Error("stderr fetch failed"); + return type === "stdout" ? mock.stdoutLog : mock.stderrLog; + }) as unknown as BlaxelSandboxInstance["process"]["logs"]; + + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + const result = await sandbox.execute({ command: "ls" }); + expect(result.stdout).toBe("ok"); + expect(result.stderr).toBe(""); + }); + + it("truncateOutput flags truncated when maxBytes <= 0 and value is non-empty", () => { + expect(truncateOutput("hello", 0)).toEqual({ content: "", truncated: true }); + expect(truncateOutput("hello", -10)).toEqual({ content: "", truncated: true }); + }); +}); + +describe("applyEnvBindings", () => { + it("writes non-nil bindings to the target object", () => { + const target: Record = {}; + applyEnvBindings({ A: "one", B: "two" }, target); + expect(target).toEqual({ A: "one", B: "two" }); + }); + + it("skips bindings whose value is null or undefined", () => { + const target: Record = { EXISTING: "keep" }; + applyEnvBindings({ A: "set", B: undefined, C: null as unknown as string, D: "" }, target); + expect(target).toEqual({ EXISTING: "keep", A: "set", D: "" }); + expect(target).not.toHaveProperty("B"); + expect(target).not.toHaveProperty("C"); + }); + + it("does not delete existing keys when binding value is nullish", () => { + const target: Record = { A: "preset" }; + applyEnvBindings({ A: undefined }, target); + expect(target.A).toBe("preset"); + }); + + it("defaults to mutating process.env when no target is provided", () => { + applyEnvBindings({ TEST_BLAXEL_BINDING: "applied" }); + expect(process.env.TEST_BLAXEL_BINDING).toBe("applied"); + // biome-ignore lint/performance/noDelete: clean up the side-effect for later tests. + delete process.env.TEST_BLAXEL_BINDING; + }); +}); + +describe("BlaxelSandbox lazy creation, getSandbox, destroy, getInfo", () => { + it("forwards config to createIfNotExists when name is provided", async () => { + const mock = makeMock(); + const ifNotExists = spyCreate(mock.instance); + + const sandbox = new BlaxelSandbox({ + config: { name: "voltagent-prod", image: "blaxel/base:latest" }, + }); + await sandbox.execute({ command: "ls" }); + + expect(ifNotExists).toHaveBeenCalledOnce(); + expect(ifNotExists.mock.calls[0][0]).toMatchObject({ + name: "voltagent-prod", + image: "blaxel/base:latest", + }); + }); + + it("calls createIfNotExists with an empty object when no config is provided", async () => { + const mock = makeMock(); + const ifNotExists = spyCreate(mock.instance); + + const sandbox = new BlaxelSandbox(); + await sandbox.execute({ command: "ls" }); + + expect(ifNotExists).toHaveBeenCalledOnce(); + expect(ifNotExists.mock.calls[0][0]).toEqual({}); + }); + + it("strips voltagent-specific config keys before forwarding to the SDK", async () => { + const mock = makeMock(); + const ifNotExists = spyCreate(mock.instance); + + const sandbox = new BlaxelSandbox({ + config: { + name: "voltagent-prod", + image: "blaxel/base:latest", + cwd: "/workspace", + defaultTimeoutMs: 30_000, + maxOutputBytes: 1024, + pollIntervalMs: 100, + }, + }); + await sandbox.execute({ command: "ls" }); + + expect(ifNotExists.mock.calls[0][0]).toEqual({ + name: "voltagent-prod", + image: "blaxel/base:latest", + }); + }); + + it("memoizes resolved sandbox across executes (one SDK call)", async () => { + const mock = makeMock(); + const ifNotExists = spyCreate(mock.instance); + + const sandbox = new BlaxelSandbox({ config: { image: "blaxel/base:latest" } }); + await sandbox.execute({ command: "ls" }); + await sandbox.execute({ command: "ls" }); + await sandbox.execute({ command: "ls" }); + + expect(ifNotExists).toHaveBeenCalledOnce(); + }); + + it("retries provisioning on a subsequent execute after a failure", async () => { + const mock = makeMock(); + const ifNotExists = vi + .spyOn(blaxelCore.SandboxInstance, "createIfNotExists") + .mockRejectedValueOnce(new Error("create failed")) + .mockResolvedValueOnce( + mock.instance as unknown as InstanceType, + ); + + const sandbox = new BlaxelSandbox({ config: { image: "blaxel/base:latest" } }); + await expect(sandbox.execute({ command: "ls" })).rejects.toThrow("create failed"); + const result = await sandbox.execute({ command: "ls" }); + + expect(ifNotExists).toHaveBeenCalledTimes(2); + expect(result.exitCode).toBe(0); + }); + + it("forwards memory, ttl, ports, labels into create params", async () => { + const mock = makeMock(); + const ifNotExists = spyCreate(mock.instance); + + const sandbox = new BlaxelSandbox({ + config: { + name: "voltagent-prod", + image: "blaxel/python:latest", + memory: 1024, + region: "us-east-1", + ttl: "600s", + ports: [{ name: "http", target: 8080, protocol: "TCP" }], + labels: { team: "voltagent" }, + }, + }); + await sandbox.execute({ command: "ls" }); + + expect(ifNotExists.mock.calls[0][0]).toEqual({ + name: "voltagent-prod", + image: "blaxel/python:latest", + memory: 1024, + region: "us-east-1", + ttl: "600s", + ports: [{ name: "http", target: 8080, protocol: "TCP" }], + labels: { team: "voltagent" }, + }); + }); + + it("getSandbox() returns the injected instance by reference", async () => { + const mock = makeMock(); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + const resolved = await sandbox.getSandbox(); + expect(resolved).toBe(mock.instance); + }); + + it("destroy() calls sandbox.delete() and clears the cached instance", async () => { + const mock = makeMock(); + const ifNotExists = spyCreate(mock.instance); + + const sandbox = new BlaxelSandbox({ config: { image: "blaxel/base:latest" } }); + await sandbox.execute({ command: "ls" }); + await sandbox.destroy(); + + expect(mock.deleteCalls).toBe(1); + // Next execute should re-provision. + await sandbox.execute({ command: "ls" }); + expect(ifNotExists).toHaveBeenCalledTimes(2); + }); + + it("destroy() is a no-op when no sandbox has been provisioned", async () => { + const sandbox = new BlaxelSandbox(); + await expect(sandbox.destroy()).resolves.toBeUndefined(); + }); + + it("destroy() swallows rejection when the in-flight create promise fails", async () => { + let rejectCreate!: (err: Error) => void; + const pending = new Promise((_, reject) => { + rejectCreate = reject; + }); + vi.spyOn(blaxelCore.SandboxInstance, "createIfNotExists").mockImplementation( + () => pending as ReturnType, + ); + + const sandbox = new BlaxelSandbox({ config: { image: "blaxel/base:latest" } }); + // Kick off the in-flight promise via execute(). + const exec = sandbox.execute({ command: "ls" }); + // Yield so resolveSandbox starts the in-flight createAndCache promise. + await Promise.resolve(); + + // destroy() captures the in-flight promise and resets this.sandbox. + const destroyPromise = sandbox.destroy(); + // Now reject the underlying create — both awaiters observe the rejection. + rejectCreate(new Error("create failed")); + + await expect(destroyPromise).resolves.toBeUndefined(); + await expect(exec).rejects.toThrow("create failed"); + }); + + it("destroy() swallows errors thrown by sandbox.delete()", async () => { + const mock = makeMock(); + mock.instance = { + ...mock.instance, + delete: async () => { + throw new Error("delete failed"); + }, + } as unknown as BlaxelSandboxInstance; + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + // Force the cache by calling getSandbox() first. + await sandbox.getSandbox(); + await expect(sandbox.destroy()).resolves.toBeUndefined(); + }); + + it("getInfo() returns the static descriptor shape", () => { + const sandbox = new BlaxelSandbox({ + config: { + name: "task-runner", + image: "blaxel/base:latest", + region: "us-east-1", + memory: 2048, + }, + }); + expect(sandbox.getInfo()).toEqual({ + provider: "blaxel", + name: "task-runner", + image: "blaxel/base:latest", + region: "us-east-1", + memory: 2048, + }); + }); + + it("getInfo() returns just provider when no config is set", () => { + expect(new BlaxelSandbox().getInfo()).toEqual({ provider: "blaxel" }); + }); + + it("getInfo() omits voltagent-specific config keys", () => { + const sandbox = new BlaxelSandbox({ + config: { + name: "x", + cwd: "/tmp", + defaultTimeoutMs: 1000, + maxOutputBytes: 100, + pollIntervalMs: 50, + }, + }); + expect(sandbox.getInfo()).toEqual({ provider: "blaxel", name: "x" }); + }); +}); diff --git a/packages/sandbox-blaxel/src/index.ts b/packages/sandbox-blaxel/src/index.ts new file mode 100644 index 000000000..ed668662d --- /dev/null +++ b/packages/sandbox-blaxel/src/index.ts @@ -0,0 +1,7 @@ +export type { SandboxCreateConfiguration } from "@blaxel/core"; +export { BlaxelSandbox } from "./sandbox"; +export type { + BlaxelSandboxConfig, + BlaxelSandboxInstance, + BlaxelSandboxOptions, +} from "./types"; diff --git a/packages/sandbox-blaxel/src/output.ts b/packages/sandbox-blaxel/src/output.ts new file mode 100644 index 000000000..92d74c94e --- /dev/null +++ b/packages/sandbox-blaxel/src/output.ts @@ -0,0 +1,21 @@ +/** + * Truncate a UTF-8 string to at most `maxBytes` bytes (not characters). + * A trailing partial multi-byte sequence may render as a replacement character. + */ +export function truncateOutput( + value: string, + maxBytes: number, +): { content: string; truncated: boolean } { + if (value.length === 0) { + return { content: "", truncated: false }; + } + if (maxBytes <= 0) { + return { content: "", truncated: true }; + } + // Avoid allocating a Buffer when input fits. + if (Buffer.byteLength(value, "utf-8") <= maxBytes) { + return { content: value, truncated: false }; + } + const data = Buffer.from(value, "utf-8"); + return { content: data.subarray(0, maxBytes).toString("utf-8"), truncated: true }; +} diff --git a/packages/sandbox-blaxel/src/sandbox.ts b/packages/sandbox-blaxel/src/sandbox.ts new file mode 100644 index 000000000..1bb07e82d --- /dev/null +++ b/packages/sandbox-blaxel/src/sandbox.ts @@ -0,0 +1,249 @@ +import { randomUUID } from "node:crypto"; +import { SandboxInstance } from "@blaxel/core"; +import { + type WorkspaceSandbox, + type WorkspaceSandboxExecuteOptions, + type WorkspaceSandboxResult, + normalizeCommandAndArgs, +} from "@voltagent/core"; +import { attempt, attemptAsync, isEmptyObject, omit } from "es-toolkit"; +import { + DEFAULT_MAX_OUTPUT_BYTES, + DEFAULT_POLL_INTERVAL_MS, + DEFAULT_TIMEOUT_MS, + NO_TIMEOUT_MAX_WAIT_MS, +} from "./constants"; +import { truncateOutput } from "./output"; +import { applyEnvBindings, buildCommandLine, normalizeEnv } from "./shell"; +import type { BlaxelSandboxConfig, BlaxelSandboxInstance, BlaxelSandboxOptions } from "./types"; + +// VoltAgent extras on BlaxelSandboxConfig — stripped before SDK calls. +const VOLTAGENT_CONFIG_KEYS = [ + "cwd", + "defaultTimeoutMs", + "maxOutputBytes", + "pollIntervalMs", +] as const satisfies ReadonlyArray; + +function resolveCallOption(callValue: number | undefined, fallback: number): number { + return callValue === undefined ? fallback : Math.max(0, callValue); +} + +/** + * VoltAgent workspace sandbox provider backed by `@blaxel/core`. + */ +export class BlaxelSandbox implements WorkspaceSandbox { + /** + * Provider identifier from the `WorkspaceSandbox` contract. Always `"blaxel"`. + */ + name = "blaxel"; + + private readonly apiKey?: string; + private readonly workspace?: string; + private readonly config?: BlaxelSandboxConfig; + private sandbox?: Promise; + + /** + * The underlying sandbox is lazily created on first `execute()` / + * `getSandbox()`. Sets `BL_API_KEY` / `BL_WORKSPACE` env vars when provided. + */ + constructor(options: BlaxelSandboxOptions = {}) { + this.apiKey = options.apiKey; + this.workspace = options.workspace; + this.config = options.config; + this.sandbox = options.sandbox ? Promise.resolve(options.sandbox) : undefined; + + applyEnvBindings({ + BL_API_KEY: this.apiKey, + BL_WORKSPACE: this.workspace, + }); + } + + /** + * Execute a single shell command in the underlying Blaxel sandbox. + * + * @throws When `options.stdin` is provided (unsupported). + * @throws When `options.command` is missing or whitespace-only. + */ + async execute(options: WorkspaceSandboxExecuteOptions): Promise { + if (options.stdin !== undefined) { + throw new Error("Workspace sandbox does not support stdin for this command."); + } + + const startTime = Date.now(); + const normalized = normalizeCommandAndArgs(options.command ?? "", options.args); + const command = normalized.command.trim(); + + if (command.length === 0) { + throw new Error("Sandbox command is required"); + } + + if (options.signal?.aborted) { + return { + stdout: "", + stderr: "", + exitCode: null, + durationMs: 0, + timedOut: false, + aborted: true, + stdoutTruncated: false, + stderrTruncated: false, + }; + } + + const sandbox = await this.resolveSandbox(); + const timeoutMs = resolveCallOption( + options.timeoutMs, + this.config?.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS, + ); + const maxOutputBytes = resolveCallOption( + options.maxOutputBytes, + this.config?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, + ); + const pollIntervalMs = this.config?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const callEnv = normalizeEnv(options.env); + const commandLine = buildCommandLine(command, normalized.args); + const processName = `voltagent-${randomUUID()}`; + + let aborted = false; + let timedOut = false; + let abortListener: (() => void) | undefined; + + const killSilently = async (): Promise => { + await attemptAsync(() => sandbox.process.kill(processName)); + }; + + if (options.signal) { + abortListener = () => { + aborted = true; + // Listener must be sync; killSilently swallows its own errors. + void killSilently(); + }; + options.signal.addEventListener("abort", abortListener, { once: true }); + } + + let started: Awaited> | undefined; + try { + started = await sandbox.process.exec({ + name: processName, + command: commandLine, + timeout: 0, + workingDir: options.cwd ?? this.config?.cwd, + env: isEmptyObject(callEnv) ? undefined : callEnv, + onStdout: options.onStdout, + onStderr: options.onStderr, + }); + + // wait() throws "Process did not finish in time" on timeout. + const [waitError] = await attemptAsync(() => + sandbox.process.wait(processName, { + maxWait: timeoutMs > 0 ? timeoutMs : NO_TIMEOUT_MAX_WAIT_MS, + interval: pollIntervalMs, + }), + ); + if (waitError) { + timedOut = true; + await killSilently(); + } + } finally { + if (options.signal && abortListener) { + options.signal.removeEventListener("abort", abortListener); + } + const close = started && "close" in started ? started.close : undefined; + if (close) attempt(close); + } + + if (maxOutputBytes <= 0) { + const [, finalState] = await attemptAsync(() => sandbox.process.get(processName)); + return { + stdout: "", + stderr: "", + exitCode: finalState?.exitCode ?? null, + durationMs: Date.now() - startTime, + timedOut, + aborted, + stdoutTruncated: true, + stderrTruncated: true, + }; + } + + const [[, finalState], [, stdoutRaw], [, stderrRaw]] = await Promise.all([ + attemptAsync(() => sandbox.process.get(processName)), + attemptAsync(() => sandbox.process.logs(processName, "stdout")), + attemptAsync(() => sandbox.process.logs(processName, "stderr")), + ]); + const exitCode = finalState?.exitCode ?? null; + const stdoutInfo = truncateOutput(stdoutRaw ?? "", maxOutputBytes); + const stderrInfo = truncateOutput(stderrRaw ?? "", maxOutputBytes); + + return { + stdout: stdoutInfo.content, + stderr: stderrInfo.content, + exitCode, + durationMs: Date.now() - startTime, + timedOut, + aborted, + stdoutTruncated: stdoutInfo.truncated, + stderrTruncated: stderrInfo.truncated, + }; + } + + /** + * Return the underlying Blaxel SDK sandbox instance, lazily creating it on + * first call. Memoized until {@link destroy} is called. + */ + async getSandbox(): Promise { + return await this.resolveSandbox(); + } + + /** + * Destroy the underlying Blaxel sandbox and clear the cached instance. + * Best-effort: errors from `sandbox.delete()` are swallowed. + */ + async destroy(): Promise { + const pending = this.sandbox; + this.sandbox = undefined; + if (!pending) { + return; + } + const [resolveError, current] = await attemptAsync(() => pending); + if (resolveError) { + return; + } + await attemptAsync(() => current.delete()); + } + + /** + * Return `{ provider: "blaxel", ...sdkConfig }` for diagnostics/UIs. + * Excludes voltagent-specific extras (cwd, defaults, etc.). + */ + getInfo(): Record { + return { provider: "blaxel", ...this.sdkConfig }; + } + + /** + * `this.config` with voltagent-specific extras stripped — what gets + * forwarded to `SandboxInstance.createIfNotExists()`. + */ + private get sdkConfig() { + return omit(this.config ?? {}, VOLTAGENT_CONFIG_KEYS); + } + + private resolveSandbox(): Promise { + if (!this.sandbox) { + this.sandbox = this.createSandbox(); + } + return this.sandbox; + } + + private async createSandbox(): Promise { + const [error, sandbox] = await attemptAsync(() => + SandboxInstance.createIfNotExists(this.sdkConfig), + ); + if (error) { + this.sandbox = undefined; + throw error; + } + return sandbox; + } +} diff --git a/packages/sandbox-blaxel/src/shell.ts b/packages/sandbox-blaxel/src/shell.ts new file mode 100644 index 000000000..8ddf35010 --- /dev/null +++ b/packages/sandbox-blaxel/src/shell.ts @@ -0,0 +1,52 @@ +import { isNil, mapValues, omitBy } from "es-toolkit"; + +const SAFE_SHELL_ARG = /^[A-Za-z0-9_./:@+=-]+$/; + +/** + * Quote a single shell argument so it survives `sh -c` evaluation. + * Empty → `''`. Safe-charset → as-is. Otherwise → single-quoted with embedded + * quotes escaped via `'\''`. + */ +export function escapeShellArg(value: string): string { + if (value.length === 0) { + return "''"; + } + if (SAFE_SHELL_ARG.test(value)) { + return value; + } + return `'${value.replace(/'/g, "'\\''")}'`; +} + +/** + * Join a command and its arguments into a shell-safe command line. + */ +export function buildCommandLine(command: string, args?: string[]): string { + const safeCommand = escapeShellArg(command); + if (!args || args.length === 0) { + return safeCommand; + } + return [safeCommand, ...args.map(escapeShellArg)].join(" "); +} + +/** + * Drop nullish entries from an env map and string-coerce the rest. + */ +export function normalizeEnv(env?: Record): Record { + if (isNil(env)) { + return {}; + } + return mapValues(omitBy(env, isNil), String); +} + +/** + * Apply non-nullish bindings to `target` (defaults to `process.env`). + */ +export function applyEnvBindings( + bindings: Record, + target: Record = process.env, +): void { + const nonNil = omitBy(bindings, isNil); + for (const [key, value] of Object.entries(nonNil)) { + target[key] = value; + } +} diff --git a/packages/sandbox-blaxel/src/types.ts b/packages/sandbox-blaxel/src/types.ts new file mode 100644 index 000000000..45cb7d9b0 --- /dev/null +++ b/packages/sandbox-blaxel/src/types.ts @@ -0,0 +1,56 @@ +import type { SandboxCreateConfiguration, SandboxInstance } from "@blaxel/core"; + +/** + * The Blaxel SDK sandbox instance type. + */ +export type BlaxelSandboxInstance = SandboxInstance; + +/** + * Sandbox configuration for {@link BlaxelSandbox}. Combines Blaxel's + * `SandboxCreateConfiguration` with VoltAgent-specific execute() defaults. + */ +export interface BlaxelSandboxConfig extends SandboxCreateConfiguration { + /** + * Default working directory for `process.exec`. Per-call `options.cwd` + * overrides this. + */ + cwd?: string; + /** + * Default command timeout in milliseconds. Per-call `options.timeoutMs` + * overrides this. Default: `60_000`. Set to `0` to disable. + */ + defaultTimeoutMs?: number; + /** + * Maximum bytes of stdout/stderr per stream before truncation. + * Default: `5 * 1024 * 1024` (5 MiB). Set to `0` to skip log fetches entirely. + * Truncation happens client-side after the SDK delivers the full payload, so + * non-zero values still pay the wire cost of the entire output. + */ + maxOutputBytes?: number; + /** + * Polling interval (ms) for `process.wait()`. Default: `250`. + */ + pollIntervalMs?: number; +} + +/** + * Public constructor options for {@link BlaxelSandbox}. + */ +export interface BlaxelSandboxOptions { + /** + * Blaxel API key. Sets `process.env.BL_API_KEY` when provided. + */ + apiKey?: string; + /** + * Blaxel workspace ID. Sets `process.env.BL_WORKSPACE` when provided. + */ + workspace?: string; + /** + * Sandbox provisioning + execute() defaults. See {@link BlaxelSandboxConfig}. + */ + config?: BlaxelSandboxConfig; + /** + * Pre-resolved Blaxel SDK instance to use instead of provisioning one. + */ + sandbox?: BlaxelSandboxInstance; +} diff --git a/packages/sandbox-blaxel/tsconfig.json b/packages/sandbox-blaxel/tsconfig.json new file mode 100644 index 000000000..d4612442c --- /dev/null +++ b/packages/sandbox-blaxel/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/sandbox-blaxel/tsup.config.ts b/packages/sandbox-blaxel/tsup.config.ts new file mode 100644 index 000000000..e019584d6 --- /dev/null +++ b/packages/sandbox-blaxel/tsup.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "tsup"; +import { markAsExternalPlugin } from "../shared/tsup-plugins/mark-as-external"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + splitting: false, + sourcemap: true, + clean: false, + target: "es2022", + outDir: "dist", + dts: true, + esbuildPlugins: [markAsExternalPlugin], + esbuildOptions(options) { + options.keepNames = true; + return options; + }, +}); diff --git a/packages/sandbox-blaxel/vitest.config.ts b/packages/sandbox-blaxel/vitest.config.ts new file mode 100644 index 000000000..981d149b7 --- /dev/null +++ b/packages/sandbox-blaxel/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["**/*.spec.ts"], + environment: "node", + globals: true, + testTimeout: 10000, + hookTimeout: 10000, + coverage: { + provider: "v8", + reporter: ["text", "html"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.spec.ts", "src/**/*.d.ts"], + thresholds: { + lines: 100, + branches: 100, + functions: 100, + statements: 100, + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62ea9b37d..c98a38cc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4239,6 +4239,34 @@ importers: specifier: ^2.2.10 version: 2.2.10 + packages/sandbox-blaxel: + dependencies: + '@blaxel/core': + specifier: ^0.2.0 + version: 0.2.81(@hey-api/openapi-ts@0.97.1) + es-toolkit: + specifier: ^1.46.1 + version: 1.46.1 + devDependencies: + '@types/node': + specifier: ^24.2.1 + version: 24.6.2 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) + '@voltagent/core': + specifier: ^2.4.1 + version: link:../core + tsup: + specifier: ^8.5.0 + version: 8.5.0(@swc/core@1.5.29)(typescript@5.9.3) + typescript: + specifier: ^5.8.2 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@24.6.2)(@vitest/ui@1.6.1)(jsdom@22.1.0) + packages/sandbox-daytona: dependencies: '@daytonaio/sdk': @@ -6715,6 +6743,7 @@ packages: hasBin: true dependencies: '@babel/types': 7.28.5 + dev: true /@babel/parser@7.28.5: resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} @@ -8006,6 +8035,7 @@ packages: dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + dev: true /@babel/types@7.28.5: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} @@ -8106,6 +8136,32 @@ packages: requiresBuild: true optional: true + /@blaxel/core@0.2.81(@hey-api/openapi-ts@0.97.1): + resolution: {integrity: sha512-uMi58QUOXm+I/aeoUW7mNHP5SW+O6PiPqHGmKnXLKjxHe9I2Zgvw/tea87oPshAq4q6mWLhjg5QfnXghB7Op0w==} + engines: {node: '>=18'} + dependencies: + '@hey-api/client-fetch': 0.10.2(@hey-api/openapi-ts@0.97.1) + '@modelcontextprotocol/sdk': 1.24.3(zod@3.25.76) + archiver: 7.0.1 + axios: 1.13.5 + dockerfile-ast: 0.7.1 + dotenv: 16.6.1 + form-data: 4.0.5 + jwt-decode: 4.0.0 + toml: 3.0.0 + uuid: 11.1.0 + ws: 8.18.3 + yaml: 2.8.2 + zod: 3.25.76 + transitivePeerDependencies: + - '@cfworker/json-schema' + - '@hey-api/openapi-ts' + - bufferutil + - debug + - supports-color + - utf-8-validate + dev: false + /@borewit/text-codec@0.1.1: resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==} dev: false @@ -9970,7 +10026,7 @@ packages: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -10314,6 +10370,84 @@ packages: axios: 1.11.0 dev: false + /@hey-api/client-fetch@0.10.2(@hey-api/openapi-ts@0.97.1): + resolution: {integrity: sha512-AGiFYDx+y8VT1wlQ3EbzzZtfU8EfV+hLLRTtr8Y/tjYZaxIECwJagVZf24YzNbtEBXONFV50bwcU1wLVGXe1ow==} + deprecated: Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts. + peerDependencies: + '@hey-api/openapi-ts': < 2 + dependencies: + '@hey-api/openapi-ts': 0.97.1(typescript@5.9.3) + dev: false + + /@hey-api/codegen-core@0.8.1: + resolution: {integrity: sha512-Iciv2vUCJTW9lWM/ROvyZLblmcbYJHPuXfzb1SzeDVVn4xEXu2ilLU1pq3fn+09FZ/Y0P7VyvRE47UDU6om8xA==} + engines: {node: '>=22.13.0'} + dependencies: + '@hey-api/types': 0.1.4 + ansi-colors: 4.1.3 + c12: 3.3.4 + color-support: 1.1.3 + transitivePeerDependencies: + - magicast + dev: false + + /@hey-api/json-schema-ref-parser@1.4.2: + resolution: {integrity: sha512-ZhCFSKI2ipZHEbgmtUHdyddvRU3wJ4elgCfYUC7T7hZa4EivSrVflTQf2w+v3TuaYxR1Y2V2kq3otqTttrrK8Q==} + engines: {node: '>=22.13.0'} + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.1.1 + dev: false + + /@hey-api/openapi-ts@0.97.1(typescript@5.9.3): + resolution: {integrity: sha512-LksUJeXAqwf6OhcCCr3/B4YjnBs5rqSqjDUKMBvkgp4OhaCQiJrOvntctFxdnugy8jUojP4yi/eJf5xYzcYzCQ==} + engines: {node: '>=22.13.0'} + hasBin: true + peerDependencies: + typescript: '>=5.5.3 || >=6.0.0 || 6.0.1-rc' + dependencies: + '@hey-api/codegen-core': 0.8.1 + '@hey-api/json-schema-ref-parser': 1.4.2 + '@hey-api/shared': 0.4.3 + '@hey-api/spec-types': 0.2.0 + '@hey-api/types': 0.1.4 + '@lukeed/ms': 2.0.2 + ansi-colors: 4.1.3 + color-support: 1.1.3 + commander: 14.0.3 + get-tsconfig: 4.14.0 + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + dev: false + + /@hey-api/shared@0.4.3: + resolution: {integrity: sha512-3tHfZNXgGOt+3P3Kq9cvqmZ9i7e3jtrkip1uDpZTX1+hTNboHhYdjxnT8AbrDuvslTaQHoAOlP4/iCDdzd9Jag==} + engines: {node: '>=22.13.0'} + dependencies: + '@hey-api/codegen-core': 0.8.1 + '@hey-api/json-schema-ref-parser': 1.4.2 + '@hey-api/spec-types': 0.2.0 + '@hey-api/types': 0.1.4 + ansi-colors: 4.1.3 + cross-spawn: 7.0.6 + open: 11.0.0 + semver: 7.7.4 + transitivePeerDependencies: + - magicast + dev: false + + /@hey-api/spec-types@0.2.0: + resolution: {integrity: sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==} + dependencies: + '@hey-api/types': 0.1.4 + dev: false + + /@hey-api/types@0.1.4: + resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==} + dev: false + /@hono/node-server@1.18.2(hono@4.10.8): resolution: {integrity: sha512-icgNvC0vRYivzyuSSaUv9ttcwtN8fDyd1k3AOIBDJgYd84tXRZSS6na8X54CY/oYoFTNhEmZraW/Rb9XYwX4KA==} engines: {node: '>=18.14.1'} @@ -11568,6 +11702,10 @@ packages: resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} dev: false + /@jsdevtools/ono@7.1.3: + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + dev: false + /@kwsites/file-exists@1.1.1: resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} dependencies: @@ -11997,7 +12135,6 @@ packages: /@lukeed/ms@2.0.2: resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} - dev: true /@lukeed/uuid@2.0.1: resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==} @@ -15233,8 +15370,8 @@ packages: dev: false optional: true - /@oxc-project/types@0.127.0: - resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + /@oxc-project/types@0.129.0: + resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==} dev: true /@oxc-project/types@0.94.0: @@ -17446,8 +17583,8 @@ packages: /@repeaterjs/repeater@3.0.6: resolution: {integrity: sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==} - /@rolldown/binding-android-arm64@1.0.0-rc.17: - resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + /@rolldown/binding-android-arm64@1.0.0: + resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] @@ -17455,8 +17592,8 @@ packages: dev: true optional: true - /@rolldown/binding-darwin-arm64@1.0.0-rc.17: - resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + /@rolldown/binding-darwin-arm64@1.0.0: + resolution: {integrity: sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] @@ -17464,8 +17601,8 @@ packages: dev: true optional: true - /@rolldown/binding-darwin-x64@1.0.0-rc.17: - resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + /@rolldown/binding-darwin-x64@1.0.0: + resolution: {integrity: sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] @@ -17473,8 +17610,8 @@ packages: dev: true optional: true - /@rolldown/binding-freebsd-x64@1.0.0-rc.17: - resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + /@rolldown/binding-freebsd-x64@1.0.0: + resolution: {integrity: sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] @@ -17482,8 +17619,8 @@ packages: dev: true optional: true - /@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17: - resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + /@rolldown/binding-linux-arm-gnueabihf@1.0.0: + resolution: {integrity: sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -17491,8 +17628,8 @@ packages: dev: true optional: true - /@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17: - resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + /@rolldown/binding-linux-arm64-gnu@1.0.0: + resolution: {integrity: sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -17500,8 +17637,8 @@ packages: dev: true optional: true - /@rolldown/binding-linux-arm64-musl@1.0.0-rc.17: - resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + /@rolldown/binding-linux-arm64-musl@1.0.0: + resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -17509,8 +17646,8 @@ packages: dev: true optional: true - /@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17: - resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + /@rolldown/binding-linux-ppc64-gnu@1.0.0: + resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] @@ -17518,8 +17655,8 @@ packages: dev: true optional: true - /@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17: - resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + /@rolldown/binding-linux-s390x-gnu@1.0.0: + resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] @@ -17527,8 +17664,8 @@ packages: dev: true optional: true - /@rolldown/binding-linux-x64-gnu@1.0.0-rc.17: - resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + /@rolldown/binding-linux-x64-gnu@1.0.0: + resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -17536,8 +17673,8 @@ packages: dev: true optional: true - /@rolldown/binding-linux-x64-musl@1.0.0-rc.17: - resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + /@rolldown/binding-linux-x64-musl@1.0.0: + resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -17545,8 +17682,8 @@ packages: dev: true optional: true - /@rolldown/binding-openharmony-arm64@1.0.0-rc.17: - resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + /@rolldown/binding-openharmony-arm64@1.0.0: + resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] @@ -17554,8 +17691,8 @@ packages: dev: true optional: true - /@rolldown/binding-wasm32-wasi@1.0.0-rc.17: - resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + /@rolldown/binding-wasm32-wasi@1.0.0: + resolution: {integrity: sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] requiresBuild: true @@ -17566,8 +17703,8 @@ packages: dev: true optional: true - /@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17: - resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + /@rolldown/binding-win32-arm64-msvc@1.0.0: + resolution: {integrity: sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] @@ -17575,8 +17712,8 @@ packages: dev: true optional: true - /@rolldown/binding-win32-x64-msvc@1.0.0-rc.17: - resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + /@rolldown/binding-win32-x64-msvc@1.0.0: + resolution: {integrity: sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -17584,6 +17721,9 @@ packages: dev: true optional: true + /@rolldown/pluginutils@1.0.0: + resolution: {integrity: sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==} + /@rolldown/pluginutils@1.0.0-beta.29: resolution: {integrity: sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==} dev: false @@ -17592,14 +17732,6 @@ packages: resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} dev: true - /@rolldown/pluginutils@1.0.0-rc.17: - resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} - dev: true - - /@rolldown/pluginutils@1.0.0-rc.9: - resolution: {integrity: sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==} - dev: false - /@rollup/plugin-alias@5.1.1(rollup@4.50.2): resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==} engines: {node: '>=14.0.0'} @@ -17913,7 +18045,7 @@ packages: '@sap/xsenv': 6.0.0 '@sap/xssec': 4.12.2 async-retry: 1.3.3 - axios: 1.13.2 + axios: 1.13.5 jks-js: 1.1.5 jsonwebtoken: 9.0.3 transitivePeerDependencies: @@ -17927,7 +18059,7 @@ packages: '@sap-cloud-sdk/connectivity': 4.3.1 '@sap-cloud-sdk/resilience': 4.3.1 '@sap-cloud-sdk/util': 4.3.1 - axios: 1.13.2 + axios: 1.13.5 transitivePeerDependencies: - debug - supports-color @@ -17940,7 +18072,7 @@ packages: '@sap-cloud-sdk/http-client': 4.3.1 '@sap-cloud-sdk/resilience': 4.3.1 '@sap-cloud-sdk/util': 4.3.1 - axios: 1.13.2 + axios: 1.13.5 transitivePeerDependencies: - debug - supports-color @@ -17951,7 +18083,7 @@ packages: dependencies: '@sap-cloud-sdk/util': 4.3.1 async-retry: 1.3.3 - axios: 1.13.2 + axios: 1.13.5 opossum: 9.0.0 transitivePeerDependencies: - debug @@ -17960,7 +18092,7 @@ packages: /@sap-cloud-sdk/util@4.3.1: resolution: {integrity: sha512-ew3+RiyUAKKhb/h/M1g2di9DjoeDQ3uQIDS7a+rN9lqlaP2+mb0mbK8x1PHLJ8agvZNjFR5y5k2jKmIiHVFT3A==} dependencies: - axios: 1.13.2 + axios: 1.13.5 chalk: 4.1.2 logform: 2.7.0 voca: 1.4.1 @@ -21295,7 +21427,7 @@ packages: '@babel/core': 7.28.5 '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) '@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.28.5) - '@rolldown/pluginutils': 1.0.0-rc.9 + '@rolldown/pluginutils': 1.0.0 '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.5) vite: 7.2.7(@types/node@24.2.1)(jiti@2.6.1) vue: 3.5.22(typescript@5.9.3) @@ -22601,7 +22733,6 @@ packages: /ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} - dev: true /ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} @@ -23592,6 +23723,28 @@ packages: pkg-types: 2.3.0 rc9: 2.1.2 + /c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.0.8 + giget: 3.2.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + rc9: 3.0.1 + dev: false + /cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -23937,6 +24090,13 @@ packages: dependencies: readdirp: 4.1.2 + /chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + dependencies: + readdirp: 5.0.0 + dev: false + /chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} dev: false @@ -24354,7 +24514,6 @@ packages: /color-support@1.1.3: resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} hasBin: true - dev: true /color@3.2.1: resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==} @@ -24465,6 +24624,11 @@ packages: engines: {node: '>=18'} dev: true + /commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + dev: false + /commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -24616,6 +24780,10 @@ packages: /confbox@0.2.2: resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + /confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + dev: false + /config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} dependencies: @@ -25661,6 +25829,14 @@ packages: bundle-name: 4.1.0 default-browser-id: 5.0.0 + /default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.0 + dev: false + /defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} dependencies: @@ -25704,6 +25880,10 @@ packages: /defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + /defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + dev: false + /delaunator@5.0.1: resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} dependencies: @@ -26055,6 +26235,11 @@ packages: resolution: {integrity: sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==} engines: {node: '>=12'} + /dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dev: false + /dotenv@8.6.0: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} @@ -26457,6 +26642,10 @@ packages: has-tostringtag: 1.0.2 hasown: 2.0.2 + /es-toolkit@1.46.1: + resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + dev: false + /es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} dev: false @@ -27166,6 +27355,10 @@ packages: /exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + /exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + dev: false + /ext-list@2.2.2: resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==} engines: {node: '>=0.10.0'} @@ -28135,6 +28328,12 @@ packages: dependencies: resolve-pkg-maps: 1.0.0 + /get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + dependencies: + resolve-pkg-maps: 1.0.0 + dev: false + /gh-release-fetch@4.0.3: resolution: {integrity: sha512-TOiP1nwLsH5shG85Yt6v6Kjq5JU/44jXyEpbcfPgmj3C829yeXIlx9nAEwQRaxtRF3SJinn2lz7XUkfG9W/U4g==} engines: {node: ^14.18.0 || ^16.13.0 || >=18.0.0} @@ -28155,6 +28354,11 @@ packages: nypm: 0.6.1 pathe: 2.0.3 + /giget@3.2.0: + resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} + hasBin: true + dev: false + /git-raw-commits@2.0.11: resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==} engines: {node: '>=10'} @@ -29650,6 +29854,11 @@ packages: hasBin: true dev: true + /is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + dev: false + /is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -30540,6 +30749,12 @@ packages: dependencies: argparse: 2.0.1 + /js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + dependencies: + argparse: 2.0.1 + /jsdoc-type-pratt-parser@4.8.0: resolution: {integrity: sha512-iZ8Bdb84lWRuGHamRXFyML07r21pcwBrLkHEuHgEY5UbCouBwv7ECknDRKzsQIXMiqpPymqtIf8TC/shYKB5rw==} engines: {node: '>=12.0.0'} @@ -30936,7 +31151,7 @@ packages: '@langchain/textsplitters': 0.1.0(@langchain/core@0.3.70) axios: 1.11.0 js-tiktoken: 1.0.21 - js-yaml: 4.1.0 + js-yaml: 4.1.1 jsonpointer: 5.0.1 langsmith: 0.3.58(openai@4.104.0) openapi-types: 12.1.3 @@ -31803,8 +32018,8 @@ packages: /magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 source-map-js: 1.2.1 /make-dir@2.1.0: @@ -34537,6 +34752,18 @@ packages: is-inside-container: 1.0.0 wsl-utils: 0.1.0 + /open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + dev: false + /open@8.4.2: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} @@ -35345,6 +35572,10 @@ packages: /perfect-debounce@2.0.0: resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==} + /perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + dev: false + /pg-cloudflare@1.2.7: resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} requiresBuild: true @@ -35990,6 +36221,11 @@ packages: - debug dev: false + /powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + dev: false + /precinct@12.2.0(supports-color@10.2.2): resolution: {integrity: sha512-NFBMuwIfaJ4SocE9YXPU/n4AcNSoFMVFjP72nvl3cx69j/ke61/hPOWFREVxLkFhhEGnA8ZuVfTqJBa+PK3b5w==} engines: {node: '>=18'} @@ -36382,6 +36618,13 @@ packages: defu: 6.1.4 destr: 2.0.5 + /rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + dependencies: + defu: 6.1.7 + destr: 2.0.5 + dev: false + /rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -36792,6 +37035,11 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + /readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + dev: false + /real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -37278,7 +37526,7 @@ packages: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} dev: false - /rolldown-plugin-dts@0.16.11(rolldown@1.0.0-rc.17)(typescript@5.9.2): + /rolldown-plugin-dts@0.16.11(rolldown@1.0.0)(typescript@5.9.2): resolution: {integrity: sha512-9IQDaPvPqTx3RjG2eQCK5GYZITo203BxKunGI80AGYicu1ySFTUyugicAaTZWRzFWh9DSnzkgNeMNbDWBbSs0w==} engines: {node: '>=20.18.0'} peerDependencies: @@ -37306,36 +37554,36 @@ packages: dts-resolver: 2.1.2 get-tsconfig: 4.10.1 magic-string: 0.30.19 - rolldown: 1.0.0-rc.17 + rolldown: 1.0.0 typescript: 5.9.2 transitivePeerDependencies: - oxc-resolver - supports-color dev: true - /rolldown@1.0.0-rc.17: - resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + /rolldown@1.0.0: + resolution: {integrity: sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true dependencies: - '@oxc-project/types': 0.127.0 - '@rolldown/pluginutils': 1.0.0-rc.17 + '@oxc-project/types': 0.129.0 + '@rolldown/pluginutils': 1.0.0 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.17 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 - '@rolldown/binding-darwin-x64': 1.0.0-rc.17 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + '@rolldown/binding-android-arm64': 1.0.0 + '@rolldown/binding-darwin-arm64': 1.0.0 + '@rolldown/binding-darwin-x64': 1.0.0 + '@rolldown/binding-freebsd-x64': 1.0.0 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0 + '@rolldown/binding-linux-arm64-gnu': 1.0.0 + '@rolldown/binding-linux-arm64-musl': 1.0.0 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0 + '@rolldown/binding-linux-s390x-gnu': 1.0.0 + '@rolldown/binding-linux-x64-gnu': 1.0.0 + '@rolldown/binding-linux-x64-musl': 1.0.0 + '@rolldown/binding-openharmony-arm64': 1.0.0 + '@rolldown/binding-wasm32-wasi': 1.0.0 + '@rolldown/binding-win32-arm64-msvc': 1.0.0 + '@rolldown/binding-win32-x64-msvc': 1.0.0 dev: true /rollup-plugin-inject@3.0.2: @@ -37645,6 +37893,12 @@ packages: hasBin: true requiresBuild: true + /semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + dev: false + /send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -39220,7 +39474,6 @@ packages: /toml@3.0.0: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} - dev: true /tomlify-j0.4@3.0.0: resolution: {integrity: sha512-2Ulkc8T7mXJ2l0W476YC/A209PR38Nw8PuaCNtk9uI3t1zzFdGQeWYGQvmj2PZkVvRC/Yoi4xQKMRnWc/N29tQ==} @@ -39517,8 +39770,8 @@ packages: empathic: 2.0.0 hookable: 5.5.3 publint: 0.3.12 - rolldown: 1.0.0-rc.17 - rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-rc.17)(typescript@5.9.2) + rolldown: 1.0.0 + rolldown-plugin-dts: 0.16.11(rolldown@1.0.0)(typescript@5.9.2) semver: 7.7.2 tinyexec: 1.0.1 tinyglobby: 0.2.15 @@ -40960,6 +41213,31 @@ packages: - yaml dev: false + /vite-node@3.2.4(@types/node@24.6.2): + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@10.2.2) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.2.7(@types/node@24.6.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + dev: true + /vite-plugin-checker@0.11.0(@biomejs/biome@1.9.4)(eslint@9.33.0)(typescript@5.9.3)(vite@7.2.7): resolution: {integrity: sha512-iUdO9Pl9UIBRPAragwi3as/BXXTtRu4G12L3CMrjx+WVTd9g/MsqNakreib9M/2YRVkhZYiTEwdH2j4Dm0w7lw==} engines: {node: '>=16.11'} @@ -41155,6 +41433,57 @@ packages: fsevents: 2.3.3 dev: false + /vite@7.2.7(@types/node@24.6.2): + resolution: {integrity: sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + dependencies: + '@types/node': 24.6.2 + esbuild: 0.25.10 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.50.2 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + dev: true + /vitefu@1.1.1(vite@7.2.7): resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==} peerDependencies: @@ -41235,6 +41564,75 @@ packages: - yaml dev: true + /vitest@3.2.4(@types/node@24.6.2)(@vitest/ui@1.6.1)(jsdom@22.1.0): + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + dependencies: + '@types/chai': 5.2.2 + '@types/node': 24.6.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(msw@2.11.6)(vite@7.2.7) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/ui': 1.6.1(vitest@3.2.4) + '@vitest/utils': 3.2.4 + chai: 5.2.1 + debug: 4.4.3(supports-color@10.2.2) + expect-type: 1.2.2 + jsdom: 22.1.0 + magic-string: 0.30.19 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.2.7(@types/node@24.6.2) + vite-node: 3.2.4(@types/node@24.6.2) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + dev: true + /viteval@0.5.3(@tanstack/query-core@5.89.0)(@tanstack/react-query@5.89.0)(@tanstack/router-core@1.131.44)(@types/node@24.2.1)(@types/react@19.2.7)(@vitejs/plugin-react@5.1.2)(tsx@4.21.0)(vite@7.2.7): resolution: {integrity: sha512-phDrceVUtOje90Oy0v0jeSuAC1FxGrho34KGUntUs9ZG5nJe+CZt59YykasOPdLv0HA5oQgRAkOY2xUvwmaRag==} hasBin: true @@ -41889,6 +42287,14 @@ packages: dependencies: is-wsl: 3.1.0 + /wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + dependencies: + is-wsl: 3.1.0 + powershell-utils: 0.1.0 + dev: false + /xdg-basedir@4.0.0: resolution: {integrity: sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==} engines: {node: '>=8'} diff --git a/website/docs/workspaces/sandbox.md b/website/docs/workspaces/sandbox.md index 2a76526bd..7fe1baa01 100644 --- a/website/docs/workspaces/sandbox.md +++ b/website/docs/workspaces/sandbox.md @@ -107,7 +107,7 @@ By default, LocalSandbox passes only `PATH` into the environment. Set `inheritPr ## Remote sandbox providers -Install the provider package you need (for example `@voltagent/sandbox-e2b` or `@voltagent/sandbox-daytona`), then configure it on the workspace: +Install the provider package you need (for example `@voltagent/sandbox-e2b`, `@voltagent/sandbox-daytona`, or `@voltagent/sandbox-blaxel`), then configure it on the workspace: ```ts import { E2BSandbox } from "@voltagent/sandbox-e2b"; @@ -127,6 +127,18 @@ const daytonaWorkspace = new Workspace({ }); ``` +```ts +import { BlaxelSandbox } from "@voltagent/sandbox-blaxel"; + +const blaxelWorkspace = new Workspace({ + sandbox: new BlaxelSandbox({ + apiKey: process.env.BL_API_KEY, + workspace: process.env.BL_WORKSPACE, + config: { name: "voltagent-prod", region: "us-pdx-1" }, + }), +}); +``` + If you need provider-specific APIs, keep a reference to the provider and access its native SDK instance: ```ts @@ -156,6 +168,21 @@ const daytonaSandbox = await sandbox.getSandbox(); const response = await daytonaSandbox.process.executeCommand("ls -la"); ``` +```ts +import { BlaxelSandbox } from "@voltagent/sandbox-blaxel"; + +const sandbox = new BlaxelSandbox({ + apiKey: process.env.BL_API_KEY, + workspace: process.env.BL_WORKSPACE, + config: { name: "voltagent-prod" }, +}); + +const workspace = new Workspace({ sandbox }); + +const blaxelSandbox = await sandbox.getSandbox(); +const file = await blaxelSandbox.fs.read("/workspace/file.txt"); +``` + ## Custom sandbox provider You can implement `WorkspaceSandbox` and plug it into `Workspace` directly. @@ -339,6 +366,56 @@ const workspace = new Workspace({ }); ``` +### Tenant-aware Blaxel router example + +```ts +import type { + WorkspaceSandbox, + WorkspaceSandboxExecuteOptions, + WorkspaceSandboxResult, +} from "@voltagent/core"; +import { Workspace } from "@voltagent/core"; +import { BlaxelSandbox } from "@voltagent/sandbox-blaxel"; + +class TenantBlaxelSandboxRouter implements WorkspaceSandbox { + name = "tenant-blaxel-router"; + status = "ready" as const; + // In production, add LRU/TTL eviction here and dispose evicted sandboxes + // (for example via stop/destroy) to avoid unbounded per-tenant growth. + private readonly sandboxes = new Map(); + + getInfo() { + return { + provider: "tenant-blaxel-router", + status: this.status, + sandboxCount: this.sandboxes.size, + }; + } + + private getSandboxForTenant(tenantId: string): BlaxelSandbox { + let sandbox = this.sandboxes.get(tenantId); + if (!sandbox) { + sandbox = new BlaxelSandbox({ + apiKey: process.env.BL_API_KEY, + workspace: process.env.BL_WORKSPACE, + config: { name: `tenant-${tenantId}` }, + }); + this.sandboxes.set(tenantId, sandbox); + } + return sandbox; + } + + async execute(options: WorkspaceSandboxExecuteOptions): Promise { + const tenantId = String(options.operationContext?.context.get("tenantId") ?? "default"); + return this.getSandboxForTenant(tenantId).execute(options); + } +} + +const workspace = new Workspace({ + sandbox: new TenantBlaxelSandboxRouter(), +}); +``` + Notes: - `onStdout`/`onStderr` are optional streaming hooks for UI integration. From 6df5c055455083d6c7621e62260965edf5180083 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Sat, 9 May 2026 17:09:31 -0400 Subject: [PATCH 2/7] refactor(sandbox-blaxel): cleanup internals, split specs, modern tsconfig Cleanup pass on top of the initial provider commit: - Extract option parsing into shell.ts. parseOptions returns the attempt-tuple shape and absorbs the stdin/empty-command validation. Shell-escape and env helpers are now private to shell.ts. - Pull bracket helpers into utils.ts (toError, truncateOutput, withEventListener). withAbort lives at the bottom of sandbox.ts as a thin wrapper over withEventListener. - Fold per-call sandbox/process plumbing into private methods on the class (killProcess, runProcess, fetchProcessOutput). Each helper resolves the sandbox via this.resolveSandbox() rather than threading it through every signature. - Class layout: fields -> constructor -> public methods -> private methods. - Replace single-file index.spec.ts with sandbox.spec.ts / shell.spec.ts / utils.spec.ts. 67 tests, 100% line/branch/func/stmt coverage. New test:coverage script in package.json. - Modernize tsconfig.json for Node 22+: target/lib ES2023, module Preserve, moduleResolution Bundler, verbatimModuleSyntax. tsup target bumped to es2023 to match. - Drop output.ts (truncateOutput moved into utils.ts). - Re-export NormalizedCommand from @voltagent/core so the wrapper has a type for the parser result. --- packages/core/src/workspace/index.ts | 2 + packages/core/src/workspace/sandbox/index.ts | 1 + packages/sandbox-blaxel/package.json | 1 + packages/sandbox-blaxel/src/output.ts | 21 - .../src/{index.spec.ts => sandbox.spec.ts} | 36 -- packages/sandbox-blaxel/src/sandbox.ts | 387 ++++++++++++------ packages/sandbox-blaxel/src/shell.spec.ts | 40 ++ packages/sandbox-blaxel/src/shell.ts | 141 ++++++- packages/sandbox-blaxel/src/types.ts | 4 +- packages/sandbox-blaxel/src/utils.spec.ts | 118 ++++++ packages/sandbox-blaxel/src/utils.ts | 59 +++ packages/sandbox-blaxel/tsconfig.json | 42 +- packages/sandbox-blaxel/tsup.config.ts | 2 +- 13 files changed, 620 insertions(+), 234 deletions(-) delete mode 100644 packages/sandbox-blaxel/src/output.ts rename packages/sandbox-blaxel/src/{index.spec.ts => sandbox.spec.ts} (94%) create mode 100644 packages/sandbox-blaxel/src/shell.spec.ts create mode 100644 packages/sandbox-blaxel/src/utils.spec.ts create mode 100644 packages/sandbox-blaxel/src/utils.ts diff --git a/packages/core/src/workspace/index.ts b/packages/core/src/workspace/index.ts index 9096b7821..197252ac8 100644 --- a/packages/core/src/workspace/index.ts +++ b/packages/core/src/workspace/index.ts @@ -28,6 +28,7 @@ import type { LocalSandboxIsolationOptions, LocalSandboxIsolationProvider, LocalSandboxOptions, + NormalizedCommand, WorkspaceSandbox, WorkspaceSandboxExecuteOptions, WorkspaceSandboxResult, @@ -470,6 +471,7 @@ export { type WorkspaceSandboxToolName, createWorkspaceSandboxToolkit, normalizeCommandAndArgs, + type NormalizedCommand, type WorkspaceSandboxToolkitOptions, type WorkspaceSandboxToolkitContext, WorkspaceSearch, diff --git a/packages/core/src/workspace/sandbox/index.ts b/packages/core/src/workspace/sandbox/index.ts index 28491f3f5..2ce2f8bde 100644 --- a/packages/core/src/workspace/sandbox/index.ts +++ b/packages/core/src/workspace/sandbox/index.ts @@ -18,3 +18,4 @@ export type { WorkspaceSandboxToolName, } from "./toolkit"; export { normalizeCommandAndArgs } from "./command-normalization"; +export type { NormalizedCommand } from "./command-normalization"; diff --git a/packages/sandbox-blaxel/package.json b/packages/sandbox-blaxel/package.json index 16791ff8c..3235484bc 100644 --- a/packages/sandbox-blaxel/package.json +++ b/packages/sandbox-blaxel/package.json @@ -44,6 +44,7 @@ "build": "tsup", "dev": "tsup --watch", "test": "vitest run", + "test:coverage": "vitest run --coverage", "test:watch": "vitest", "typecheck": "tsc --noEmit" }, diff --git a/packages/sandbox-blaxel/src/output.ts b/packages/sandbox-blaxel/src/output.ts deleted file mode 100644 index 92d74c94e..000000000 --- a/packages/sandbox-blaxel/src/output.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Truncate a UTF-8 string to at most `maxBytes` bytes (not characters). - * A trailing partial multi-byte sequence may render as a replacement character. - */ -export function truncateOutput( - value: string, - maxBytes: number, -): { content: string; truncated: boolean } { - if (value.length === 0) { - return { content: "", truncated: false }; - } - if (maxBytes <= 0) { - return { content: "", truncated: true }; - } - // Avoid allocating a Buffer when input fits. - if (Buffer.byteLength(value, "utf-8") <= maxBytes) { - return { content: value, truncated: false }; - } - const data = Buffer.from(value, "utf-8"); - return { content: data.subarray(0, maxBytes).toString("utf-8"), truncated: true }; -} diff --git a/packages/sandbox-blaxel/src/index.spec.ts b/packages/sandbox-blaxel/src/sandbox.spec.ts similarity index 94% rename from packages/sandbox-blaxel/src/index.spec.ts rename to packages/sandbox-blaxel/src/sandbox.spec.ts index 210cdd5b8..c0deffdb3 100644 --- a/packages/sandbox-blaxel/src/index.spec.ts +++ b/packages/sandbox-blaxel/src/sandbox.spec.ts @@ -1,8 +1,6 @@ import * as blaxelCore from "@blaxel/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BlaxelSandbox, type BlaxelSandboxInstance } from "./index"; -import { truncateOutput } from "./output"; -import { applyEnvBindings } from "./shell"; interface ExecRequest { name: string; @@ -508,40 +506,6 @@ describe("BlaxelSandbox.execute (validation and post-state robustness)", () => { expect(result.stdout).toBe("ok"); expect(result.stderr).toBe(""); }); - - it("truncateOutput flags truncated when maxBytes <= 0 and value is non-empty", () => { - expect(truncateOutput("hello", 0)).toEqual({ content: "", truncated: true }); - expect(truncateOutput("hello", -10)).toEqual({ content: "", truncated: true }); - }); -}); - -describe("applyEnvBindings", () => { - it("writes non-nil bindings to the target object", () => { - const target: Record = {}; - applyEnvBindings({ A: "one", B: "two" }, target); - expect(target).toEqual({ A: "one", B: "two" }); - }); - - it("skips bindings whose value is null or undefined", () => { - const target: Record = { EXISTING: "keep" }; - applyEnvBindings({ A: "set", B: undefined, C: null as unknown as string, D: "" }, target); - expect(target).toEqual({ EXISTING: "keep", A: "set", D: "" }); - expect(target).not.toHaveProperty("B"); - expect(target).not.toHaveProperty("C"); - }); - - it("does not delete existing keys when binding value is nullish", () => { - const target: Record = { A: "preset" }; - applyEnvBindings({ A: undefined }, target); - expect(target.A).toBe("preset"); - }); - - it("defaults to mutating process.env when no target is provided", () => { - applyEnvBindings({ TEST_BLAXEL_BINDING: "applied" }); - expect(process.env.TEST_BLAXEL_BINDING).toBe("applied"); - // biome-ignore lint/performance/noDelete: clean up the side-effect for later tests. - delete process.env.TEST_BLAXEL_BINDING; - }); }); describe("BlaxelSandbox lazy creation, getSandbox, destroy, getInfo", () => { diff --git a/packages/sandbox-blaxel/src/sandbox.ts b/packages/sandbox-blaxel/src/sandbox.ts index 1bb07e82d..532543cfd 100644 --- a/packages/sandbox-blaxel/src/sandbox.ts +++ b/packages/sandbox-blaxel/src/sandbox.ts @@ -1,33 +1,15 @@ import { randomUUID } from "node:crypto"; import { SandboxInstance } from "@blaxel/core"; -import { - type WorkspaceSandbox, - type WorkspaceSandboxExecuteOptions, - type WorkspaceSandboxResult, - normalizeCommandAndArgs, +import type { + WorkspaceSandbox, + WorkspaceSandboxExecuteOptions, + WorkspaceSandboxResult, } from "@voltagent/core"; -import { attempt, attemptAsync, isEmptyObject, omit } from "es-toolkit"; -import { - DEFAULT_MAX_OUTPUT_BYTES, - DEFAULT_POLL_INTERVAL_MS, - DEFAULT_TIMEOUT_MS, - NO_TIMEOUT_MAX_WAIT_MS, -} from "./constants"; -import { truncateOutput } from "./output"; -import { applyEnvBindings, buildCommandLine, normalizeEnv } from "./shell"; +import { attempt, attemptAsync, isNil, isNotNil, omit } from "es-toolkit"; +import { NO_TIMEOUT_MAX_WAIT_MS } from "./constants"; +import { type ParsedExecuteOptions, applyEnvBindings, parseOptions } from "./shell"; import type { BlaxelSandboxConfig, BlaxelSandboxInstance, BlaxelSandboxOptions } from "./types"; - -// VoltAgent extras on BlaxelSandboxConfig — stripped before SDK calls. -const VOLTAGENT_CONFIG_KEYS = [ - "cwd", - "defaultTimeoutMs", - "maxOutputBytes", - "pollIntervalMs", -] as const satisfies ReadonlyArray; - -function resolveCallOption(callValue: number | undefined, fallback: number): number { - return callValue === undefined ? fallback : Math.max(0, callValue); -} +import { toError, truncateOutput, withEventListener } from "./utils"; /** * VoltAgent workspace sandbox provider backed by `@blaxel/core`. @@ -38,11 +20,38 @@ export class BlaxelSandbox implements WorkspaceSandbox { */ name = "blaxel"; + /** + * Constructor-supplied Blaxel API key. Mirrored to `process.env.BL_API_KEY` + * by the constructor so the SDK picks it up. + */ private readonly apiKey?: string; + + /** + * Constructor-supplied Blaxel workspace ID. Mirrored to + * `process.env.BL_WORKSPACE` by the constructor so the SDK picks it up. + */ private readonly workspace?: string; + + /** + * Constructor-supplied sandbox provisioning config + voltagent-specific + * `execute()` defaults (`cwd`, `defaultTimeoutMs`, `maxOutputBytes`, + * `pollIntervalMs`). The voltagent extras are stripped via + * {@link getSdkConfig} before forwarding to the SDK. + */ private readonly config?: BlaxelSandboxConfig; + + /** + * In-flight or resolved promise for the underlying SDK sandbox. Memoizes + * provisioning across concurrent `execute()` / `getSandbox()` calls. Cleared + * by {@link destroy} or {@link createSandbox} on failure so the next call + * retries provisioning instead of replaying a rejected promise. + */ private sandbox?: Promise; + // --------------------------------------------------------------------------- + // Constructor + // --------------------------------------------------------------------------- + /** * The underlying sandbox is lazily created on first `execute()` / * `getSandbox()`. Sets `BL_API_KEY` / `BL_WORKSPACE` env vars when provided. @@ -66,16 +75,10 @@ export class BlaxelSandbox implements WorkspaceSandbox { * @throws When `options.command` is missing or whitespace-only. */ async execute(options: WorkspaceSandboxExecuteOptions): Promise { - if (options.stdin !== undefined) { - throw new Error("Workspace sandbox does not support stdin for this command."); - } - const startTime = Date.now(); - const normalized = normalizeCommandAndArgs(options.command ?? "", options.args); - const command = normalized.command.trim(); - - if (command.length === 0) { - throw new Error("Sandbox command is required"); + const [parseError, parsed] = parseOptions(options, this.config); + if (parseError) { + throw parseError; } if (options.signal?.aborted) { @@ -91,159 +94,271 @@ export class BlaxelSandbox implements WorkspaceSandbox { }; } - const sandbox = await this.resolveSandbox(); - const timeoutMs = resolveCallOption( - options.timeoutMs, - this.config?.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS, - ); - const maxOutputBytes = resolveCallOption( - options.maxOutputBytes, - this.config?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, - ); - const pollIntervalMs = this.config?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; - const callEnv = normalizeEnv(options.env); - const commandLine = buildCommandLine(command, normalized.args); const processName = `voltagent-${randomUUID()}`; - let aborted = false; - let timedOut = false; - let abortListener: (() => void) | undefined; + return await withAbort({ + signal: options.signal, + onAbort: () => { + void this.killProcess({ processName }); + }, + run: async () => { + const { timedOut } = await this.runProcess({ + parsed, + processName, + options, + }); + const output = await this.fetchProcessOutput({ + processName, + maxOutputBytes: parsed.maxOutputBytes, + }); + return { + ...output, + durationMs: Date.now() - startTime, + timedOut, + aborted: options.signal?.aborted ?? false, + }; + }, + }); + } - const killSilently = async (): Promise => { - await attemptAsync(() => sandbox.process.kill(processName)); - }; + /** + * Destroy the underlying Blaxel sandbox and clear the cached instance. + * Best-effort: errors from `sandbox.delete()` are swallowed. + */ + async destroy(): Promise { + const pending = this.sandbox; + this.sandbox = undefined; - if (options.signal) { - abortListener = () => { - aborted = true; - // Listener must be sync; killSilently swallows its own errors. - void killSilently(); - }; - options.signal.addEventListener("abort", abortListener, { once: true }); + if (isNil(pending)) { + return; } - let started: Awaited> | undefined; - try { - started = await sandbox.process.exec({ - name: processName, - command: commandLine, - timeout: 0, - workingDir: options.cwd ?? this.config?.cwd, - env: isEmptyObject(callEnv) ? undefined : callEnv, - onStdout: options.onStdout, - onStderr: options.onStderr, - }); + const [resolveError, current] = await attemptAsync(() => { + return pending; + }); + + if (resolveError) { + return; + } + + await attemptAsync(() => { + return current.delete(); + }); + } + + /** + * Return `{ provider: "blaxel", ...sdkConfig }` for diagnostics/UIs. + * Excludes voltagent-specific extras (cwd, defaults, etc.). + */ + getInfo(): Record { + return { provider: "blaxel", ...this.getSdkConfig() }; + } + + /** + * Return the underlying Blaxel SDK sandbox instance, lazily creating it on + * first call. Memoized until {@link destroy} is called. + */ + async getSandbox(): Promise { + return await this.resolveSandbox(); + } + + /** + * Best-effort kill of a process inside the sandbox by name. Errors are + * swallowed via `attemptAsync` — kill is wired to abort listeners and timeout + * paths where we can't surface a failure to the caller. + */ + private async killProcess({ + processName, + }: { + processName: string; + }): Promise<{ status: "success" | "failure"; error?: Error }> { + const sandbox = await this.resolveSandbox(); + const [err] = await attemptAsync(() => { + return sandbox.process.kill(processName); + }); + + if (err) { + return { status: "failure", error: toError(err) }; + } + return { status: "success" }; + } + /** + * Run a single process to completion: `process.exec` → `process.wait`, with + * kill-on-timeout and a `started.close` cleanup in `finally`. + * + * Caller owns abort wiring (see {@link withAbort}). On `wait()` rejection we + * treat it as a timeout, fire {@link killProcess}, and surface `{ timedOut: true }`. + * + * @returns `{ timedOut }` — `true` iff `wait()` rejected. + */ + private async runProcess({ + parsed, + processName, + options, + }: { + parsed: ParsedExecuteOptions; + processName: string; + options: Pick; + }): Promise<{ timedOut: boolean }> { + const sandbox = await this.resolveSandbox(); + const { command, env, cwd, timeoutMs, pollIntervalMs } = parsed; + const started = await sandbox.process.exec({ + name: processName, + command, + timeout: 0, + workingDir: cwd, + env, + onStdout: options.onStdout, + onStderr: options.onStderr, + }); + try { // wait() throws "Process did not finish in time" on timeout. - const [waitError] = await attemptAsync(() => - sandbox.process.wait(processName, { + const [waitError] = await attemptAsync(() => { + return sandbox.process.wait(processName, { maxWait: timeoutMs > 0 ? timeoutMs : NO_TIMEOUT_MAX_WAIT_MS, interval: pollIntervalMs, - }), - ); - if (waitError) { - timedOut = true; - await killSilently(); + }); + }); + const timedOut = isNotNil(waitError); + if (timedOut) { + await this.killProcess({ processName }); } + return { timedOut }; } finally { - if (options.signal && abortListener) { - options.signal.removeEventListener("abort", abortListener); + if ("close" in started) { + attempt(() => started.close()); } - const close = started && "close" in started ? started.close : undefined; - if (close) attempt(close); } + } + /** + * Gather final state and stdout/stderr from a process that's already exited. + * + * - When `maxOutputBytes <= 0`, skips the log fetches entirely and flags both + * streams as truncated. Use this to opt out of the wire cost of streaming + * output back when the caller doesn't care. + * - Otherwise fetches state + both log streams in parallel and runs each + * stream through {@link truncateOutput} for client-side capping. + * + * Each SDK call is wrapped in `attemptAsync` so a single failure (e.g. the + * process record was reaped) degrades gracefully to empty output / `null` + * exit code rather than throwing. + */ + private async fetchProcessOutput({ + processName, + maxOutputBytes, + }: { + processName: string; + maxOutputBytes: number; + }): Promise< + Pick< + WorkspaceSandboxResult, + "stdout" | "stderr" | "exitCode" | "stdoutTruncated" | "stderrTruncated" + > + > { + const sandbox = await this.resolveSandbox(); if (maxOutputBytes <= 0) { - const [, finalState] = await attemptAsync(() => sandbox.process.get(processName)); + const [, finalState] = await attemptAsync(() => { + return sandbox.process.get(processName); + }); return { stdout: "", stderr: "", exitCode: finalState?.exitCode ?? null, - durationMs: Date.now() - startTime, - timedOut, - aborted, stdoutTruncated: true, stderrTruncated: true, }; } const [[, finalState], [, stdoutRaw], [, stderrRaw]] = await Promise.all([ - attemptAsync(() => sandbox.process.get(processName)), - attemptAsync(() => sandbox.process.logs(processName, "stdout")), - attemptAsync(() => sandbox.process.logs(processName, "stderr")), + attemptAsync(() => { + return sandbox.process.get(processName); + }), + attemptAsync(() => { + return sandbox.process.logs(processName, "stdout"); + }), + attemptAsync(() => { + return sandbox.process.logs(processName, "stderr"); + }), ]); - const exitCode = finalState?.exitCode ?? null; const stdoutInfo = truncateOutput(stdoutRaw ?? "", maxOutputBytes); const stderrInfo = truncateOutput(stderrRaw ?? "", maxOutputBytes); - return { stdout: stdoutInfo.content, stderr: stderrInfo.content, - exitCode, - durationMs: Date.now() - startTime, - timedOut, - aborted, + exitCode: finalState?.exitCode ?? null, stdoutTruncated: stdoutInfo.truncated, stderrTruncated: stderrInfo.truncated, }; } /** - * Return the underlying Blaxel SDK sandbox instance, lazily creating it on - * first call. Memoized until {@link destroy} is called. + * Return the cached sandbox promise, kicking off provisioning on first call. + * Memoizes the in-flight promise so concurrent `execute()` calls share a + * single SDK provisioning request. */ - async getSandbox(): Promise { - return await this.resolveSandbox(); + private resolveSandbox(): Promise { + if (!this.sandbox) { + this.sandbox = this.createSandbox(); + } + return this.sandbox; } /** - * Destroy the underlying Blaxel sandbox and clear the cached instance. - * Best-effort: errors from `sandbox.delete()` are swallowed. + * Provision the underlying Blaxel SDK sandbox via `createIfNotExists`. On + * failure, clears the cached promise so the next `execute()` retries + * provisioning instead of replaying the failed promise. */ - async destroy(): Promise { - const pending = this.sandbox; - this.sandbox = undefined; - if (!pending) { - return; - } - const [resolveError, current] = await attemptAsync(() => pending); - if (resolveError) { - return; + private async createSandbox(): Promise { + const [error, sandbox] = await attemptAsync(() => { + return SandboxInstance.createIfNotExists(this.getSdkConfig()); + }); + + if (error) { + this.sandbox = undefined; + throw error; } - await attemptAsync(() => current.delete()); - } - /** - * Return `{ provider: "blaxel", ...sdkConfig }` for diagnostics/UIs. - * Excludes voltagent-specific extras (cwd, defaults, etc.). - */ - getInfo(): Record { - return { provider: "blaxel", ...this.sdkConfig }; + return sandbox; } /** * `this.config` with voltagent-specific extras stripped — what gets * forwarded to `SandboxInstance.createIfNotExists()`. */ - private get sdkConfig() { - return omit(this.config ?? {}, VOLTAGENT_CONFIG_KEYS); - } - - private resolveSandbox(): Promise { - if (!this.sandbox) { - this.sandbox = this.createSandbox(); - } - return this.sandbox; + private getSdkConfig() { + return omit(this.config ?? {}, ["cwd", "defaultTimeoutMs", "maxOutputBytes", "pollIntervalMs"]); } +} - private async createSandbox(): Promise { - const [error, sandbox] = await attemptAsync(() => - SandboxInstance.createIfNotExists(this.sdkConfig), - ); - if (error) { - this.sandbox = undefined; - throw error; - } - return sandbox; +/** + * Bracket-style abort scope: attaches `onAbort` to `signal`, runs `run`, + * always detaches the listener — even if `run` throws. Returns whatever `run` + * resolves to. + * + * When `signal` is `undefined`, the listener wiring is skipped and `run` is + * invoked directly. + * + * @private + */ +async function withAbort({ + signal, + onAbort, + run, +}: { + signal: AbortSignal | undefined; + onAbort: () => void; + run: () => Promise; +}): Promise { + if (!signal) { + return await run(); } + return withEventListener({ + target: signal, + event: "abort", + listener: onAbort, + options: { once: true }, + run, + }); } diff --git a/packages/sandbox-blaxel/src/shell.spec.ts b/packages/sandbox-blaxel/src/shell.spec.ts new file mode 100644 index 000000000..f5d479751 --- /dev/null +++ b/packages/sandbox-blaxel/src/shell.spec.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { applyEnvBindings } from "./shell"; + +describe("applyEnvBindings", () => { + afterEach(() => { + // biome-ignore lint/performance/noDelete: clean up env mutations between tests. + delete process.env.TEST_BLAXEL_A; + // biome-ignore lint/performance/noDelete: clean up env mutations between tests. + delete process.env.TEST_BLAXEL_B; + // biome-ignore lint/performance/noDelete: clean up env mutations between tests. + delete process.env.TEST_BLAXEL_C; + // biome-ignore lint/performance/noDelete: clean up env mutations between tests. + delete process.env.TEST_BLAXEL_D; + }); + + it("writes non-nil bindings to process.env", () => { + applyEnvBindings({ TEST_BLAXEL_A: "one", TEST_BLAXEL_B: "two" }); + expect(process.env.TEST_BLAXEL_A).toBe("one"); + expect(process.env.TEST_BLAXEL_B).toBe("two"); + }); + + it("skips bindings whose value is null or undefined", () => { + applyEnvBindings({ + TEST_BLAXEL_A: "set", + TEST_BLAXEL_B: undefined, + TEST_BLAXEL_C: null as unknown as string, + TEST_BLAXEL_D: "", + }); + expect(process.env.TEST_BLAXEL_A).toBe("set"); + expect(process.env.TEST_BLAXEL_D).toBe(""); + expect(process.env).not.toHaveProperty("TEST_BLAXEL_B"); + expect(process.env).not.toHaveProperty("TEST_BLAXEL_C"); + }); + + it("does not delete existing process.env keys when binding value is nullish", () => { + process.env.TEST_BLAXEL_A = "preset"; + applyEnvBindings({ TEST_BLAXEL_A: undefined }); + expect(process.env.TEST_BLAXEL_A).toBe("preset"); + }); +}); diff --git a/packages/sandbox-blaxel/src/shell.ts b/packages/sandbox-blaxel/src/shell.ts index 8ddf35010..91ae5d709 100644 --- a/packages/sandbox-blaxel/src/shell.ts +++ b/packages/sandbox-blaxel/src/shell.ts @@ -1,13 +1,123 @@ -import { isNil, mapValues, omitBy } from "es-toolkit"; +import { + type NormalizedCommand, + type WorkspaceSandboxExecuteOptions, + normalizeCommandAndArgs, +} from "@voltagent/core"; +import { attempt, isEmptyObject, isNil, mapValues, omitBy } from "es-toolkit"; +import { + DEFAULT_MAX_OUTPUT_BYTES, + DEFAULT_POLL_INTERVAL_MS, + DEFAULT_TIMEOUT_MS, +} from "./constants"; +import type { BlaxelSandboxConfig } from "./types"; +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * Fully resolved per-call execute inputs — the result of merging + * {@link WorkspaceSandboxExecuteOptions} with {@link BlaxelSandboxConfig} defaults. + * `command` is shell-escaped and ready for `sh -c`. + */ +export type ParsedExecuteOptions = { + command: string; + env?: Record; + cwd?: string; + timeoutMs: number; + maxOutputBytes: number; + pollIntervalMs: number; +}; + +/** + * Resolve per-call execute options against constructor-time config defaults. + * Returns an `[error, options]` tuple matching the es-toolkit `attempt` shape: + * - On invalid input (stdin set, empty command), `error` is populated. + * - Otherwise `options` carries everything `execute()` needs for the SDK call. + */ +export function parseOptions( + options: WorkspaceSandboxExecuteOptions, + config?: BlaxelSandboxConfig, +) { + return attempt((): ParsedExecuteOptions => { + if (options.stdin !== undefined) { + throw new Error("Workspace sandbox does not support stdin for this command."); + } + const { command, args } = parseCommand(options); + if (command.length === 0) { + throw new Error("Sandbox command is required"); + } + const env = parseEnv(options); + return { + command: buildCommandLine(command, args), + env: isEmptyObject(env) ? undefined : env, + cwd: options.cwd ?? config?.cwd, + timeoutMs: resolveCallOption( + options.timeoutMs, + config?.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS, + ), + maxOutputBytes: resolveCallOption( + options.maxOutputBytes, + config?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, + ), + pollIntervalMs: config?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, + }; + }); +} + +/** + * Apply non-nullish bindings to `process.env`. + */ +export function applyEnvBindings(bindings: Record): void { + const nonNil = omitBy(bindings, isNil); + for (const [key, value] of Object.entries(nonNil)) { + process.env[key] = value; + } +} + +// --------------------------------------------------------------------------- +// Internal +// --------------------------------------------------------------------------- + +/** + * Charset that is safe to leave unquoted in `sh -c`. Anything outside this set + * gets single-quoted by {@link escapeShellArg}. + * + * @private + */ const SAFE_SHELL_ARG = /^[A-Za-z0-9_./:@+=-]+$/; +/** + * Normalize `command` / `args` from execute options into a {@link NormalizedCommand}. + * The returned `command` is already trimmed by `normalizeCommandAndArgs`. + * + * @private + */ +function parseCommand( + options: Pick, +): NormalizedCommand { + return normalizeCommandAndArgs(options.command ?? "", options.args); +} + +/** + * Pick the per-call value when provided, otherwise the configured fallback. + * Negative call values are clamped to `0` so callers can't accidentally pass a + * pathological timeout / byte limit. + * + * @private + */ +function resolveCallOption(callValue: number | undefined, fallback: number): number { + return callValue === undefined ? fallback : Math.max(0, callValue); +} + /** * Quote a single shell argument so it survives `sh -c` evaluation. * Empty → `''`. Safe-charset → as-is. Otherwise → single-quoted with embedded * quotes escaped via `'\''`. + * + * @private */ -export function escapeShellArg(value: string): string { +function escapeShellArg(value: string): string { if (value.length === 0) { return "''"; } @@ -19,8 +129,10 @@ export function escapeShellArg(value: string): string { /** * Join a command and its arguments into a shell-safe command line. + * + * @private */ -export function buildCommandLine(command: string, args?: string[]): string { +function buildCommandLine(command: string, args?: string[]): string { const safeCommand = escapeShellArg(command); if (!args || args.length === 0) { return safeCommand; @@ -29,24 +141,13 @@ export function buildCommandLine(command: string, args?: string[]): string { } /** - * Drop nullish entries from an env map and string-coerce the rest. + * Pull `env` from execute options, drop nullish entries, and string-coerce the rest. + * + * @private */ -export function normalizeEnv(env?: Record): Record { - if (isNil(env)) { +function parseEnv(options: Pick): Record { + if (isNil(options.env)) { return {}; } - return mapValues(omitBy(env, isNil), String); -} - -/** - * Apply non-nullish bindings to `target` (defaults to `process.env`). - */ -export function applyEnvBindings( - bindings: Record, - target: Record = process.env, -): void { - const nonNil = omitBy(bindings, isNil); - for (const [key, value] of Object.entries(nonNil)) { - target[key] = value; - } + return mapValues(omitBy(options.env, isNil), String); } diff --git a/packages/sandbox-blaxel/src/types.ts b/packages/sandbox-blaxel/src/types.ts index 45cb7d9b0..28088bed6 100644 --- a/packages/sandbox-blaxel/src/types.ts +++ b/packages/sandbox-blaxel/src/types.ts @@ -38,11 +38,11 @@ export interface BlaxelSandboxConfig extends SandboxCreateConfiguration { */ export interface BlaxelSandboxOptions { /** - * Blaxel API key. Sets `process.env.BL_API_KEY` when provided. + * Blaxel API key. Automatically sets `process.env.BL_API_KEY` when provided. */ apiKey?: string; /** - * Blaxel workspace ID. Sets `process.env.BL_WORKSPACE` when provided. + * Blaxel workspace ID. Automatically sets `process.env.BL_WORKSPACE` when provided. */ workspace?: string; /** diff --git a/packages/sandbox-blaxel/src/utils.spec.ts b/packages/sandbox-blaxel/src/utils.spec.ts new file mode 100644 index 000000000..d1ddfbf6c --- /dev/null +++ b/packages/sandbox-blaxel/src/utils.spec.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from "vitest"; +import { toError, truncateOutput, withEventListener } from "./utils"; + +describe("toError", () => { + it("returns the same Error instance when given an Error", () => { + const err = new Error("oops"); + expect(toError(err)).toBe(err); + }); + + it("preserves Error subclass instances", () => { + const err = new TypeError("bad type"); + expect(toError(err)).toBe(err); + }); + + it("wraps a string in a new Error", () => { + const result = toError("string error"); + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe("string error"); + }); + + it("wraps null in a new Error with 'null' message", () => { + expect(toError(null).message).toBe("null"); + }); + + it("wraps undefined in a new Error with 'undefined' message", () => { + expect(toError(undefined).message).toBe("undefined"); + }); + + it("wraps a plain object via String()", () => { + expect(toError({ foo: 1 }).message).toBe("[object Object]"); + }); +}); + +describe("truncateOutput", () => { + it("returns input unchanged when within byte limit", () => { + expect(truncateOutput("hello", 100)).toEqual({ content: "hello", truncated: false }); + }); + + it("returns empty + not truncated when input is empty string", () => { + expect(truncateOutput("", 100)).toEqual({ content: "", truncated: false }); + }); + + it("flags truncated when maxBytes is 0 and value is non-empty", () => { + expect(truncateOutput("hello", 0)).toEqual({ content: "", truncated: true }); + }); + + it("flags truncated when maxBytes is negative and value is non-empty", () => { + expect(truncateOutput("hello", -10)).toEqual({ content: "", truncated: true }); + }); + + it("truncates byte-bounded for ASCII content", () => { + const { content, truncated } = truncateOutput("abcdefghij", 4); + expect(content).toBe("abcd"); + expect(truncated).toBe(true); + }); + + it("counts UTF-8 bytes, not characters", () => { + // "é" is 2 bytes in UTF-8. + const input = "éééé"; // 8 bytes + const { content, truncated } = truncateOutput(input, 4); + expect(Buffer.byteLength(content, "utf-8")).toBeLessThanOrEqual(4); + expect(truncated).toBe(true); + }); +}); + +describe("withEventListener", () => { + it("attaches listener, runs callback, returns its value, then detaches", async () => { + const target = new EventTarget(); + const listener = vi.fn(); + const result = await withEventListener({ + target, + event: "ping", + listener, + run: async () => { + target.dispatchEvent(new Event("ping")); + return 42; + }, + }); + expect(result).toBe(42); + expect(listener).toHaveBeenCalledOnce(); + // After run resolves the listener should be detached. + target.dispatchEvent(new Event("ping")); + expect(listener).toHaveBeenCalledOnce(); + }); + + it("detaches the listener even when run() throws", async () => { + const target = new EventTarget(); + const listener = vi.fn(); + await expect( + withEventListener({ + target, + event: "ping", + listener, + run: async () => { + throw new Error("boom"); + }, + }), + ).rejects.toThrow("boom"); + target.dispatchEvent(new Event("ping")); + expect(listener).not.toHaveBeenCalled(); + }); + + it("forwards options to addEventListener (e.g. once)", async () => { + const target = new EventTarget(); + const listener = vi.fn(); + await withEventListener({ + target, + event: "ping", + listener, + options: { once: true }, + run: async () => { + target.dispatchEvent(new Event("ping")); + target.dispatchEvent(new Event("ping")); + }, + }); + expect(listener).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/sandbox-blaxel/src/utils.ts b/packages/sandbox-blaxel/src/utils.ts new file mode 100644 index 000000000..a0f2e645f --- /dev/null +++ b/packages/sandbox-blaxel/src/utils.ts @@ -0,0 +1,59 @@ +/** + * Coerce an unknown value into an `Error` instance. If `value` is already an + * `Error`, returns it unchanged; otherwise wraps its string form in a new + * `Error`. Useful for normalizing thrown values from APIs that don't guarantee + * `Error` instances (e.g. `attempt` / `attemptAsync` tuples typed as `unknown`). + */ +export function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +interface WithEventListenerOptions { + target: EventTarget; + event: string; + listener: Parameters[1]; + options?: Parameters[2]; + run: () => Promise; +} + +/** + * Bracket-style event-listener scope: attaches `listener` to `target` for + * `event`, runs `run`, always detaches the listener — even if `run` throws. + * Returns whatever `run` resolves to. + */ +export async function withEventListener({ + target, + event, + listener, + options, + run, +}: WithEventListenerOptions): Promise { + target.addEventListener(event, listener, options); + try { + return await run(); + } finally { + target.removeEventListener(event, listener, options); + } +} + +/** + * Truncate a UTF-8 string to at most `maxBytes` bytes (not characters). + * A trailing partial multi-byte sequence may render as a replacement character. + */ +export function truncateOutput( + value: string, + maxBytes: number, +): { content: string; truncated: boolean } { + if (value.length === 0) { + return { content: "", truncated: false }; + } + if (maxBytes <= 0) { + return { content: "", truncated: true }; + } + // Avoid allocating a Buffer when input fits. + if (Buffer.byteLength(value, "utf-8") <= maxBytes) { + return { content: value, truncated: false }; + } + const data = Buffer.from(value, "utf-8"); + return { content: data.subarray(0, maxBytes).toString("utf-8"), truncated: true }; +} diff --git a/packages/sandbox-blaxel/tsconfig.json b/packages/sandbox-blaxel/tsconfig.json index d4612442c..2f8553a49 100644 --- a/packages/sandbox-blaxel/tsconfig.json +++ b/packages/sandbox-blaxel/tsconfig.json @@ -1,29 +1,35 @@ { "compilerOptions": { - "target": "es2018", - "lib": ["dom", "dom.iterable", "esnext"], - "module": "esnext", - "moduleResolution": "node", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "./dist", - "rootDir": "./", + // Node 22+ targets — supports all of ES2023 natively. + "target": "ES2023", + "lib": ["ES2023"], + + // tsup handles emission; "Preserve" + "Bundler" lets us write extension-less + // imports and keeps `import`/`export` syntax intact for the bundler. + "module": "Preserve", + "moduleResolution": "Bundler", + + // Server-only library — no DOM globals. + "types": ["node", "vitest/globals"], + + // Strict TypeScript. "strict": true, - "noImplicitAny": true, - "strictNullChecks": true, - "strictFunctionTypes": true, - "strictBindCallApply": true, - "strictPropertyInitialization": true, - "noImplicitThis": true, - "noUnusedLocals": true, - "noUnusedParameters": true, + "noImplicitOverride": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + + // Modern module ergonomics. + "isolatedModules": true, + "verbatimModuleSyntax": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "types": ["node"] + "resolveJsonModule": true, + + // tsup emits; tsc is typecheck-only. + "noEmit": true }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"] diff --git a/packages/sandbox-blaxel/tsup.config.ts b/packages/sandbox-blaxel/tsup.config.ts index e019584d6..7dcbd0624 100644 --- a/packages/sandbox-blaxel/tsup.config.ts +++ b/packages/sandbox-blaxel/tsup.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ splitting: false, sourcemap: true, clean: false, - target: "es2022", + target: "es2023", outDir: "dist", dts: true, esbuildPlugins: [markAsExternalPlugin], From 88ff69492f8356d2a7d3000bd5c4a8081e5d7030 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Sat, 9 May 2026 18:44:33 -0400 Subject: [PATCH 3/7] docs(workspaces): reorganize sandbox page, unify experimental admonitions - Add a plain-English "what is a sandbox" intro and explain how agents use the execute_command tool. - Reorganize the remote providers section per-provider (Blaxel, Daytona, E2B) with a uniform layout: blurb, install, configure, getSandbox example, multi-tenant routing. - Add an "Available providers" table at the top with links to upstream docs. - Add a small italic note under each install snippet calling out that the upstream SDK ships as a regular dependency. - Convert the "Experimental" blockquote at the top of every workspace doc to the site's :::warning admonition style. - Rewrite the sandbox-blaxel changeset to be consumer-focused with a code example. --- .changeset/sandbox-blaxel.md | 17 +- website/docs/workspaces/filesystem.md | 5 +- website/docs/workspaces/overview.md | 5 +- website/docs/workspaces/sandbox.md | 365 +++++++++++++++----------- website/docs/workspaces/search.md | 5 +- website/docs/workspaces/security.md | 5 +- website/docs/workspaces/skills.md | 5 +- 7 files changed, 246 insertions(+), 161 deletions(-) diff --git a/.changeset/sandbox-blaxel.md b/.changeset/sandbox-blaxel.md index 1d79115f0..2d0995454 100644 --- a/.changeset/sandbox-blaxel.md +++ b/.changeset/sandbox-blaxel.md @@ -2,4 +2,19 @@ "@voltagent/sandbox-blaxel": minor --- -Add `@voltagent/sandbox-blaxel`, a new VoltAgent workspace sandbox provider built on `@blaxel/core`. Implements `execute()` with polling-based stdout/stderr streaming, timeout and `AbortSignal` enforcement via `sandbox.process.kill()`, output truncation, and `destroy()` via `sandbox.delete()`. Also exposes `getSandbox()` for direct access to the underlying Blaxel SDK. +Add `@voltagent/sandbox-blaxel` — a new workspace sandbox provider that runs your agents' shell commands inside [Blaxel](https://blaxel.ai)-managed sandboxes. + +```ts +import { Workspace } from "@voltagent/core"; +import { BlaxelSandbox } from "@voltagent/sandbox-blaxel"; + +const workspace = new Workspace({ + sandbox: new BlaxelSandbox({ + apiKey: process.env.BL_API_KEY, + workspace: process.env.BL_WORKSPACE, + config: { name: "voltagent-prod", region: "us-pdx-1" }, + }), +}); +``` + +Supports streaming stdout/stderr, per-call timeouts and `AbortSignal`, output truncation, and lazy provisioning. Reach the underlying Blaxel SDK directly via `sandbox.getSandbox()` when you need provider-specific APIs (filesystem, previews, sessions, etc.). diff --git a/website/docs/workspaces/filesystem.md b/website/docs/workspaces/filesystem.md index 24f3307c9..6cef9c84c 100644 --- a/website/docs/workspaces/filesystem.md +++ b/website/docs/workspaces/filesystem.md @@ -5,8 +5,9 @@ slug: /workspaces/filesystem # Workspace Filesystem -> **Note: Workspace Filesystem is Experimental** -> The Workspace API is experimental. Expect iteration and possible breaking changes as we refine the API. +:::warning Experimental +The Workspace API is experimental. Expect iteration and possible breaking changes as we refine the API. +::: Workspace filesystem provides a persistent (or in-memory) file layer for agents. Use it to store notes, datasets, and intermediate outputs. diff --git a/website/docs/workspaces/overview.md b/website/docs/workspaces/overview.md index c38ac4ce8..2a830031d 100644 --- a/website/docs/workspaces/overview.md +++ b/website/docs/workspaces/overview.md @@ -5,8 +5,9 @@ slug: /workspaces # Workspace -> **Note: Workspace is Experimental** -> The Workspace API is experimental. Expect iteration and possible breaking changes as we refine the API. +:::warning Experimental +The Workspace API is experimental. Expect iteration and possible breaking changes as we refine the API. +::: Workspace gives agents a persistent home base with filesystem, sandbox execution, search, and skills. It keeps tool usage structured and observable while staying configurable per agent or conversation. diff --git a/website/docs/workspaces/sandbox.md b/website/docs/workspaces/sandbox.md index 7fe1baa01..f2a08c90d 100644 --- a/website/docs/workspaces/sandbox.md +++ b/website/docs/workspaces/sandbox.md @@ -5,10 +5,13 @@ slug: /workspaces/sandbox # Workspace Sandbox -> **Note: Workspace Sandbox is Experimental** -> The Workspace API is experimental. Expect iteration and possible breaking changes as we refine the API. +:::warning Experimental +The Workspace API is experimental. Expect iteration and possible breaking changes as we refine the API. +::: -The sandbox toolkit adds `execute_command` with timeout/env/cwd control and automatic stdout/stderr eviction when output is large. +A sandbox is an isolated environment where an agent can run shell commands without touching the host. Usually a container, a remote VM, or an OS-level jail. VoltAgent reaches them through the `WorkspaceSandbox` interface. First-party providers exist for [Blaxel](#blaxel), [Daytona](#daytona), and [E2B](#e2b), plus `LocalSandbox` for running things locally. + +Agents interact with the sandbox through a tool called `execute_command`. They pass a command (plus optional env vars, working directory, and timeout), and the workspace runs it in the sandbox and returns the result. Large stdout or stderr gets truncated so the model doesn't drown in logs. ## LocalSandbox basics @@ -107,30 +110,35 @@ By default, LocalSandbox passes only `PATH` into the environment. Set `inheritPr ## Remote sandbox providers -Install the provider package you need (for example `@voltagent/sandbox-e2b`, `@voltagent/sandbox-daytona`, or `@voltagent/sandbox-blaxel`), then configure it on the workspace: +Every provider implements `WorkspaceSandbox`, so the workspace toolkit drives them all the same way. Need something the abstraction doesn't expose? `sandbox.getSandbox()` returns the underlying client. -```ts -import { E2BSandbox } from "@voltagent/sandbox-e2b"; -import { DaytonaSandbox } from "@voltagent/sandbox-daytona"; +### Available providers -const workspace = new Workspace({ - sandbox: new E2BSandbox({ - apiKey: process.env.E2B_API_KEY, - }), -}); +| Provider | Package | Upstream docs | +| -------- | ---------------------------- | ---------------------------------------------- | +| Blaxel | `@voltagent/sandbox-blaxel` | [docs.blaxel.ai](https://docs.blaxel.ai) | +| Daytona | `@voltagent/sandbox-daytona` | [daytona.io/docs](https://www.daytona.io/docs) | +| E2B | `@voltagent/sandbox-e2b` | [e2b.dev/docs](https://e2b.dev/docs) | -const daytonaWorkspace = new Workspace({ - sandbox: new DaytonaSandbox({ - apiKey: process.env.DAYTONA_API_KEY, - apiUrl: "http://localhost:3000", - }), -}); +### Blaxel + +Cloud sandbox runtime with multi-region edge presence. Pre-warms HTTP/2 connections so the first `execute()` doesn't pay a cold-start penalty. Built on [`@blaxel/core`](https://www.npmjs.com/package/@blaxel/core). + +Install: + +```bash +pnpm add @voltagent/sandbox-blaxel ``` +_Pulls in `@blaxel/core` automatically. No separate install._ + +Configure it on a workspace: + ```ts +import { Workspace } from "@voltagent/core"; import { BlaxelSandbox } from "@voltagent/sandbox-blaxel"; -const blaxelWorkspace = new Workspace({ +const workspace = new Workspace({ sandbox: new BlaxelSandbox({ apiKey: process.env.BL_API_KEY, workspace: process.env.BL_WORKSPACE, @@ -139,34 +147,7 @@ const blaxelWorkspace = new Workspace({ }); ``` -If you need provider-specific APIs, keep a reference to the provider and access its native SDK instance: - -```ts -import { E2BSandbox } from "@voltagent/sandbox-e2b"; - -const sandbox = new E2BSandbox({ - apiKey: process.env.E2B_API_KEY, -}); - -const workspace = new Workspace({ sandbox }); - -const e2bSandbox = await sandbox.getSandbox(); -const bytes = await e2bSandbox.files.read("/workspace/file.txt", { format: "bytes" }); -``` - -```ts -import { DaytonaSandbox } from "@voltagent/sandbox-daytona"; - -const sandbox = new DaytonaSandbox({ - apiKey: process.env.DAYTONA_API_KEY, - apiUrl: "http://localhost:3000", -}); - -const workspace = new Workspace({ sandbox }); - -const daytonaSandbox = await sandbox.getSandbox(); -const response = await daytonaSandbox.process.executeCommand("ls -la"); -``` +When you want filesystem, previews, or sessions APIs, grab the underlying client: ```ts import { BlaxelSandbox } from "@voltagent/sandbox-blaxel"; @@ -183,9 +164,7 @@ const blaxelSandbox = await sandbox.getSandbox(); const file = await blaxelSandbox.fs.read("/workspace/file.txt"); ``` -## Custom sandbox provider - -You can implement `WorkspaceSandbox` and plug it into `Workspace` directly. +Multi-tenant routing: one Blaxel sandbox per tenant, picked from `operationContext`. ```ts import type { @@ -194,78 +173,90 @@ import type { WorkspaceSandboxResult, } from "@voltagent/core"; import { Workspace } from "@voltagent/core"; +import { BlaxelSandbox } from "@voltagent/sandbox-blaxel"; -class CustomSandbox implements WorkspaceSandbox { - name = "custom"; +class TenantBlaxelSandboxRouter implements WorkspaceSandbox { + name = "tenant-blaxel-router"; status = "ready" as const; + // In production, add LRU/TTL eviction here and dispose evicted sandboxes + // (for example via stop/destroy) to avoid unbounded per-tenant growth. + private readonly sandboxes = new Map(); getInfo() { - return { provider: "custom-sandbox", status: this.status }; + return { + provider: "tenant-blaxel-router", + status: this.status, + sandboxCount: this.sandboxes.size, + }; } - async execute(options: WorkspaceSandboxExecuteOptions): Promise { - const start = Date.now(); - // TODO: run command in your custom environment - // Respect options.timeoutMs, options.signal, and stream via onStdout/onStderr when possible. + private getSandboxForTenant(tenantId: string): BlaxelSandbox { + let sandbox = this.sandboxes.get(tenantId); + if (!sandbox) { + sandbox = new BlaxelSandbox({ + apiKey: process.env.BL_API_KEY, + workspace: process.env.BL_WORKSPACE, + config: { name: `tenant-${tenantId}` }, + }); + this.sandboxes.set(tenantId, sandbox); + } + return sandbox; + } - return { - stdout: "", - stderr: "", - exitCode: 0, - durationMs: Date.now() - start, - timedOut: false, - aborted: false, - stdoutTruncated: false, - stderrTruncated: false, - }; + async execute(options: WorkspaceSandboxExecuteOptions): Promise { + const tenantId = String(options.operationContext?.context.get("tenantId") ?? "default"); + return this.getSandboxForTenant(tenantId).execute(options); } } const workspace = new Workspace({ - sandbox: new CustomSandbox(), + sandbox: new TenantBlaxelSandboxRouter(), }); ``` -## Access runtime context in custom sandboxes +### Daytona -When `execute_command` runs through the workspace sandbox toolkit, VoltAgent forwards the current operation context to your sandbox as `options.operationContext`. +Sandbox plus dev-environment platform. Hosted or self-hosted. Built on [`@daytonaio/sdk`](https://www.npmjs.com/package/@daytonaio/sdk). -This lets you build custom routing, such as tenant-aware sandbox selection. +Install: -```ts -import type { - WorkspaceSandbox, - WorkspaceSandboxExecuteOptions, - WorkspaceSandboxResult, -} from "@voltagent/core"; +```bash +pnpm add @voltagent/sandbox-daytona +``` -class TenantAwareSandbox implements WorkspaceSandbox { - name = "tenant-aware"; - status = "ready" as const; +_Pulls in `@daytonaio/sdk` automatically. No separate install._ - async execute(options: WorkspaceSandboxExecuteOptions): Promise { - const tenantId = String(options.operationContext?.context.get("tenantId") ?? "default"); +Configure it on a workspace: - // Route by tenant (for example: separate container/session per tenant). - // Implement your own provider lookup here. - const start = Date.now(); - return { - stdout: `running for tenant ${tenantId}`, - stderr: "", - exitCode: 0, - durationMs: Date.now() - start, - timedOut: false, - aborted: false, - stdoutTruncated: false, - stderrTruncated: false, - }; - } -} +```ts +import { Workspace } from "@voltagent/core"; +import { DaytonaSandbox } from "@voltagent/sandbox-daytona"; + +const workspace = new Workspace({ + sandbox: new DaytonaSandbox({ + apiKey: process.env.DAYTONA_API_KEY, + apiUrl: "http://localhost:3000", + }), +}); ``` -If you call `workspace.sandbox.execute(...)` directly (outside the toolkit), pass `operationContext` yourself if you need it. +For Daytona-specific APIs, grab the underlying client: -### Tenant-aware E2B router example +```ts +import { DaytonaSandbox } from "@voltagent/sandbox-daytona"; + +const sandbox = new DaytonaSandbox({ + apiKey: process.env.DAYTONA_API_KEY, + apiUrl: "http://localhost:3000", +}); + +const workspace = new Workspace({ sandbox }); + +const daytonaSandbox = await sandbox.getSandbox(); +const response = await daytonaSandbox.process.executeCommand("ls -la"); +``` + +Multi-tenant routing: one Daytona sandbox per tenant, dispatched via `operationContext`. ```ts import type { @@ -274,30 +265,31 @@ import type { WorkspaceSandboxResult, } from "@voltagent/core"; import { Workspace } from "@voltagent/core"; -import { E2BSandbox } from "@voltagent/sandbox-e2b"; +import { DaytonaSandbox } from "@voltagent/sandbox-daytona"; -class TenantE2BSandboxRouter implements WorkspaceSandbox { - name = "tenant-e2b-router"; +class TenantDaytonaSandboxRouter implements WorkspaceSandbox { + name = "tenant-daytona-router"; status = "ready" as const; // In production, add LRU/TTL eviction here and dispose evicted sandboxes // (for example via stop/destroy) to avoid unbounded per-tenant growth. - private readonly sandboxes = new Map(); + private readonly sandboxes = new Map(); getInfo() { return { - provider: "tenant-e2b-router", + provider: "tenant-daytona-router", status: this.status, sandboxCount: this.sandboxes.size, }; } - private getSandboxForTenant(tenantId: string): E2BSandbox { + private getSandboxForTenant(tenantId: string): DaytonaSandbox { let sandbox = this.sandboxes.get(tenantId); if (!sandbox) { - sandbox = new E2BSandbox({ - apiKey: process.env.E2B_API_KEY, - // Example strategy: map tenant to a template/session naming scheme - template: `tenant-${tenantId}`, + sandbox = new DaytonaSandbox({ + apiKey: process.env.DAYTONA_API_KEY, + apiUrl: process.env.DAYTONA_API_URL, + // Example strategy: pass tenant metadata to your Daytona create params + createParams: { name: `tenant-${tenantId}` }, }); this.sandboxes.set(tenantId, sandbox); } @@ -311,11 +303,51 @@ class TenantE2BSandboxRouter implements WorkspaceSandbox { } const workspace = new Workspace({ - sandbox: new TenantE2BSandboxRouter(), + sandbox: new TenantDaytonaSandboxRouter(), +}); +``` + +### E2B + +Cloud sandboxes built for AI agent workloads: code interpreters, browser automation, that kind of thing. Built on [`e2b`](https://www.npmjs.com/package/e2b). + +Install: + +```bash +pnpm add @voltagent/sandbox-e2b +``` + +_Pulls in `e2b` automatically. No separate install._ + +Configure it on a workspace: + +```ts +import { Workspace } from "@voltagent/core"; +import { E2BSandbox } from "@voltagent/sandbox-e2b"; + +const workspace = new Workspace({ + sandbox: new E2BSandbox({ + apiKey: process.env.E2B_API_KEY, + }), }); ``` -### Tenant-aware Daytona router example +For E2B-specific APIs (filesystem, code interpreter sessions, etc.), grab the underlying client: + +```ts +import { E2BSandbox } from "@voltagent/sandbox-e2b"; + +const sandbox = new E2BSandbox({ + apiKey: process.env.E2B_API_KEY, +}); + +const workspace = new Workspace({ sandbox }); + +const e2bSandbox = await sandbox.getSandbox(); +const bytes = await e2bSandbox.files.read("/workspace/file.txt", { format: "bytes" }); +``` + +Multi-tenant routing: one E2B sandbox per tenant, keyed on `operationContext`. ```ts import type { @@ -324,31 +356,30 @@ import type { WorkspaceSandboxResult, } from "@voltagent/core"; import { Workspace } from "@voltagent/core"; -import { DaytonaSandbox } from "@voltagent/sandbox-daytona"; +import { E2BSandbox } from "@voltagent/sandbox-e2b"; -class TenantDaytonaSandboxRouter implements WorkspaceSandbox { - name = "tenant-daytona-router"; +class TenantE2BSandboxRouter implements WorkspaceSandbox { + name = "tenant-e2b-router"; status = "ready" as const; // In production, add LRU/TTL eviction here and dispose evicted sandboxes // (for example via stop/destroy) to avoid unbounded per-tenant growth. - private readonly sandboxes = new Map(); + private readonly sandboxes = new Map(); getInfo() { return { - provider: "tenant-daytona-router", + provider: "tenant-e2b-router", status: this.status, sandboxCount: this.sandboxes.size, }; } - private getSandboxForTenant(tenantId: string): DaytonaSandbox { + private getSandboxForTenant(tenantId: string): E2BSandbox { let sandbox = this.sandboxes.get(tenantId); if (!sandbox) { - sandbox = new DaytonaSandbox({ - apiKey: process.env.DAYTONA_API_KEY, - apiUrl: process.env.DAYTONA_API_URL, - // Example strategy: pass tenant metadata to your Daytona create params - createParams: { name: `tenant-${tenantId}` }, + sandbox = new E2BSandbox({ + apiKey: process.env.E2B_API_KEY, + // Example strategy: map tenant to a template/session naming scheme + template: `tenant-${tenantId}`, }); this.sandboxes.set(tenantId, sandbox); } @@ -362,11 +393,13 @@ class TenantDaytonaSandboxRouter implements WorkspaceSandbox { } const workspace = new Workspace({ - sandbox: new TenantDaytonaSandboxRouter(), + sandbox: new TenantE2BSandboxRouter(), }); ``` -### Tenant-aware Blaxel router example +## Custom sandbox provider + +You can implement `WorkspaceSandbox` and plug it into `Workspace` directly. ```ts import type { @@ -375,47 +408,79 @@ import type { WorkspaceSandboxResult, } from "@voltagent/core"; import { Workspace } from "@voltagent/core"; -import { BlaxelSandbox } from "@voltagent/sandbox-blaxel"; -class TenantBlaxelSandboxRouter implements WorkspaceSandbox { - name = "tenant-blaxel-router"; +class CustomSandbox implements WorkspaceSandbox { + name = "custom"; status = "ready" as const; - // In production, add LRU/TTL eviction here and dispose evicted sandboxes - // (for example via stop/destroy) to avoid unbounded per-tenant growth. - private readonly sandboxes = new Map(); getInfo() { + return { provider: "custom-sandbox", status: this.status }; + } + + async execute(options: WorkspaceSandboxExecuteOptions): Promise { + const start = Date.now(); + // TODO: run command in your custom environment + // Respect options.timeoutMs, options.signal, and stream via onStdout/onStderr when possible. + return { - provider: "tenant-blaxel-router", - status: this.status, - sandboxCount: this.sandboxes.size, + stdout: "", + stderr: "", + exitCode: 0, + durationMs: Date.now() - start, + timedOut: false, + aborted: false, + stdoutTruncated: false, + stderrTruncated: false, }; } +} - private getSandboxForTenant(tenantId: string): BlaxelSandbox { - let sandbox = this.sandboxes.get(tenantId); - if (!sandbox) { - sandbox = new BlaxelSandbox({ - apiKey: process.env.BL_API_KEY, - workspace: process.env.BL_WORKSPACE, - config: { name: `tenant-${tenantId}` }, - }); - this.sandboxes.set(tenantId, sandbox); - } - return sandbox; - } +const workspace = new Workspace({ + sandbox: new CustomSandbox(), +}); +``` + +## Access runtime context in custom sandboxes + +When `execute_command` runs through the workspace sandbox toolkit, VoltAgent forwards the current operation context to your sandbox as `options.operationContext`. + +This lets you build custom routing, such as tenant-aware sandbox selection. + +```ts +import type { + WorkspaceSandbox, + WorkspaceSandboxExecuteOptions, + WorkspaceSandboxResult, +} from "@voltagent/core"; + +class TenantAwareSandbox implements WorkspaceSandbox { + name = "tenant-aware"; + status = "ready" as const; async execute(options: WorkspaceSandboxExecuteOptions): Promise { const tenantId = String(options.operationContext?.context.get("tenantId") ?? "default"); - return this.getSandboxForTenant(tenantId).execute(options); + + // Route by tenant (for example: separate container/session per tenant). + // Implement your own provider lookup here. + const start = Date.now(); + return { + stdout: `running for tenant ${tenantId}`, + stderr: "", + exitCode: 0, + durationMs: Date.now() - start, + timedOut: false, + aborted: false, + stdoutTruncated: false, + stderrTruncated: false, + }; } } - -const workspace = new Workspace({ - sandbox: new TenantBlaxelSandboxRouter(), -}); ``` +If you call `workspace.sandbox.execute(...)` directly (outside the toolkit), pass `operationContext` yourself if you need it. + +For full per-provider tenant routing examples, see the [Remote sandbox providers](#remote-sandbox-providers) section above. + Notes: - `onStdout`/`onStderr` are optional streaming hooks for UI integration. diff --git a/website/docs/workspaces/search.md b/website/docs/workspaces/search.md index 63f2d138b..736b4a005 100644 --- a/website/docs/workspaces/search.md +++ b/website/docs/workspaces/search.md @@ -5,8 +5,9 @@ slug: /workspaces/search # Workspace Search -> **Note: Workspace Search is Experimental** -> The Workspace API is experimental. Expect iteration and possible breaking changes as we refine the API. +:::warning Experimental +The Workspace API is experimental. Expect iteration and possible breaking changes as we refine the API. +::: Workspace search supports BM25, vector, and hybrid modes over indexed workspace content. diff --git a/website/docs/workspaces/security.md b/website/docs/workspaces/security.md index fb87d4df6..9a9262b6c 100644 --- a/website/docs/workspaces/security.md +++ b/website/docs/workspaces/security.md @@ -5,8 +5,9 @@ slug: /workspaces/security # Workspace Security -> **Note: Workspace Security is Experimental** -> The Workspace API is experimental. Expect iteration and possible breaking changes as we refine the API. +:::warning Experimental +The Workspace API is experimental. Expect iteration and possible breaking changes as we refine the API. +::: Workspace is powerful; treat it like a production capability. Recommended practices: diff --git a/website/docs/workspaces/skills.md b/website/docs/workspaces/skills.md index 52a1fedc1..7c4b0392c 100644 --- a/website/docs/workspaces/skills.md +++ b/website/docs/workspaces/skills.md @@ -5,8 +5,9 @@ slug: /workspaces/skills # Workspace Skills -> **Note: Workspace Skills are Experimental** -> The Skills API is experimental and may change as the ecosystem evolves. +:::warning Experimental +The Skills API is experimental and may change as the ecosystem evolves. +::: Workspace skills are reusable instructions stored as `SKILL.md` files. They can be discovered, searched, activated, and injected into the agent prompt. From 1552c3ecf05195374b9110298193a33f9441592c Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Sat, 9 May 2026 19:24:42 -0400 Subject: [PATCH 4/7] fix(sandbox-blaxel): address PR review feedback - Test env hygiene: snapshot BL_API_KEY/BL_WORKSPACE/BL_REGION at file load and restore them in afterEach so tests don't leak inherited env state to other workers. - Document the env-mutation auth model on BlaxelSandboxOptions, the constructor, and as an :::info admonition under the Blaxel docs section, with a link to the upstream Blaxel auth docs. - runProcess: only treat wait() rejections that match "did not finish in time" as timeouts; re-throw other rejections so real failures (network, teardown) aren't masked. Adds an isWaitTimeoutError helper. - truncateOutput: walk the cut point back to a UTF-8 codepoint boundary so the result is always valid UTF-8 with byte length <= maxBytes. Strengthen the spec to assert exact value, byte length, and round-trip validity; add a 4-byte-doesn't-fit edge case. - execute(): re-check options.signal?.aborted after resolveSandbox() returns, so a signal that fires during provisioning bails before starting a process. Extracts an abortedResult helper. - Tenant router doc examples (Blaxel/Daytona/E2B): add destroy() so copy-pasted code disposes per-tenant sandboxes. - Test plumbing: replace fragile microtask-counting in the abort mid-flight test with a setImmediate yield. --- packages/sandbox-blaxel/src/sandbox.spec.ts | 62 +++++++++++++++++- packages/sandbox-blaxel/src/sandbox.ts | 71 ++++++++++++++++----- packages/sandbox-blaxel/src/types.ts | 14 +++- packages/sandbox-blaxel/src/utils.spec.ts | 21 +++++- packages/sandbox-blaxel/src/utils.ts | 13 +++- website/docs/workspaces/sandbox.md | 22 +++++++ 6 files changed, 179 insertions(+), 24 deletions(-) diff --git a/packages/sandbox-blaxel/src/sandbox.spec.ts b/packages/sandbox-blaxel/src/sandbox.spec.ts index c0deffdb3..7cf467b3e 100644 --- a/packages/sandbox-blaxel/src/sandbox.spec.ts +++ b/packages/sandbox-blaxel/src/sandbox.spec.ts @@ -131,6 +131,24 @@ function spyCreate(instance: BlaxelSandboxInstance) { .mockResolvedValue(instance as unknown as InstanceType); } +// Snapshot the inherited env values once so each test can restore them and we +// don't leak BL_* mutations across tests (or to other workers in shared runs). +const ORIGINAL_ENV = { + BL_API_KEY: process.env.BL_API_KEY, + BL_WORKSPACE: process.env.BL_WORKSPACE, + BL_REGION: process.env.BL_REGION, +} as const; + +function restoreEnv(): void { + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + beforeEach(() => { vi.restoreAllMocks(); // biome-ignore lint/performance/noDelete: removing the property; assigning undefined coerces to string "undefined". @@ -143,6 +161,7 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers(); + restoreEnv(); }); describe("BlaxelSandbox constructor", () => { @@ -322,6 +341,16 @@ describe("BlaxelSandbox.execute (timeout, abort, truncation)", () => { expect(result.stdout).toBe("partial"); }); + it("re-throws non-timeout errors from wait() instead of masking them as timeouts", async () => { + const mock = makeMock(); + mock.waitError = new Error("connection reset by peer"); + const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); + + await expect(sandbox.execute({ command: "ls" })).rejects.toThrow("connection reset by peer"); + // wait() error wasn't a timeout, so kill should not have been invoked. + expect(mock.killed).not.toContain(mock.execCalls[0]?.name); + }); + it("swallows errors thrown by kill() during the timeout path", async () => { const mock = makeMock(); mock.waitError = new Error("Process did not finish in time"); @@ -357,6 +386,32 @@ describe("BlaxelSandbox.execute (timeout, abort, truncation)", () => { expect(mock.execCalls).toHaveLength(0); }); + it("bails before exec() when the AbortSignal fires during sandbox provisioning", async () => { + const mock = makeMock(); + let resolveCreate: (instance: BlaxelSandboxInstance) => void = () => {}; + const pending = new Promise((resolve) => { + resolveCreate = resolve; + }); + vi.spyOn(blaxelCore.SandboxInstance, "createIfNotExists").mockImplementation( + () => pending as ReturnType, + ); + + const controller = new AbortController(); + const sandbox = new BlaxelSandbox({ config: { image: "blaxel/base:latest" } }); + const promise = sandbox.execute({ command: "ls", signal: controller.signal }); + // Yield so the in-flight provisioning starts and we're parked on the + // `await this.resolveSandbox()` inside execute(). + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + // Now resolve provisioning — execute() should detect the abort and bail + // before starting a process. + resolveCreate(mock.instance); + + const result = await promise; + expect(result.aborted).toBe(true); + expect(mock.execCalls).toHaveLength(0); + }); + it("kills the process when the AbortSignal fires mid-flight", async () => { const mock = makeMock(); const controller = new AbortController(); @@ -370,9 +425,10 @@ describe("BlaxelSandbox.execute (timeout, abort, truncation)", () => { const sandbox = new BlaxelSandbox({ sandbox: mock.instance }); const promise = sandbox.execute({ command: "tail", signal: controller.signal }); - // Let exec + wait start - await Promise.resolve(); - await Promise.resolve(); + // Yield to the event loop so the full execute chain reaches `process.exec` + // (signal checks + resolveSandbox + runProcess) before we abort. + await new Promise((resolve) => setImmediate(resolve)); + expect(mock.execCalls.length).toBe(1); controller.abort(); waitResolve(mock.finalState); const result = await promise; diff --git a/packages/sandbox-blaxel/src/sandbox.ts b/packages/sandbox-blaxel/src/sandbox.ts index 532543cfd..5500995a5 100644 --- a/packages/sandbox-blaxel/src/sandbox.ts +++ b/packages/sandbox-blaxel/src/sandbox.ts @@ -54,7 +54,12 @@ export class BlaxelSandbox implements WorkspaceSandbox { /** * The underlying sandbox is lazily created on first `execute()` / - * `getSandbox()`. Sets `BL_API_KEY` / `BL_WORKSPACE` env vars when provided. + * `getSandbox()`. Writes `BL_API_KEY` / `BL_WORKSPACE` to `process.env` when + * provided — this is the only auth path the Blaxel SDK supports, so + * constructing multiple `BlaxelSandbox` instances with different credentials + * in the same process will last-write-win. + * + * See: https://docs.blaxel.ai/Sandboxes/Overview#learn-more-about-authentication-on-blaxel */ constructor(options: BlaxelSandboxOptions = {}) { this.apiKey = options.apiKey; @@ -82,16 +87,7 @@ export class BlaxelSandbox implements WorkspaceSandbox { } if (options.signal?.aborted) { - return { - stdout: "", - stderr: "", - exitCode: null, - durationMs: 0, - timedOut: false, - aborted: true, - stdoutTruncated: false, - stderrTruncated: false, - }; + return abortedResult(0); } const processName = `voltagent-${randomUUID()}`; @@ -102,6 +98,14 @@ export class BlaxelSandbox implements WorkspaceSandbox { void this.killProcess({ processName }); }, run: async () => { + // Provisioning can take seconds. Check the signal after it completes + // so we don't start a process for a call that was cancelled mid-flight + // (covers both the listener-attach race and abort-during-provisioning). + await this.resolveSandbox(); + if (options.signal?.aborted) { + return abortedResult(Date.now() - startTime); + } + const { timedOut } = await this.runProcess({ parsed, processName, @@ -213,18 +217,24 @@ export class BlaxelSandbox implements WorkspaceSandbox { onStderr: options.onStderr, }); try { - // wait() throws "Process did not finish in time" on timeout. + // wait() throws "Process did not finish in time" on timeout. Other + // rejections (network failures, SDK teardown, etc.) are real errors and + // must surface to the caller — only the timeout-message match is + // treated as `timedOut: true`. const [waitError] = await attemptAsync(() => { return sandbox.process.wait(processName, { maxWait: timeoutMs > 0 ? timeoutMs : NO_TIMEOUT_MAX_WAIT_MS, interval: pollIntervalMs, }); }); - const timedOut = isNotNil(waitError); - if (timedOut) { + if (isNotNil(waitError)) { + if (!isWaitTimeoutError(waitError)) { + throw waitError; + } await this.killProcess({ processName }); + return { timedOut: true }; } - return { timedOut }; + return { timedOut: false }; } finally { if ("close" in started) { attempt(() => started.close()); @@ -362,3 +372,34 @@ async function withAbort({ run, }); } + +/** + * Is this error the SDK's "wait exceeded `maxWait`" timeout signal? The SDK + * surfaces it as a plain `Error` with the message "Process did not finish in + * time" — anything else is a real failure (network, teardown, malformed + * response) and must propagate. + * + * @private + */ +function isWaitTimeoutError(error: unknown): boolean { + return error instanceof Error && /did not finish in time/i.test(error.message); +} + +/** + * Empty `aborted: true` result returned when the call's `AbortSignal` fires + * before the sandbox process is started. + * + * @private + */ +function abortedResult(durationMs: number): WorkspaceSandboxResult { + return { + stdout: "", + stderr: "", + exitCode: null, + durationMs, + timedOut: false, + aborted: true, + stdoutTruncated: false, + stderrTruncated: false, + }; +} diff --git a/packages/sandbox-blaxel/src/types.ts b/packages/sandbox-blaxel/src/types.ts index 28088bed6..88eb02134 100644 --- a/packages/sandbox-blaxel/src/types.ts +++ b/packages/sandbox-blaxel/src/types.ts @@ -35,14 +35,24 @@ export interface BlaxelSandboxConfig extends SandboxCreateConfiguration { /** * Public constructor options for {@link BlaxelSandbox}. + * + * **Authentication note:** `apiKey` and `workspace` are written to + * `process.env.BL_API_KEY` / `process.env.BL_WORKSPACE` because that is the + * only auth path the Blaxel SDK supports. Credentials resolve through a + * module-level singleton — constructing two `BlaxelSandbox` instances with + * different credentials in the same process will last-write-win. + * + * See: https://docs.blaxel.ai/Sandboxes/Overview#learn-more-about-authentication-on-blaxel */ export interface BlaxelSandboxOptions { /** - * Blaxel API key. Automatically sets `process.env.BL_API_KEY` when provided. + * Blaxel API key. Written to `process.env.BL_API_KEY` when provided + * (the only auth path the Blaxel SDK supports — see interface docs). */ apiKey?: string; /** - * Blaxel workspace ID. Automatically sets `process.env.BL_WORKSPACE` when provided. + * Blaxel workspace ID. Written to `process.env.BL_WORKSPACE` when provided + * (the only auth path the Blaxel SDK supports — see interface docs). */ workspace?: string; /** diff --git a/packages/sandbox-blaxel/src/utils.spec.ts b/packages/sandbox-blaxel/src/utils.spec.ts index d1ddfbf6c..b2087923e 100644 --- a/packages/sandbox-blaxel/src/utils.spec.ts +++ b/packages/sandbox-blaxel/src/utils.spec.ts @@ -58,7 +58,26 @@ describe("truncateOutput", () => { // "é" is 2 bytes in UTF-8. const input = "éééé"; // 8 bytes const { content, truncated } = truncateOutput(input, 4); - expect(Buffer.byteLength(content, "utf-8")).toBeLessThanOrEqual(4); + expect(content).toBe("éé"); + expect(Buffer.byteLength(content, "utf-8")).toBe(4); + expect(truncated).toBe(true); + // Result round-trips as valid UTF-8 (no split codepoints). + expect(Buffer.from(content, "utf-8").toString("utf-8")).toBe(content); + }); + + it("walks back to a codepoint boundary instead of splitting mid-byte", () => { + // "h" = 1 byte, "é" = 2 bytes. maxBytes=2 would land inside "é"; the cut + // point should walk back to just "h". + const { content, truncated } = truncateOutput("hé", 2); + expect(content).toBe("h"); + expect(truncated).toBe(true); + }); + + it("handles a 4-byte codepoint that doesn't fit by walking back to empty", () => { + // U+1F600 GRINNING FACE = 4 bytes. With maxBytes=3 there is no valid + // prefix that ends on a codepoint boundary, so the result is empty. + const { content, truncated } = truncateOutput("😀", 3); + expect(content).toBe(""); expect(truncated).toBe(true); }); }); diff --git a/packages/sandbox-blaxel/src/utils.ts b/packages/sandbox-blaxel/src/utils.ts index a0f2e645f..ca0235d08 100644 --- a/packages/sandbox-blaxel/src/utils.ts +++ b/packages/sandbox-blaxel/src/utils.ts @@ -37,8 +37,9 @@ export async function withEventListener({ } /** - * Truncate a UTF-8 string to at most `maxBytes` bytes (not characters). - * A trailing partial multi-byte sequence may render as a replacement character. + * Truncate a UTF-8 string to at most `maxBytes` bytes. The cut point is walked + * back to the nearest codepoint boundary so the result is always valid UTF-8 + * (and its byte length is always `<= maxBytes`, never more). */ export function truncateOutput( value: string, @@ -55,5 +56,11 @@ export function truncateOutput( return { content: value, truncated: false }; } const data = Buffer.from(value, "utf-8"); - return { content: data.subarray(0, maxBytes).toString("utf-8"), truncated: true }; + // UTF-8 continuation bytes match `0b10xxxxxx` (`byte & 0xc0 === 0x80`). Walk + // the cut point back over them so we never slice mid-codepoint. + let end = maxBytes; + while (end > 0 && (data[end] & 0xc0) === 0x80) { + end--; + } + return { content: data.subarray(0, end).toString("utf-8"), truncated: true }; } diff --git a/website/docs/workspaces/sandbox.md b/website/docs/workspaces/sandbox.md index f2a08c90d..9ffa727b4 100644 --- a/website/docs/workspaces/sandbox.md +++ b/website/docs/workspaces/sandbox.md @@ -124,6 +124,10 @@ Every provider implements `WorkspaceSandbox`, so the workspace toolkit drives th Cloud sandbox runtime with multi-region edge presence. Pre-warms HTTP/2 connections so the first `execute()` doesn't pay a cold-start penalty. Built on [`@blaxel/core`](https://www.npmjs.com/package/@blaxel/core). +:::info Authentication +`apiKey` and `workspace` passed to `BlaxelSandbox` are written to `process.env.BL_API_KEY` / `process.env.BL_WORKSPACE` — the only auth path the Blaxel SDK supports. Credentials resolve through a module-level singleton, so constructing multiple `BlaxelSandbox` instances with different credentials in the same process will last-write-win. See [Blaxel auth docs](https://docs.blaxel.ai/Sandboxes/Overview#learn-more-about-authentication-on-blaxel). +::: + Install: ```bash @@ -207,6 +211,12 @@ class TenantBlaxelSandboxRouter implements WorkspaceSandbox { const tenantId = String(options.operationContext?.context.get("tenantId") ?? "default"); return this.getSandboxForTenant(tenantId).execute(options); } + + async destroy(): Promise { + const pending = Array.from(this.sandboxes.values()).map((s) => s.destroy()); + this.sandboxes.clear(); + await Promise.allSettled(pending); + } } const workspace = new Workspace({ @@ -300,6 +310,12 @@ class TenantDaytonaSandboxRouter implements WorkspaceSandbox { const tenantId = String(options.operationContext?.context.get("tenantId") ?? "default"); return this.getSandboxForTenant(tenantId).execute(options); } + + async destroy(): Promise { + const pending = Array.from(this.sandboxes.values()).map((s) => s.destroy()); + this.sandboxes.clear(); + await Promise.allSettled(pending); + } } const workspace = new Workspace({ @@ -390,6 +406,12 @@ class TenantE2BSandboxRouter implements WorkspaceSandbox { const tenantId = String(options.operationContext?.context.get("tenantId") ?? "default"); return this.getSandboxForTenant(tenantId).execute(options); } + + async destroy(): Promise { + const pending = Array.from(this.sandboxes.values()).map((s) => s.destroy()); + this.sandboxes.clear(); + await Promise.allSettled(pending); + } } const workspace = new Workspace({ From 8b84d0a07aca951d3268d329a002138d1905eeba Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Sat, 9 May 2026 20:11:47 -0400 Subject: [PATCH 5/7] fix(sandbox-blaxel): close destroy/abort races during execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread the resolved sandbox through one execute() call so a concurrent destroy() can't cause re-entrant resolveSandbox() calls in runProcess / fetchProcessOutput / killProcess to provision a fresh sandbox and operate on the wrong instance. Add a post-exec abort check inside runProcess to handle the case where the abort listener fires while exec() is in flight — its kill lands before the remote process exists, then exec() leaves the just-launched process running unchecked. Matches the post-run check in the e2b provider. Kill failures during timeout cleanup remain swallowed to stay consistent with the e2b provider (`requestKill().catch(() => undefined)`); callers see `timedOut: true` and can `destroy()` if they suspect the remote process is still running. --- packages/sandbox-blaxel/src/sandbox.ts | 49 ++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/sandbox-blaxel/src/sandbox.ts b/packages/sandbox-blaxel/src/sandbox.ts index 5500995a5..a749f2c87 100644 --- a/packages/sandbox-blaxel/src/sandbox.ts +++ b/packages/sandbox-blaxel/src/sandbox.ts @@ -91,27 +91,39 @@ export class BlaxelSandbox implements WorkspaceSandbox { } const processName = `voltagent-${randomUUID()}`; + // Captured once after resolveSandbox() and threaded through every + // sub-operation in this execute() so a concurrent destroy() can't + // resurrect a fresh sandbox via re-entrant resolveSandbox() calls. + let resolvedSandbox: BlaxelSandboxInstance | undefined; return await withAbort({ signal: options.signal, onAbort: () => { - void this.killProcess({ processName }); + // Skip if the sandbox hasn't resolved yet — there is no remote + // process to kill, and re-resolving here would defeat the threading. + if (resolvedSandbox) { + void this.killProcess({ sandbox: resolvedSandbox, processName }); + } }, run: async () => { // Provisioning can take seconds. Check the signal after it completes // so we don't start a process for a call that was cancelled mid-flight // (covers both the listener-attach race and abort-during-provisioning). - await this.resolveSandbox(); + const sandbox = await this.resolveSandbox(); + resolvedSandbox = sandbox; if (options.signal?.aborted) { return abortedResult(Date.now() - startTime); } const { timedOut } = await this.runProcess({ + sandbox, parsed, processName, + signal: options.signal, options, }); const output = await this.fetchProcessOutput({ + sandbox, processName, maxOutputBytes: parsed.maxOutputBytes, }); @@ -170,13 +182,18 @@ export class BlaxelSandbox implements WorkspaceSandbox { * Best-effort kill of a process inside the sandbox by name. Errors are * swallowed via `attemptAsync` — kill is wired to abort listeners and timeout * paths where we can't surface a failure to the caller. + * + * Takes the sandbox as a param (rather than calling `resolveSandbox()`) + * so concurrent `destroy()` can't cause this kill to land on a freshly + * provisioned sandbox instead of the one that owns `processName`. */ private async killProcess({ + sandbox, processName, }: { + sandbox: BlaxelSandboxInstance; processName: string; }): Promise<{ status: "success" | "failure"; error?: Error }> { - const sandbox = await this.resolveSandbox(); const [err] = await attemptAsync(() => { return sandbox.process.kill(processName); }); @@ -194,18 +211,26 @@ export class BlaxelSandbox implements WorkspaceSandbox { * Caller owns abort wiring (see {@link withAbort}). On `wait()` rejection we * treat it as a timeout, fire {@link killProcess}, and surface `{ timedOut: true }`. * + * Re-checks `signal.aborted` immediately after `exec()` returns to close the + * window where the abort listener fires while exec is in-flight (its kill + * lands before the remote process exists) and `exec()` then leaves the + * just-launched process running unchecked. + * * @returns `{ timedOut }` — `true` iff `wait()` rejected. */ private async runProcess({ + sandbox, parsed, processName, + signal, options, }: { + sandbox: BlaxelSandboxInstance; parsed: ParsedExecuteOptions; processName: string; + signal: AbortSignal | undefined; options: Pick; }): Promise<{ timedOut: boolean }> { - const sandbox = await this.resolveSandbox(); const { command, env, cwd, timeoutMs, pollIntervalMs } = parsed; const started = await sandbox.process.exec({ name: processName, @@ -217,6 +242,13 @@ export class BlaxelSandbox implements WorkspaceSandbox { onStderr: options.onStderr, }); try { + // Late abort: a signal that fired during exec() may have caused the + // listener's kill to land before the remote process existed. Now that + // exec() has returned, fire kill ourselves and skip the wait(). + if (signal?.aborted) { + await this.killProcess({ sandbox, processName }); + return { timedOut: false }; + } // wait() throws "Process did not finish in time" on timeout. Other // rejections (network failures, SDK teardown, etc.) are real errors and // must surface to the caller — only the timeout-message match is @@ -231,7 +263,11 @@ export class BlaxelSandbox implements WorkspaceSandbox { if (!isWaitTimeoutError(waitError)) { throw waitError; } - await this.killProcess({ processName }); + // Best-effort cleanup. We intentionally swallow kill failures here to + // match the e2b provider's posture (e2b: `requestKill().catch(() => undefined)`). + // Callers see `timedOut: true` and can `destroy()` the sandbox if they + // suspect the remote process is still running. + await this.killProcess({ sandbox, processName }); return { timedOut: true }; } return { timedOut: false }; @@ -256,9 +292,11 @@ export class BlaxelSandbox implements WorkspaceSandbox { * exit code rather than throwing. */ private async fetchProcessOutput({ + sandbox, processName, maxOutputBytes, }: { + sandbox: BlaxelSandboxInstance; processName: string; maxOutputBytes: number; }): Promise< @@ -267,7 +305,6 @@ export class BlaxelSandbox implements WorkspaceSandbox { "stdout" | "stderr" | "exitCode" | "stdoutTruncated" | "stderrTruncated" > > { - const sandbox = await this.resolveSandbox(); if (maxOutputBytes <= 0) { const [, finalState] = await attemptAsync(() => { return sandbox.process.get(processName); From 57b160ae8737206536a8057bed379687ae6df1c0 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Sat, 9 May 2026 20:17:15 -0400 Subject: [PATCH 6/7] chore(sandbox-blaxel): trim verbose inline comments Tighten comments added in the previous commit so each is at most one line, in keeping with the codebase's "comments only when the why is non-obvious" posture. --- packages/sandbox-blaxel/src/sandbox.ts | 29 +++++++------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/packages/sandbox-blaxel/src/sandbox.ts b/packages/sandbox-blaxel/src/sandbox.ts index a749f2c87..4caac3d16 100644 --- a/packages/sandbox-blaxel/src/sandbox.ts +++ b/packages/sandbox-blaxel/src/sandbox.ts @@ -91,16 +91,12 @@ export class BlaxelSandbox implements WorkspaceSandbox { } const processName = `voltagent-${randomUUID()}`; - // Captured once after resolveSandbox() and threaded through every - // sub-operation in this execute() so a concurrent destroy() can't - // resurrect a fresh sandbox via re-entrant resolveSandbox() calls. + // Pinned once so a concurrent destroy() can't redirect sub-operations to a fresh sandbox. let resolvedSandbox: BlaxelSandboxInstance | undefined; return await withAbort({ signal: options.signal, onAbort: () => { - // Skip if the sandbox hasn't resolved yet — there is no remote - // process to kill, and re-resolving here would defeat the threading. if (resolvedSandbox) { void this.killProcess({ sandbox: resolvedSandbox, processName }); } @@ -181,11 +177,8 @@ export class BlaxelSandbox implements WorkspaceSandbox { /** * Best-effort kill of a process inside the sandbox by name. Errors are * swallowed via `attemptAsync` — kill is wired to abort listeners and timeout - * paths where we can't surface a failure to the caller. - * - * Takes the sandbox as a param (rather than calling `resolveSandbox()`) - * so concurrent `destroy()` can't cause this kill to land on a freshly - * provisioned sandbox instead of the one that owns `processName`. + * paths where we can't surface a failure to the caller. Takes `sandbox` as a + * param so a concurrent `destroy()` can't reroute the kill to a fresh instance. */ private async killProcess({ sandbox, @@ -210,11 +203,8 @@ export class BlaxelSandbox implements WorkspaceSandbox { * * Caller owns abort wiring (see {@link withAbort}). On `wait()` rejection we * treat it as a timeout, fire {@link killProcess}, and surface `{ timedOut: true }`. - * - * Re-checks `signal.aborted` immediately after `exec()` returns to close the - * window where the abort listener fires while exec is in-flight (its kill - * lands before the remote process exists) and `exec()` then leaves the - * just-launched process running unchecked. + * Re-checks `signal.aborted` after `exec()` to catch aborts that fired while + * exec was in flight (the listener's kill lands before the process exists). * * @returns `{ timedOut }` — `true` iff `wait()` rejected. */ @@ -242,9 +232,7 @@ export class BlaxelSandbox implements WorkspaceSandbox { onStderr: options.onStderr, }); try { - // Late abort: a signal that fired during exec() may have caused the - // listener's kill to land before the remote process existed. Now that - // exec() has returned, fire kill ourselves and skip the wait(). + // Late abort: kill the just-launched process; the listener's kill no-op'd before exec resolved. if (signal?.aborted) { await this.killProcess({ sandbox, processName }); return { timedOut: false }; @@ -263,10 +251,7 @@ export class BlaxelSandbox implements WorkspaceSandbox { if (!isWaitTimeoutError(waitError)) { throw waitError; } - // Best-effort cleanup. We intentionally swallow kill failures here to - // match the e2b provider's posture (e2b: `requestKill().catch(() => undefined)`). - // Callers see `timedOut: true` and can `destroy()` the sandbox if they - // suspect the remote process is still running. + // Swallow kill failures to match other implementations; callers can destroy() if they suspect a leak. await this.killProcess({ sandbox, processName }); return { timedOut: true }; } From 320cf75971ddd9faa5e23a74a036c645eb25dcd4 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Sat, 9 May 2026 20:27:13 -0400 Subject: [PATCH 7/7] refactor(sandbox-blaxel): tighten public API surface and SDK config typing Drop the `SandboxCreateConfiguration` re-export from index.ts. It was unnecessary (BlaxelSandboxConfig already extends it, so consumers get the full SDK shape transitively) and inconsistent with sandbox-daytona and sandbox-e2b, which don't re-export their SDK config types. Consumers who genuinely need the raw SDK type can import it directly from `@blaxel/core`. Annotate `getSdkConfig` with an explicit return type (`Omit`) so the SDK contract is declared at the function boundary and typos in the omit list become a type error instead of a silent runtime no-op. --- packages/sandbox-blaxel/src/index.ts | 1 - packages/sandbox-blaxel/src/sandbox.ts | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/sandbox-blaxel/src/index.ts b/packages/sandbox-blaxel/src/index.ts index ed668662d..fd34aa314 100644 --- a/packages/sandbox-blaxel/src/index.ts +++ b/packages/sandbox-blaxel/src/index.ts @@ -1,4 +1,3 @@ -export type { SandboxCreateConfiguration } from "@blaxel/core"; export { BlaxelSandbox } from "./sandbox"; export type { BlaxelSandboxConfig, diff --git a/packages/sandbox-blaxel/src/sandbox.ts b/packages/sandbox-blaxel/src/sandbox.ts index 4caac3d16..0fea17fb0 100644 --- a/packages/sandbox-blaxel/src/sandbox.ts +++ b/packages/sandbox-blaxel/src/sandbox.ts @@ -359,7 +359,10 @@ export class BlaxelSandbox implements WorkspaceSandbox { * `this.config` with voltagent-specific extras stripped — what gets * forwarded to `SandboxInstance.createIfNotExists()`. */ - private getSdkConfig() { + private getSdkConfig(): Omit< + BlaxelSandboxConfig, + "cwd" | "defaultTimeoutMs" | "maxOutputBytes" | "pollIntervalMs" + > { return omit(this.config ?? {}, ["cwd", "defaultTimeoutMs", "maxOutputBytes", "pollIntervalMs"]); } }