Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/sandbox-tenki/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Tenki sessions are billed resources — call `sandbox.destroy()` (or `workspace.

Tenki reports a fork/exec/wait failure as a _completed_ run carrying an `errno` (`ENOENT`, `EACCES`, `EMFILE`, …) rather than as an error, and the process itself never writes anything — so an exit code alone cannot tell "command not found" from "ran and failed silently". `WorkspaceSandboxResult` has no field for that errno, so the adapter appends it to `stderr` as a single line:

```
```text
tenki: exec failed: ENOENT (errno 2), reason=exec_failed
```

Expand Down
2 changes: 1 addition & 1 deletion packages/sandbox-tenki/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"description": "VoltAgent Tenki sandbox provider",
"version": "2.0.0",
"dependencies": {
"@tenkicloud/sandbox": "^0.5.1"
"@tenkicloud/sandbox": "^0.5.4"
},
"devDependencies": {
"@types/node": "^24.2.1",
Expand Down
1 change: 1 addition & 0 deletions packages/sandbox-tenki/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { TenkiSandbox } from "./sandbox";
export type { TenkiSandboxOptions, TenkiSandboxInstance } from "./sandbox";
export { createTenkiToolkit } from "./tools";
export type { TenkiToolkitSandbox } from "./tools";
102 changes: 90 additions & 12 deletions packages/sandbox-tenki/src/sandbox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ type HandleOptions = {
killThrowsWith?: unknown;
rejectWith?: unknown;
errorStdout?: boolean;
/** Error the stdout stream after its chunks were delivered (mid-read death). */
errorStdoutMidStream?: boolean;
/** Override the aggregate stdout bytes on the resolved result. */
resultStdout?: string;
};

const makeHandle = (options: HandleOptions = {}) => {
Expand All @@ -68,6 +72,8 @@ const makeHandle = (options: HandleOptions = {}) => {
killThrowsWith,
rejectWith,
errorStdout = false,
errorStdoutMidStream = false,
resultStdout,
} = options;

let stdoutCtl!: ReadableStreamDefaultController<Uint8Array>;
Expand Down Expand Up @@ -102,7 +108,7 @@ const makeHandle = (options: HandleOptions = {}) => {
durationMs,
reason,
errno,
stdout: concat(stdout),
stdout: resultStdout !== undefined ? enc.encode(resultStdout) : concat(stdout),
stderr: concat(stderr),
};

Expand All @@ -126,7 +132,12 @@ const makeHandle = (options: HandleOptions = {}) => {
safeClose(stderrCtl);
rejectResult(rejectWith);
} else if (!hangUntilKill) {
if (!keepStreamsOpen) {
if (errorStdoutMidStream) {
// Erroring synchronously would discard the queued chunks; defer it a
// macrotask so the pump consumes them first, then hits the error.
setTimeout(() => stdoutCtl.error(new Error("stream died mid-read")), 0);
safeClose(stderrCtl);
} else if (!keepStreamsOpen) {
safeClose(stdoutCtl);
safeClose(stderrCtl);
}
Expand All @@ -152,17 +163,9 @@ const makeHandle = (options: HandleOptions = {}) => {
}
});

const writeSpy = vi.fn();
const stdin = new WritableStream<Uint8Array>({
write(chunk) {
writeSpy(chunk);
},
});

return {
stdout: stdoutStream,
stderr: stderrStream,
stdin,
pid: Promise.resolve(1),
signal: vi.fn(async () => {}),
kill,
Expand All @@ -173,7 +176,6 @@ const makeHandle = (options: HandleOptions = {}) => {
) {
return resultPromise.then(onfulfilled, onrejected);
},
_writeSpy: writeSpy,
_rejectResult: rejectResult,
};
};
Expand Down Expand Up @@ -211,7 +213,10 @@ const makeSession = (overrides: Record<string, unknown> = {}) => {
};

beforeEach(() => {
vi.clearAllMocks();
// Reset implementations too (not just call history): tests install
// per-test `mocks.createAndWait` behaviors (e.g. never-resolving promises)
// that must not leak into later tests.
vi.resetAllMocks();
});

afterEach(() => {
Expand Down Expand Up @@ -413,6 +418,45 @@ describe("TenkiSandbox.execute", () => {
expect(result.stderr).toBe("side");
});

it("falls back to the aggregate output when a stream dies mid-read", async () => {
// The pump captured only "par" before the transport failed; the resolved
// run's aggregate bytes are complete and must win over the partial buffer.
const session = makeSession({
run: vi.fn(() =>
makeHandle({
stdout: ["par"],
resultStdout: "partial output\n",
errorStdoutMidStream: true,
}),
),
});
const sandbox = new TenkiSandbox({ session: session as never });

const result = await sandbox.execute({ command: "flaky" });

expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("partial output\n");
expect(result.stdoutTruncated).toBe(false);
});

it("applies the byte cap to the aggregate fallback after a mid-read failure", async () => {
const session = makeSession({
run: vi.fn(() =>
makeHandle({
stdout: ["par"],
resultStdout: "partial output\n",
errorStdoutMidStream: true,
}),
),
});
const sandbox = new TenkiSandbox({ session: session as never });

const result = await sandbox.execute({ command: "flaky", maxOutputBytes: 7 });

expect(result.stdout).toBe("partial");
expect(result.stdoutTruncated).toBe(true);
});

it("returns an aborted result when the signal is already aborted", async () => {
const session = makeSession();
const sandbox = new TenkiSandbox({ session: session as never });
Expand Down Expand Up @@ -715,6 +759,40 @@ describe("TenkiSandbox.execute", () => {
expect(session.resume).toHaveBeenCalledOnce();
expect(session.run).not.toHaveBeenCalled();
});

it("bounds a hung resume RPC and skips queued resumes whose executes were canceled", async () => {
// A resume RPC with no transport deadline must not wedge the lifecycle
// queue: after both executes time out, the bounded wait expires, the
// second (abandoned) transition bails out without issuing its own resume,
// and the queue is usable again.
vi.useFakeTimers();
const session = makeSession({
state: "PAUSED",
resume: vi.fn(() => new Promise<void>(() => {})),
});
const sandbox = new TenkiSandbox({ session: session as never });

const first = sandbox.execute({ command: "a", timeoutMs: 10 });
const second = sandbox.execute({ command: "b", timeoutMs: 10 });
await vi.advanceTimersByTimeAsync(10);
const [firstResult, secondResult] = await Promise.all([first, second]);

expect(firstResult.timedOut).toBe(true);
expect(secondResult.timedOut).toBe(true);
expect(session.resume).toHaveBeenCalledOnce();

// Expire the bounded wait: the hung transition rejects, the abandoned one
// skips its RPC (still exactly one resume call), and a fresh execute can
// resume and run.
await vi.advanceTimersByTimeAsync(180_000);
expect(session.resume).toHaveBeenCalledOnce();

session.resume.mockImplementation(async () => {});
const result = await sandbox.execute({ command: "c" });
expect(result.stdout).toBe("ok\n");
expect(session.resume).toHaveBeenCalledTimes(2);
expect(session.run).toHaveBeenCalledOnce();
});
});

describe("TenkiSandbox lifecycle", () => {
Expand Down
38 changes: 34 additions & 4 deletions packages/sandbox-tenki/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,22 @@ import {
isCommandTimeoutError,
normalizeEnv,
resolveOutput,
resolveWithin,
stringToReadableStream,
timedOutResult,
} from "./utils";

/**
* Upper bound on how long a queued lifecycle transition waits for
* `session.resume()`. The resume RPC has no transport deadline, so without a
* bound one dead connection would hold {@link TenkiSandbox.lifecycleTransition}
* — and every later `stop()`/`start()`/`execute()` on a paused sandbox —
* hostage forever. Matches the SDK's own `waitResumed` default. On expiry the
* sandbox stays `paused`, and retrying is safe: the engine treats a resume on
* an already-RUNNING session as idempotent.
*/
const RESUME_TRANSITION_TIMEOUT_MS = 180_000;

/**
* The underlying Tenki SDK session type, re-exported for consumers that reach
* past the `WorkspaceSandbox` seam via {@link TenkiSandbox.getSandbox}.
Expand Down Expand Up @@ -348,18 +360,32 @@ export class TenkiSandbox implements WorkspaceSandbox {
/**
* Resume the microVM when a previous {@link stop} paused it, returning the
* sandbox to `ready` so commands can run again. No-op otherwise.
*
* `signal` is the requesting `execute()`'s cancellation: a canceled execute
* has already returned by the time its queued transition reaches the head of
* the queue, so the transition bails out instead of issuing a resume RPC
* nobody is waiting for. The RPC itself is bounded by
* {@link RESUME_TRANSITION_TIMEOUT_MS} so an unresponsive resume cannot wedge
* every later lifecycle transition.
*/
private async resumeIfPaused(session: Session): Promise<void> {
private async resumeIfPaused(session: Session, signal?: AbortSignal): Promise<void> {
return this.serializeLifecycleTransition(async () => {
if (this.status === "destroyed" || this.session !== session) {
throw new Error("Sandbox has been destroyed");
}
if (!this.paused) {
return;
}
if (signal?.aborted) {
throw new Error("Sandbox resume canceled: the requesting execute() timed out or aborted");
}

const generation = this.generation;
await session.resume();
await resolveWithin(
session.resume(),
RESUME_TRANSITION_TIMEOUT_MS,
`timed out waiting for session ${session.id} to resume`,
);

// Destruction eagerly invalidates the generation and drops the owned
// session. A resume RPC may still finish afterward, but it must neither
Expand Down Expand Up @@ -574,7 +600,9 @@ export class TenkiSandbox implements WorkspaceSandbox {
}
}
} catch {
// ignore stream errors; result bytes are used as a fallback
// A dead pump leaves the buffer silently short; flag it so resolveOutput
// prefers the resolved run's complete aggregate bytes over partial ones.
buffer.failed = true;
} finally {
// Flush any bytes the decoder is still holding for a partial code point.
if (decoder) {
Expand Down Expand Up @@ -731,7 +759,9 @@ export class TenkiSandbox implements WorkspaceSandbox {
// does not exist yet, so `requestKill()` would have nothing to kill. This
// is the only await left between the guards and `session.run()`; the
// `runOptions` build below is synchronous.
const resumed = await raceCancellation(this.resumeIfPaused(session));
const resumed = await raceCancellation(
this.resumeIfPaused(session, cancellationController.signal),
);
if (resumed === cancellationMarker) {
return cancellationResult();
}
Expand Down
49 changes: 34 additions & 15 deletions packages/sandbox-tenki/src/tools.spec.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,63 @@
import { describe, expect, it, vi } from "vitest";
import type { TenkiSandbox } from "./sandbox";
import { createTenkiToolkit } from "./tools";
import { type TenkiToolkitSandbox, createTenkiToolkit } from "./tools";

type PreviewParameters = {
type ToolParameters = {
safeParse: (input: unknown) => { success: boolean };
};

const getPreviewParameters = (): PreviewParameters => {
const sandbox = {
const getToolParameters = (name: string): ToolParameters => {
const sandbox: TenkiToolkitSandbox = {
getSandbox: vi.fn(),
authorizeSshKey: vi.fn(),
} as unknown as TenkiSandbox;
const previewTool = createTenkiToolkit(sandbox).tools.find(
(tool) => (tool as { name?: string }).name === "expose_preview_url",
};
const tool = createTenkiToolkit(sandbox).tools.find(
(candidate) => (candidate as { name?: string }).name === name,
);

if (!previewTool) {
throw new Error("expose_preview_url tool not found");
if (!tool) {
throw new Error(`${name} tool not found`);
}

return (previewTool as { parameters: PreviewParameters }).parameters;
return (tool as { parameters: ToolParameters }).parameters;
};

describe("createTenkiToolkit preview input schema", () => {
it.each([1, 65535])("accepts boundary port %s", (port) => {
expect(getPreviewParameters().safeParse({ port }).success).toBe(true);
expect(getToolParameters("expose_preview_url").safeParse({ port }).success).toBe(true);
});

it.each([0, -1, 65536, 1.5])("rejects invalid port %s", (port) => {
expect(getPreviewParameters().safeParse({ port }).success).toBe(false);
expect(getToolParameters("expose_preview_url").safeParse({ port }).success).toBe(false);
});

it("accepts an omitted or positive integer TTL", () => {
const parameters = getPreviewParameters();
const parameters = getToolParameters("expose_preview_url");

expect(parameters.safeParse({ port: 3000 }).success).toBe(true);
expect(parameters.safeParse({ port: 3000, ttlMs: 1 }).success).toBe(true);
});

it.each([0, -1, 1.5])("rejects invalid TTL %s", (ttlMs) => {
expect(getPreviewParameters().safeParse({ port: 3000, ttlMs }).success).toBe(false);
expect(getToolParameters("expose_preview_url").safeParse({ port: 3000, ttlMs }).success).toBe(
false,
);
});
});

describe("createTenkiToolkit ssh key input schema", () => {
it("accepts a single-line authorized_keys entry", () => {
expect(
getToolParameters("authorize_ssh_key").safeParse({ publicKey: "ssh-ed25519 AAAA user" })
.success,
).toBe(true);
});

it.each([
["empty", ""],
["whitespace-only", " "],
["multiline", "ssh-ed25519 AAAA\nssh-rsa BBBB"],
["carriage return", "ssh-ed25519 AAAA\r\n"],
])("rejects a %s public key", (_label, publicKey) => {
expect(getToolParameters("authorize_ssh_key").safeParse({ publicKey }).success).toBe(false);
});
});
13 changes: 12 additions & 1 deletion packages/sandbox-tenki/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { type Toolkit, createTool, createToolkit } from "@voltagent/core";
import { z } from "zod";
import type { TenkiSandbox } from "./sandbox";

/**
* The slice of {@link TenkiSandbox} that {@link createTenkiToolkit} depends on.
*/
export type TenkiToolkitSandbox = Pick<TenkiSandbox, "getSandbox" | "authorizeSshKey">;

/**
* Build a toolkit of Tenki-specific tools that reach past the
* `WorkspaceSandbox` seam: exposing a preview URL for a port, and authorizing
Expand All @@ -12,7 +17,7 @@ import type { TenkiSandbox } from "./sandbox";
* These are intentionally separate from the core `execute_command` adapter so a
* consumer can opt in without them.
*/
export function createTenkiToolkit(sandbox: TenkiSandbox): Toolkit {
export function createTenkiToolkit(sandbox: TenkiToolkitSandbox): Toolkit {
const exposePreviewUrl = createTool({
name: "expose_preview_url",
description:
Expand Down Expand Up @@ -52,6 +57,12 @@ export function createTenkiToolkit(sandbox: TenkiSandbox): Toolkit {
parameters: z.object({
publicKey: z
.string()
// `updateSshAuthorizedKeys` would accept a blank or newline-carrying
// value verbatim; reject obvious non-entries before mutating the set.
.refine(
(value) => value.trim().length > 0 && !/[\r\n]/.test(value),
"publicKey must be a non-empty, single-line authorized_keys entry",
)
.describe("SSH public key in authorized_keys format (e.g. 'ssh-ed25519 AAAA... user')"),
}),
outputSchema: z.object({
Expand Down
Loading