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;