feat(sandbox-tenki): add Tenki sandbox provider - #1387
Conversation
Add @voltagent/sandbox-tenki, a WorkspaceSandbox provider that runs each execute_command inside a disposable Tenki Linux microVM via @tenkicloud/sandbox, mirroring the existing E2B / Blaxel / Daytona providers. TenkiSandbox is built on the SDK's session run() API, which returns a process handle and is the only primitive that can enforce a per-command timeout and AbortSignal. It forwards cwd/env/stdin natively, keeps stdout and stderr separate, truncates each stream at maxOutputBytes on a UTF-8 character boundary, and serializes start/stop/destroy so concurrent lifecycle calls observe one transition at a time. createTenkiToolkit adds optional expose_preview_url and authorize_ssh_key tools, kept in tools.ts so consumers can omit them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 94889cb The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
📝 WalkthroughWalkthroughAdds ChangesTenki provider package
Estimated code review effort: 5 (Critical) | ~90+ minutes Sequence Diagram(s)sequenceDiagram
participant Agent
participant TenkiSandbox
participant TenkiSession
Agent->>TenkiSandbox: Request sandbox command
TenkiSandbox->>TenkiSession: Run command with execution options
TenkiSession-->>TenkiSandbox: Stream stdout and stderr
TenkiSandbox-->>Agent: Return execution result and streamed output
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (4)
packages/sandbox-tenki/src/tools.ts (1)
15-15: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse a narrow typed sandbox dependency.
The factory only needs
getSandboxandauthorizeSshKey, but requiringTenkiSandboxforces the test to bypass type checking withas unknown as TenkiSandbox.
packages/sandbox-tenki/src/tools.ts#L15-L15: accept an exportedPick<TenkiSandbox, "getSandbox" | "authorizeSshKey">(or equivalent interface).packages/sandbox-tenki/src/tools.spec.ts#L10-L13: type the mock against that narrow contract without a double assertion.As per coding guidelines,
**/*.ts: Maintain type safety in TypeScript-first codebase.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/sandbox-tenki/src/tools.ts` at line 15, Update createTenkiToolkit in packages/sandbox-tenki/src/tools.ts:15-15 to accept an exported narrow contract containing only getSandbox and authorizeSshKey, such as Pick<TenkiSandbox, "getSandbox" | "authorizeSshKey">. In packages/sandbox-tenki/src/tools.spec.ts:10-13, type the mock against that narrow contract and remove the as unknown as TenkiSandbox double assertion.Source: Coding guidelines
packages/sandbox-tenki/src/sandbox.spec.ts (2)
213-215: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
vi.clearAllMocks()clears calls but keeps implementations, somocks.createAndWaitimplementations set withmockResolvedValue/mockReturnValue(e.g. Line 647's never-resolving promise) leak into subsequent tests. Every test that relies oncreateAndWaitcurrently re-sets it, but this makes the suite order-dependent. Prefer resetting implementations.♻️ Proposed fix
beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/sandbox-tenki/src/sandbox.spec.ts` around lines 213 - 215, Update the sandbox.spec.ts beforeEach setup to reset mock implementations, not only invocation history, so mocks.createAndWait does not retain mockResolvedValue or mockReturnValue behavior across tests; use the appropriate Vitest reset operation while preserving the existing per-test setup.
155-160: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUnused mock scaffolding.
stdin/_writeSpyare never exercised — the adapter passes its ownstdinReadableStreaminrunOptions(verified by the stdin test at Line 359) and never writes to the handle'sstdin. Consider dropping them.Also applies to: 176-176
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/sandbox-tenki/src/sandbox.spec.ts` around lines 155 - 160, Remove the unused writeSpy mock and stdin WritableStream declaration from the test setup around the adapter invocation, including the corresponding _writeSpy scaffolding. Keep the runOptions stdin ReadableStream used by the stdin test unchanged, since the handle’s stdin is never exercised.packages/sandbox-tenki/README.md (1)
107-109: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAdd a language to the fenced block to satisfy markdownlint MD040.
📝 Proposed fix
-``` +```text tenki: exec failed: ENOENT (errno 2), reason=exec_failed</details> <details> <summary>🤖 Prompt for AI Agents</summary>Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.In
@packages/sandbox-tenki/README.mdaround lines 107 - 109, Update the fenced
code block in the README to specify the text language identifier, preserving its
existing contents so it satisfies markdownlint MD040.</details> <!-- cr-comment:v1:8bf42207551c9adc25f15ebd --> _Source: Linters/SAST tools_ </blockquote></details> </blockquote></details> <details> <summary>🤖 Prompt for all review comments with AI agents</summary>Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.Inline comments:
In@packages/sandbox-tenki/src/tools.ts:
- Around line 53-56: Update the publicKey schema in the tool definition to
reject whitespace-only values and any values containing newline characters
before updateSshAuthorizedKeys is invoked. Preserve valid single-line
authorized_keys values, and add matching schema cases covering blank,
whitespace-only, and multiline inputs.
Nitpick comments:
In@packages/sandbox-tenki/README.md:
- Around line 107-109: Update the fenced code block in the README to specify the
text language identifier, preserving its existing contents so it satisfies
markdownlint MD040.In
@packages/sandbox-tenki/src/sandbox.spec.ts:
- Around line 213-215: Update the sandbox.spec.ts beforeEach setup to reset mock
implementations, not only invocation history, so mocks.createAndWait does not
retain mockResolvedValue or mockReturnValue behavior across tests; use the
appropriate Vitest reset operation while preserving the existing per-test setup.- Around line 155-160: Remove the unused writeSpy mock and stdin WritableStream
declaration from the test setup around the adapter invocation, including the
corresponding _writeSpy scaffolding. Keep the runOptions stdin ReadableStream
used by the stdin test unchanged, since the handle’s stdin is never exercised.In
@packages/sandbox-tenki/src/tools.ts:
- Line 15: Update createTenkiToolkit in
packages/sandbox-tenki/src/tools.ts:15-15 to accept an exported narrow contract
containing only getSandbox and authorizeSshKey, such as Pick<TenkiSandbox,
"getSandbox" | "authorizeSshKey">. In
packages/sandbox-tenki/src/tools.spec.ts:10-13, type the mock against that
narrow contract and remove the as unknown as TenkiSandbox double assertion.</details> <details> <summary>🪄 Autofix (Beta)</summary> Fix all unresolved CodeRabbit comments on this PR: - [ ] <!-- {"checkboxId": "4b0d0e0a-96d7-4f10-b296-3a18ea78f0b9"} --> Push a commit to this branch (recommended) - [ ] <!-- {"checkboxId": "ff5b1114-7d8c-49e6-8ac1-43f82af23a33"} --> Create a new PR with the fixes </details> --- <details> <summary>ℹ️ Review info</summary> <details> <summary>⚙️ Run configuration</summary> **Configuration used**: defaults **Review profile**: CHILL **Plan**: Pro Plus **Run ID**: `cbead9fa-c9af-4414-ba84-70e0f150afab` </details> <details> <summary>📥 Commits</summary> Reviewing files that changed from the base of the PR and between 3377f6d1fd05d86617f54628d785eef7c9c0a607 and 94889cb8d4fb24e09a5157de6aac6e54aa38e4b6. </details> <details> <summary>⛔ Files ignored due to path filters (1)</summary> * `pnpm-lock.yaml` is excluded by `!**/pnpm-lock.yaml` </details> <details> <summary>📒 Files selected for processing (15)</summary> * `.changeset/sandbox-tenki.md` * `packages/sandbox-tenki/README.md` * `packages/sandbox-tenki/package.json` * `packages/sandbox-tenki/src/index.spec.ts` * `packages/sandbox-tenki/src/index.ts` * `packages/sandbox-tenki/src/sandbox.spec.ts` * `packages/sandbox-tenki/src/sandbox.ts` * `packages/sandbox-tenki/src/tools.spec.ts` * `packages/sandbox-tenki/src/tools.ts` * `packages/sandbox-tenki/src/utils.spec.ts` * `packages/sandbox-tenki/src/utils.ts` * `packages/sandbox-tenki/tsconfig.json` * `packages/sandbox-tenki/tsup.config.ts` * `packages/sandbox-tenki/vitest.config.ts` * `website/docs/workspaces/sandbox.md` </details> </details> <!-- This is an auto-generated comment by CodeRabbit for review status -->
| publicKey: z | ||
| .string() | ||
| .describe("SSH public key in authorized_keys format (e.g. 'ssh-ed25519 AAAA... user')"), | ||
| }), |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Reject blank and multiline SSH-key values.
The schema accepts empty/whitespace-only strings and newline-delimited values, then forwards them to updateSshAuthorizedKeys. Reject obvious non-entries before provisioning or mutating the session, and add matching schema cases.
Proposed fix
publicKey: z
.string()
+ .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')"),📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| publicKey: z | |
| .string() | |
| .describe("SSH public key in authorized_keys format (e.g. 'ssh-ed25519 AAAA... user')"), | |
| }), | |
| publicKey: z | |
| .string() | |
| .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')"), |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/sandbox-tenki/src/tools.ts` around lines 53 - 56, Update the
publicKey schema in the tool definition to reject whitespace-only values and any
values containing newline characters before updateSshAuthorizedKeys is invoked.
Preserve valid single-line authorized_keys values, and add matching schema cases
covering blank, whitespace-only, and multiline inputs.
There was a problem hiding this comment.
4 issues found across 16 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/sandbox-tenki/src/sandbox.ts">
<violation number="1" location="packages/sandbox-tenki/src/sandbox.ts:734">
P1: A timeout or abort while resuming can permanently wedge this sandbox: the first `execute()` returns, but its unresolved `resumeIfPaused()` remains at the head of `lifecycleTransition`, so all later lifecycle operations wait forever. The lifecycle queue would need cancellation/timeout handling (or a way to detach a canceled transition) before returning the cancellation result.</violation>
<violation number="2" location="packages/sandbox-tenki/src/sandbox.ts:803">
P2: A mid-stream transport failure can silently truncate returned stdout/stderr: partial streamed bytes win over the completed handle's aggregate output. Track stream completion/error and use the aggregate result when a pump fails, while preserving byte-limit truncation.</violation>
</file>
<file name="packages/sandbox-tenki/src/utils.ts">
<violation number="1" location="packages/sandbox-tenki/src/utils.ts:262">
P3: Multiline Tenki `reason` values are appended verbatim, so one diagnostic can inject extra stderr lines despite the documented single-line format. Normalize or escape CR/LF before composing the diagnostic.</violation>
</file>
<file name="website/docs/workspaces/sandbox.md">
<violation number="1" location="website/docs/workspaces/sandbox.md:448">
P3: The website docs only mention `TENKI_API_KEY` as the fallback environment variable, but the Tenki SDK also supports `TENKI_AUTH_TOKEN`. Consider updating the docs to mention both, consistent with the README which says: "The API key defaults to the `TENKI_API_KEY` (or `TENKI_AUTH_TOKEN`) environment variable when `apiKey` is omitted."</violation>
</file>
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
| // 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)); |
There was a problem hiding this comment.
P1: A timeout or abort while resuming can permanently wedge this sandbox: the first execute() returns, but its unresolved resumeIfPaused() remains at the head of lifecycleTransition, so all later lifecycle operations wait forever. The lifecycle queue would need cancellation/timeout handling (or a way to detach a canceled transition) before returning the cancellation result.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sandbox-tenki/src/sandbox.ts, line 734:
<comment>A timeout or abort while resuming can permanently wedge this sandbox: the first `execute()` returns, but its unresolved `resumeIfPaused()` remains at the head of `lifecycleTransition`, so all later lifecycle operations wait forever. The lifecycle queue would need cancellation/timeout handling (or a way to detach a canceled transition) before returning the cancellation result.</comment>
<file context>
@@ -0,0 +1,830 @@
+ // 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));
+ if (resumed === cancellationMarker) {
+ return cancellationResult();
</file context>
| cleanup(); | ||
| } | ||
|
|
||
| const stdoutInfo = resolveOutput( |
There was a problem hiding this comment.
P2: A mid-stream transport failure can silently truncate returned stdout/stderr: partial streamed bytes win over the completed handle's aggregate output. Track stream completion/error and use the aggregate result when a pump fails, while preserving byte-limit truncation.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sandbox-tenki/src/sandbox.ts, line 803:
<comment>A mid-stream transport failure can silently truncate returned stdout/stderr: partial streamed bytes win over the completed handle's aggregate output. Track stream completion/error and use the aggregate result when a pump fails, while preserving byte-limit truncation.</comment>
<file context>
@@ -0,0 +1,830 @@
+ cleanup();
+ }
+
+ const stdoutInfo = resolveOutput(
+ stdoutBuffer,
+ result ? decodeBytes(result.stdout) : undefined,
</file context>
| // `errno` is a non-optional proto scalar, so it arrives as 0 (not absent) | ||
| // whenever the guest agent has nothing to report. | ||
| const errno = typeof record.errno === "number" && record.errno !== 0 ? record.errno : undefined; | ||
| const rawReason = typeof record.reason === "string" ? record.reason.trim() : ""; |
There was a problem hiding this comment.
P3: Multiline Tenki reason values are appended verbatim, so one diagnostic can inject extra stderr lines despite the documented single-line format. Normalize or escape CR/LF before composing the diagnostic.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sandbox-tenki/src/utils.ts, line 262:
<comment>Multiline Tenki `reason` values are appended verbatim, so one diagnostic can inject extra stderr lines despite the documented single-line format. Normalize or escape CR/LF before composing the diagnostic.</comment>
<file context>
@@ -0,0 +1,333 @@
+ // `errno` is a non-optional proto scalar, so it arrives as 0 (not absent)
+ // whenever the guest agent has nothing to report.
+ const errno = typeof record.errno === "number" && record.errno !== 0 ? record.errno : undefined;
+ const rawReason = typeof record.reason === "string" ? record.reason.trim() : "";
+ const reason = BENIGN_RUN_REASONS.has(rawReason.toLowerCase())
+ ? undefined
</file context>
| }); | ||
| ``` | ||
|
|
||
| The API key defaults to the `TENKI_API_KEY` environment variable when omitted. Ordinary workspace API keys infer their workspace scope server-side, so omit `workspaceId` for normal usage. If you use trusted service credentials that can access multiple workspaces, pass `workspaceId` to select one explicitly: |
There was a problem hiding this comment.
P3: The website docs only mention TENKI_API_KEY as the fallback environment variable, but the Tenki SDK also supports TENKI_AUTH_TOKEN. Consider updating the docs to mention both, consistent with the README which says: "The API key defaults to the TENKI_API_KEY (or TENKI_AUTH_TOKEN) environment variable when apiKey is omitted."
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At website/docs/workspaces/sandbox.md, line 448:
<comment>The website docs only mention `TENKI_API_KEY` as the fallback environment variable, but the Tenki SDK also supports `TENKI_AUTH_TOKEN`. Consider updating the docs to mention both, consistent with the README which says: "The API key defaults to the `TENKI_API_KEY` (or `TENKI_AUTH_TOKEN`) environment variable when `apiKey` is omitted."</comment>
<file context>
@@ -419,6 +420,131 @@ const workspace = new Workspace({
+});
+```
+
+The API key defaults to the `TENKI_API_KEY` environment variable when omitted. Ordinary workspace API keys infer their workspace scope server-side, so omit `workspaceId` for normal usage. If you use trusted service credentials that can access multiple workspaces, pass `workspaceId` to select one explicitly:
+
+```ts
</file context>
| The API key defaults to the `TENKI_API_KEY` environment variable when omitted. Ordinary workspace API keys infer their workspace scope server-side, so omit `workspaceId` for normal usage. If you use trusted service credentials that can access multiple workspaces, pass `workspaceId` to select one explicitly: | |
| The API key defaults to the `TENKI_API_KEY` (or `TENKI_AUTH_TOKEN`) environment variable when omitted. Ordinary workspace API keys infer their workspace scope server-side, so omit `workspaceId` for normal usage. If you use trusted service credentials that can access multiple workspaces, pass `workspaceId` to select one explicitly: |
PR Checklist
Bugs / Features
What is the current behavior?
VoltAgent ships first-party workspace sandbox providers for Blaxel, Daytona, and E2B. There is none for Tenki, so anyone who wants agents to run shell commands in Tenki microVMs has to implement
WorkspaceSandboxthemselves.What is the new behavior?
Adds
@voltagent/sandbox-tenki, aWorkspaceSandboximplementation backed by@tenkicloud/sandbox, mirroring the existing E2B / Blaxel / Daytona providers. EachTenkiSandboxlazily provisions one disposable Tenki Linux microVM and reuses it across everyexecute_command:maxOutputBytestruncation on UTF-8 codepoint boundariestimeoutMsandAbortSignal, enforced by killing the remote processcwd/env/stdinforwardingstart()/stop()/destroy()with serialized lifecycle transitions; a paused session is resumed (and timeout/abort re-checked) before a command runsgetSandbox()for direct SDK access (filesystem, port exposure, SSH), plusgetInfo()/getInstructions()for the workspace toolkitcreateTenkiToolkit(sandbox)— optionalexpose_preview_urlandauthorize_ssh_keytools on the same session, isolated intools.tsso they can be dropped without touching the adapterNo changes to
@voltagent/core. Also included: a package README, a### Tenkisection plus provider-table row inwebsite/docs/workspaces/sandbox.md, and aminorchangeset.Notes for reviewers
Disclosure: I work with Tenki team.
CI: no sandbox provider is currently in the
test-packagesmatrix, so this suite (likesandbox-blaxel's) will not run upstream. I deliberately left the workflow files untouched — addingsandbox-tenkitorelease.ymlwould make it a gate onpublish, which isn't my call. Glad to add all four providers to the matrix in a follow-up if you'd like them there.E2E Testsruns rather than skips on this PR because its path filter matchespnpm-lock.yaml. Nothing here touches an e2e input.Validation: 133 tests, 100% line/branch/function/statement coverage (SDK fully mocked — no network, no credentials), plus
pnpm install --frozen-lockfile,pnpm lint:ci,pnpm sp lint,pnpm build:all,pnpm publint:all,typecheck, andcommitlint. No live microVM run — there is noTENKI_API_KEYin this environment.Summary by cubic
Add
@voltagent/sandbox-tenki, a new sandbox provider that runs agent commands in a disposable Tenki Linux microVM via@tenkicloud/sandbox. This brings Tenki support alongside the E2B, Blaxel, and Daytona adapters.WorkspaceSandbox; lazily provisions one Tenki session and reuses it acrossexecute_command.maxOutputBytes), per-calltimeoutMsandAbortSignal, and nativecwd/env/stdinforwarding.start(),stop(),destroy()with serialized transitions; paused sessions resume before execution.getSandbox()exposes the Tenki session; also supportsgetInfo()/getInstructions().createTenkiToolkit(sandbox)addsexpose_preview_urlandauthorize_ssh_key.@voltagent/core.Written for commit 94889cb. Summary will update on new commits.
Summary by CodeRabbit
New Features
@voltagent/sandbox-tenkipackage with setup guidance and configuration examples.Documentation