Skip to content

feat(core): put sandbox execution behind a provider seam, off by default - #36

Draft
khaliqgant wants to merge 1 commit into
mainfrom
agent/relayflows-sandbox-abstraction-0819
Draft

feat(core): put sandbox execution behind a provider seam, off by default#36
khaliqgant wants to merge 1 commit into
mainfrom
agent/relayflows-sandbox-abstraction-0819

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 19, 2026

Copy link
Copy Markdown
Member

What this is

The engine could already run a step somewhere other than a local child process — ProcessBackend has been in types.ts for a while — but nothing in this repo could select where. The provider decision lived entirely in whatever caller injected the backend, so there was no config, no default, and nothing to test. The only trace of a provider anywhere in packages/core/src was the word "Daytona" in five doc comments.

This adds the missing half: a provider seam that resolves a sandbox from config or env, adapts it to ProcessBackend, and stays off unless you turn it on.

The default does not change

RELAYFLOWS_SANDBOX_PROVIDER defaults to none, which produces no backend, which leaves the local child-process path exactly as it is today. An explicit executor or processBackend still wins over sandbox config, so a host that injects its own backend keeps it. Three of the must-not-fire tests exist specifically to hold that line.

Shape

  • sandbox-backend.ts — adapts any @agent-relay/sandbox WorkflowRuntime to ProcessBackend (including the units mismatch: ProcessEnvironment.exec takes timeoutSeconds, the sandbox port takes timeoutMs), resolves providers from config or env, and exposes registerSandboxProvider so a host can plug in a runtime that does not live here.
  • sandbox-local-runtime.ts — a real local-process provider: real processes, a private directory and HOME per step, real exit codes, real teardown.
  • Runner wiring — one branch in the constructor, reached only when the caller injected neither an executor nor a backend.

The engine depends on the provider-agnostic port, never on a vendor SDK. @daytonaio/sdk remains an optional peer of @agent-relay/sandbox, imported lazily and only when the daytona provider is actually selected. The port is restated locally rather than re-exported, so consumers are not forced to install @agent-relay/sandbox — with a type-level assertion in the tests that fails the build if the restatement ever drifts from the real type.

Why the tests look like that

A mock backend proves only that the runner called something. It cannot tell "ran inside a sandbox" apart from "quietly fell back to a local child process" — which is exactly the regression this seam is most likely to suffer, and exactly the one that would look green.

So the workload is real: a real WorkflowRunner.execute over a real deterministic step running a real sh command. The evidence is a fact only the sandbox can produce — the runtime injects RELAYFLOWS_SANDBOX_ENV_ID and repoints HOME at the environment root at exec time, and neither exists in the parent process. The runtime also sets those markers last, so launch env and exec env cannot forge them.

Each must-fire has a paired must-not-fire control on the same assertion.

Discriminating evidence. Disabling the constructor wiring (void sandboxConfig in place of the backend call) and rerunning:

× MUST FIRE: a deterministic step runs inside a sandbox the provider created
  → expected 'ABSENT' not to be 'ABSENT'
× MUST FIRE: the env flag alone flips routing on, with no code change
  → expected 'ABSENT' to match /^relayflows-probe-/
× MUST FIRE: a real non-zero exit inside the sandbox fails the real step
  → expected 'Command failed with exit code 17' to contain 'relayflows-probe-'
× MUST FIRE: the sandbox is torn down after the step
  → expected '/Users/khaliqgant' to contain 'relayflows-probe-'

Tests  4 failed | 5 passed (9)      exit 1

All four must-fire tests red; all five must-not-fire controls green. Wiring restored, 9 passed, exit 0.

What is proven and what is not

Proven end to end: the real workload path — WorkflowRunnersandbox config → adapter → runtime → real process — with real commands, real exit codes, real teardown, through the local-process provider.

Not proven here: a real Daytona run. No Daytona credentials are available in this environment, so the daytona provider is exercised only for its config/precondition behavior, not against live infrastructure. It shares every line of the adapter and runner path with the proven provider — the divergence is confined to DaytonaRuntime construction inside @agent-relay/sandbox — but that last mile is untested and should not be read as verified.

