-
Notifications
You must be signed in to change notification settings - Fork 163
feat(sandbox): add MIOSA as an optional cloud sandbox provider #1211
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
robertohluna
wants to merge
1
commit into
hackerai-tech:main
from
robertohluna:feat/miosa-sandbox-provider
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| # MIOSA as a cloud sandbox provider | ||
|
|
||
| Opt-in. `CLOUD_SANDBOX_PROVIDER` still defaults to `e2b`, and nothing in the | ||
| E2B path changes. | ||
|
|
||
| ## Why this fits without restructuring the agent | ||
|
|
||
| MIOSA is a Firecracker microVM platform, and the sandbox **is** the image - the | ||
| same model as E2B's `Template().fromDockerfile()`, not a container running | ||
| inside a VM. `docker/Dockerfile` is the rootfs on both providers, so the agent's | ||
| tool paths, `/home/user` workdir, and installed binaries are identical. | ||
|
|
||
| That is why this integration is three small files rather than a port. | ||
|
|
||
| ## What is here | ||
|
|
||
| | file | role | | ||
| | ------------------------------------------ | ---------------------------------------------------------------------------------- | | ||
| | `miosa-sandbox.ts` | `MiosaSandbox`, implementing `CommonSandboxInterface` with `sandboxKind = "miosa"` | | ||
| | `cloud-sandbox-provider.ts` | `CloudSandboxProvider` widened to `"e2b" \| "miosa"` | | ||
| | `__tests__/cloud-sandbox-provider.test.ts` | selection + fail-closed coverage for both | | ||
|
|
||
| `MiosaSandbox` satisfies the same contract `CentrifugoSandbox` does, so | ||
| `asCommonSandbox()` and every call site that already goes through it work | ||
| unchanged. | ||
|
|
||
| ## Configuration | ||
|
|
||
| ```bash | ||
| CLOUD_SANDBOX_PROVIDER=miosa | ||
| MIOSA_API_KEY=msk_... | ||
| MIOSA_TEMPLATE_ID=hackerai-kali # the rootfs built from docker/Dockerfile | ||
| MIOSA_ENDPOINT=https://api.miosa.ai # optional | ||
| ``` | ||
|
|
||
| ```ts | ||
| import { MiosaSandbox, isMiosaSandbox } from "./miosa-sandbox"; | ||
|
|
||
| const sandbox = await MiosaSandbox.create({ | ||
| templateId: process.env.MIOSA_TEMPLATE_ID, | ||
| size: "medium", | ||
| timeoutSec: 3600, | ||
| }); | ||
|
|
||
| const { stdout, exitCode } = await sandbox.commands.run("nmap --version", { | ||
| cwd: "/home/user", | ||
| }); | ||
| ``` | ||
|
|
||
| Reattach with `MiosaSandbox.connect(sandboxId)`. | ||
|
|
||
| ## Two places the contract does not line up, and how each is handled | ||
|
|
||
| **1. `getHost(port)` is synchronous; MIOSA resolves a preview URL over the | ||
| network.** The host is resolved once during `create()` and cached, so the | ||
| accessor stays synchronous and the interface is unchanged. For any other port, | ||
| call `await sandbox.prewarmHost(port)` first. | ||
|
|
||
| `getHost` on an unwarmed port **throws** rather than returning a constructed | ||
| URL. A fabricated host would fail later, somewhere else, and read as a network | ||
| fault rather than a missing call. | ||
|
|
||
| **2. MIOSA has no file-delete endpoint yet**, so `files.remove` shells out to | ||
| `rm -f`. It is marked in the source for replacement when a native call exists, | ||
| and it raises on a non-zero exit rather than resolving as though the file were | ||
| gone. | ||
|
|
||
| ## Deliberately not done in this PR | ||
|
|
||
| `lib/ai/tools/utils/sandbox.ts` is **untouched**. Its lifecycle - E2B clusters, | ||
| leases, 429 retry, auto-pause/auto-resume - is E2B-shaped and load-bearing, and | ||
| rewriting it to be provider-generic is a change that deserves its own review | ||
| rather than riding along with an adapter. | ||
|
|
||
| The seam to do that later is `getSandbox()`: branch on | ||
| `getCloudSandboxProvider()` before the cluster lookup, since MIOSA has no | ||
| cluster concept. Happy to follow up with that once the adapter itself is | ||
| agreed. | ||
|
|
||
| ## Mapping, for review | ||
|
|
||
| | `CommonSandboxInterface` | MIOSA SDK | | ||
| | ------------------------ | ----------------------------------------------------------------------------- | | ||
| | `commands.run` | `sandbox.exec.run` / `sandbox.exec.stream` when `onStdout`/`signal` is passed | | ||
| | `files.write` | `sandbox.files.write` | | ||
| | `files.read` | `sandbox.files.readText` | | ||
| | `files.list` | `sandbox.files.list` | | ||
| | `files.remove` | `exec rm -f` (no native endpoint yet) | | ||
| | `getHost` | `sandbox.expose(port)`, resolved at create and cached | | ||
| | `close` | `sandbox.destroy()` | | ||
|
|
||
| `timeoutMs` is converted to MIOSA's seconds and rounded **up** - rounding down | ||
| would cut a command short of the budget the caller asked for. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| import { MiosaSandbox, isMiosaSandbox } from "../miosa-sandbox"; | ||
|
|
||
| /** | ||
| * The adapter's job is to satisfy `CommonSandboxInterface` faithfully. What is | ||
| * worth asserting is the places the two contracts do NOT line up, because those | ||
| * are where a wrong answer is silent. | ||
| */ | ||
|
|
||
| type ExecResult = { stdout: string; stderr: string; exitCode: number }; | ||
|
|
||
| function fakeSandbox(overrides: Record<string, any> = {}) { | ||
| const calls: any[] = []; | ||
| return { | ||
| calls, | ||
| id: "sbx_test", | ||
| exec: { | ||
| run: jest.fn( | ||
| async (command: string, options?: any): Promise<ExecResult> => { | ||
| calls.push({ command, options }); | ||
| return { stdout: "ok", stderr: "", exitCode: 0, exit_code: 0 } as any; | ||
| }, | ||
| ), | ||
| stream: jest.fn(), | ||
| }, | ||
| files: { | ||
| write: jest.fn(async () => undefined), | ||
| readText: jest.fn(async () => "contents"), | ||
| list: jest.fn(async () => [{ name: "a.txt" }, { name: "b.txt" }]), | ||
| }, | ||
| expose: jest.fn(async (port: number) => `https://p${port}.miosa.app/`), | ||
| destroy: jest.fn(async () => undefined), | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| function build(sandbox: any): MiosaSandbox { | ||
| // The constructor is private by design; tests build through it directly | ||
| // rather than reaching the network in create(). | ||
| return new (MiosaSandbox as any)({}, sandbox, sandbox.id); | ||
| } | ||
|
|
||
| describe("MiosaSandbox", () => { | ||
| describe("discriminant", () => { | ||
| it("identifies itself so call sites can branch like they do for Centrifugo", () => { | ||
| expect(isMiosaSandbox(build(fakeSandbox()))).toBe(true); | ||
| }); | ||
|
|
||
| it("does not claim unrelated objects", () => { | ||
| expect(isMiosaSandbox(null)).toBe(false); | ||
| expect(isMiosaSandbox({})).toBe(false); | ||
| expect(isMiosaSandbox({ sandboxKind: "centrifugo" })).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe("commands.run", () => { | ||
| it("passes cwd and env straight through", async () => { | ||
| const fake = fakeSandbox(); | ||
| const sandbox = build(fake); | ||
|
|
||
| await sandbox.commands.run("nmap --version", { | ||
| cwd: "/home/user", | ||
| envVars: { FOO: "bar" }, | ||
| }); | ||
|
|
||
| expect(fake.calls[0].options).toMatchObject({ | ||
| cwd: "/home/user", | ||
| env: { FOO: "bar" }, | ||
| }); | ||
| }); | ||
|
|
||
| it("rounds a millisecond timeout UP to whole seconds", async () => { | ||
| // Rounding down would cut a command short of the budget the caller asked | ||
| // for - a 1500ms request must not become a 1s limit. | ||
| const fake = fakeSandbox(); | ||
| const sandbox = build(fake); | ||
|
|
||
| await sandbox.commands.run("sleep 2", { timeoutMs: 1500 }); | ||
| expect(fake.calls[0].options.timeoutSec).toBe(2); | ||
| }); | ||
|
|
||
| it("never rounds a sub-second timeout down to zero", async () => { | ||
| const fake = fakeSandbox(); | ||
| const sandbox = build(fake); | ||
|
|
||
| await sandbox.commands.run("true", { timeoutMs: 10 }); | ||
| expect(fake.calls[0].options.timeoutSec).toBe(1); | ||
| }); | ||
|
|
||
| it("normalises exit_code and exitCode to one field", async () => { | ||
| const fake = fakeSandbox({ | ||
| exec: { | ||
| run: jest.fn(async () => ({ | ||
| stdout: "", | ||
| stderr: "boom", | ||
| exit_code: 3, | ||
| })), | ||
| stream: jest.fn(), | ||
| }, | ||
| }); | ||
|
|
||
| const result = await build(fake).commands.run("false"); | ||
| expect(result.exitCode).toBe(3); | ||
| expect(result.stderr).toBe("boom"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("getHost", () => { | ||
| it("throws for an unresolved port instead of inventing a URL", async () => { | ||
| // A fabricated host fails later, somewhere else, and reads as a network | ||
| // fault rather than a missing prewarm. | ||
| const sandbox = build(fakeSandbox()); | ||
| expect(() => sandbox.getHost(3000)).toThrow("has not been resolved"); | ||
| }); | ||
|
|
||
| it("returns a bare host once the port is warmed", async () => { | ||
| const sandbox = build(fakeSandbox()); | ||
| await sandbox.prewarmHost(8080); | ||
|
|
||
| // No scheme, no trailing slash - E2B's getHost returns a host, not a URL. | ||
| expect(sandbox.getHost(8080)).toBe("p8080.miosa.app"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("files", () => { | ||
| it("reads text through the SDK's text accessor", async () => { | ||
| const fake = fakeSandbox(); | ||
| await expect(build(fake).files.read("/tmp/x")).resolves.toBe("contents"); | ||
| expect(fake.files.readText).toHaveBeenCalledWith("/tmp/x"); | ||
| }); | ||
|
|
||
| it("normalises listings to { name }", async () => { | ||
| const entries = await build(fakeSandbox()).files.list("/home/user"); | ||
| expect(entries).toEqual([{ name: "a.txt" }, { name: "b.txt" }]); | ||
| }); | ||
|
|
||
| it("remove raises on a non-zero exit rather than resolving silently", async () => { | ||
| // Resolving here would report a file as deleted when it is still there. | ||
| const fake = fakeSandbox({ | ||
| exec: { | ||
| run: jest.fn(async () => ({ | ||
| stdout: "", | ||
| stderr: "permission denied", | ||
| exitCode: 1, | ||
| })), | ||
| stream: jest.fn(), | ||
| }, | ||
| }); | ||
|
|
||
| await expect(build(fake).files.remove("/tmp/x")).rejects.toThrow( | ||
| "permission denied", | ||
| ); | ||
| }); | ||
|
|
||
| it("quotes paths so a space or quote cannot break the shell call", async () => { | ||
| const fake = fakeSandbox(); | ||
| await build(fake).files.remove("/tmp/a file's name.txt"); | ||
|
|
||
| const command: string = fake.calls[0].command; | ||
| expect(command).toContain("rm -f --"); | ||
| // The embedded quote must be escaped, not terminating the argument. | ||
| expect(command).toContain(`'\\''`); | ||
| }); | ||
| }); | ||
|
|
||
| describe("close", () => { | ||
| it("destroys the underlying sandbox", async () => { | ||
| const fake = fakeSandbox(); | ||
| await build(fake).close(); | ||
| expect(fake.destroy).toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Account for MIOSA runtime after adding the runtime bucket.
emptySandboxRuntimeMs()now includesmiosa, buttrackSandboxUsagerecords only"e2b"ornull.getSandboxSessionUsage()also calculates and returns only E2B runtime and cost. MIOSA executions therefore remain at zero and are excluded from session usage and cost reporting.Identify MIOSA in
trackSandboxUsageand extendSandboxSessionUsageand its cost calculation. If MIOSA is intentionally unmetered, remove this bucket and document that contract.🤖 Prompt for AI Agents