From d89956e59b3b8777063b6e436706cb492afeccf5 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 20 Aug 2026 00:53:29 +0200 Subject: [PATCH] feat(core): put sandbox execution behind a provider seam, off by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Session-Id: 7845eb84-47da-4695-a3cd-0cb8517d472b --- README.md | 51 ++ package-lock.json | 103 ++-- packages/core/package.json | 1 + .../src/__tests__/sandbox-backend.test.ts | 445 ++++++++++++++++++ .../src/__tests__/sandbox-routing.test.ts | 234 +++++++++ packages/core/src/builder.ts | 2 +- packages/core/src/index.ts | 2 + packages/core/src/runner.ts | 25 +- packages/core/src/sandbox-backend.ts | 409 ++++++++++++++++ packages/core/src/sandbox-local-runtime.ts | 206 ++++++++ packages/core/src/schema.ts | 4 +- packages/core/src/types.ts | 8 +- 12 files changed, 1416 insertions(+), 74 deletions(-) create mode 100644 packages/core/src/__tests__/sandbox-backend.test.ts create mode 100644 packages/core/src/__tests__/sandbox-routing.test.ts create mode 100644 packages/core/src/sandbox-backend.ts create mode 100644 packages/core/src/sandbox-local-runtime.ts diff --git a/README.md b/README.md index 65c5f47..e8a84ee 100644 --- a/README.md +++ b/README.md @@ -851,6 +851,57 @@ For interactive agent steps, the runner uses a point-person-led completion model Deterministic and worktree steps are unchanged and do not require owner/review delegation. +## Sandbox Execution + +By default the runner spawns steps as local child processes. To run them in +isolated sandboxes instead, select a provider — the runner still owns command +construction, env, cwd, timeout, and the whole DAG/retry/verification pipeline; +the provider only supplies "where the command runs". + +```bash +# Off by default. Unset the flag to get local child processes back. +export RELAYFLOWS_SANDBOX_PROVIDER=daytona +export DAYTONA_API_KEY=... +export RELAYFLOWS_SANDBOX_HOME_DIR=/home/daytona # image-specific, required +export RELAYFLOWS_SANDBOX_SNAPSHOT=my-snapshot # optional +``` + +Or in code: + +```typescript +import { WorkflowRunner } from "@relayflows/core"; + +const runner = new WorkflowRunner({ + sandbox: { provider: "daytona", homeDir: "/home/daytona" }, +}); +``` + +| Provider | What it gives you | +| --- | --- | +| `none` (default) | No sandbox. Local child processes, exactly as before. | +| `daytona` | Real remote sandboxes via `@agent-relay/sandbox`. Needs the optional peer `@daytonaio/sdk`. | +| `local-process` | Real local processes in a private per-step directory with its own `HOME`. Isolates the filesystem root, not the machine — good for development and CI, not a security boundary. | + +**Reversibility.** `provider: "none"` (or an unset `RELAYFLOWS_SANDBOX_PROVIDER`) +produces no backend at all, so nothing about the default path changes. An +explicit `executor` or `processBackend` still wins over sandbox config, so a +host that injects its own backend today keeps it. + +**Custom providers.** Register a runtime under any name, or hand one in +directly. This is the seam a host uses to plug in a runtime that does not live +in this repo: + +```typescript +import { registerSandboxProvider, WorkflowRunner } from "@relayflows/core"; + +registerSandboxProvider("my-runtime", (config) => new MyRuntime(config)); +// ...or skip the registry entirely: +new WorkflowRunner({ sandbox: { runtime: myRuntime } }); +``` + +A runtime needs five methods — `launch`, `exec`, `uploadFile`, `getHomeDir`, +`destroy` — matching `@agent-relay/sandbox`'s `WorkflowRuntime`. + ## Schema Validation A JSON Schema is available at `packages/core/src/schema.json` for editor autocompletion and validation of `relay.yaml` files. diff --git a/package-lock.json b/package-lock.json index 9f21c39..a841235 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "relayflows", - "version": "1.0.5", + "version": "1.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "relayflows", - "version": "1.0.5", + "version": "1.0.6", "workspaces": [ "packages/core", "packages/cli", @@ -136,6 +136,27 @@ "@agent-relay/sdk": "8.2.0" } }, + "node_modules/@agent-relay/sandbox": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@agent-relay/sandbox/-/sandbox-0.1.2.tgz", + "integrity": "sha512-3oOEYg9pPj1m3EsOn6AjyNoiVDuQULqm8z4wOc2Zo26JmtdKqbWQmYH6IO4eaJZLRJyO4AAiHhXhTIq6M+/txA==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@daytonaio/sdk": ">=0.180.0 <0.181.0", + "e2b": "^2" + }, + "peerDependenciesMeta": { + "@daytonaio/sdk": { + "optional": true + }, + "e2b": { + "optional": true + } + } + }, "node_modules/@agent-relay/sdk": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/@agent-relay/sdk/-/sdk-8.2.0.tgz", @@ -1451,9 +1472,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1474,9 +1492,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1497,9 +1512,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1520,9 +1532,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1543,9 +1552,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1566,9 +1572,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1843,9 +1846,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1860,9 +1860,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1877,9 +1874,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1894,9 +1888,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1911,9 +1902,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1928,9 +1916,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1945,9 +1930,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1962,9 +1944,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1979,9 +1958,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1996,9 +1972,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2013,9 +1986,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2030,9 +2000,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2047,9 +2014,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5038,7 +5002,7 @@ }, "packages/browser-primitive": { "name": "@relayflows/browser-primitive", - "version": "1.0.5", + "version": "1.0.6", "dependencies": { "@agent-relay/sdk": "^8.2.0", "playwright": "^1.51.1" @@ -5292,9 +5256,9 @@ }, "packages/cli": { "name": "@relayflows/cli", - "version": "1.0.5", + "version": "1.0.6", "dependencies": { - "@relayflows/core": "1.0.5", + "@relayflows/core": "1.0.6", "commander": "^12.1.0" }, "bin": { @@ -5307,20 +5271,21 @@ }, "packages/core": { "name": "@relayflows/core", - "version": "1.0.5", + "version": "1.0.6", "dependencies": { "@agent-relay/cloud": "^8.2.0", "@agent-relay/config": "^8.2.0", "@agent-relay/harness-driver": "^8.2.0", "@agent-relay/harnesses": "^8.2.0", + "@agent-relay/sandbox": "^0.1.2", "@agent-relay/sdk": "^8.2.0", "@agentworkforce/persona-kit": "^4.1.39", "@agentworkforce/persona-registry": "^4.1.39", "@relaycast/sdk": "^1.1.0", "@relayfile/sdk": "^0.8.0", - "@relayflows/browser-primitive": "1.0.5", - "@relayflows/github-primitive": "1.0.5", - "@relayflows/slack-primitive": "1.0.5", + "@relayflows/browser-primitive": "1.0.6", + "@relayflows/github-primitive": "1.0.6", + "@relayflows/slack-primitive": "1.0.6", "@sinclair/typebox": "^0.34.48", "agent-trajectories": "^0.6.0", "chalk": "^4.1.2", @@ -5338,7 +5303,7 @@ }, "packages/github-primitive": { "name": "@relayflows/github-primitive", - "version": "1.0.5", + "version": "1.0.6", "devDependencies": { "@types/node": "^22.19.3", "typescript": "^5.9.3", @@ -5585,12 +5550,12 @@ }, "packages/slack-primitive": { "name": "@relayflows/slack-primitive", - "version": "1.0.5", + "version": "1.0.6", "dependencies": { "@slack/web-api": "^7.16.0" }, "devDependencies": { - "@relayflows/github-primitive": "1.0.5", + "@relayflows/github-primitive": "1.0.6", "@types/node": "^22.19.3", "typescript": "^5.9.3", "vitest": "^3.2.4" diff --git a/packages/core/package.json b/packages/core/package.json index 66b2560..8dfb4e5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -56,6 +56,7 @@ "@agent-relay/config": "^8.2.0", "@agent-relay/harness-driver": "^8.2.0", "@agent-relay/harnesses": "^8.2.0", + "@agent-relay/sandbox": "^0.1.2", "@agent-relay/sdk": "^8.2.0", "@agentworkforce/persona-kit": "^4.1.39", "@agentworkforce/persona-registry": "^4.1.39", diff --git a/packages/core/src/__tests__/sandbox-backend.test.ts b/packages/core/src/__tests__/sandbox-backend.test.ts new file mode 100644 index 0000000..e88d645 --- /dev/null +++ b/packages/core/src/__tests__/sandbox-backend.test.ts @@ -0,0 +1,445 @@ +/** + * Contract tests for the sandbox provider seam. + * + * Two things need holding still here. First, the default: a config that names + * no provider must produce no backend, because that is what keeps the local + * child-process path — today's behavior — untouched. Second, the port itself: + * this engine restates `@agent-relay/sandbox`'s runtime shape so consumers are + * not forced to install the package, and a restatement that drifts from the + * real type is worse than no abstraction at all. The type-level assertion below + * makes that drift a build failure rather than a runtime surprise. + */ +import { describe, it, expect, vi } from 'vitest'; +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import type { WorkflowRuntime } from '@agent-relay/sandbox'; + +import { + createLazySandboxProcessBackend, + createSandboxProcessBackend, + createSandboxProcessBackendFromConfig, + hasSandboxProvider, + isSandboxEnabled, + listSandboxProviders, + registerSandboxProvider, + resolveSandboxConfigFromEnv, + type SandboxWorkflowRuntime, +} from '../sandbox-backend.js'; +import { + SANDBOX_ENV_ID_VAR, + SANDBOX_TIMEOUT_EXIT_CODE, + createLocalProcessSandboxRuntime, +} from '../sandbox-local-runtime.js'; + +// ── Port fidelity ─────────────────────────────────────────────────────────── + +describe('the restated port matches @agent-relay/sandbox', () => { + it('accepts the real WorkflowRuntime where SandboxWorkflowRuntime is required', () => { + // Compile-time only: if @agent-relay/sandbox changes `launch`, `exec`, + // `uploadFile`, `getHomeDir`, or `destroy` in a way our restatement does + // not cover, `npm run typecheck` fails here instead of a caller failing in + // production against a provider that no longer fits. + const assignable = (value: T): T => value; + type Check = WorkflowRuntime extends SandboxWorkflowRuntime ? true : never; + const proof: Check = true; + expect(proof).toBe(true); + expect(typeof assignable).toBe('function'); + }); +}); + +// ── Config resolution ─────────────────────────────────────────────────────── + +describe('resolveSandboxConfigFromEnv', () => { + it('defaults to no provider on an empty environment', () => { + const config = resolveSandboxConfigFromEnv({}); + expect(config.provider).toBe('none'); + expect(isSandboxEnabled(config)).toBe(false); + }); + + it('treats a blank or whitespace-only flag as unset', () => { + // A flag exported as an empty string is how a CI job "unsets" it; reading + // that as a provider name would fail every step with "unknown provider". + expect(isSandboxEnabled(resolveSandboxConfigFromEnv({ RELAYFLOWS_SANDBOX_PROVIDER: '' }))).toBe( + false + ); + expect( + isSandboxEnabled(resolveSandboxConfigFromEnv({ RELAYFLOWS_SANDBOX_PROVIDER: ' ' })) + ).toBe(false); + }); + + it('reads the provider and its knobs', () => { + const config = resolveSandboxConfigFromEnv({ + RELAYFLOWS_SANDBOX_PROVIDER: 'daytona', + RELAYFLOWS_SANDBOX_SNAPSHOT: 'snap-1', + RELAYFLOWS_SANDBOX_HOME_DIR: '/home/daytona', + RELAYFLOWS_SANDBOX_CREATE_TIMEOUT_SECONDS: '90', + DAYTONA_API_KEY: 'dt-key', + }); + + expect(config).toMatchObject({ + provider: 'daytona', + snapshot: 'snap-1', + homeDir: '/home/daytona', + createTimeoutSeconds: 90, + apiKey: 'dt-key', + }); + }); + + it('prefers the namespaced key over the vendor one', () => { + const config = resolveSandboxConfigFromEnv({ + RELAYFLOWS_SANDBOX_API_KEY: 'namespaced', + DAYTONA_API_KEY: 'vendor', + }); + expect(config.apiKey).toBe('namespaced'); + }); + + it('ignores a non-numeric or non-positive create timeout', () => { + for (const value of ['abc', '0', '-5', '']) { + const config = resolveSandboxConfigFromEnv({ + RELAYFLOWS_SANDBOX_CREATE_TIMEOUT_SECONDS: value, + }); + expect(config.createTimeoutSeconds).toBeUndefined(); + } + }); + + it('counts an injected runtime as enabled regardless of provider name', () => { + const runtime = { id: 'router' } as unknown as SandboxWorkflowRuntime; + expect(isSandboxEnabled({ runtime })).toBe(true); + expect(isSandboxEnabled({ provider: 'none', runtime })).toBe(true); + }); +}); + +// ── Provider registry ─────────────────────────────────────────────────────── + +describe('provider registry', () => { + it('ships daytona and local-process', () => { + expect(listSandboxProviders()).toEqual(expect.arrayContaining(['daytona', 'local-process'])); + expect(hasSandboxProvider('nope')).toBe(false); + }); + + it('resolves a registered provider, which is the router injection seam', async () => { + const runtime = { id: 'router-runtime' } as unknown as SandboxWorkflowRuntime; + const factory = vi.fn(() => runtime); + registerSandboxProvider('test-router', factory); + + const backend = await createSandboxProcessBackendFromConfig({ provider: 'test-router' }); + + expect(backend).toBeDefined(); + expect(factory).toHaveBeenCalledTimes(1); + }); + + it('names the registered providers when asked for one that does not exist', async () => { + await expect( + createSandboxProcessBackendFromConfig({ provider: 'invented' }) + ).rejects.toThrow(/Unknown sandbox provider "invented".*local-process/s); + }); + + it('prefers an injected runtime over the named provider', async () => { + const exec = vi.fn(async () => ({ output: 'injected', exitCode: 0 })); + const runtime: SandboxWorkflowRuntime = { + id: 'injected', + launch: async () => ({ id: 'e1', homeDir: '/injected' }), + exec, + uploadFile: async () => undefined, + getHomeDir: async () => '/injected', + destroy: async () => undefined, + }; + + const backend = await createSandboxProcessBackendFromConfig({ + provider: 'local-process', + runtime, + }); + const env = await backend!.createEnvironment('step'); + await env.exec('true'); + + expect(exec).toHaveBeenCalledTimes(1); + }); +}); + +// ── ProcessBackend adapter ────────────────────────────────────────────────── + +function recordingRuntime() { + const execCalls: Array<{ command: string; options: unknown }> = []; + const destroyed: string[] = []; + const runtime: SandboxWorkflowRuntime = { + id: 'recording', + launch: vi.fn(async (options) => ({ id: `env-${options?.label ?? 'x'}`, homeDir: '/home/box' })), + exec: vi.fn(async (_handle, command, options) => { + execCalls.push({ command, options }); + return { output: 'ok', exitCode: 0 }; + }), + uploadFile: vi.fn(async () => undefined), + getHomeDir: vi.fn(async () => '/resolved/home'), + destroy: vi.fn(async (handle) => { + destroyed.push(handle.id); + }), + }; + return { runtime, execCalls, destroyed }; +} + +describe('createSandboxProcessBackend', () => { + it('launches per environment and passes the step label through', async () => { + const { runtime } = recordingRuntime(); + const backend = createSandboxProcessBackend(runtime, { labels: { run: 'r1' } }); + + const env = await backend.createEnvironment('build'); + + expect(runtime.launch).toHaveBeenCalledWith({ label: 'build', labels: { run: 'r1' } }); + expect(env.id).toBe('env-build'); + expect(env.homeDir).toBe('/home/box'); + }); + + it('converts the ProcessEnvironment timeout from seconds to milliseconds', async () => { + // The two contracts disagree on units. Getting this backwards is a 1000x + // timeout error that looks like a hang, so it is asserted directly. + const { runtime, execCalls } = recordingRuntime(); + const backend = createSandboxProcessBackend(runtime); + const env = await backend.createEnvironment('build'); + + await env.exec('npm test', { timeoutSeconds: 30 }); + + expect(execCalls[0]!.options).toMatchObject({ timeoutMs: 30_000 }); + }); + + it('omits the timeout entirely when none or a non-positive one is given', async () => { + const { runtime, execCalls } = recordingRuntime(); + const backend = createSandboxProcessBackend(runtime); + const env = await backend.createEnvironment('build'); + + await env.exec('npm test'); + await env.exec('npm test', { timeoutSeconds: 0 }); + + expect(execCalls[0]!.options).not.toHaveProperty('timeoutMs'); + expect(execCalls[1]!.options).not.toHaveProperty('timeoutMs'); + }); + + it('layers per-exec env over sandbox env rather than replacing it', async () => { + const { runtime, execCalls } = recordingRuntime(); + const backend = createSandboxProcessBackend(runtime, { + env: { BASE: 'base', SHARED: 'from-sandbox' }, + }); + const env = await backend.createEnvironment('build'); + + await env.exec('npm test', { env: { SHARED: 'from-step', STEP: 'step' } }); + + expect(execCalls[0]!.options).toMatchObject({ + env: { BASE: 'base', SHARED: 'from-step', STEP: 'step' }, + }); + }); + + it('only asks for the home directory when the handle did not carry one', async () => { + const { runtime } = recordingRuntime(); + const backend = createSandboxProcessBackend(runtime); + + await backend.createEnvironment('build'); + expect(runtime.getHomeDir).not.toHaveBeenCalled(); + + (runtime.launch as ReturnType).mockResolvedValueOnce({ id: 'env-bare' }); + const bare = await backend.createEnvironment('build'); + + expect(runtime.getHomeDir).toHaveBeenCalledTimes(1); + expect(bare.homeDir).toBe('/resolved/home'); + }); + + it('destroys the environment it launched', async () => { + const { runtime, destroyed } = recordingRuntime(); + const backend = createSandboxProcessBackend(runtime); + + const env = await backend.createEnvironment('build'); + await env.destroy(); + + expect(destroyed).toEqual(['env-build']); + }); +}); + +// ── Lazy resolution ───────────────────────────────────────────────────────── + +describe('createLazySandboxProcessBackend', () => { + it('returns undefined for a disabled config, so the runner keeps its default', () => { + expect(createLazySandboxProcessBackend({})).toBeUndefined(); + expect(createLazySandboxProcessBackend({ provider: 'none' })).toBeUndefined(); + }); + + it('resolves the provider once across concurrent steps', async () => { + const runtime = { + id: 'once', + launch: async () => ({ id: 'e', homeDir: '/h' }), + exec: async () => ({ output: '', exitCode: 0 }), + uploadFile: async () => undefined, + getHomeDir: async () => '/h', + destroy: async () => undefined, + } satisfies SandboxWorkflowRuntime; + const factory = vi.fn(async () => runtime); + registerSandboxProvider('lazy-once', factory); + + const backend = createLazySandboxProcessBackend({ provider: 'lazy-once' })!; + await Promise.all([ + backend.createEnvironment('a'), + backend.createEnvironment('b'), + backend.createEnvironment('c'), + ]); + + expect(factory).toHaveBeenCalledTimes(1); + }); + + it('does not cache a failed resolution, so a transient error can be retried', async () => { + // A credential that is not yet loaded should not poison the backend for the + // rest of the process. + let attempt = 0; + registerSandboxProvider('lazy-flaky', async () => { + attempt += 1; + if (attempt === 1) throw new Error('credentials not ready'); + return { + id: 'flaky', + launch: async () => ({ id: 'e', homeDir: '/h' }), + exec: async () => ({ output: '', exitCode: 0 }), + uploadFile: async () => undefined, + getHomeDir: async () => '/h', + destroy: async () => undefined, + } satisfies SandboxWorkflowRuntime; + }); + + const backend = createLazySandboxProcessBackend({ provider: 'lazy-flaky' })!; + await expect(backend.createEnvironment('a')).rejects.toThrow('credentials not ready'); + await expect(backend.createEnvironment('a')).resolves.toMatchObject({ id: 'e' }); + expect(attempt).toBe(2); + }); + + it('reports a missing daytona key instead of constructing a broken client', async () => { + await expect( + createSandboxProcessBackendFromConfig({ provider: 'daytona', apiKey: '' }) + ).rejects.toThrow(/requires an API key/); + }); + + it('reports a missing daytona home directory, which is image-specific', async () => { + await expect( + createSandboxProcessBackendFromConfig({ provider: 'daytona', apiKey: 'k' }) + ).rejects.toThrow(/requires a home directory/); + }); +}); + +// ── local-process runtime ─────────────────────────────────────────────────── + +describe('local-process runtime', () => { + it('runs a real command and returns its real output and exit code', async () => { + const runtime = createLocalProcessSandboxRuntime(); + const handle = await runtime.launch({ label: 'probe' }); + try { + const ok = await runtime.exec(handle, 'echo hello'); + const bad = await runtime.exec(handle, 'echo oops >&2; exit 3'); + + expect(ok).toEqual({ output: 'hello\n', exitCode: 0 }); + expect(bad.exitCode).toBe(3); + expect(bad.output).toContain('oops'); + } finally { + await runtime.destroy(handle); + } + }); + + it('stamps the environment id into every command it runs', async () => { + const runtime = createLocalProcessSandboxRuntime(); + const handle = await runtime.launch({ label: 'probe' }); + try { + const result = await runtime.exec(handle, `echo $${SANDBOX_ENV_ID_VAR}`); + expect(result.output.trim()).toBe(handle.id); + } finally { + await runtime.destroy(handle); + } + }); + + it('refuses to let a caller spoof the marker through exec env', async () => { + // The marker is the discriminator the routing tests rely on. If launch or + // exec env could overwrite it, a broken backend could forge a green. + const runtime = createLocalProcessSandboxRuntime(); + const handle = await runtime.launch({ label: 'probe', env: { [SANDBOX_ENV_ID_VAR]: 'fake' } }); + try { + const result = await runtime.exec(handle, `echo $${SANDBOX_ENV_ID_VAR}`, { + env: { [SANDBOX_ENV_ID_VAR]: 'also-fake' }, + }); + expect(result.output.trim()).toBe(handle.id); + } finally { + await runtime.destroy(handle); + } + }); + + it('kills a command that outruns its timeout and reports it as a failure', async () => { + const runtime = createLocalProcessSandboxRuntime(); + const handle = await runtime.launch({ label: 'probe' }); + try { + const result = await runtime.exec(handle, 'sleep 5', { timeoutMs: 150 }); + expect(result.exitCode).toBe(SANDBOX_TIMEOUT_EXIT_CODE); + expect(result.output).toContain('exceeded 150ms'); + } finally { + await runtime.destroy(handle); + } + }); + + it('gives each environment its own root and deletes it on destroy', async () => { + const runtime = createLocalProcessSandboxRuntime(); + const a = await runtime.launch({ label: 'a' }); + const b = await runtime.launch({ label: 'b' }); + + expect(a.id).not.toBe(b.id); + await expect(stat(await runtime.getHomeDir(a))).resolves.toBeDefined(); + + const rootA = await runtime.getHomeDir(a); + await runtime.destroy(a); + await expect(stat(rootA)).rejects.toThrow(); + // Destroying one environment must not touch its sibling. + await expect(stat(await runtime.getHomeDir(b))).resolves.toBeDefined(); + await runtime.destroy(b); + }); + + it('uploads files under the environment root, creating parents', async () => { + const runtime = createLocalProcessSandboxRuntime(); + const handle = await runtime.launch({ label: 'probe' }); + try { + await runtime.uploadFile(handle, 'contents', 'nested/dir/file.txt'); + const root = await runtime.getHomeDir(handle); + await expect(readFile(path.join(root, 'nested/dir/file.txt'), 'utf8')).resolves.toBe( + 'contents' + ); + } finally { + await runtime.destroy(handle); + } + }); + + it('refuses an upload that escapes the environment root', async () => { + const runtime = createLocalProcessSandboxRuntime(); + const handle = await runtime.launch({ label: 'probe' }); + try { + await expect(runtime.uploadFile(handle, 'x', '../escaped.txt')).rejects.toThrow( + /Refusing to upload outside sandbox/ + ); + await expect(runtime.uploadFile(handle, 'x', '/etc/escaped.txt')).rejects.toThrow( + /Refusing to upload outside sandbox/ + ); + } finally { + await runtime.destroy(handle); + } + }); + + it('rejects work against an environment that was already destroyed', async () => { + const runtime = createLocalProcessSandboxRuntime(); + const handle = await runtime.launch({ label: 'probe' }); + await runtime.destroy(handle); + + await expect(runtime.exec(handle, 'echo hi')).rejects.toThrow(/is not live/); + // Destroying twice is a no-op, because teardown runs in a finally block. + await expect(runtime.destroy(handle)).resolves.toBeUndefined(); + }); + + it('honours an explicit root directory', async () => { + const parent = await mkdtemp(path.join(tmpdir(), 'relayflows-root-')); + try { + const runtime = createLocalProcessSandboxRuntime({ rootDir: parent }); + const handle = await runtime.launch({ label: 'probe' }); + expect(await runtime.getHomeDir(handle)).toContain(parent); + await runtime.destroy(handle); + } finally { + await rm(parent, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/src/__tests__/sandbox-routing.test.ts b/packages/core/src/__tests__/sandbox-routing.test.ts new file mode 100644 index 0000000..97f9eb6 --- /dev/null +++ b/packages/core/src/__tests__/sandbox-routing.test.ts @@ -0,0 +1,234 @@ +/** + * Does a real workload actually run inside the sandbox? + * + * These tests deliberately do not assert on a mock. A mock backend proves only + * that the runner called something; it cannot tell "routed into a sandbox" apart + * from "quietly fell back to a local child process", which is the exact + * regression this seam can suffer. So the workload here is real — a real + * `WorkflowRunner.execute` over a real deterministic step, running a real `sh` + * command as a real OS process — and the evidence is a fact only the sandbox can + * produce: `RELAYFLOWS_SANDBOX_ENV_ID` is injected by the runtime at exec time + * and exists nowhere in the parent process, so a command that prints it was + * provably executed inside an environment the provider created. + * + * Each must-fire has a paired must-not-fire control on the same assertion, so a + * change that routes everything (or nothing) into the sandbox turns one of the + * pair red rather than sliding through. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, realpathSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { InMemoryWorkflowDb } from '../memory-db.js'; +import { SANDBOX_ENV_ID_VAR, createLocalProcessSandboxRuntime } from '../sandbox-local-runtime.js'; +import { createSandboxProcessBackend } from '../sandbox-backend.js'; +import type { RelayYamlConfig, WorkflowStepRow } from '../types.js'; +import { WorkflowRunner } from '../runner.js'; +import { workflow } from '../builder.js'; + +/** + * Prints the two facts only a sandbox can supply: the environment id the + * provider minted, and the HOME it repointed at the environment root. Neither + * exists in the parent process, so neither can be produced by a fallback to a + * local child process. + */ +const PROBE_COMMAND = `echo "env_id=\${${SANDBOX_ENV_ID_VAR}:-ABSENT}"; echo "home=$HOME"`; + +function probeConfig(command = PROBE_COMMAND): RelayYamlConfig { + // Built through the public builder so the probe config stays valid the same + // way a user's workflow does, rather than by hand-rolling schema fields. + return workflow('sandbox-routing-probe') + .pattern('pipeline') + .step('probe', { type: 'deterministic', command }) + .toConfig(); +} + +async function runProbe( + options: ConstructorParameters[0], + command?: string +): Promise<{ status: string; step: WorkflowStepRow }> { + const db = new InMemoryWorkflowDb(); + const runner = new WorkflowRunner({ ...options, db }); + const run = await runner.execute(probeConfig(command), 'sandbox-routing-probe-workflow'); + const steps = await db.getStepsByRunId(run.id); + const step = steps.find((s) => s.stepName === 'probe'); + if (!step) throw new Error('probe step missing from run'); + return { status: run.status, step }; +} + +function parseEnvId(output: string | undefined): string { + return /env_id=(\S+)/.exec(output ?? '')?.[1] ?? ''; +} + +function parseHome(output: string | undefined): string { + return /home=(\S+)/.exec(output ?? '')?.[1] ?? ''; +} + +describe('sandbox routing — real workload through the abstraction', () => { + let workspace: string; + let savedProvider: string | undefined; + + beforeEach(async () => { + // realpath because macOS resolves the temp dir through a /private symlink, + // and the probe prints the resolved path a shell actually sees. + workspace = realpathSync(await mkdtemp(path.join(tmpdir(), 'relayflows-routing-'))); + savedProvider = process.env.RELAYFLOWS_SANDBOX_PROVIDER; + delete process.env.RELAYFLOWS_SANDBOX_PROVIDER; + }); + + afterEach(async () => { + if (savedProvider === undefined) delete process.env.RELAYFLOWS_SANDBOX_PROVIDER; + else process.env.RELAYFLOWS_SANDBOX_PROVIDER = savedProvider; + await rm(workspace, { recursive: true, force: true }); + }); + + // ── MUST FIRE ───────────────────────────────────────────────────────────── + + it('MUST FIRE: a deterministic step runs inside a sandbox the provider created', async () => { + const { status, step } = await runProbe({ + cwd: workspace, + sandbox: { provider: 'local-process' }, + }); + + expect(status).toBe('completed'); + const envId = parseEnvId(step.output); + // Not merely "present" — it must name a real environment this provider + // minted, which the parent process has no way to produce. + expect(envId).not.toBe('ABSENT'); + expect(envId).toMatch(/^relayflows-probe-/); + expect(process.env[SANDBOX_ENV_ID_VAR]).toBeUndefined(); + + // Second independent signal: HOME was repointed at the environment root, + // so the process really lived inside the sandbox rather than merely being + // handed an env var. + const home = parseHome(step.output); + expect(path.basename(home)).toBe(envId); + expect(path.resolve(home)).not.toBe(path.resolve(workspace)); + }); + + it('MUST FIRE: the env flag alone flips routing on, with no code change', async () => { + process.env.RELAYFLOWS_SANDBOX_PROVIDER = 'local-process'; + const { status, step } = await runProbe({ cwd: workspace }); + + expect(status).toBe('completed'); + expect(parseEnvId(step.output)).toMatch(/^relayflows-probe-/); + }); + + it('MUST FIRE: a real non-zero exit inside the sandbox fails the real step', async () => { + // Proves the exit code is the sandboxed process's own, not a synthesized + // one: a backend that swallowed exit codes would report success here. + const { status, step } = await runProbe( + { cwd: workspace, sandbox: { provider: 'local-process' } }, + `echo "env_id=\${${SANDBOX_ENV_ID_VAR}:-ABSENT}"; exit 17` + ); + + expect(status).toBe('failed'); + expect(step.status).toBe('failed'); + // Both halves matter: the code is the sandboxed process's own (a backend + // that swallowed exit codes would report success), and the marker proves + // the process that produced it ran inside the sandbox. + expect(step.error ?? '').toContain('17'); + expect(step.error ?? '').toContain('relayflows-probe-'); + }); + + it('MUST FIRE: the sandbox is torn down after the step', async () => { + const { step } = await runProbe({ + cwd: workspace, + sandbox: { provider: 'local-process' }, + }); + + const home = parseHome(step.output); + expect(home).toContain('relayflows-probe-'); + // The directory existed while the command ran — it is where the command's + // own HOME pointed — and must not survive the step. + expect(existsSync(home)).toBe(false); + }); + + // ── MUST NOT FIRE (controls) ────────────────────────────────────────────── + + it('MUST NOT FIRE: the default path never enters a sandbox', async () => { + const { status, step } = await runProbe({ cwd: workspace }); + + expect(status).toBe('completed'); + // Same workflow, same assertion target as the must-fire — only the flag + // differs. If the default ever started routing, this goes red. + expect(parseEnvId(step.output)).toBe('ABSENT'); + expect(parseHome(step.output)).not.toContain('relayflows-probe-'); + }); + + it('MUST NOT FIRE: provider "none" is inert even when spelled out', async () => { + const { step } = await runProbe({ cwd: workspace, sandbox: { provider: 'none' } }); + expect(parseEnvId(step.output)).toBe('ABSENT'); + }); + + it('MUST NOT FIRE: explicit sandbox config beats the env flag, so opting out works', async () => { + process.env.RELAYFLOWS_SANDBOX_PROVIDER = 'local-process'; + const { step } = await runProbe({ cwd: workspace, sandbox: { provider: 'none' } }); + + // Reversibility is the whole promise of the flag: a caller that says "none" + // must not be dragged into a sandbox by ambient environment. + expect(parseEnvId(step.output)).toBe('ABSENT'); + }); + + it('MUST NOT FIRE: an injected processBackend still wins over sandbox config', async () => { + const calls: string[] = []; + const backend = { + createEnvironment: async (label: string) => { + calls.push(label); + return { + id: 'injected', + homeDir: '/injected', + exec: async () => ({ output: 'env_id=INJECTED\nhome=/injected\n', exitCode: 0 }), + uploadFile: async () => undefined, + destroy: async () => undefined, + }; + }, + }; + + const { step } = await runProbe({ + cwd: workspace, + sandbox: { provider: 'local-process' }, + processBackend: backend, + }); + + // An existing caller who injects a backend must keep it — this is what + // makes the change safe for the router that does exactly that today. + expect(calls).toEqual(['probe']); + expect(parseEnvId(step.output)).toBe('INJECTED'); + }); + + // ── Broken-routing control ──────────────────────────────────────────────── + + it('goes red when routing is broken: a backend that drops the sandbox loses the marker', async () => { + // Simulates the regression the must-fire tests exist to catch — a backend + // that reports success while executing outside the environment it claimed + // to create. The marker assertion is what turns red, proving those tests + // are load-bearing rather than trivially green. + const runtime = createLocalProcessSandboxRuntime(); + const honest = createSandboxProcessBackend(runtime); + const broken = { + createEnvironment: async (label: string) => { + const env = await honest.createEnvironment(label); + return { + ...env, + // The break: run in the parent instead of the sandbox. + exec: async (command: string) => { + const { execFile } = await import('node:child_process'); + const { promisify } = await import('node:util'); + const { stdout } = await promisify(execFile)('sh', ['-c', command], { + cwd: workspace, + }); + return { output: stdout, exitCode: 0 }; + }, + }; + }, + }; + + const { step } = await runProbe({ cwd: workspace, processBackend: broken }); + + expect(parseEnvId(step.output)).toBe('ABSENT'); + expect(parseHome(step.output)).not.toContain('relayflows-probe-'); + }); +}); diff --git a/packages/core/src/builder.ts b/packages/core/src/builder.ts index 51d7161..6c22d2e 100644 --- a/packages/core/src/builder.ts +++ b/packages/core/src/builder.ts @@ -163,7 +163,7 @@ export interface WorkflowRunOptions { onEvent?: WorkflowEventListener; /** Validate and print execution plan without spawning agents. */ dryRun?: boolean; - /** External step executor (e.g. Daytona sandbox backend). */ + /** External step executor (e.g. a sandbox-backed backend). */ executor?: RunnerStepExecutor; /** Start from a specific step, skipping all predecessors. */ startFrom?: string; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 42de8eb..2269a0b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,8 @@ export { createProcessBackendExecutor, type ProcessBackendExecutorOptions, } from './process-backend-executor.js'; +export * from './sandbox-backend.js'; +export * from './sandbox-local-runtime.js'; export * from './run-summary-table.js'; export * from './template-resolver.js'; export * from './verification.js'; diff --git a/packages/core/src/runner.ts b/packages/core/src/runner.ts index 2abd057..66aabe1 100644 --- a/packages/core/src/runner.ts +++ b/packages/core/src/runner.ts @@ -72,6 +72,11 @@ import { import { InMemoryWorkflowDb } from './memory-db.js'; import { buildCommand as buildProcessCommand, spawnProcess } from './process-spawner.js'; import { createProcessBackendExecutor } from './process-backend-executor.js'; +import { + createLazySandboxProcessBackend, + resolveSandboxConfigFromEnv, + type SandboxBackendConfig, +} from './sandbox-backend.js'; import { formatRunSummaryTable } from './run-summary-table.js'; import { StepExecutor as WorkflowStepLifecycleExecutor, @@ -407,6 +412,17 @@ export interface WorkflowRunnerOptions { * When neither is set, the broker spawns local child processes (default). */ processBackend?: ProcessBackend; + /** + * Sandbox provider selection. Used only when neither `executor` nor + * `processBackend` is supplied — an explicit backend still wins, so nothing + * an existing caller passes changes meaning. + * + * Omitted, this falls back to {@link resolveSandboxConfigFromEnv}, whose + * provider defaults to `none`. So the default path is unchanged: no sandbox, + * local child processes. Pass `{ provider: 'none' }` to opt out of the env + * flag entirely. + */ + sandbox?: SandboxBackendConfig; } // ── Internal step state ───────────────────────────────────────────────────── @@ -937,7 +953,7 @@ export class WorkflowRunner { /** Optional per-run token budget tracker; only created when budgets are configured. */ private budgetTracker?: BudgetTracker; private static readonly PTY_TASK_ARG_SIZE_LIMIT = 2 * 1024 * 1024; // 2 MB - private readonly processBackend?: ProcessBackend; + private processBackend?: ProcessBackend; constructor(options: WorkflowRunnerOptions = {}) { this.db = options.db ?? new InMemoryWorkflowDb(); @@ -949,6 +965,13 @@ export class WorkflowRunner { this.executor = options.executor; this.processBackend = options.processBackend; this.envSecrets = options.envSecrets; + if (!this.executor && !this.processBackend) { + // Only reached when the caller injected neither an executor nor a + // backend. The config's provider defaults to `none`, which yields + // `undefined` here and leaves the local child-process path intact. + const sandboxConfig = options.sandbox ?? resolveSandboxConfigFromEnv(); + this.processBackend = createLazySandboxProcessBackend(sandboxConfig); + } if (!this.executor && this.processBackend) { this.executor = createProcessBackendExecutor(this.processBackend, { env: this.envSecrets, diff --git a/packages/core/src/sandbox-backend.ts b/packages/core/src/sandbox-backend.ts new file mode 100644 index 0000000..dce41ef --- /dev/null +++ b/packages/core/src/sandbox-backend.ts @@ -0,0 +1,409 @@ +/** + * Sandbox provider seam. + * + * Relay owns command construction, auth env, cwd, timeout, and step lifecycle. + * A *sandbox provider* owns only "where the command runs". Historically the one + * provider was Daytona, and it was reachable only by a caller hand-injecting a + * `processBackend` into the runner — the vendor choice lived entirely outside + * this engine, so nothing here could be configured, defaulted, or tested. + * + * This module moves that decision behind `@agent-relay/sandbox`'s + * provider-agnostic runtime port. The engine depends on the port, never on a + * vendor SDK: `@daytonaio/sdk` is an optional peer loaded lazily, and only when + * the Daytona provider is actually selected. + * + * Reversibility: the default provider is `none`, which yields no backend at + * all, so the runner keeps spawning local child processes exactly as it does + * today. Turning the flag off — unsetting `RELAYFLOWS_SANDBOX_PROVIDER` or + * passing `{ provider: 'none' }` — restores current behavior byte for byte. + */ + +import type { ProcessBackend, ProcessEnvironment } from './types.js'; + +// ── The port ──────────────────────────────────────────────────────────────── +// +// Structurally identical to the subset of `@agent-relay/sandbox`'s +// `WorkflowRuntime` that a ProcessBackend needs. It is restated here so the +// engine's public types do not force every consumer to install the sandbox +// package, and so an injected runtime (e.g. the Relay router's own +// RelayRuntime) can satisfy the seam without importing it either. +// `sandbox-backend.test.ts` asserts at type level that the real +// `WorkflowRuntime` is assignable to this, so drift is a build failure. + +/** A live sandbox, as handed back by {@link SandboxWorkflowRuntime.launch}. */ +export interface SandboxRuntimeHandle { + id: string; + homeDir?: string; + workdir?: string; +} + +/** Options a provider accepts when creating a sandbox. */ +export interface SandboxLaunchOptions { + label?: string; + name?: string; + env?: Record; + labels?: Record; + workdir?: string; + createTimeoutSeconds?: number; +} + +/** Options for a single command executed inside a sandbox. */ +export interface SandboxExecOptions { + cwd?: string; + env?: Record; + timeoutMs?: number; +} + +/** Result of a single command executed inside a sandbox. */ +export interface SandboxExecResult { + output: string; + exitCode: number; +} + +/** + * The narrow provider contract this engine depends on — create a sandbox, run + * commands in it, put files in it, tear it down. + */ +export interface SandboxWorkflowRuntime { + readonly id: string; + launch(options?: SandboxLaunchOptions): Promise; + exec( + handle: SandboxRuntimeHandle, + command: string, + options?: SandboxExecOptions + ): Promise; + uploadFile( + handle: SandboxRuntimeHandle, + source: string | Buffer, + destination: string + ): Promise; + getHomeDir(handle: SandboxRuntimeHandle): Promise; + destroy(handle: SandboxRuntimeHandle): Promise; +} + +// ── Configuration ─────────────────────────────────────────────────────────── + +/** + * Built-in provider names. Any other string resolves through + * {@link registerSandboxProvider}, which is how the Relay router plugs in its + * own runtime without this repo knowing anything about it. + */ +export type SandboxProviderName = 'none' | 'daytona' | 'local-process' | (string & {}); + +export interface SandboxBackendConfig { + /** + * Which provider supplies execution environments. Default `none` — no + * backend, so the runner spawns local child processes as it does today. + */ + provider?: SandboxProviderName; + /** + * A ready-made runtime. When set it wins over `provider`, and is the seam an + * out-of-repo router satisfies with its own client. + */ + runtime?: SandboxWorkflowRuntime; + /** Provider credential (Daytona API key). Falls back to `DAYTONA_API_KEY`. */ + apiKey?: string; + /** Provider image/snapshot to launch from. */ + snapshot?: string; + /** + * Home directory inside the image. Required by Daytona because it is + * image-specific; there is no default that is right for every image. + */ + homeDir?: string; + /** Working directory inside the sandbox. */ + workdir?: string; + /** Env injected at sandbox creation (per-step env is layered on top). */ + env?: Record; + /** Provider labels stamped on each created sandbox. */ + labels?: Record; + /** Deadline for sandbox creation. */ + createTimeoutSeconds?: number; +} + +/** A factory that turns config into a live runtime. */ +export type SandboxProviderFactory = ( + config: SandboxBackendConfig +) => SandboxWorkflowRuntime | Promise; + +const providerRegistry = new Map(); + +/** + * Register a provider under `name`, so `provider: name` resolves to it. + * + * This is the dependency-injection seam for runtimes that cannot live in this + * repo. Registering a name that already exists replaces it, which is what lets + * a host override a built-in provider. + */ +export function registerSandboxProvider(name: string, factory: SandboxProviderFactory): void { + providerRegistry.set(name, factory); +} + +/** Whether a provider name has a registered factory. Exported for diagnostics. */ +export function hasSandboxProvider(name: string): boolean { + return providerRegistry.has(name); +} + +/** Registered provider names, sorted. Exported for diagnostics and errors. */ +export function listSandboxProviders(): string[] { + return [...providerRegistry.keys()].sort(); +} + +// ── Env-driven config ─────────────────────────────────────────────────────── + +/** + * Read sandbox config off the environment. Every knob is optional and the + * provider defaults to `none`, so an environment that sets none of these + * produces today's behavior. + */ +export function resolveSandboxConfigFromEnv( + env: NodeJS.ProcessEnv = process.env +): SandboxBackendConfig { + const provider = env.RELAYFLOWS_SANDBOX_PROVIDER?.trim(); + const config: SandboxBackendConfig = { provider: provider ? provider : 'none' }; + + const apiKey = env.RELAYFLOWS_SANDBOX_API_KEY?.trim() || env.DAYTONA_API_KEY?.trim(); + if (apiKey) config.apiKey = apiKey; + + const snapshot = env.RELAYFLOWS_SANDBOX_SNAPSHOT?.trim(); + if (snapshot) config.snapshot = snapshot; + + const homeDir = env.RELAYFLOWS_SANDBOX_HOME_DIR?.trim(); + if (homeDir) config.homeDir = homeDir; + + const workdir = env.RELAYFLOWS_SANDBOX_WORKDIR?.trim(); + if (workdir) config.workdir = workdir; + + const createTimeout = Number(env.RELAYFLOWS_SANDBOX_CREATE_TIMEOUT_SECONDS); + if (Number.isFinite(createTimeout) && createTimeout > 0) { + config.createTimeoutSeconds = createTimeout; + } + + return config; +} + +/** + * Whether a config asks for a sandbox at all. Cheap and synchronous, so the + * runner can decide without paying for a provider import. + */ +export function isSandboxEnabled(config: SandboxBackendConfig | undefined): boolean { + if (!config) return false; + if (config.runtime) return true; + const provider = config.provider ?? 'none'; + return provider !== 'none' && provider !== ''; +} + +// ── Runtime resolution ────────────────────────────────────────────────────── + +/** Resolve config to a live runtime, or `undefined` when sandboxing is off. */ +export async function resolveSandboxRuntime( + config: SandboxBackendConfig +): Promise { + if (config.runtime) return config.runtime; + + const provider = config.provider ?? 'none'; + if (provider === 'none' || provider === '') return undefined; + + const factory = providerRegistry.get(provider); + if (!factory) { + throw new Error( + `Unknown sandbox provider "${provider}". Registered providers: ${ + listSandboxProviders().join(', ') || '(none)' + }. Register one with registerSandboxProvider(), or pass config.runtime directly.` + ); + } + return await factory(config); +} + +// ── ProcessBackend adapter ────────────────────────────────────────────────── + +export interface SandboxProcessBackendOptions { + /** Env injected at sandbox creation; per-exec env is layered on top. */ + env?: Record; + /** Labels stamped on each created sandbox. */ + labels?: Record; + /** Working directory inside the sandbox. */ + workdir?: string; + /** Deadline for sandbox creation. */ + createTimeoutSeconds?: number; +} + +/** + * Adapt a {@link SandboxWorkflowRuntime} to the runner's {@link ProcessBackend}. + * + * One sandbox per step: `createEnvironment` launches, the returned environment + * execs, and `destroy` tears it down. The two contracts differ in one detail + * that matters — `ProcessEnvironment.exec` takes `timeoutSeconds` while the + * sandbox port takes `timeoutMs` — so the conversion happens here rather than + * at every call site. + */ +export function createSandboxProcessBackend( + runtime: SandboxWorkflowRuntime, + options: SandboxProcessBackendOptions = {} +): ProcessBackend { + return { + async createEnvironment(label: string): Promise { + const launchOptions: SandboxLaunchOptions = { label }; + if (options.env && Object.keys(options.env).length > 0) launchOptions.env = options.env; + if (options.labels && Object.keys(options.labels).length > 0) { + launchOptions.labels = options.labels; + } + if (options.workdir) launchOptions.workdir = options.workdir; + if (options.createTimeoutSeconds) { + launchOptions.createTimeoutSeconds = options.createTimeoutSeconds; + } + + const handle = await runtime.launch(launchOptions); + // Prefer the handle's own homeDir; only pay for a round trip when the + // provider did not already resolve one. + const homeDir = handle.homeDir ?? (await runtime.getHomeDir(handle)); + + return { + id: handle.id, + homeDir, + async exec(command, execOpts) { + const sandboxOpts: SandboxExecOptions = {}; + if (execOpts?.cwd) sandboxOpts.cwd = execOpts.cwd; + const mergedEnv = { ...(options.env ?? {}), ...(execOpts?.env ?? {}) }; + if (Object.keys(mergedEnv).length > 0) sandboxOpts.env = mergedEnv; + if (execOpts?.timeoutSeconds && execOpts.timeoutSeconds > 0) { + sandboxOpts.timeoutMs = execOpts.timeoutSeconds * 1000; + } + const result = await runtime.exec(handle, command, sandboxOpts); + return { output: result.output, exitCode: result.exitCode }; + }, + async uploadFile(content, remotePath) { + await runtime.uploadFile(handle, content, remotePath); + }, + async destroy() { + await runtime.destroy(handle); + }, + }; + }, + }; +} + +/** + * Build a ProcessBackend from config, or `undefined` when sandboxing is off. + * Async because providers are imported lazily; see + * {@link createLazySandboxProcessBackend} for the synchronous entry point the + * runner constructor uses. + */ +export async function createSandboxProcessBackendFromConfig( + config: SandboxBackendConfig +): Promise { + const runtime = await resolveSandboxRuntime(config); + if (!runtime) return undefined; + return createSandboxProcessBackend(runtime, backendOptionsFrom(config)); +} + +function backendOptionsFrom(config: SandboxBackendConfig): SandboxProcessBackendOptions { + const options: SandboxProcessBackendOptions = {}; + if (config.env) options.env = config.env; + if (config.labels) options.labels = config.labels; + if (config.workdir) options.workdir = config.workdir; + if (config.createTimeoutSeconds) options.createTimeoutSeconds = config.createTimeoutSeconds; + return options; +} + +/** + * Synchronous entry point: returns `undefined` immediately when sandboxing is + * off, otherwise a ProcessBackend that resolves its provider on first use. + * + * The runner's constructor is synchronous and a provider import is not, so the + * import is deferred to the first `createEnvironment` call. The resolution + * promise is memoized, so N concurrent steps import the provider once; a failed + * resolution is not cached, so a transient credential error can be retried. + */ +export function createLazySandboxProcessBackend( + config: SandboxBackendConfig +): ProcessBackend | undefined { + if (!isSandboxEnabled(config)) return undefined; + + let pending: Promise | undefined; + const resolveBackend = (): Promise => { + if (!pending) { + pending = (async () => { + const backend = await createSandboxProcessBackendFromConfig(config); + if (!backend) { + throw new Error( + `Sandbox provider "${config.provider}" resolved to no backend after reporting enabled.` + ); + } + return backend; + })().catch((error: unknown) => { + pending = undefined; + throw error; + }); + } + return pending; + }; + + return { + async createEnvironment(label: string) { + const backend = await resolveBackend(); + return backend.createEnvironment(label); + }, + }; +} + +// ── Built-in provider: local-process ──────────────────────────────────────── +// +// Imported lazily so selecting a remote provider never pulls in the local one, +// and so registration stays in this module rather than depending on whether a +// consumer happened to import `sandbox-local-runtime.js`. + +registerSandboxProvider('local-process', async (config) => { + const { createLocalProcessSandboxRuntime } = await import('./sandbox-local-runtime.js'); + const runtimeOptions: { env?: Record } = {}; + if (config.env) runtimeOptions.env = config.env; + return createLocalProcessSandboxRuntime(runtimeOptions); +}); + +// ── Built-in provider: Daytona, via @agent-relay/sandbox ──────────────────── + +registerSandboxProvider('daytona', async (config) => { + const apiKey = config.apiKey ?? process.env.DAYTONA_API_KEY; + if (!apiKey) { + throw new Error( + 'Sandbox provider "daytona" requires an API key. Set DAYTONA_API_KEY or pass sandbox.apiKey.' + ); + } + if (!config.homeDir) { + throw new Error( + 'Sandbox provider "daytona" requires a home directory (it is image-specific). ' + + 'Set RELAYFLOWS_SANDBOX_HOME_DIR or pass sandbox.homeDir.' + ); + } + + const { DaytonaRuntime } = await import('@agent-relay/sandbox'); + // `@daytonaio/sdk` is an optional peer of @agent-relay/sandbox and is not a + // dependency of this engine. The specifier is held in a variable so the + // module is resolved at runtime only — installing it is the price of + // selecting this provider, not of installing relayflows. + const daytonaSdkSpecifier = '@daytonaio/sdk'; + let DaytonaClient: new (options: { apiKey: string }) => unknown; + try { + ({ Daytona: DaytonaClient } = (await import(daytonaSdkSpecifier)) as { + Daytona: new (options: { apiKey: string }) => unknown; + }); + } catch (error) { + throw new Error( + 'Sandbox provider "daytona" requires the optional peer "@daytonaio/sdk". ' + + `Install it to use this provider. Original error: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + const runtimeOptions: { + daytona: never; + defaultHomeDir: string; + snapshot?: string; + } = { + daytona: new DaytonaClient({ apiKey }) as never, + defaultHomeDir: config.homeDir, + }; + if (config.snapshot) runtimeOptions.snapshot = config.snapshot; + + return new DaytonaRuntime(runtimeOptions) as unknown as SandboxWorkflowRuntime; +}); diff --git a/packages/core/src/sandbox-local-runtime.ts b/packages/core/src/sandbox-local-runtime.ts new file mode 100644 index 0000000..e3fe6e3 --- /dev/null +++ b/packages/core/src/sandbox-local-runtime.ts @@ -0,0 +1,206 @@ +/** + * `local-process` sandbox provider. + * + * A real provider, not a test double: it creates a private directory per + * environment, runs real `sh -c` commands in it as real OS processes, returns + * real exit codes and real combined output, and deletes the directory on + * destroy. It exists for two reasons — it is the provider a contributor can run + * with no vendor account, and it is what lets the sandbox path be proven + * end-to-end in CI instead of only against a mock. + * + * Be honest about what it isolates: the filesystem root, HOME, and the working + * directory. It is not a VM and not a container — a command can still reach the + * wider machine. For strong isolation use a provider that gives you a real + * boundary (`daytona`); this one's isolation level is "process". + */ + +import { spawn } from 'node:child_process'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import type { + SandboxExecOptions, + SandboxExecResult, + SandboxLaunchOptions, + SandboxRuntimeHandle, + SandboxWorkflowRuntime, +} from './sandbox-backend.js'; + +/** + * Env var stamped into every command run by this runtime, carrying the + * environment id. A command that observes it is provably running inside a + * sandbox rather than in the runner's own process tree — which is exactly the + * discriminator the routing tests assert on. + */ +export const SANDBOX_ENV_ID_VAR = 'RELAYFLOWS_SANDBOX_ENV_ID'; + +/** Companion marker naming the provider that supplied the environment. */ +export const SANDBOX_PROVIDER_VAR = 'RELAYFLOWS_SANDBOX_PROVIDER_ID'; + +/** Exit code reported when a command is killed for exceeding its timeout. */ +export const SANDBOX_TIMEOUT_EXIT_CODE = 124; + +export interface LocalProcessSandboxRuntimeOptions { + /** Parent directory for environment roots. Default: the OS temp dir. */ + rootDir?: string; + /** Env applied to every command, beneath launch env and per-exec env. */ + env?: Record; + /** + * Whether commands inherit the parent process env. Default `true`, matching + * the local child-process path this provider stands in for — agent CLIs need + * PATH, HOME-adjacent config, and credentials to work at all. + */ + inheritEnv?: boolean; +} + +interface EnvironmentState { + root: string; + launchEnv: Record; + workdir: string; +} + +/** + * Create a runtime that executes commands as real local processes inside a + * per-environment directory. + */ +export function createLocalProcessSandboxRuntime( + options: LocalProcessSandboxRuntimeOptions = {} +): SandboxWorkflowRuntime { + const states = new Map(); + const parentDir = options.rootDir ?? tmpdir(); + const inheritEnv = options.inheritEnv !== false; + + function requireState(handle: SandboxRuntimeHandle): EnvironmentState { + const state = states.get(handle.id); + if (!state) { + throw new Error( + `local-process sandbox "${handle.id}" is not live (never launched, or already destroyed).` + ); + } + return state; + } + + return { + id: 'local-process', + + async launch(launchOptions: SandboxLaunchOptions = {}): Promise { + await mkdir(parentDir, { recursive: true }); + // The label lands in the directory name purely to make a leaked temp dir + // traceable back to the step that made it; it is sanitized because a step + // name is free text. + const slug = (launchOptions.label ?? launchOptions.name ?? 'sandbox') + .replace(/[^A-Za-z0-9_-]+/g, '-') + .slice(0, 40); + const root = await mkdtemp(path.join(parentDir, `relayflows-${slug}-`)); + const workdir = launchOptions.workdir ?? root; + if (workdir !== root) await mkdir(workdir, { recursive: true }); + + const id = path.basename(root); + states.set(id, { root, workdir, launchEnv: { ...(launchOptions.env ?? {}) } }); + return { id, homeDir: root, workdir }; + }, + + async exec( + handle: SandboxRuntimeHandle, + command: string, + execOptions: SandboxExecOptions = {} + ): Promise { + const state = requireState(handle); + const env: Record = {}; + if (inheritEnv) { + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) env[key] = value; + } + } + Object.assign(env, options.env ?? {}, state.launchEnv, execOptions.env ?? {}); + // HOME points at the sandbox root so tools that write dotfiles do it + // inside the environment. The markers are set last so a caller cannot + // spoof them through launch or exec env. + env.HOME = state.root; + env[SANDBOX_ENV_ID_VAR] = handle.id; + env[SANDBOX_PROVIDER_VAR] = 'local-process'; + + const cwd = execOptions.cwd ?? state.workdir; + await mkdir(cwd, { recursive: true }); + + return await new Promise((resolve, reject) => { + const child = spawn('sh', ['-c', command], { cwd, env, stdio: 'pipe' }); + let output = ''; + let settled = false; + let timedOut = false; + + const timer = + execOptions.timeoutMs && execOptions.timeoutMs > 0 + ? setTimeout(() => { + timedOut = true; + child.kill('SIGKILL'); + }, execOptions.timeoutMs) + : undefined; + + const finish = (result: SandboxExecResult): void => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(result); + }; + + child.stdout?.on('data', (chunk: Buffer) => { + output += chunk.toString(); + }); + child.stderr?.on('data', (chunk: Buffer) => { + output += chunk.toString(); + }); + + child.on('error', (error) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + reject(error); + }); + + child.on('close', (code, signal) => { + if (timedOut) { + finish({ + output: `${output}\n[sandbox] command exceeded ${execOptions.timeoutMs}ms and was killed`, + exitCode: SANDBOX_TIMEOUT_EXIT_CODE, + }); + return; + } + // A signal death has no exit code; report it the way a shell does so + // callers comparing against 0 still see a failure. + const exitCode = code ?? (signal ? 128 : 1); + finish({ output, exitCode }); + }); + }); + }, + + async uploadFile( + handle: SandboxRuntimeHandle, + source: string | Buffer, + destination: string + ): Promise { + const state = requireState(handle); + const target = path.resolve(state.root, destination); + const rootWithSep = state.root.endsWith(path.sep) ? state.root : state.root + path.sep; + if (target !== state.root && !target.startsWith(rootWithSep)) { + throw new Error( + `Refusing to upload outside sandbox "${handle.id}": ${destination} resolves to ${target}.` + ); + } + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, source); + }, + + async getHomeDir(handle: SandboxRuntimeHandle): Promise { + return requireState(handle).root; + }, + + async destroy(handle: SandboxRuntimeHandle): Promise { + const state = states.get(handle.id); + if (!state) return; + states.delete(handle.id); + await rm(state.root, { recursive: true, force: true }); + }, + }; +} diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 129b489..5c21595 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -472,8 +472,8 @@ export interface VerificationCheck { /** * Extension point for delegating step execution to an external backend - * (e.g. Daytona sandboxes) while keeping the runner's DAG/retry/verification - * machinery intact. + * (a sandbox provider, typically) while keeping the runner's DAG/retry/ + * verification machinery intact. */ export interface RunnerStepExecutor { executeAgentStep( diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0879a8e..325d081 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -637,7 +637,13 @@ export interface WorkflowStepRow { // uploadFile is reserved for future file asset staging; current executors run // commands directly with env/cwd/timeout passed through exec options. -/** Backend for creating isolated execution environments (e.g. Daytona sandboxes). */ +/** + * Backend for creating isolated execution environments. + * + * Prefer configuring a provider through `sandbox-backend.ts` rather than + * implementing this by hand — `createSandboxProcessBackend` adapts any + * `@agent-relay/sandbox` runtime (Daytona among them) to this interface. + */ export interface ProcessBackend { /** Create an isolated execution environment. */ createEnvironment(label: string): Promise;