Checks

Command Exit
npm install 0
npm run build 0
npm run typecheck 0
npm test 0 — 52 files, 951 tests

One note: npm run typecheck fails with exit 2 on a tree where packages/core/dist has never been built, because @relayflows/cli typechecks against core's emitted declarations and the script does not build core first. That is pre-existing and unrelated to this change (only packages/core/src is touched here); npm run build first, then npm run typecheck, is clean.

The package-lock.json diff is larger than the one added dependency: npm install also synced stale 1.0.5 workspace versions to 1.0.6 and rewrote some libc platform metadata. No packages were removed.

🤖 Generated with Claude Code


Summary by cubic

Adds a sandbox provider seam for step execution, selectable by config or env and off by default. Previously steps ran as local child processes unless a caller injected a backend; now the runner can resolve a provider and adapt it to ProcessBackend without changing the default path.

  • sandbox-backend.ts adapts any @agent-relay/sandbox runtime to ProcessBackend, resolves providers from config/env, and exposes registerSandboxProvider. The port is restated locally with a type-level check; no consumer is forced to install @agent-relay/sandbox.
  • Built-ins: local-process (real local processes with per-step HOME and teardown) and daytona (via @agent-relay/sandbox; requires optional peer @daytonaio/sdk and a homeDir).
  • Runner wiring: if no executor and no processBackend are provided, it lazily creates a backend from the sandbox config. An explicit executor or processBackend still wins.
  • Tests run real commands end to end to prove routing (markers injected at exec time) and teardown; Daytona is validated for config/preconditions only. README adds provider usage.

Bolded section title:

  • Rollout
    • Default behavior does not change. No action needed unless you opt in.
    • To enable via env: set RELAYFLOWS_SANDBOX_PROVIDER=daytona, DAYTONA_API_KEY, and RELAYFLOWS_SANDBOX_HOME_DIR (or use local-process).
    • To enable in code: pass sandbox: { provider: 'daytona', homeDir: '/path' } (or register a custom runtime with registerSandboxProvider).
    • If you select daytona, install @daytonaio/sdk.

Written for commit d89956e. Summary will update on new commits.

Review in cubic

The engine could already run steps somewhere other than a local child
process — `ProcessBackend` has been there — but nothing in this repo could
select one. The vendor decision lived entirely in the caller that injected the
backend, so there was no config, no default, and nothing to test: the only
mention of a provider anywhere in `packages/core/src` was the word "Daytona"
in five doc comments.

This adds the missing half. `sandbox-backend.ts` adapts any
`@agent-relay/sandbox` `WorkflowRuntime` to `ProcessBackend`, resolves a
provider from config or env, and lets a host register its own runtime under any
name. The engine depends on the provider-agnostic port, never on a vendor SDK:
`@daytonaio/sdk` stays an optional peer, imported lazily and only when the
daytona provider is actually selected.

Nothing about the default changes. `RELAYFLOWS_SANDBOX_PROVIDER` defaults to
`none`, which produces no backend, which leaves the local child-process path
exactly as it was; an explicit `executor` or `processBackend` still wins over
sandbox config, so a host injecting its own backend today keeps it.

`sandbox-local-runtime.ts` ships a real `local-process` provider — real
processes, a private directory and `HOME` per step, real exit codes — so the
sandbox path can be exercised without a vendor account, and so the routing
tests can assert on something a mock cannot fake. That matters, because a mock
backend cannot distinguish "ran in a sandbox" from "quietly fell back to a
local process", which is the regression this seam is most likely to suffer.
The tests instead run a real workflow whose command prints a marker the runtime
injects at exec time and that exists nowhere in the parent process. Disabling
the wiring turns all four must-fire tests red while all five must-not-fire
controls stay green.

Also restates the port locally rather than re-exporting it, so consumers are
not forced to install `@agent-relay/sandbox` — with a type-level assertion that
fails the build if the two ever drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: 7845eb84-47da-4695-a3cd-0cb8517d472b
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dcfd8dfd-3b68-4efa-b9a3-8c9c623890a3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant