feat: add microsandbox sandbox flow and feature flag toggle (#1446)

* add pre-baked microsandbox image

Bake openwork, openwork-server, and the pinned opencode binary into a single Docker image so micro-sandbox remote-connect smoke tests can boot quickly and be verified with curl and container health checks.

* add Rust microsandbox example

Add a standalone microsandbox SDK example that boots the OpenWork image, validates remote-connect endpoints, and streams sandbox logs so backend-only sandbox behavior can be exercised without Docker.

* exclude Rust example build output

Keep the standalone microsandbox example in git, but drop generated Cargo target artifacts so the branch only contains source, docs, and lockfile.

* test

* add microsandbox feature flag for sandbox creation

Made-with: Cursor

* refactor sandbox mode isolation

Made-with: Cursor
This commit is contained in:
ben
2026-04-15 15:10:52 -07:00
committed by GitHub
parent 9bd844c93d
commit 800602f4e3
27 changed files with 7120 additions and 38 deletions

View File

@@ -0,0 +1 @@
target/

View File

@@ -0,0 +1,103 @@
import { tool } from "@opencode-ai/plugin"
const redactTarget = (value) => {
const text = String(value || '').trim()
if (!text) return ''
if (text.length <= 6) return 'hidden'
return `${text.slice(0, 2)}${text.slice(-2)}`
}
const buildGuidance = (result) => {
const sent = Number(result?.sent || 0)
const attempted = Number(result?.attempted || 0)
const reason = String(result?.reason || '')
const failures = Array.isArray(result?.failures) ? result.failures : []
if (sent > 0 && failures.length === 0) return 'Delivered successfully.'
if (sent > 0) return 'Delivered to at least one conversation, but some targets failed.'
const chatNotFound = failures.some((item) => /chat not found/i.test(String(item?.error || '')))
if (chatNotFound) {
return 'Delivery failed because the recipient has not started a chat with the bot yet. Ask them to send /start, then retry.'
}
if (/No bound conversations/i.test(reason)) {
return 'No linked conversation found for this workspace yet. Ask the recipient to message the bot first, then retry.'
}
if (attempted === 0) return 'No eligible delivery target found.'
return 'Delivery failed. Retry after confirming the recipient and bot linkage.'
}
export default tool({
description: "Send a message via opencodeRouter (Telegram/Slack) to a peer or directory bindings.",
args: {
text: tool.schema.string().describe("Message text to send"),
channel: tool.schema.enum(["telegram", "slack"]).optional().describe("Channel to send on (default: telegram)"),
identityId: tool.schema.string().optional().describe("OpenCodeRouter identity id (default: all identities)"),
directory: tool.schema.string().optional().describe("Directory to target for fan-out (default: current session directory)"),
peerId: tool.schema.string().optional().describe("Direct destination peer id (chat/thread id)"),
autoBind: tool.schema.boolean().optional().describe("When direct sending, bind peerId to directory if provided"),
},
async execute(args, context) {
const rawPort = (process.env.OPENCODE_ROUTER_HEALTH_PORT || "3005").trim()
const port = Number(rawPort)
if (!Number.isFinite(port) || port <= 0) {
throw new Error(`Invalid OPENCODE_ROUTER_HEALTH_PORT: ${rawPort}`)
}
const channel = (args.channel || "telegram").trim()
if (channel !== "telegram" && channel !== "slack") {
throw new Error("channel must be telegram or slack")
}
const text = String(args.text || "")
if (!text.trim()) throw new Error("text is required")
const directory = (args.directory || context.directory || "").trim()
const peerId = String(args.peerId || "").trim()
if (!directory && !peerId) throw new Error("Either directory or peerId is required")
const payload = {
channel,
text,
...(args.identityId ? { identityId: String(args.identityId) } : {}),
...(directory ? { directory } : {}),
...(peerId ? { peerId } : {}),
...(args.autoBind === true ? { autoBind: true } : {}),
}
const response = await fetch(`http://127.0.0.1:${port}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
const body = await response.text()
let json = null
try {
json = JSON.parse(body)
} catch {
json = null
}
if (!response.ok) {
throw new Error(`opencodeRouter /send failed (${response.status}): ${body}`)
}
const sent = Number(json?.sent || 0)
const attempted = Number(json?.attempted || 0)
const reason = typeof json?.reason === 'string' ? json.reason : ''
const failuresRaw = Array.isArray(json?.failures) ? json.failures : []
const failures = failuresRaw.map((item) => ({
identityId: String(item?.identityId || ''),
error: String(item?.error || 'delivery failed'),
...(item?.peerId ? { target: redactTarget(item.peerId) } : {}),
}))
const result = {
ok: true,
channel,
sent,
attempted,
guidance: buildGuidance({ sent, attempted, reason, failures }),
...(reason ? { reason } : {}),
...(failures.length ? { failures } : {}),
}
return JSON.stringify(result, null, 2)
},
})

View File

@@ -0,0 +1,157 @@
import { tool } from "@opencode-ai/plugin"
const redactTarget = (value) => {
const text = String(value || '').trim()
if (!text) return ''
if (text.length <= 6) return 'hidden'
return `${text.slice(0, 2)}${text.slice(-2)}`
}
const isNumericTelegramPeerId = (value) => /^-?\d+$/.test(String(value || '').trim())
export default tool({
description: "Check opencodeRouter messaging readiness (health, identities, bindings).",
args: {
channel: tool.schema.enum(["telegram", "slack"]).optional().describe("Channel to inspect (default: telegram)"),
identityId: tool.schema.string().optional().describe("Identity id to scope checks"),
directory: tool.schema.string().optional().describe("Directory to inspect bindings for (default: current session directory)"),
peerId: tool.schema.string().optional().describe("Peer id to inspect bindings for"),
includeBindings: tool.schema.boolean().optional().describe("Include binding details (default: false)"),
},
async execute(args, context) {
const rawPort = (process.env.OPENCODE_ROUTER_HEALTH_PORT || "3005").trim()
const port = Number(rawPort)
if (!Number.isFinite(port) || port <= 0) {
throw new Error(`Invalid OPENCODE_ROUTER_HEALTH_PORT: ${rawPort}`)
}
const channel = (args.channel || "telegram").trim()
if (channel !== "telegram" && channel !== "slack") {
throw new Error("channel must be telegram or slack")
}
const identityId = String(args.identityId || "").trim()
const directory = (args.directory || context.directory || "").trim()
const peerId = String(args.peerId || "").trim()
const targetValid = channel !== 'telegram' || !peerId || isNumericTelegramPeerId(peerId)
const includeBindings = args.includeBindings === true
const fetchJson = async (path) => {
const response = await fetch(`http://127.0.0.1:${port}${path}`)
const body = await response.text()
let json = null
try {
json = JSON.parse(body)
} catch {
json = null
}
if (!response.ok) {
return { ok: false, status: response.status, json, error: typeof json?.error === "string" ? json.error : body }
}
return { ok: true, status: response.status, json }
}
const health = await fetchJson('/health')
const identities = await fetchJson(`/identities/${channel}`)
let bindings = null
if (includeBindings) {
const search = new URLSearchParams()
search.set('channel', channel)
if (identityId) search.set('identityId', identityId)
bindings = await fetchJson(`/bindings?${search.toString()}`)
}
const identityItems = Array.isArray(identities?.json?.items) ? identities.json.items : []
const scopedIdentityItems = identityId
? identityItems.filter((item) => String(item?.id || '').trim() === identityId)
: identityItems
const runningItems = scopedIdentityItems.filter((item) => item && item.enabled === true && item.running === true)
const enabledItems = scopedIdentityItems.filter((item) => item && item.enabled === true)
const bindingItems = Array.isArray(bindings?.json?.items) ? bindings.json.items : []
const filteredBindings = bindingItems.filter((item) => {
if (!item || typeof item !== 'object') return false
if (directory && String(item.directory || '').trim() !== directory) return false
if (peerId && String(item.peerId || '').trim() !== peerId) return false
return true
})
const publicBindings = filteredBindings.map((item) => ({
channel: String(item.channel || channel),
identityId: String(item.identityId || ''),
directory: String(item.directory || ''),
...(item?.peerId ? { target: redactTarget(item.peerId) } : {}),
updatedAt: item?.updatedAt,
}))
let ready = false
let guidance = ''
let nextAction = ''
if (!health.ok) {
guidance = 'OpenCode Router health endpoint is unavailable'
nextAction = 'check_router_health'
} else if (!identities.ok) {
guidance = `Identity lookup failed for ${channel}`
nextAction = 'check_identity_config'
} else if (runningItems.length === 0) {
guidance = `No running ${channel} identity`
nextAction = 'start_identity'
} else if (!targetValid) {
guidance = 'Telegram direct targets must be numeric chat IDs. Prefer linked conversations over asking users for raw IDs.'
nextAction = 'use_linked_conversation'
} else if (peerId) {
ready = true
guidance = 'Ready for direct send'
nextAction = 'send_direct'
} else if (directory) {
ready = filteredBindings.length > 0
guidance = ready
? 'Ready for directory fan-out send'
: channel === 'telegram'
? 'No linked Telegram conversations yet. Ask the recipient to message your bot (for example /start), then retry.'
: 'No linked conversations found for this directory yet'
nextAction = ready ? 'send_directory' : channel === 'telegram' ? 'wait_for_recipient_start' : 'link_conversation'
} else {
ready = true
guidance = 'Ready. Provide a message target (peer or directory).'
nextAction = 'choose_target'
}
const result = {
ok: health.ok && identities.ok && (!bindings || bindings.ok),
ready,
guidance,
nextAction,
channel,
...(identityId ? { identityId } : {}),
...(directory ? { directory } : {}),
...(peerId ? { targetProvided: true } : {}),
...(peerId ? { targetValid } : {}),
health: {
ok: health.ok,
status: health.status,
error: health.ok ? undefined : health.error,
snapshot: health.ok ? health.json : undefined,
},
identities: {
ok: identities.ok,
status: identities.status,
error: identities.ok ? undefined : identities.error,
configured: scopedIdentityItems.length,
enabled: enabledItems.length,
running: runningItems.length,
items: scopedIdentityItems,
},
...(includeBindings
? {
bindings: {
ok: Boolean(bindings?.ok),
status: bindings?.status,
error: bindings?.ok ? undefined : bindings?.error,
count: filteredBindings.length,
items: publicBindings,
},
}
: {}),
}
return JSON.stringify(result, null, 2)
},
})

View File

@@ -0,0 +1,3 @@
{
"$schema": "https://opencode.ai/config.json"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
[package]
name = "microsandbox-openwork-rust"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
anyhow = "1"
microsandbox = "0.3.12"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time"] }

View File

@@ -0,0 +1,69 @@
# Microsandbox OpenWork Rust Example
Small standalone Rust example that starts the OpenWork micro-sandbox image with the `microsandbox` SDK, publishes the OpenWork server on a host port, persists `/workspace` and `/data` with host bind mounts, verifies `/health`, checks that `/workspaces` is `401` without a token and `200` with the client token, then keeps the sandbox alive until `Ctrl+C` while streaming the sandbox logs to your terminal.
## Run
```bash
cargo run --manifest-path examples/microsandbox-openwork-rust/Cargo.toml
```
Useful environment overrides:
- `OPENWORK_MICROSANDBOX_IMAGE` - OCI image reference to boot. Defaults to `openwork-microsandbox:dev`.
- `OPENWORK_MICROSANDBOX_NAME` - sandbox name. Defaults to `openwork-microsandbox-rust`.
- `OPENWORK_MICROSANDBOX_WORKSPACE_DIR` - host directory bind-mounted at `/workspace`. Defaults to `examples/microsandbox-openwork-rust/.state/<sandbox-name>/workspace`.
- `OPENWORK_MICROSANDBOX_DATA_DIR` - host directory bind-mounted at `/data`. Defaults to `examples/microsandbox-openwork-rust/.state/<sandbox-name>/data`.
- `OPENWORK_MICROSANDBOX_REPLACE` - set to `1` or `true` to replace the sandbox instead of reusing persistent state. Defaults to off.
- `OPENWORK_MICROSANDBOX_PORT` - published host port. Defaults to `8787`.
- `OPENWORK_CONNECT_HOST` - hostname you want clients to use. Defaults to `127.0.0.1`.
- `OPENWORK_TOKEN` - remote-connect client token. Defaults to `microsandbox-token`.
- `OPENWORK_HOST_TOKEN` - host/admin token. Defaults to `microsandbox-host-token`.
Example:
```bash
OPENWORK_MICROSANDBOX_IMAGE=ghcr.io/example/openwork-microsandbox:dev \
OPENWORK_MICROSANDBOX_WORKSPACE_DIR="$PWD/examples/microsandbox-openwork-rust/.state/demo/workspace" \
OPENWORK_MICROSANDBOX_DATA_DIR="$PWD/examples/microsandbox-openwork-rust/.state/demo/data" \
OPENWORK_CONNECT_HOST=127.0.0.1 \
OPENWORK_TOKEN=some-shared-secret \
OPENWORK_HOST_TOKEN=some-owner-secret \
cargo run --manifest-path examples/microsandbox-openwork-rust/Cargo.toml
```
## Test
The crate includes an ignored end-to-end smoke test that:
- boots the microsandbox image
- waits for `/health`
- verifies unauthenticated `/workspaces` returns `401`
- verifies authenticated `/workspaces` returns `200`
- creates an OpenCode session through `/w/:workspaceId/opencode/session`
- fetches the created session and its messages
Run it explicitly:
```bash
OPENWORK_MICROSANDBOX_IMAGE=ttl.sh/openwork-microsandbox-11559:1d \
cargo test --manifest-path examples/microsandbox-openwork-rust/Cargo.toml -- --ignored --nocapture
```
## Persistence behavior
By default, the example creates and reuses two host directories under `examples/microsandbox-openwork-rust/.state/<sandbox-name>/`:
- `/workspace`
- `/data`
That keeps OpenWork and OpenCode state around across sandbox restarts, while using normal host filesystem semantics instead of managed microsandbox named volumes.
If you want a clean reset, either:
- change the sandbox name or bind mount paths, or
- set `OPENWORK_MICROSANDBOX_REPLACE=1`
## Note on local Docker images
`microsandbox` expects an OCI image reference. If `openwork-microsandbox:dev` only exists in your local Docker daemon, the SDK may not be able to resolve it directly. In that case, push the image to a registry or otherwise make it available as a pullable OCI image reference first, then set `OPENWORK_MICROSANDBOX_IMAGE` to that ref.

View File

@@ -0,0 +1,30 @@
use anyhow::Result;
use microsandbox::{NetworkPolicy, Sandbox};
#[tokio::main]
async fn main() -> Result<()> {
let sandbox = Sandbox::builder("owmsb-env-debug")
.image("ttl.sh/openwork-microsandbox-11559:1d")
.replace()
.memory(1024)
.cpus(1)
.network(|n| n.policy(NetworkPolicy::allow_all()))
.create()
.await?;
let out = sandbox
.exec(
"/bin/sh",
[
"-lc",
"id; pwd; echo HOME=$HOME; echo USER=$USER; echo SHELL=$SHELL; env | sort | grep -E '^(HOME|USER|SHELL|XDG|PATH)=' || true; ls -ld /root /tmp /workspace /data 2>/dev/null || true; /usr/local/bin/opencode --version; rm -f /tmp/opencode.log; (/usr/local/bin/opencode serve --hostname 127.0.0.1 --port 4096 >/tmp/opencode.log 2>&1 &) ; sleep 5; echo '--- HEALTH ---'; curl -iS http://127.0.0.1:4096/health || true; echo; echo '--- SESSION CREATE ---'; curl -iS -X POST -H 'content-type: application/json' -d '{\"title\":\"debug\"}' http://127.0.0.1:4096/session || true; echo; echo '--- PROVIDER ---'; curl -iS http://127.0.0.1:4096/provider || true; echo; echo '--- OPENCODE LOG ---'; cat /tmp/opencode.log || true",
],
)
.await?;
println!("stdout:\n{}", out.stdout()?);
eprintln!("stderr:\n{}", out.stderr()?);
sandbox.stop().await?;
Ok(())
}

View File

@@ -0,0 +1,322 @@
use anyhow::{Context, Result};
use microsandbox::{ExecEvent, NetworkPolicy, Sandbox};
use reqwest::StatusCode;
use std::env;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
#[tokio::main]
async fn main() -> Result<()> {
let image = env::var("OPENWORK_MICROSANDBOX_IMAGE")
.unwrap_or_else(|_| "openwork-microsandbox:dev".to_string());
let name = env::var("OPENWORK_MICROSANDBOX_NAME")
.unwrap_or_else(|_| "openwork-microsandbox-rust".to_string());
let workspace_dir = env::var("OPENWORK_MICROSANDBOX_WORKSPACE_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| default_bind_dir(&name, "workspace"));
let data_dir = env::var("OPENWORK_MICROSANDBOX_DATA_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| default_bind_dir(&name, "data"));
let replace = env_flag("OPENWORK_MICROSANDBOX_REPLACE");
let host_port = env::var("OPENWORK_MICROSANDBOX_PORT")
.ok()
.and_then(|value| value.parse::<u16>().ok())
.unwrap_or(8787);
let connect_host =
env::var("OPENWORK_CONNECT_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let client_token =
env::var("OPENWORK_TOKEN").unwrap_or_else(|_| "microsandbox-token".to_string());
let host_token =
env::var("OPENWORK_HOST_TOKEN").unwrap_or_else(|_| "microsandbox-host-token".to_string());
println!(
"Starting microsandbox `{name}` from image `{image}` on http://{connect_host}:{host_port}"
);
ensure_bind_dir(&workspace_dir).await?;
ensure_bind_dir(&data_dir).await?;
let mut builder = Sandbox::builder(&name)
.image(image.as_str())
.memory(2048)
.cpus(2)
.env("OPENWORK_CONNECT_HOST", &connect_host)
.env("OPENWORK_TOKEN", &client_token)
.env("OPENWORK_HOST_TOKEN", &host_token)
.env("OPENWORK_APPROVAL_MODE", "auto")
.port(host_port, 8787)
.volume("/workspace", |v| {
v.bind(workspace_dir.to_string_lossy().as_ref())
})
.volume("/data", |v| v.bind(data_dir.to_string_lossy().as_ref()))
.network(|n| n.policy(NetworkPolicy::allow_all()));
if replace {
builder = builder.replace();
}
let sandbox = builder
.create()
.await
.with_context(|| {
format!(
"failed to create microsandbox from image `{image}`; if this image only exists in Docker, push it to a registry or otherwise make it available as an OCI image reference first"
)
})?;
let server = sandbox
.exec_stream(
"/bin/sh",
["-lc", "/usr/local/bin/microsandbox-entrypoint.sh"],
)
.await
.context("failed to start the OpenWork microsandbox entrypoint inside the VM")?;
let log_task = tokio::spawn(async move {
let mut server = server;
while let Some(event) = server.recv().await {
match event {
ExecEvent::Stdout(data) => print!("{}", String::from_utf8_lossy(&data)),
ExecEvent::Stderr(data) => eprint!("{}", String::from_utf8_lossy(&data)),
ExecEvent::Exited { code } => {
eprintln!("microsandbox entrypoint exited with code {code}");
break;
}
_ => {}
}
}
});
let base_url = format!("http://127.0.0.1:{host_port}");
wait_for_health(&base_url).await?;
verify_remote_connect(&base_url, &client_token).await?;
println!();
println!("Health check passed: {base_url}/health");
println!("Remote connect URL: http://{connect_host}:{host_port}");
println!("Remote connect token: {client_token}");
println!("Host/admin token: {host_token}");
println!("Workspace dir: {}", workspace_dir.display());
println!("Data dir: {}", data_dir.display());
println!("Sandbox logs are streaming below.");
println!("Press Ctrl+C to stop the sandbox.");
tokio::signal::ctrl_c()
.await
.context("failed waiting for Ctrl+C")?;
println!("Stopping microsandbox `{name}`...");
sandbox
.stop()
.await
.context("failed to stop microsandbox")?;
let _ = tokio::time::timeout(Duration::from_secs(5), log_task).await;
Ok(())
}
fn env_flag(name: &str) -> bool {
matches!(
env::var(name).ok().as_deref(),
Some("1") | Some("true") | Some("TRUE") | Some("yes") | Some("YES")
)
}
fn default_bind_dir(name: &str, suffix: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join(".state")
.join(name)
.join(suffix)
}
async fn ensure_bind_dir(path: &Path) -> Result<()> {
tokio::fs::create_dir_all(path)
.await
.with_context(|| format!("failed to create bind mount directory `{}`", path.display()))
}
async fn wait_for_health(base_url: &str) -> Result<()> {
let client = reqwest::Client::new();
let deadline = Instant::now() + Duration::from_secs(60);
let health_url = format!("{base_url}/health");
loop {
match client.get(&health_url).send().await {
Ok(response) if response.status().is_success() => return Ok(()),
Ok(_) | Err(_) if Instant::now() < deadline => {
tokio::time::sleep(Duration::from_secs(1)).await;
}
Ok(response) => {
anyhow::bail!("health check failed with status {}", response.status());
}
Err(error) => {
return Err(error).context("health check never succeeded before timeout");
}
}
}
}
async fn verify_remote_connect(base_url: &str, token: &str) -> Result<()> {
let client = reqwest::Client::new();
let workspaces_url = format!("{base_url}/workspaces");
let unauthorized = client
.get(&workspaces_url)
.send()
.await
.context("failed to query workspaces without auth")?;
if unauthorized.status() != StatusCode::UNAUTHORIZED {
anyhow::bail!(
"expected unauthenticated /workspaces to return 401, got {}",
unauthorized.status()
);
}
let authorized = client
.get(&workspaces_url)
.bearer_auth(token)
.send()
.await
.context("failed to query workspaces with client token")?;
if !authorized.status().is_success() {
anyhow::bail!(
"expected authenticated /workspaces to succeed, got {}",
authorized.status()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{json, Value};
use std::env::temp_dir;
use std::time::{SystemTime, UNIX_EPOCH};
#[tokio::test]
#[ignore = "requires microsandbox runtime and a pullable OCI image"]
async fn rust_example_smoke_test_checks_health_and_session_endpoints() -> Result<()> {
let image = env::var("OPENWORK_MICROSANDBOX_IMAGE")
.unwrap_or_else(|_| "ttl.sh/openwork-microsandbox-11559:1d".to_string());
let connect_host = "127.0.0.1";
let client_token = "some-shared-secret";
let host_token = "some-owner-secret";
let host_port = 28787;
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_millis();
let short = unique % 1_000_000;
let name = format!("owmsb-{short}");
let base_dir = temp_dir().join(format!("owmsb-{short}"));
let workspace_dir = base_dir.join("workspace");
let data_dir = base_dir.join("data");
ensure_bind_dir(&workspace_dir).await?;
ensure_bind_dir(&data_dir).await?;
let sandbox = Sandbox::builder(&name)
.image(image.as_str())
.replace()
.memory(2048)
.cpus(2)
.env("OPENWORK_CONNECT_HOST", connect_host)
.env("OPENWORK_TOKEN", client_token)
.env("OPENWORK_HOST_TOKEN", host_token)
.env("OPENWORK_APPROVAL_MODE", "auto")
.port(host_port, 8787)
.volume("/workspace", |v| {
v.bind(workspace_dir.to_string_lossy().as_ref())
})
.volume("/data", |v| v.bind(data_dir.to_string_lossy().as_ref()))
.network(|n| n.policy(NetworkPolicy::allow_all()))
.create()
.await?;
let server = sandbox
.exec_stream(
"/bin/sh",
["-lc", "/usr/local/bin/microsandbox-entrypoint.sh"],
)
.await?;
let log_task = tokio::spawn(async move {
let mut server = server;
while let Some(event) = server.recv().await {
match event {
ExecEvent::Stdout(data) => print!("{}", String::from_utf8_lossy(&data)),
ExecEvent::Stderr(data) => eprint!("{}", String::from_utf8_lossy(&data)),
ExecEvent::Exited { code } => {
eprintln!("test microsandbox entrypoint exited with code {code}");
break;
}
_ => {}
}
}
});
let base_url = format!("http://127.0.0.1:{host_port}");
let result = async {
wait_for_health(&base_url).await?;
verify_remote_connect(&base_url, client_token).await?;
let client = reqwest::Client::new();
let workspaces: Value = client
.get(format!("{base_url}/workspaces"))
.bearer_auth(client_token)
.send()
.await?
.error_for_status()?
.json()
.await?;
let workspace_id = workspaces
.get("items")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get("id"))
.and_then(Value::as_str)
.context("missing workspace id from /workspaces")?;
let created: Value = client
.post(format!("{base_url}/w/{workspace_id}/opencode/session"))
.bearer_auth(client_token)
.json(&json!({ "title": "Rust microsandbox smoke test" }))
.send()
.await?
.error_for_status()?
.json()
.await?;
let session_id = created
.get("id")
.and_then(Value::as_str)
.context("missing session id from session create response")?;
client
.get(format!(
"{base_url}/w/{workspace_id}/opencode/session/{session_id}"
))
.bearer_auth(client_token)
.send()
.await?
.error_for_status()?;
client
.get(format!(
"{base_url}/w/{workspace_id}/opencode/session/{session_id}/message?limit=10"
))
.bearer_auth(client_token)
.send()
.await?
.error_for_status()?;
Result::<()>::Ok(())
}
.await;
let stop_result = sandbox.stop().await;
let _ = tokio::time::timeout(Duration::from_secs(5), log_task).await;
stop_result?;
result
}
}