Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
90 commits
Select commit Hold shift + click to select a range
99e8cf0
feat(sandbox): multi-backend router with per-session backend selection
penso Apr 30, 2026
f1b6047
feat(sandbox): workspace sync for isolated backends
penso Apr 30, 2026
87dfeaf
feat(sandbox): add Vercel Sandbox backend (Firecracker microVM)
penso Apr 30, 2026
0d79fb2
feat(sandbox): add Daytona Sandbox backend (cloud sandbox)
penso Apr 30, 2026
95be5c6
feat(sandbox): add local Firecracker backend (microVM without Docker)
penso Apr 30, 2026
3977ed9
feat(web-ui): remote sandbox backend configuration settings
penso Apr 30, 2026
8dec2c2
fix(sandbox): address greptile review feedback (greploop iteration 1)
penso Apr 30, 2026
d887e9d
fix(sandbox): address greptile review feedback (greploop iteration 2)
penso Apr 30, 2026
1a57c1a
fix(sandbox): address greptile review feedback (greploop iteration 3)
penso Apr 30, 2026
3d48f5a
fix(sandbox): address greptile review feedback (greploop iteration 4)
penso Apr 30, 2026
beb99bf
fix(sandbox): address greptile review feedback (greploop iteration 5)
penso Apr 30, 2026
41567c3
feat(sandbox): cross-backend package provisioning for remote sandboxes
penso May 1, 2026
bdcefed
feat(sandbox): pre-built image/snapshot caching for remote backends
penso May 1, 2026
98c4934
fix(sandbox): address PR review threads
penso May 1, 2026
96192a5
fix(sandbox): quote cwd in Firecracker SSH exec
penso May 1, 2026
b77fb73
fix(sandbox): kill leaked VM process and escape mkdir path
penso May 1, 2026
ced6414
fix(sandbox): prevent process leak and subnet overflow in Firecracker
penso May 1, 2026
bae8cbb
fix(sandbox): use base64-encoded command wrapper for Daytona stderr
penso May 1, 2026
18b72bb
fix(sandbox): quote sandbox_workspace paths in sync shell commands
penso May 1, 2026
e6b5b56
fix(sandbox): add #[allow(clippy::unwrap_used)] to test modules
penso May 1, 2026
bdeb04e
fix: use short HashMap import in web api.rs
penso May 1, 2026
3711776
Merge remote-tracking branch 'origin/main' into flaxen-cemetery
penso May 1, 2026
fef9147
refactor(gateway): extract sandbox init into prepare_core/sandbox.rs
penso May 1, 2026
03f3c6a
fix: resolve pre-existing clippy lints from main merge
penso May 1, 2026
d4d8f56
fix: remove unused Ordering import from prepare_core.rs
penso May 1, 2026
9d17e71
style: format prepare_core.rs imports
penso May 1, 2026
034fc06
test(e2e): add remote sandbox backend configuration tests
penso May 1, 2026
7e36ae3
test(e2e): add live integration tests for Vercel and Daytona sandboxes
penso May 1, 2026
f65b87b
docs: add remote sandbox backends documentation
penso May 1, 2026
7bed45d
fix(e2e): add VERCEL_PROJECT_ID to live sandbox tests (required by API)
penso May 1, 2026
68efad4
fix(e2e): fix Vercel stop 415 (add Content-Type) and Daytona exec 404…
penso May 1, 2026
650bfd6
fix(e2e): retry Daytona sandbox creation on transient 5xx errors
penso May 1, 2026
6443e50
fix(e2e): increase Daytona live test timeout to 120s (slow sandbox st…
penso May 1, 2026
1d18b8e
fix(sandbox): update Daytona API paths from /workspace to /sandbox
penso May 1, 2026
5273b74
chore: add root-level test-results/ to gitignore
penso May 1, 2026
28503d7
Merge remote-tracking branch 'origin/main' into flaxen-cemetery
penso May 1, 2026
c007011
feat(web-ui): split sandbox settings into tabbed layout
penso May 1, 2026
6cb0957
fix(sandbox): Vercel project_id is required, improve build error mess…
penso May 1, 2026
476290b
fix(e2e): fix Daytona cleanup paths from /workspace to /sandbox
penso May 1, 2026
2128276
feat(web-ui): add backend selector dropdown in General tab
penso May 1, 2026
4d68e18
fix(sandbox): add debug/warn logging to image build pipeline
penso May 1, 2026
64def80
fix(sandbox): image builder falls back to alternate CLI on daemon error
penso May 1, 2026
995176c
fix(sandbox): web UI image build uses correct CLI for active backend
penso May 2, 2026
c1be950
fix(sandbox): image list handler uses correct CLI for active backend
penso May 2, 2026
056523f
refactor(sandbox): DRY image CLI selection via DockerImageBuilder::fo…
penso May 2, 2026
017b1d9
feat(web-ui): per-session backend selector in sandbox image dropdown
penso May 2, 2026
fde5cf3
feat(web-ui): General tab shows all available backends, no restart ne…
penso May 2, 2026
4c6df95
fix(sandbox): remote backend 'configured' status checks env vars too
penso May 2, 2026
eb30648
refactor(config): env var aliases for sandbox credentials, remove man…
penso May 2, 2026
a8f8eb5
test(config): add unit tests for env var alias feature
penso May 2, 2026
ccc23d9
fix(web-ui): fix configured badge visibility and show token hint
penso May 2, 2026
06bdaab
feat(web-ui): disable token inputs when set via environment variable
penso May 2, 2026
2635047
fix(web-ui): badge colors, clickable backend pills, prune button style
penso May 2, 2026
202534f
fix(web-ui): backend pill click sets default, smaller prune button
penso May 2, 2026
0f301b8
fix(sandbox): remap /home/sandbox to correct workspace for remote bac…
penso May 2, 2026
bab7504
feat(terminal): allow opening terminal into running sandbox containers
penso May 2, 2026
bfc48cf
fix(terminal): pass container_target to restart spawn call
penso May 2, 2026
13936b2
Merge remote-tracking branch 'origin/main' into flaxen-cemetery
penso May 6, 2026
2c60351
fix(sandbox): harden remote backend sync and config writes
penso May 6, 2026
15551f0
fix(web-ui): remove unused backend labels
penso May 6, 2026
b95cfd7
fix(tools): preserve safe sync-out symlinks
penso May 6, 2026
ea5fa5a
fix(config): redact sandbox credentials in debug
penso May 6, 2026
e4529e2
fix(config): store sandbox credentials as secrets
penso May 6, 2026
da36050
fix(tools): handle hardlinks and sandbox creation races
penso May 6, 2026
5a25716
fix(tools): harden workspace sync extraction
penso May 6, 2026
8484bf9
fix(tools): preserve remote workspace subdirectories
penso May 6, 2026
472b074
fix(tools): forward exec env in remote sandboxes
penso May 6, 2026
23c542e
fix(tools): harden cloud sandbox validation
penso May 6, 2026
b0350b2
fix(tools): stop leaked vercel setup sandboxes
penso May 6, 2026
abe041b
fix(gateway): restore persisted sandbox backend overrides
penso May 6, 2026
3dd7fd9
fix(tools): clean up firecracker build failures
penso May 6, 2026
406d551
fix(tools): scope sandbox prebuild images by backend
penso May 6, 2026
c5fc297
style(web): format remote sandbox live test
penso May 6, 2026
8a304a0
fix(tools): harden remote sandbox command cleanup
penso May 6, 2026
20d754c
fix(tools): stop vercel sandbox on snapshot request failure
penso May 6, 2026
fdc95e6
fix(tools): unblock isolated exec waiters on prepare failure
penso May 6, 2026
87bba11
fix(web): report firecracker backend and skip unusable live e2e
penso May 6, 2026
12c7012
fix(tools): clean up sandbox build resources
penso May 6, 2026
4ac1d7b
fix(tools): fail fast on isolated sync preparation errors
penso May 6, 2026
2152a1d
fix(tools): harden vercel sandbox lifecycle parsing
penso May 6, 2026
ce7b26b
fix(tools): resolve firecracker binary from path
penso May 6, 2026
25e173a
fix(tools): preserve vercel inline command output
penso May 6, 2026
fce7297
fix(tools): fall back to vercel inline output
penso May 6, 2026
86fcd68
fix(tools): handle unparseable vercel sandbox ids
penso May 6, 2026
7e04bd2
fix(tools): upload firecracker writes with scp
penso May 6, 2026
d99fd40
fix(tools): skip symlink collisions during sync extract
penso May 6, 2026
120c80f
fix(tools): skip unsafe sync extract parents
penso May 6, 2026
f4e580d
fix(tools): remove host tar dependency from sync
penso May 6, 2026
48045f3
fix(tools): retry isolated sync after failure
penso May 6, 2026
eebffd8
fix(tools): clear stale sync state before retry
penso May 6, 2026
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ full = [
"tls",
"trusted-network",
"vault",
"vercel-sandbox",
"voice",
"wasm",
"web-ui",
Expand Down Expand Up @@ -231,6 +232,7 @@ tls = ["moltis-httpd/tls"]
trusted-network = ["moltis-httpd/trusted-network"]
vault = ["moltis-httpd/vault", "moltis-web?/vault"]
voice = ["moltis-gateway/voice", "moltis-web?/voice"]
vercel-sandbox = ["moltis-tools/vercel-sandbox"]
wasm = ["moltis-tools/wasm"]
web-ui = ["dep:moltis-web", "moltis-httpd/web-ui"]
whatsapp = ["moltis-gateway/whatsapp"]
Expand Down
73 changes: 73 additions & 0 deletions crates/config/src/schema/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,62 @@ pub struct SandboxConfig {
/// Acts as layer 6 in the policy resolution chain.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools_policy: Option<ToolPolicyConfig>,

// ── Remote sandbox backends ─────────────────────────────────────────
/// Vercel API token for the Vercel Sandbox backend.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vercel_token: Option<String>,
/// Vercel project ID.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vercel_project_id: Option<String>,
/// Vercel team ID.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vercel_team_id: Option<String>,
/// Vercel sandbox runtime (e.g. "node24", "python3.13").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vercel_runtime: Option<String>,
/// Vercel sandbox timeout in milliseconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vercel_timeout_ms: Option<u64>,
/// Vercel sandbox vCPU count.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vercel_vcpus: Option<u32>,

/// Daytona API key.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daytona_api_key: Option<String>,
/// Daytona API URL (default: https://app.daytona.io/api).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daytona_api_url: Option<String>,
/// Daytona target region/environment.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daytona_target: Option<String>,
/// Custom image for Daytona sandbox creation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daytona_image: Option<String>,

/// Vercel snapshot ID for fast cold starts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vercel_snapshot_id: Option<String>,

/// Path to the `firecracker` binary (Linux only).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub firecracker_bin: Option<String>,
/// Path to the uncompressed Linux kernel (`vmlinux`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub firecracker_kernel: Option<String>,
/// Path to the base ext4 rootfs image.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub firecracker_rootfs: Option<String>,
/// Path to the SSH private key for VM access.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub firecracker_ssh_key: Option<String>,
/// Number of vCPUs per Firecracker VM.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub firecracker_vcpus: Option<u32>,
/// Memory in MiB per Firecracker VM.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub firecracker_memory_mb: Option<u32>,
}

/// Default packages installed in sandbox containers.
Expand Down Expand Up @@ -910,6 +966,23 @@ impl Default for SandboxConfig {
wasm_epoch_interval_ms: None,
wasm_tool_limits: None,
tools_policy: None,
vercel_token: None,
vercel_project_id: None,
vercel_team_id: None,
vercel_runtime: None,
vercel_timeout_ms: None,
vercel_vcpus: None,
daytona_api_key: None,
daytona_api_url: None,
daytona_target: None,
daytona_image: None,
vercel_snapshot_id: None,
firecracker_bin: None,
firecracker_kernel: None,
firecracker_rootfs: None,
firecracker_ssh_key: None,
firecracker_vcpus: None,
firecracker_memory_mb: None,
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/gateway/src/approval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ mod tests {
worktree_branch: None,
sandbox_enabled: None,
sandbox_image: None,
sandbox_backend: None,
channel_binding: Some(
serde_json::json!({
"channel_type": "telegram",
Expand Down Expand Up @@ -302,6 +303,7 @@ mod tests {
worktree_branch: None,
sandbox_enabled: None,
sandbox_image: None,
sandbox_backend: None,
channel_binding: Some("{not-json".into()),
parent_session_key: None,
fork_point: None,
Expand Down
3 changes: 3 additions & 0 deletions crates/gateway/src/channel_events/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ fn attachable_session_filter_skips_archived_and_cron_sessions() {
worktree_branch: None,
sandbox_enabled: None,
sandbox_image: None,
sandbox_backend: None,
channel_binding: None,
parent_session_key: None,
fork_point: None,
Expand Down Expand Up @@ -218,6 +219,7 @@ fn format_attachable_sessions_shows_session_keys_when_labels_are_present() {
worktree_branch: None,
sandbox_enabled: None,
sandbox_image: None,
sandbox_backend: None,
channel_binding: None,
parent_session_key: None,
fork_point: None,
Expand All @@ -242,6 +244,7 @@ fn format_attachable_sessions_shows_session_keys_when_labels_are_present() {
worktree_branch: None,
sandbox_enabled: None,
sandbox_image: None,
sandbox_backend: None,
channel_binding: None,
parent_session_key: None,
fork_point: None,
Expand Down
12 changes: 12 additions & 0 deletions crates/gateway/src/server/prepare_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1292,6 +1292,18 @@ pub async fn prepare_gateway_core(
.set_image_override(&entry.key, image.clone())
.await;
}
if let Some(ref backend) = entry.sandbox_backend
&& let Err(e) = sandbox_router
.set_backend_override(&entry.key, backend)
.await
{
tracing::debug!(
session = entry.key,
backend = backend.as_str(),
error = %e,
"skipping persisted sandbox backend override (backend not available)"
);
}
}
}

Expand Down
16 changes: 16 additions & 0 deletions crates/gateway/src/session/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,21 @@ impl SessionService for LiveSessionService {
}
}
}
if let Some(sandbox_backend_opt) = p.sandbox_backend {
let sandbox_backend = sandbox_backend_opt.filter(|s| !s.is_empty());
self.metadata
.set_sandbox_backend(key, sandbox_backend.clone())
.await;
if let Some(ref router) = self.sandbox_router {
if let Some(ref name) = sandbox_backend {
if let Err(e) = router.set_backend_override(key, name).await {
warn!(session = key, error = %e, "failed to set sandbox backend override");
}
} else {
router.remove_backend_override(key).await;
}
}
}

let entry = self
.metadata
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Expand All @@ -573,6 +588,7 @@ impl SessionService for LiveSessionService {
"archived": entry.archived,
"sandbox_enabled": entry.sandbox_enabled,
"sandbox_image": entry.sandbox_image,
"sandbox_backend": entry.sandbox_backend,
"worktree_branch": entry.worktree_branch,
"mcpDisabled": entry.mcp_disabled,
"agent_id": entry.agent_id,
Expand Down
2 changes: 2 additions & 0 deletions crates/gateway/src/session_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ pub struct PatchParams {
pub mcp_disabled: Option<Option<bool>>,
#[serde(default, deserialize_with = "double_option", alias = "sandbox_enabled")]
pub sandbox_enabled: Option<Option<bool>>,
#[serde(default, deserialize_with = "double_option", alias = "sandbox_backend")]
pub sandbox_backend: Option<Option<String>>,
}

/// Deserialize a field as `Some(inner)` when present (even if null),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE sessions ADD COLUMN sandbox_backend TEXT;
31 changes: 31 additions & 0 deletions crates/sessions/src/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ pub struct SessionEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox_image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox_backend: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel_binding: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_session_key: Option<String>,
Expand Down Expand Up @@ -132,6 +134,7 @@ impl SessionMetadata {
worktree_branch: None,
sandbox_enabled: None,
sandbox_image: None,
sandbox_backend: None,
channel_binding: None,
parent_session_key: None,
fork_point: None,
Expand Down Expand Up @@ -207,6 +210,15 @@ impl SessionMetadata {
}
}

/// Set the sandbox_backend override for a session.
pub fn set_sandbox_backend(&mut self, key: &str, backend: Option<String>) {
if let Some(entry) = self.entries.get_mut(key) {
entry.sandbox_backend = backend;
entry.updated_at = now_ms();
entry.version += 1;
}
}

/// Set the mcp_disabled override for a session.
pub fn set_mcp_disabled(&mut self, key: &str, disabled: Option<bool>) {
if let Some(entry) = self.entries.get_mut(key) {
Expand Down Expand Up @@ -317,6 +329,7 @@ struct SessionRow {
worktree_branch: Option<String>,
sandbox_enabled: Option<i32>,
sandbox_image: Option<String>,
sandbox_backend: Option<String>,
channel_binding: Option<String>,
parent_session_key: Option<String>,
fork_point: Option<i32>,
Expand Down Expand Up @@ -344,6 +357,7 @@ impl From<SessionRow> for SessionEntry {
worktree_branch: r.worktree_branch,
sandbox_enabled: r.sandbox_enabled.map(|v| v != 0),
sandbox_image: r.sandbox_image,
sandbox_backend: r.sandbox_backend,
channel_binding: r.channel_binding,
parent_session_key: r.parent_session_key,
fork_point: r.fork_point.map(|v| v as u32),
Expand Down Expand Up @@ -409,6 +423,7 @@ impl SqliteSessionMetadata {
worktree_branch TEXT,
sandbox_enabled INTEGER,
sandbox_image TEXT,
sandbox_backend TEXT,
channel_binding TEXT,
parent_session_key TEXT,
fork_point INTEGER,
Expand Down Expand Up @@ -650,6 +665,22 @@ impl SqliteSessionMetadata {
});
}

pub async fn set_sandbox_backend(&self, key: &str, backend: Option<String>) {
let now = now_ms() as i64;
sqlx::query(
"UPDATE sessions SET sandbox_backend = ?, updated_at = ?, version = version + 1 WHERE key = ?",
)
.bind(&backend)
.bind(now)
.bind(key)
.execute(&self.pool)
.await
.ok();
self.emit(crate::session_events::SessionEvent::Patched {
session_key: key.to_string(),
});
}

pub async fn set_worktree_branch(&self, key: &str, branch: Option<String>) {
let now = now_ms() as i64;
sqlx::query(
Expand Down
4 changes: 3 additions & 1 deletion crates/tools/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ version.workspace = true

[features]
bundled-skills = ["moltis-skills/bundled-skills"]
default = ["firecrawl", "fs-tools", "metrics", "wasm"]
default = ["firecrawl", "fs-tools", "metrics", "vercel-sandbox", "wasm"]
vercel-sandbox = ["dep:flate2"]
embedded-wasm = []
firecrawl = []
# Native filesystem tools (Read, Write, Edit, MultiEdit, Glob, Grep).
Expand All @@ -27,6 +28,7 @@ anyhow = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
bytes = { workspace = true }
flate2 = { optional = true, workspace = true }
futures = { workspace = true }
globset = { optional = true, workspace = true }
grep-matcher = { optional = true, workspace = true }
Expand Down
25 changes: 23 additions & 2 deletions crates/tools/src/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,8 @@ impl AgentTool for ExecTool {
// fail with ENOENT on the host, so we must fall back to the host
// data directory.
let has_container_backend = if let Some(ref router) = self.sandbox_router {
router.backend().provides_fs_isolation()
let sk = session_key.unwrap_or("main");
router.resolve_backend(sk).await.provides_fs_isolation()
} else {
self.sandbox.provides_fs_isolation()
};
Expand Down Expand Up @@ -603,7 +604,7 @@ impl AgentTool for ExecTool {
if is_sandboxed {
let id = router.sandbox_id_for(sk);
let image = router.resolve_image(sk, None).await;
let backend = router.backend();
let backend = router.resolve_backend(sk).await;
info!(session = sk, sandbox_id = %id, backend = backend.backend_name(), image, "sandbox ensure_ready");
let announce_prepare = router.mark_preparing_once(sk).await;
if announce_prepare {
Expand Down Expand Up @@ -633,6 +634,26 @@ impl AgentTool for ExecTool {
backend: backend.backend_name().to_string(),
image: image.clone(),
});

// Sync workspace for isolated backends on first run.
if backend.is_isolated()
&& let Some(host_workspace) =
crate::sandbox::sync::resolve_sync_workspace(router.config(), &id)
&& let Err(e) = crate::sandbox::sync::sync_in(
&*backend,
&id,
&host_workspace,
crate::sandbox::sync::DEFAULT_SANDBOX_WORKSPACE,
)
.await
{
warn!(
session = sk,
sandbox_id = %id,
error = %e,
"workspace sync-in failed, sandbox starts with empty workspace"
);
}
}
debug!(session = sk, sandbox_id = %id, command, "sandbox running command");
Comment thread
greptile-apps[bot] marked this conversation as resolved.
let mut sandbox_result = backend.exec(&id, command, &opts).await?;
Expand Down
2 changes: 1 addition & 1 deletion crates/tools/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ impl ProcessTool {
if is_sandboxed {
let id = router.sandbox_id_for(session_key);
let image = router.resolve_image(session_key, None).await;
let backend = router.backend();
let backend = router.resolve_backend(session_key).await;
backend.ensure_ready(&id, Some(&image)).await?;
return Ok(backend.exec(&id, &command, &opts).await?);
}
Expand Down
Loading
Loading