@persistent-ai/fireflow-sandbox
Isolated container environments for FireFlow — code execution, testing, and deployment inside ephemeral sandboxes, brokered through DBOS durable workflows.
What It Is
This package gives FireFlow flows and LLM agents a safe place to run untrusted or generated code. Each sandbox is an ephemeral, isolated container provisioned through the OpenSandbox runtime. A flow can create a container, run commands in it, stream output, move files in and out of the VFS, deliver secrets, reach services on container ports over HTTP/WebSocket, and tear the container down — all as durable operations that survive worker crashes.
Sandboxes are owner-bound: every operation is checked against the fireflow_sandbox_instances record, and only the caller who created a sandbox may act on it. Containers carry a TTL and are tracked by a durable watcher workflow that keeps their lifecycle status in sync.
Key properties:
- Durable — every sandbox operation runs as DBOS steps with automatic crash recovery
- Owner-only access — ACL check is the first step of every operation
- Path-scoped — optional deny-first access rules restrict read / readwrite / execute by container path
- Secret-safe —
.ffsecretreferences are resolved late, in the node, via ECDH; plaintext never crosses a DBOS boundary - Observable — operations emit events to a DBOS stream; long-running commands stream stdout/stderr to PortStream
- Composable — nodes use
@Passthrough()ports sosandboxId, access rules, and endpoint config chain across a flow
How It Works
Operation pattern
Sandbox operations are exposed to nodes via ISandboxContextService (context.services.sandbox), implemented by SandboxContextService. Every method follows the same three-phase shape, each phase wrapped in its own DBOS step so it is checkpointed and replay-safe:
Step 1: ACL check — load sandbox_instances row, verify caller is owner,
optionally enforce path access rules
Step 2: Operation — call the OpenSandbox SDK (create / run / pause / sync / ...)
Step 3: Event emit — update the DB record + DBOS.writeStream(sandbox-events)Short-lived operations (create, execute, kill, pause, resume, renew, getStatus, resolveEndpoint, httpRequest, writeSecretFile) are awaited inline. Long-running operations (executeStream, syncFromVFS, syncToVFS) return an ISandboxWorkflowHandle ({ workflowId, getResult, getStatus }) so a node can fire-and-forget or await completion. Their output is written to a DBOS stream as it is produced and the stream is closed at workflow level on completion.
The steps run inside the current execution workflow (so they share its DBOS context and stream lifetime). A dedicated
fireflow-sandboxWorkflowQueueis created at module level — beforeDBOS.launch()— so a future deployment can route sandbox operations to specialized sandbox workers. TheSandboxWorkflowsclass provides the same operations as standalone@DBOS.workflow()methods for that queue.
Lifecycle watcher
SandboxContextService.create() starts a SandboxWatcherWorkflow child workflow with the deterministic ID sandbox-watcher:${sandboxId}. It polls the container state every 10s inside a single DBOS step (the loop, including its delays, lives in one step — on restart DBOS re-runs the step fresh rather than replaying each poll) and syncs status / terminatedAt on the sandbox_instances row, stopping on any terminal state or when the container disappears.
Access control
checkSandboxACL() is the first step of every operation: it loads the sandbox record, rejects unknown / non-owner / terminated sandboxes. On top of ownership, checkSandboxPathAccess() enforces optional deny-first path rules with three access levels — read, readwrite, execute. Empty rules mean "allow all" (backward compatible); once any rule is set, access defaults to deny unless an allow rule matches the path prefix and level.
Secret delivery
resolveSecretMappingsToEnvs() resolves an array of .ffsecret VFS references to plaintext environment variables using the same ECDH flow as the vault secret nodes: read the .ffsecret file → request the secret from the vault with a per-batch ECDH public key → vault re-encrypts to that key pair → decrypt locally → extract the field. This must run in node.execute(context), where the ExecutionContext (and its ECDH key pair) exists; the resolved env map is then handed to the sandbox service via an in-memory call, never serialized across a DBOS boundary. Secret scope can be the caller or the flowOwner. SandboxWriteSecretFileNode writes a resolved secret to a file inside the container for credential-file workflows.
Endpoint proxying
resolveEndpoint() and httpRequest() resolve a container port to its OpenSandbox endpoint URL (with routing headers) behind the ACL check — the raw endpoint URL is never exposed to the flow graph. The HTTP/WebSocket nodes use this to talk to services running inside a sandbox. Owner-authenticated browser access to those ports (OAuth/PKCE) is provided separately by the proxy mounted on fireflow-execution-api; this package contributes the SandboxProxyConfig type and the proxy_config DB column that drive it.
Package Exports
The package ships client-safe and server-only entry points. Importing the server entry registers the sandbox nodes via their @Node() decorators.
| Export | Environment | Contents |
|---|---|---|
@persistent-ai/fireflow-sandbox | Client + Server | Type definitions, access-rule schema, event types, config schemas |
@persistent-ai/fireflow-sandbox/server | Server only | Everything: DB schema, nodes, services, steps, types, workflows (triggers node registration) |
@persistent-ai/fireflow-sandbox/nodes | Server only | The 15 sandbox node classes (importing registers them) |
@persistent-ai/fireflow-sandbox/db | Server only | sandboxInstances Drizzle table + SandboxInstanceRow / SandboxInstanceInsert types |
@persistent-ai/fireflow-sandbox/types | Client + Server | All type / schema definitions (re-exported from the root entry) |
// Frontend / shared — types and schemas only
import { SandboxAccessRule, type SandboxEventPayload } from '@persistent-ai/fireflow-sandbox'
// Backend — full surface (services, workflows, steps, nodes)
import { SandboxContextService, SandboxWorkflows, checkSandboxACL } from '@persistent-ai/fireflow-sandbox/server'
// Schema sharing (executor / trpc re-export the table from here)
import { sandboxInstances } from '@persistent-ai/fireflow-sandbox/db'Nodes
15 nodes in the sandbox category. Importing /server or /nodes registers them in the NodeRegistry; all are marked execution: { dbos: true }.
| Node | Purpose |
|---|---|
SandboxCreateNode | Provision a container (image, resources, users, env, access rules, secrets, network policy, proxy config) → returns sandboxId |
SandboxExecuteNode | Run a command and return stdout / stderr / exit code (blocking) |
SandboxExecuteStreamNode | Run a long-lived command, streaming stdout/stderr/exit chunks to a PortStream |
SandboxKillNode | Terminate the container |
SandboxPauseNode | Pause a running container |
SandboxResumeNode | Resume a paused container |
SandboxRenewNode | Extend the container TTL |
SandboxStatusNode | Read-only liveness / state / optional metrics (works on terminated sandboxes too) |
SandboxSyncFromVFSNode | Transfer files VFS → container (filters, gitignore, empty-dir handling) |
SandboxSyncToVFSNode | Transfer files container → VFS (globs, exclude patterns, mirror mode, optional commit) |
SandboxWriteSecretFileNode | Resolve a .ffsecret and write it as a file inside the container |
SandboxHttpRequestNode | Authenticated HTTP request to a container port (endpoint resolution hidden) |
SandboxHttpStreamNode | Streaming HTTP response from a container port |
SandboxWebSocketNode | WebSocket connection to a container port |
SandboxStreamSplitterNode | Split a sandbox output stream into separate stdout / stderr / exit channels |
Database
One table, fireflow_sandbox_instances (Drizzle, fireflow_ prefix), defined in src/db/schema.ts. It is the ownership / ACL entity for every sandbox operation.
| Column | Type | Notes |
|---|---|---|
id | text PK | Sandbox ID (from OpenSandbox) |
owner_id | text | Caller who created the sandbox — checked on every operation |
execution_id / workflow_id | text | Originating execution / workflow |
image | text | Container image |
status | text | creating / running / paused / terminated |
visibility | text | private (default) |
workspace_id | text | Associated VFS workspace |
access_rules | jsonb | SandboxAccessRule[] |
sandbox_users / default_uid | jsonb / int | Per-command uid/gid isolation |
metadata / resource_spec | jsonb | Tags, CPU/memory spec |
proxy_config | jsonb | { defaultPort?, exposedPorts? } for the endpoint proxy |
created_at / expires_at / terminated_at | timestamp | TTL + lifecycle tracking |
Indexes: owner_id, execution_id, status.
Events
Operations write a SandboxEventPayload to the sandbox-events DBOS stream:
SANDBOX_CREATED, SANDBOX_KILLED, SANDBOX_PAUSED, SANDBOX_RESUMED, SANDBOX_RENEWED, COMMAND_COMPLETED, SYNC_FROM_VFS_COMPLETED, SYNC_TO_VFS_COMPLETED.
Integration with Flow Execution
The package plugs into fireflow-executor at three points:
- Node registration — the executor imports
@persistent-ai/fireflow-sandbox/server(viaserialization.ts) so the sandbox node classes register at startup. - Context service —
ExecutionWorkflowsconstructsnew SandboxContextService(dbosService, db, callerId, vfs, vfsWrite, sandboxWatcher)and injects it ascontext.services.sandbox, so sandbox nodes can broker operations using the flow's DBOS context, VFS access, and the caller identity for ACL. - Watcher + queue wiring —
ServiceFactorycreates the singletonSandboxWatcherWorkflow(a DBOSConfiguredInstance) and the executor'squeue.tsimports the module-levelsandboxQueue, both beforeDBOS.launch().
The sandbox_instances table is also re-exported through fireflow-trpc's Postgres schema so migrations and the rest of the platform share a single definition.
Configuration
OpenSandbox connection settings are read from the environment by createConnectionConfig() (cached per worker process):
| Variable | Default | Description |
|---|---|---|
OPENSANDBOX_DOMAIN | localhost:8080 | OpenSandbox API host |
OPENSANDBOX_API_KEY | (none) | API key (optional) |
OPENSANDBOX_PROTOCOL | http | http or https |
OPENSANDBOX_USE_SERVER_PROXY | false | Route SDK calls through the server proxy |
OPENSANDBOX_REQUEST_TIMEOUT | 30 | Request timeout (seconds) |
Sandbox queue concurrency:
| Variable | Default | Description |
|---|---|---|
DBOS_SANDBOX_QUEUE_CONCURRENCY | 30 | Global concurrency across workers for the sandbox queue |
DBOS_SANDBOX_WORKER_CONCURRENCY | 3 | Per-worker concurrency |
Directory Structure
src/
├── index.ts # Client-safe exports (types)
├── server.ts # Server-only barrel (db, nodes, services, steps, types, workflows)
├── db/ # sandbox_instances Drizzle schema
├── types/ # Access rules, events, config schemas (resource/network/proxy/secrets/...)
├── nodes/ # 15 sandbox node classes
├── services/ # SandboxContextService + OpenSandbox connection config
├── steps/ # SDK calls, ACL, file transfer, glob utils, secret resolution
└── workflows/ # SandboxWorkflows, SandboxWatcherWorkflow, sandbox queueDevelopment
pnpm --filter @persistent-ai/fireflow-sandbox build # Compile
pnpm --filter @persistent-ai/fireflow-sandbox typecheck # Type check
pnpm --filter @persistent-ai/fireflow-sandbox dev # tsc watch
pnpm --filter @persistent-ai/fireflow-sandbox test # Vitest
pnpm --filter @persistent-ai/fireflow-sandbox test:coverageDependencies
| Package | Purpose |
|---|---|
@alibaba-group/opensandbox | Sandbox container runtime SDK |
@dbos-inc/dbos-sdk | Durable workflow execution |
@persistent-ai/fireflow-types | Node base classes, decorators, execution context, secret wrapping |
ignore / minimatch | Gitignore + glob filtering for file sync |
nanoid | Durable workflow ID generation |
turndown | HTML → Markdown for HTTP responses |
ws | WebSocket connections to container ports |
Peer dependencies: drizzle-orm, pg, superjson.
License
Business Source License 1.1 (BUSL-1.1) — see LICENSE.txt.