diff --git a/.changeset/sandbox-blaxel.md b/.changeset/sandbox-blaxel.md new file mode 100644 index 000000000..2d0995454 --- /dev/null +++ b/.changeset/sandbox-blaxel.md @@ -0,0 +1,20 @@ +--- +"@voltagent/sandbox-blaxel": minor +--- + +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/.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/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 new file mode 100644 index 000000000..3235484bc --- /dev/null +++ b/packages/sandbox-blaxel/package.json @@ -0,0 +1,52 @@ +{ + "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:coverage": "vitest run --coverage", + "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.ts b/packages/sandbox-blaxel/src/index.ts new file mode 100644 index 000000000..fd34aa314 --- /dev/null +++ b/packages/sandbox-blaxel/src/index.ts @@ -0,0 +1,6 @@ +export { BlaxelSandbox } from "./sandbox"; +export type { + BlaxelSandboxConfig, + BlaxelSandboxInstance, + BlaxelSandboxOptions, +} from "./types"; diff --git a/packages/sandbox-blaxel/src/sandbox.spec.ts b/packages/sandbox-blaxel/src/sandbox.spec.ts new file mode 100644 index 000000000..7cf467b3e --- /dev/null +++ b/packages/sandbox-blaxel/src/sandbox.spec.ts @@ -0,0 +1,772 @@ +import * as blaxelCore from "@blaxel/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BlaxelSandbox, type BlaxelSandboxInstance } from "./index"; + +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); +} + +// 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". + 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(); + restoreEnv(); +}); + +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("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"); + 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("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(); + 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 }); + // 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; + + 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(""); + }); +}); + +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/sandbox.ts b/packages/sandbox-blaxel/src/sandbox.ts new file mode 100644 index 000000000..0fea17fb0 --- /dev/null +++ b/packages/sandbox-blaxel/src/sandbox.ts @@ -0,0 +1,430 @@ +import { randomUUID } from "node:crypto"; +import { SandboxInstance } from "@blaxel/core"; +import type { + WorkspaceSandbox, + WorkspaceSandboxExecuteOptions, + WorkspaceSandboxResult, +} from "@voltagent/core"; +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"; +import { toError, truncateOutput, withEventListener } from "./utils"; + +/** + * VoltAgent workspace sandbox provider backed by `@blaxel/core`. + */ +export class BlaxelSandbox implements WorkspaceSandbox { + /** + * Provider identifier from the `WorkspaceSandbox` contract. Always `"blaxel"`. + */ + 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()`. 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; + 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 { + const startTime = Date.now(); + const [parseError, parsed] = parseOptions(options, this.config); + if (parseError) { + throw parseError; + } + + if (options.signal?.aborted) { + return abortedResult(0); + } + + const processName = `voltagent-${randomUUID()}`; + // 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: () => { + 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). + 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, + }); + return { + ...output, + durationMs: Date.now() - startTime, + timedOut, + aborted: options.signal?.aborted ?? false, + }; + }, + }); + } + + /** + * 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 (isNil(pending)) { + return; + } + + 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. Takes `sandbox` as a + * param so a concurrent `destroy()` can't reroute the kill to a fresh instance. + */ + private async killProcess({ + sandbox, + processName, + }: { + sandbox: BlaxelSandboxInstance; + processName: string; + }): Promise<{ status: "success" | "failure"; error?: Error }> { + 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 }`. + * 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. + */ + private async runProcess({ + sandbox, + parsed, + processName, + signal, + options, + }: { + sandbox: BlaxelSandboxInstance; + parsed: ParsedExecuteOptions; + processName: string; + signal: AbortSignal | undefined; + options: Pick; + }): Promise<{ timedOut: boolean }> { + 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 { + // 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 }; + } + // 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, + }); + }); + if (isNotNil(waitError)) { + if (!isWaitTimeoutError(waitError)) { + throw waitError; + } + // Swallow kill failures to match other implementations; callers can destroy() if they suspect a leak. + await this.killProcess({ sandbox, processName }); + return { timedOut: true }; + } + return { timedOut: false }; + } finally { + if ("close" in started) { + attempt(() => started.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({ + sandbox, + processName, + maxOutputBytes, + }: { + sandbox: BlaxelSandboxInstance; + processName: string; + maxOutputBytes: number; + }): Promise< + Pick< + WorkspaceSandboxResult, + "stdout" | "stderr" | "exitCode" | "stdoutTruncated" | "stderrTruncated" + > + > { + if (maxOutputBytes <= 0) { + const [, finalState] = await attemptAsync(() => { + return sandbox.process.get(processName); + }); + return { + stdout: "", + stderr: "", + exitCode: finalState?.exitCode ?? null, + stdoutTruncated: true, + stderrTruncated: true, + }; + } + + const [[, finalState], [, stdoutRaw], [, stderrRaw]] = await Promise.all([ + attemptAsync(() => { + return sandbox.process.get(processName); + }), + attemptAsync(() => { + return sandbox.process.logs(processName, "stdout"); + }), + attemptAsync(() => { + return sandbox.process.logs(processName, "stderr"); + }), + ]); + const stdoutInfo = truncateOutput(stdoutRaw ?? "", maxOutputBytes); + const stderrInfo = truncateOutput(stderrRaw ?? "", maxOutputBytes); + return { + stdout: stdoutInfo.content, + stderr: stderrInfo.content, + exitCode: finalState?.exitCode ?? null, + stdoutTruncated: stdoutInfo.truncated, + stderrTruncated: stderrInfo.truncated, + }; + } + + /** + * 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. + */ + private resolveSandbox(): Promise { + if (!this.sandbox) { + this.sandbox = this.createSandbox(); + } + return this.sandbox; + } + + /** + * 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. + */ + private async createSandbox(): Promise { + const [error, sandbox] = await attemptAsync(() => { + return SandboxInstance.createIfNotExists(this.getSdkConfig()); + }); + + if (error) { + this.sandbox = undefined; + throw error; + } + + return sandbox; + } + + /** + * `this.config` with voltagent-specific extras stripped — what gets + * forwarded to `SandboxInstance.createIfNotExists()`. + */ + private getSdkConfig(): Omit< + BlaxelSandboxConfig, + "cwd" | "defaultTimeoutMs" | "maxOutputBytes" | "pollIntervalMs" + > { + return omit(this.config ?? {}, ["cwd", "defaultTimeoutMs", "maxOutputBytes", "pollIntervalMs"]); + } +} + +/** + * 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, + }); +} + +/** + * 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/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 new file mode 100644 index 000000000..91ae5d709 --- /dev/null +++ b/packages/sandbox-blaxel/src/shell.ts @@ -0,0 +1,153 @@ +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 + */ +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. + * + * @private + */ +function buildCommandLine(command: string, args?: string[]): string { + const safeCommand = escapeShellArg(command); + if (!args || args.length === 0) { + return safeCommand; + } + return [safeCommand, ...args.map(escapeShellArg)].join(" "); +} + +/** + * Pull `env` from execute options, drop nullish entries, and string-coerce the rest. + * + * @private + */ +function parseEnv(options: Pick): Record { + if (isNil(options.env)) { + return {}; + } + return mapValues(omitBy(options.env, isNil), String); +} diff --git a/packages/sandbox-blaxel/src/types.ts b/packages/sandbox-blaxel/src/types.ts new file mode 100644 index 000000000..88eb02134 --- /dev/null +++ b/packages/sandbox-blaxel/src/types.ts @@ -0,0 +1,66 @@ +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}. + * + * **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. 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. Written to `process.env.BL_WORKSPACE` when provided + * (the only auth path the Blaxel SDK supports — see interface docs). + */ + 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/src/utils.spec.ts b/packages/sandbox-blaxel/src/utils.spec.ts new file mode 100644 index 000000000..b2087923e --- /dev/null +++ b/packages/sandbox-blaxel/src/utils.spec.ts @@ -0,0 +1,137 @@ +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(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); + }); +}); + +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..ca0235d08 --- /dev/null +++ b/packages/sandbox-blaxel/src/utils.ts @@ -0,0 +1,66 @@ +/** + * 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. 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, + 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"); + // 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/packages/sandbox-blaxel/tsconfig.json b/packages/sandbox-blaxel/tsconfig.json new file mode 100644 index 000000000..2f8553a49 --- /dev/null +++ b/packages/sandbox-blaxel/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + // 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, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + + // Modern module ergonomics. + "isolatedModules": true, + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "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 new file mode 100644 index 000000000..7dcbd0624 --- /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: "es2023", + 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/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 2a76526bd..9ffa727b4 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,41 +110,148 @@ 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: +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. + +### Available providers + +| 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) | + +### 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). + +:::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 +pnpm add @voltagent/sandbox-blaxel +``` + +_Pulls in `@blaxel/core` automatically. No separate install._ + +Configure it on a workspace: ```ts -import { E2BSandbox } from "@voltagent/sandbox-e2b"; -import { DaytonaSandbox } from "@voltagent/sandbox-daytona"; +import { Workspace } from "@voltagent/core"; +import { BlaxelSandbox } from "@voltagent/sandbox-blaxel"; const workspace = new Workspace({ - sandbox: new E2BSandbox({ - apiKey: process.env.E2B_API_KEY, + sandbox: new BlaxelSandbox({ + apiKey: process.env.BL_API_KEY, + workspace: process.env.BL_WORKSPACE, + config: { name: "voltagent-prod", region: "us-pdx-1" }, }), }); +``` -const daytonaWorkspace = new Workspace({ - sandbox: new DaytonaSandbox({ - apiKey: process.env.DAYTONA_API_KEY, - apiUrl: "http://localhost:3000", - }), +When you want filesystem, previews, or sessions APIs, grab the underlying client: + +```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"); ``` -If you need provider-specific APIs, keep a reference to the provider and access its native SDK instance: +Multi-tenant routing: one Blaxel sandbox per tenant, picked from `operationContext`. ```ts -import { E2BSandbox } from "@voltagent/sandbox-e2b"; +import type { + WorkspaceSandbox, + WorkspaceSandboxExecuteOptions, + WorkspaceSandboxResult, +} from "@voltagent/core"; +import { Workspace } from "@voltagent/core"; +import { BlaxelSandbox } from "@voltagent/sandbox-blaxel"; -const sandbox = new E2BSandbox({ - apiKey: process.env.E2B_API_KEY, +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); + } + + 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({ + sandbox: new TenantBlaxelSandboxRouter(), }); +``` -const workspace = new Workspace({ sandbox }); +### Daytona -const e2bSandbox = await sandbox.getSandbox(); -const bytes = await e2bSandbox.files.read("/workspace/file.txt", { format: "bytes" }); +Sandbox plus dev-environment platform. Hosted or self-hosted. Built on [`@daytonaio/sdk`](https://www.npmjs.com/package/@daytonaio/sdk). + +Install: + +```bash +pnpm add @voltagent/sandbox-daytona +``` + +_Pulls in `@daytonaio/sdk` automatically. No separate install._ + +Configure it on a workspace: + +```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", + }), +}); ``` +For Daytona-specific APIs, grab the underlying client: + ```ts import { DaytonaSandbox } from "@voltagent/sandbox-daytona"; @@ -156,9 +266,7 @@ const daytonaSandbox = await sandbox.getSandbox(); const response = await daytonaSandbox.process.executeCommand("ls -la"); ``` -## Custom sandbox provider - -You can implement `WorkspaceSandbox` and plug it into `Workspace` directly. +Multi-tenant routing: one Daytona sandbox per tenant, dispatched via `operationContext`. ```ts import type { @@ -167,78 +275,95 @@ import type { WorkspaceSandboxResult, } from "@voltagent/core"; import { Workspace } from "@voltagent/core"; +import { DaytonaSandbox } from "@voltagent/sandbox-daytona"; -class CustomSandbox implements WorkspaceSandbox { - name = "custom"; +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(); getInfo() { - return { provider: "custom-sandbox", status: this.status }; + return { + provider: "tenant-daytona-router", + status: this.status, + sandboxCount: this.sandboxes.size, + }; + } + + private getSandboxForTenant(tenantId: string): DaytonaSandbox { + 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}` }, + }); + this.sandboxes.set(tenantId, sandbox); + } + return sandbox; } 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. + const tenantId = String(options.operationContext?.context.get("tenantId") ?? "default"); + return this.getSandboxForTenant(tenantId).execute(options); + } - return { - stdout: "", - stderr: "", - exitCode: 0, - durationMs: Date.now() - start, - timedOut: false, - aborted: false, - stdoutTruncated: false, - stderrTruncated: false, - }; + 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({ - sandbox: new CustomSandbox(), + sandbox: new TenantDaytonaSandboxRouter(), }); ``` -## Access runtime context in custom sandboxes +### E2B -When `execute_command` runs through the workspace sandbox toolkit, VoltAgent forwards the current operation context to your sandbox as `options.operationContext`. +Cloud sandboxes built for AI agent workloads: code interpreters, browser automation, that kind of thing. Built on [`e2b`](https://www.npmjs.com/package/e2b). -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-e2b +``` -class TenantAwareSandbox implements WorkspaceSandbox { - name = "tenant-aware"; - status = "ready" as const; +_Pulls in `e2b` 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 { E2BSandbox } from "@voltagent/sandbox-e2b"; + +const workspace = new Workspace({ + sandbox: new E2BSandbox({ + apiKey: process.env.E2B_API_KEY, + }), +}); ``` -If you call `workspace.sandbox.execute(...)` directly (outside the toolkit), pass `operationContext` yourself if you need it. +For E2B-specific APIs (filesystem, code interpreter sessions, etc.), grab the underlying client: -### Tenant-aware E2B router example +```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 { @@ -281,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({ @@ -288,7 +419,9 @@ const workspace = new Workspace({ }); ``` -### Tenant-aware Daytona router example +## Custom sandbox provider + +You can implement `WorkspaceSandbox` and plug it into `Workspace` directly. ```ts import type { @@ -297,48 +430,79 @@ import type { WorkspaceSandboxResult, } from "@voltagent/core"; import { Workspace } from "@voltagent/core"; -import { DaytonaSandbox } from "@voltagent/sandbox-daytona"; -class TenantDaytonaSandboxRouter implements WorkspaceSandbox { - name = "tenant-daytona-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-daytona-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): DaytonaSandbox { - 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}` }, - }); - 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 TenantDaytonaSandboxRouter(), -}); ``` +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.