@smithers-orchestrator/sandbox 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 William Cory
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@smithers-orchestrator/sandbox",
3
+ "version": "0.16.0",
4
+ "description": "Sandbox bundle, execution, and transport integration for Smithers",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.d.ts",
10
+ "import": "./src/index.js",
11
+ "default": "./src/index.js"
12
+ },
13
+ "./*": {
14
+ "types": "./src/index.d.ts",
15
+ "import": "./src/*.js",
16
+ "default": "./src/*.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "src/"
21
+ ],
22
+ "dependencies": {
23
+ "@smithers-orchestrator/components": "0.16.0",
24
+ "@smithers-orchestrator/driver": "0.16.0",
25
+ "@smithers-orchestrator/errors": "0.16.0",
26
+ "@smithers-orchestrator/graph": "0.16.0",
27
+ "@smithers-orchestrator/db": "0.16.0",
28
+ "@smithers-orchestrator/scheduler": "0.16.0",
29
+ "@smithers-orchestrator/observability": "0.16.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/bun": "latest",
33
+ "typescript": "~5.9.3"
34
+ },
35
+ "scripts": {
36
+ "build": "tsup --dts-only",
37
+ "test": "bun test tests",
38
+ "typecheck": "tsc -p tsconfig.json --noEmit"
39
+ }
40
+ }
@@ -0,0 +1,18 @@
1
+ import type { SmithersWorkflow } from "@smithers-orchestrator/components/SmithersWorkflow";
2
+ import type { ChildWorkflowDefinition } from "@smithers-orchestrator/engine/child-workflow";
3
+ import type { SandboxRuntime } from "./SandboxRuntime.ts";
4
+
5
+ export type ExecuteSandboxOptions = {
6
+ parentWorkflow?: SmithersWorkflow<unknown>;
7
+ sandboxId: string;
8
+ runtime?: SandboxRuntime;
9
+ workflow: ChildWorkflowDefinition;
10
+ input?: unknown;
11
+ rootDir: string;
12
+ allowNetwork: boolean;
13
+ maxOutputBytes: number;
14
+ toolTimeoutMs: number;
15
+ reviewDiffs?: boolean;
16
+ autoAcceptDiffs?: boolean;
17
+ config?: Record<string, unknown>;
18
+ };
@@ -0,0 +1,6 @@
1
+ export type SandboxBundleManifest = {
2
+ outputs: unknown;
3
+ status: "finished" | "failed" | "cancelled";
4
+ runId?: string;
5
+ patches?: string[];
6
+ };
@@ -0,0 +1,3 @@
1
+ export type SandboxBundleResult = {
2
+ bundlePath: string;
3
+ };
@@ -0,0 +1,12 @@
1
+ import type { SandboxRuntime } from "./SandboxRuntime.ts";
2
+
3
+ export type SandboxHandle = {
4
+ runtime: SandboxRuntime;
5
+ runId: string;
6
+ sandboxId: string;
7
+ sandboxRoot: string;
8
+ requestPath: string;
9
+ resultPath: string;
10
+ containerId?: string;
11
+ workspaceId?: string;
12
+ };
@@ -0,0 +1 @@
1
+ export type SandboxRuntime = "bubblewrap" | "docker" | "codeplane";
@@ -0,0 +1,9 @@
1
+ import type { SandboxRuntime } from "./SandboxRuntime.ts";
2
+
3
+ export type SandboxTransportConfig = {
4
+ runId: string;
5
+ sandboxId: string;
6
+ runtime: SandboxRuntime;
7
+ rootDir: string;
8
+ image?: string;
9
+ };
@@ -0,0 +1,15 @@
1
+ import type { Effect } from "effect";
2
+ import type { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
3
+ import type { SandboxTransportConfig } from "./SandboxTransportConfig.ts";
4
+ import type { SandboxHandle } from "./SandboxHandle.ts";
5
+ import type { SandboxBundleResult } from "./SandboxBundleResult.ts";
6
+
7
+ export type SandboxTransportService = {
8
+ readonly create: (config: SandboxTransportConfig) => Effect.Effect<SandboxHandle, SmithersError>;
9
+ readonly ship: (bundlePath: string, handle: SandboxHandle) => Effect.Effect<void, SmithersError>;
10
+ readonly execute: (command: string, handle: SandboxHandle) => Effect.Effect<{
11
+ exitCode: number;
12
+ }, SmithersError>;
13
+ readonly collect: (handle: SandboxHandle) => Effect.Effect<SandboxBundleResult, SmithersError>;
14
+ readonly cleanup: (handle: SandboxHandle) => Effect.Effect<void, SmithersError>;
15
+ };
@@ -0,0 +1,9 @@
1
+ import type { SandboxBundleManifest } from "./SandboxBundleManifest.ts";
2
+
3
+ export type ValidatedSandboxBundle = {
4
+ manifest: SandboxBundleManifest;
5
+ bundleSizeBytes: number;
6
+ patchFiles: string[];
7
+ logsPath: string | null;
8
+ bundlePath: string;
9
+ };
package/src/bundle.js ADDED
@@ -0,0 +1,201 @@
1
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, join, relative } from "node:path";
3
+ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
4
+ import { assertJsonPayloadWithinBounds, assertOptionalArrayMaxLength, assertOptionalStringMaxLength, } from "@smithers-orchestrator/db/input-bounds";
5
+ import { resolveSandboxPath } from "./sandboxPath.js";
6
+ /** @typedef {import("./SandboxBundleManifest.ts").SandboxBundleManifest} SandboxBundleManifest */
7
+ /** @typedef {import("./ValidatedSandboxBundle.ts").ValidatedSandboxBundle} ValidatedSandboxBundle */
8
+
9
+ export const SANDBOX_MAX_BUNDLE_BYTES = 100 * 1024 * 1024; // 100MB
10
+ export const SANDBOX_MAX_README_BYTES = 5 * 1024 * 1024; // 5MB
11
+ export const SANDBOX_MAX_PATCH_FILES = 1000;
12
+ export const SANDBOX_BUNDLE_RUN_ID_MAX_LENGTH = 256;
13
+ export const SANDBOX_BUNDLE_PATH_MAX_LENGTH = 1024;
14
+ export const SANDBOX_BUNDLE_OUTPUT_MAX_DEPTH = 16;
15
+ export const SANDBOX_BUNDLE_OUTPUT_MAX_ARRAY_LENGTH = 512;
16
+ export const SANDBOX_BUNDLE_OUTPUT_MAX_STRING_LENGTH = 64 * 1024;
17
+ /**
18
+ * @param {string} dir
19
+ * @returns {Promise<WalkResult>}
20
+ */
21
+ async function walkFiles(dir) {
22
+ const pending = [dir];
23
+ const files = [];
24
+ let totalBytes = 0;
25
+ while (pending.length > 0) {
26
+ const current = pending.pop();
27
+ const entries = await readdir(current, { withFileTypes: true });
28
+ for (const entry of entries) {
29
+ const full = join(current, entry.name);
30
+ if (entry.isDirectory()) {
31
+ pending.push(full);
32
+ }
33
+ else if (entry.isFile()) {
34
+ files.push(full);
35
+ const info = await stat(full);
36
+ totalBytes += info.size;
37
+ }
38
+ }
39
+ }
40
+ return { files, totalBytes };
41
+ }
42
+ /**
43
+ * @param {string} readme
44
+ * @returns {SandboxBundleManifest}
45
+ */
46
+ function parseReadmeJson(readme) {
47
+ const trimmed = readme.trim();
48
+ if (trimmed.length === 0) {
49
+ throw new SmithersError("INVALID_INPUT", "Sandbox bundle README.md is empty.");
50
+ }
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(trimmed);
54
+ }
55
+ catch {
56
+ throw new SmithersError("INVALID_INPUT", "Sandbox bundle README.md must contain valid JSON.");
57
+ }
58
+ if (!parsed || typeof parsed !== "object") {
59
+ throw new SmithersError("INVALID_INPUT", "Sandbox bundle README.md JSON must be an object.");
60
+ }
61
+ const manifest = parsed;
62
+ const status = manifest.status;
63
+ if (status !== "finished" && status !== "failed" && status !== "cancelled") {
64
+ throw new SmithersError("INVALID_INPUT", "Sandbox bundle README.md must include status: finished | failed | cancelled.");
65
+ }
66
+ return {
67
+ outputs: manifest.outputs,
68
+ status,
69
+ runId: typeof manifest.runId === "string" ? manifest.runId : undefined,
70
+ patches: Array.isArray(manifest.patches)
71
+ ? manifest.patches.filter((v) => typeof v === "string")
72
+ : undefined,
73
+ };
74
+ }
75
+ /**
76
+ * @param {string} bundlePath
77
+ * @param {string} patchPath
78
+ */
79
+ function assertPatchPathSafe(bundlePath, patchPath) {
80
+ const base = resolveSandboxPath(bundlePath, "patches");
81
+ const resolved = resolveSandboxPath(bundlePath, patchPath);
82
+ const rel = relative(base, resolved);
83
+ if (rel.startsWith("..") || rel === "") {
84
+ throw new SmithersError("TOOL_PATH_ESCAPE", `Sandbox patch path escapes bundle root: ${patchPath}`, { patchPath });
85
+ }
86
+ }
87
+ /**
88
+ * @param {{ output: unknown; patches?: Array<{ path: string; content: string }>; artifacts?: Array<{ path: string; content: string }>; runId?: string; status: "finished" | "failed" | "cancelled"; streamLogPath?: string | null; }} params
89
+ */
90
+ async function estimateBundleWriteBytes(params) {
91
+ const readmeBytes = Buffer.byteLength(JSON.stringify({
92
+ outputs: params.output,
93
+ status: params.status,
94
+ runId: params.runId,
95
+ patches: (params.patches ?? []).map((patch) => patch.path),
96
+ }, null, 2), "utf8");
97
+ const patchBytes = (params.patches ?? []).reduce((total, patch) => total + Buffer.byteLength(patch.content, "utf8"), 0);
98
+ const artifactBytes = (params.artifacts ?? []).reduce((total, artifact) => total + Buffer.byteLength(artifact.content, "utf8"), 0);
99
+ const streamLogBytes = params.streamLogPath
100
+ ? (await stat(params.streamLogPath).catch(() => null))?.size ?? 0
101
+ : 0;
102
+ return readmeBytes + patchBytes + artifactBytes + streamLogBytes;
103
+ }
104
+ /**
105
+ * @param {{ bundlePath: string; output: unknown; status: "finished" | "failed" | "cancelled"; runId?: string; streamLogPath?: string | null; patches?: Array<{ path: string; content: string }>; artifacts?: Array<{ path: string; content: string }>; }} params
106
+ */
107
+ async function validateSandboxBundleWriteParams(params) {
108
+ assertOptionalStringMaxLength("bundlePath", params.bundlePath, SANDBOX_BUNDLE_PATH_MAX_LENGTH);
109
+ assertOptionalStringMaxLength("runId", params.runId, SANDBOX_BUNDLE_RUN_ID_MAX_LENGTH);
110
+ assertOptionalArrayMaxLength("patches", params.patches, SANDBOX_MAX_PATCH_FILES);
111
+ assertOptionalArrayMaxLength("artifacts", params.artifacts, SANDBOX_MAX_PATCH_FILES);
112
+ if (params.output !== undefined) {
113
+ assertJsonPayloadWithinBounds("output", params.output, {
114
+ maxArrayLength: SANDBOX_BUNDLE_OUTPUT_MAX_ARRAY_LENGTH,
115
+ maxDepth: SANDBOX_BUNDLE_OUTPUT_MAX_DEPTH,
116
+ maxStringLength: SANDBOX_BUNDLE_OUTPUT_MAX_STRING_LENGTH,
117
+ });
118
+ }
119
+ for (const patch of params.patches ?? []) {
120
+ assertOptionalStringMaxLength("patch.path", patch.path, SANDBOX_BUNDLE_PATH_MAX_LENGTH);
121
+ }
122
+ for (const artifact of params.artifacts ?? []) {
123
+ assertOptionalStringMaxLength("artifact.path", artifact.path, SANDBOX_BUNDLE_PATH_MAX_LENGTH);
124
+ }
125
+ const estimatedBytes = await estimateBundleWriteBytes(params);
126
+ if (estimatedBytes > SANDBOX_MAX_BUNDLE_BYTES) {
127
+ throw new SmithersError("INVALID_INPUT", `Sandbox bundle exceeds ${SANDBOX_MAX_BUNDLE_BYTES} bytes`, { maxBytes: SANDBOX_MAX_BUNDLE_BYTES, estimatedBytes });
128
+ }
129
+ }
130
+ /**
131
+ * @param {string} bundlePath
132
+ * @returns {Promise<ValidatedSandboxBundle>}
133
+ */
134
+ export async function validateSandboxBundle(bundlePath) {
135
+ const resolvedReadme = resolveSandboxPath(bundlePath, "README.md");
136
+ const readmeStats = await stat(resolvedReadme).catch(() => null);
137
+ if (!readmeStats?.isFile()) {
138
+ throw new SmithersError("INVALID_INPUT", "Sandbox bundle is missing README.md", { bundlePath });
139
+ }
140
+ if (readmeStats.size > SANDBOX_MAX_README_BYTES) {
141
+ throw new SmithersError("INVALID_INPUT", `Sandbox bundle README.md exceeds ${SANDBOX_MAX_README_BYTES} bytes`, { bundlePath, maxBytes: SANDBOX_MAX_README_BYTES });
142
+ }
143
+ const readmeRaw = await readFile(resolvedReadme, "utf8");
144
+ const manifest = parseReadmeJson(readmeRaw);
145
+ const walked = await walkFiles(bundlePath);
146
+ if (walked.totalBytes > SANDBOX_MAX_BUNDLE_BYTES) {
147
+ throw new SmithersError("INVALID_INPUT", `Sandbox bundle exceeds ${SANDBOX_MAX_BUNDLE_BYTES} bytes`, { bundlePath, maxBytes: SANDBOX_MAX_BUNDLE_BYTES });
148
+ }
149
+ const patchFiles = walked.files
150
+ .filter((file) => relative(bundlePath, file).startsWith("patches/"))
151
+ .filter((file) => file.endsWith(".patch"))
152
+ .map((file) => relative(bundlePath, file));
153
+ if (patchFiles.length > SANDBOX_MAX_PATCH_FILES) {
154
+ throw new SmithersError("INVALID_INPUT", `Sandbox bundle has too many patch files (max ${SANDBOX_MAX_PATCH_FILES}).`, { patchCount: patchFiles.length, maxPatches: SANDBOX_MAX_PATCH_FILES });
155
+ }
156
+ for (const patchPath of patchFiles) {
157
+ assertPatchPathSafe(bundlePath, patchPath);
158
+ }
159
+ for (const patchPath of manifest.patches ?? []) {
160
+ assertPatchPathSafe(bundlePath, patchPath);
161
+ }
162
+ const logsPath = resolveSandboxPath(bundlePath, "logs/stream.ndjson");
163
+ const logsStats = await stat(logsPath).catch(() => null);
164
+ return {
165
+ manifest,
166
+ bundleSizeBytes: walked.totalBytes,
167
+ patchFiles,
168
+ logsPath: logsStats?.isFile() ? logsPath : null,
169
+ bundlePath,
170
+ };
171
+ }
172
+ /**
173
+ * @param {{ bundlePath: string; output: unknown; status: "finished" | "failed" | "cancelled"; runId?: string; streamLogPath?: string | null; patches?: Array<{ path: string; content: string }>; artifacts?: Array<{ path: string; content: string }>; }} params
174
+ */
175
+ export async function writeSandboxBundle(params) {
176
+ await validateSandboxBundleWriteParams(params);
177
+ await mkdir(params.bundlePath, { recursive: true });
178
+ await mkdir(join(params.bundlePath, "patches"), { recursive: true });
179
+ await mkdir(join(params.bundlePath, "artifacts"), { recursive: true });
180
+ await mkdir(join(params.bundlePath, "logs"), { recursive: true });
181
+ for (const patch of params.patches ?? []) {
182
+ const file = resolveSandboxPath(params.bundlePath, patch.path);
183
+ await mkdir(dirname(file), { recursive: true });
184
+ await writeFile(file, patch.content, "utf8");
185
+ }
186
+ for (const artifact of params.artifacts ?? []) {
187
+ const file = resolveSandboxPath(params.bundlePath, artifact.path);
188
+ await mkdir(dirname(file), { recursive: true });
189
+ await writeFile(file, artifact.content, "utf8");
190
+ }
191
+ if (params.streamLogPath) {
192
+ const logContent = await readFile(params.streamLogPath, "utf8").catch(() => "");
193
+ await writeFile(resolveSandboxPath(params.bundlePath, "logs/stream.ndjson"), logContent, "utf8");
194
+ }
195
+ await writeFile(resolveSandboxPath(params.bundlePath, "README.md"), JSON.stringify({
196
+ outputs: params.output,
197
+ status: params.status,
198
+ runId: params.runId,
199
+ patches: (params.patches ?? []).map((p) => p.path),
200
+ }, null, 2), "utf8");
201
+ }
@@ -0,0 +1,90 @@
1
+ import { HttpRunner } from "@effect/cluster";
2
+ import { mkdir, cp, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { Effect, Layer } from "effect";
5
+ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
6
+ import { spawnCaptureEffect } from "@smithers-orchestrator/driver/child-process";
7
+ import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
8
+ import { SandboxEntityExecutor } from "./sandbox-entity.js";
9
+ /** @typedef {import("../SandboxTransportConfig.ts").SandboxTransportConfig} SandboxTransportConfig */
10
+ /** @typedef {import("../SandboxHandle.ts").SandboxHandle} SandboxHandle */
11
+ /**
12
+ * @param {SandboxTransportConfig} config
13
+ * @returns {SandboxHandle}
14
+ */
15
+ function baseHandle(config) {
16
+ const sandboxRoot = join(config.rootDir, ".smithers", "sandboxes", config.runId, config.sandboxId);
17
+ return {
18
+ runtime: config.runtime,
19
+ runId: config.runId,
20
+ sandboxId: config.sandboxId,
21
+ sandboxRoot,
22
+ requestPath: join(sandboxRoot, "request"),
23
+ resultPath: join(sandboxRoot, "result"),
24
+ };
25
+ }
26
+ /** @type {Layer.Layer<SandboxEntityExecutor, never, never>} */
27
+ export const DockerSandboxExecutorLive = Layer.succeed(SandboxEntityExecutor, SandboxEntityExecutor.of({
28
+ create: (config) => Effect.gen(function* () {
29
+ const handle = baseHandle(config);
30
+ yield* spawnCaptureEffect("docker", ["info"], {
31
+ cwd: config.rootDir,
32
+ env: process.env,
33
+ timeoutMs: 10_000,
34
+ maxOutputBytes: 200_000,
35
+ }).pipe(Effect.catchAll(() => Effect.fail(new SmithersError("PROCESS_SPAWN_FAILED", "Docker daemon not reachable.", { runtime: "docker" }))));
36
+ yield* Effect.tryPromise({
37
+ try: async () => {
38
+ await mkdir(handle.requestPath, { recursive: true });
39
+ await mkdir(handle.resultPath, { recursive: true });
40
+ },
41
+ catch: (cause) => toSmithersError(cause, "create docker sandbox workspace"),
42
+ });
43
+ return handle;
44
+ }),
45
+ ship: (bundlePath, handle) => Effect.tryPromise({
46
+ try: async () => {
47
+ await rm(handle.requestPath, { recursive: true, force: true });
48
+ await mkdir(handle.requestPath, { recursive: true });
49
+ await cp(bundlePath, handle.requestPath, { recursive: true });
50
+ },
51
+ catch: (cause) => toSmithersError(cause, "ship docker bundle"),
52
+ }),
53
+ execute: (_command, _handle) => Effect.succeed({ exitCode: 0 }),
54
+ collect: (handle) => Effect.succeed({ bundlePath: handle.resultPath }),
55
+ cleanup: (_handle) => Effect.void,
56
+ }));
57
+ /** @type {Layer.Layer<SandboxEntityExecutor, never, never>} */
58
+ export const CodeplaneSandboxExecutorLive = Layer.succeed(SandboxEntityExecutor, SandboxEntityExecutor.of({
59
+ create: (config) => Effect.gen(function* () {
60
+ const apiUrl = process.env.CODEPLANE_API_URL;
61
+ const apiKey = process.env.CODEPLANE_API_KEY;
62
+ if (!apiUrl || !apiKey) {
63
+ yield* Effect.fail(new SmithersError("INVALID_INPUT", "Codeplane runtime requires CODEPLANE_API_URL and CODEPLANE_API_KEY."));
64
+ }
65
+ const handle = baseHandle(config);
66
+ yield* Effect.tryPromise({
67
+ try: async () => {
68
+ await mkdir(handle.requestPath, { recursive: true });
69
+ await mkdir(handle.resultPath, { recursive: true });
70
+ },
71
+ catch: (cause) => toSmithersError(cause, "create codeplane sandbox workspace"),
72
+ });
73
+ return {
74
+ ...handle,
75
+ workspaceId: `${config.runId}:${config.sandboxId}`,
76
+ };
77
+ }),
78
+ ship: (bundlePath, handle) => Effect.tryPromise({
79
+ try: async () => {
80
+ await rm(handle.requestPath, { recursive: true, force: true });
81
+ await mkdir(handle.requestPath, { recursive: true });
82
+ await cp(bundlePath, handle.requestPath, { recursive: true });
83
+ },
84
+ catch: (cause) => toSmithersError(cause, "ship codeplane bundle"),
85
+ }),
86
+ execute: (_command, _handle) => Effect.succeed({ exitCode: 0 }),
87
+ collect: (handle) => Effect.succeed({ bundlePath: handle.resultPath }),
88
+ cleanup: (_handle) => Effect.void,
89
+ }));
90
+ export const SandboxHttpRunner = HttpRunner;
@@ -0,0 +1,134 @@
1
+ import { Entity, ShardingConfig } from "@effect/cluster";
2
+ import * as Rpc from "@effect/rpc/Rpc";
3
+ import { Context, Effect, Layer, Schema } from "effect";
4
+ import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
5
+ const SandboxRuntimeSchema = Schema.Literal("bubblewrap", "docker", "codeplane");
6
+ const SandboxTransportConfigSchema = Schema.Struct({
7
+ runId: Schema.String,
8
+ sandboxId: Schema.String,
9
+ runtime: SandboxRuntimeSchema,
10
+ rootDir: Schema.String,
11
+ image: Schema.optional(Schema.String),
12
+ });
13
+ const SandboxHandleSchema = Schema.Struct({
14
+ runtime: SandboxRuntimeSchema,
15
+ runId: Schema.String,
16
+ sandboxId: Schema.String,
17
+ sandboxRoot: Schema.String,
18
+ requestPath: Schema.String,
19
+ resultPath: Schema.String,
20
+ containerId: Schema.optional(Schema.String),
21
+ workspaceId: Schema.optional(Schema.String),
22
+ });
23
+ const SandboxBundleResultSchema = Schema.Struct({
24
+ bundlePath: Schema.String,
25
+ });
26
+ const SandboxExecuteResultSchema = Schema.Struct({
27
+ exitCode: Schema.Number,
28
+ });
29
+ const SandboxShipPayloadSchema = Schema.Struct({
30
+ bundlePath: Schema.String,
31
+ handle: SandboxHandleSchema,
32
+ });
33
+ const SandboxExecutePayloadSchema = Schema.Struct({
34
+ command: Schema.String,
35
+ handle: SandboxHandleSchema,
36
+ });
37
+ const SandboxHandlePayloadSchema = Schema.Struct({
38
+ handle: SandboxHandleSchema,
39
+ });
40
+ const SandboxCreateRpc = Rpc.make("create", {
41
+ payload: SandboxTransportConfigSchema,
42
+ success: SandboxHandleSchema,
43
+ error: Schema.Unknown,
44
+ });
45
+ const SandboxShipRpc = Rpc.make("ship", {
46
+ payload: SandboxShipPayloadSchema,
47
+ success: Schema.Void,
48
+ error: Schema.Unknown,
49
+ });
50
+ const SandboxExecuteRpc = Rpc.make("execute", {
51
+ payload: SandboxExecutePayloadSchema,
52
+ success: SandboxExecuteResultSchema,
53
+ error: Schema.Unknown,
54
+ });
55
+ const SandboxCollectRpc = Rpc.make("collect", {
56
+ payload: SandboxHandlePayloadSchema,
57
+ success: SandboxBundleResultSchema,
58
+ error: Schema.Unknown,
59
+ });
60
+ const SandboxCleanupRpc = Rpc.make("cleanup", {
61
+ payload: SandboxHandlePayloadSchema,
62
+ success: Schema.Void,
63
+ error: Schema.Unknown,
64
+ });
65
+ export const SandboxEntity = Entity.make("Sandbox", [
66
+ SandboxCreateRpc,
67
+ SandboxShipRpc,
68
+ SandboxExecuteRpc,
69
+ SandboxCollectRpc,
70
+ SandboxCleanupRpc,
71
+ ]);
72
+ /** @typedef {import("../SandboxTransportService.ts").SandboxTransportService} SandboxTransportService */
73
+ const SandboxEntityExecutorTag = /** @type {Context.TagClass<SandboxEntityExecutor, "SandboxEntityExecutor", SandboxTransportService>} */ (
74
+ Context.Tag("SandboxEntityExecutor")()
75
+ );
76
+ export class SandboxEntityExecutor extends SandboxEntityExecutorTag {
77
+ }
78
+ /**
79
+ * @param {{ runId: string; sandboxId: string; }} input
80
+ * @returns {string}
81
+ */
82
+ export function makeSandboxEntityId(input) {
83
+ return `${input.runId}:${input.sandboxId}`;
84
+ }
85
+ const SandboxEntityLayer = SandboxEntity.toLayer(Effect.gen(function* () {
86
+ const executor = yield* SandboxEntityExecutor;
87
+ return SandboxEntity.of({
88
+ create: ({ payload }) => executor.create(payload),
89
+ ship: ({ payload }) => executor.ship(payload.bundlePath, payload.handle),
90
+ execute: ({ payload }) => executor.execute(payload.command, payload.handle),
91
+ collect: ({ payload }) => executor.collect(payload.handle),
92
+ cleanup: ({ payload }) => executor.cleanup(payload.handle),
93
+ });
94
+ }));
95
+ /**
96
+ * @param {unknown} error
97
+ * @param {string} operation
98
+ * @param {Record<string, unknown>} details
99
+ * @returns {SmithersError}
100
+ */
101
+ function sandboxEntityError(error, operation, details) {
102
+ return toSmithersError(error, `sandbox entity ${operation} failed`, {
103
+ code: "SANDBOX_EXECUTION_FAILED",
104
+ details,
105
+ });
106
+ }
107
+ /**
108
+ * @template R, E
109
+ * @param {Layer.Layer<SandboxEntityExecutor, E, R>} executorLayer
110
+ */
111
+ export const makeSandboxTransportServiceEffect = (executorLayer) => Effect.gen(function* () {
112
+ const makeClient = yield* Entity.makeTestClient(SandboxEntity, SandboxEntityLayer.pipe(Layer.provide(executorLayer))).pipe(Effect.provide(ShardingConfig.layer()));
113
+ /**
114
+ * @template A
115
+ * @param {{ runId: string; sandboxId: string }} input
116
+ * @param {string} operation
117
+ * @param {Record<string, unknown>} details
118
+ * @param {(client: SandboxEntityClient) => Effect.Effect<A, unknown>} f
119
+ */
120
+ const withClient = (input, operation, details, f) => makeClient(makeSandboxEntityId(input)).pipe(Effect.flatMap((client) => f(client)), Effect.mapError((error) => sandboxEntityError(error, operation, {
121
+ runId: input.runId,
122
+ sandboxId: input.sandboxId,
123
+ ...details,
124
+ })));
125
+ /** @type {SandboxTransportService} */
126
+ const service = {
127
+ create: (config) => withClient(config, "create", { runtime: config.runtime, rootDir: config.rootDir }, (client) => client.create(config)),
128
+ ship: (bundlePath, handle) => withClient(handle, "ship", { bundlePath }, (client) => client.ship({ bundlePath, handle })),
129
+ execute: (command, handle) => withClient(handle, "execute", { command }, (client) => client.execute({ command, handle })),
130
+ collect: (handle) => withClient(handle, "collect", {}, (client) => client.collect({ handle })),
131
+ cleanup: (handle) => withClient(handle, "cleanup", {}, (client) => client.cleanup({ handle })),
132
+ };
133
+ return service;
134
+ });
@@ -0,0 +1,62 @@
1
+ import { SocketRunner } from "@effect/cluster";
2
+ import { mkdir, cp, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { Effect, Layer } from "effect";
5
+ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
6
+ import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
7
+ import { SandboxEntityExecutor } from "./sandbox-entity.js";
8
+ /** @typedef {import("../SandboxTransportConfig.ts").SandboxTransportConfig} SandboxTransportConfig */
9
+ /** @typedef {import("../SandboxHandle.ts").SandboxHandle} SandboxHandle */
10
+ /**
11
+ * @param {SandboxTransportConfig} config
12
+ * @returns {SandboxHandle}
13
+ */
14
+ function baseHandle(config) {
15
+ const sandboxRoot = join(config.rootDir, ".smithers", "sandboxes", config.runId, config.sandboxId);
16
+ return {
17
+ runtime: config.runtime,
18
+ runId: config.runId,
19
+ sandboxId: config.sandboxId,
20
+ sandboxRoot,
21
+ requestPath: join(sandboxRoot, "request"),
22
+ resultPath: join(sandboxRoot, "result"),
23
+ };
24
+ }
25
+ /** @type {Layer.Layer<SandboxEntityExecutor, never, never>} */
26
+ export const BubblewrapSandboxExecutorLive = Layer.succeed(SandboxEntityExecutor, SandboxEntityExecutor.of({
27
+ create: (config) => Effect.gen(function* () {
28
+ if (process.platform === "linux") {
29
+ const bwrap = typeof Bun !== "undefined" ? Bun.which("bwrap") : null;
30
+ if (!bwrap) {
31
+ yield* Effect.fail(new SmithersError("PROCESS_SPAWN_FAILED", "Bubblewrap runtime requested but `bwrap` is not installed. Install bubblewrap (package: bubblewrap) or use runtime=\"docker\".", { runtime: "bubblewrap" }));
32
+ }
33
+ }
34
+ if (process.platform === "darwin") {
35
+ const sandboxExec = typeof Bun !== "undefined" ? Bun.which("sandbox-exec") : null;
36
+ if (!sandboxExec) {
37
+ yield* Effect.fail(new SmithersError("PROCESS_SPAWN_FAILED", "bubblewrap runtime on macOS requires `sandbox-exec` for fallback isolation.", { runtime: "bubblewrap" }));
38
+ }
39
+ }
40
+ const handle = baseHandle(config);
41
+ yield* Effect.tryPromise({
42
+ try: async () => {
43
+ await mkdir(handle.requestPath, { recursive: true });
44
+ await mkdir(handle.resultPath, { recursive: true });
45
+ },
46
+ catch: (cause) => toSmithersError(cause, "create sandbox workspace"),
47
+ });
48
+ return handle;
49
+ }),
50
+ ship: (bundlePath, handle) => Effect.tryPromise({
51
+ try: async () => {
52
+ await rm(handle.requestPath, { recursive: true, force: true });
53
+ await mkdir(handle.requestPath, { recursive: true });
54
+ await cp(bundlePath, handle.requestPath, { recursive: true });
55
+ },
56
+ catch: (cause) => toSmithersError(cause, "ship sandbox bundle"),
57
+ }),
58
+ execute: (_command, _handle) => Effect.succeed({ exitCode: 0 }),
59
+ collect: (handle) => Effect.succeed({ bundlePath: handle.resultPath }),
60
+ cleanup: (_handle) => Effect.void,
61
+ }));
62
+ export const SandboxSocketRunner = SocketRunner;
package/src/execute.js ADDED
@@ -0,0 +1,373 @@
1
+ import { mkdir, stat, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { Effect, Metric } from "effect";
4
+ import { SmithersDb } from "@smithers-orchestrator/db/adapter";
5
+ import { trackEvent, sandboxTransportDurationMs } from "@smithers-orchestrator/observability/metrics";
6
+ import { nowMs } from "@smithers-orchestrator/scheduler/nowMs";
7
+ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
8
+ import { errorToJson } from "@smithers-orchestrator/errors/errorToJson";
9
+ import { requireTaskRuntime } from "@smithers-orchestrator/driver/task-runtime";
10
+ import { executeChildWorkflow } from "@smithers-orchestrator/engine/child-workflow";
11
+ import { validateSandboxBundle, writeSandboxBundle } from "./bundle.js";
12
+ import { SandboxTransport, layerForSandboxRuntime, resolveSandboxRuntime, } from "./transport.js";
13
+ /** @typedef {import("./ExecuteSandboxOptions.ts").ExecuteSandboxOptions} ExecuteSandboxOptions */
14
+ /** @typedef {import("./SandboxRuntime.ts").SandboxRuntime} SandboxRuntime */
15
+ /** @typedef {import("./SandboxHandle.ts").SandboxHandle} SandboxHandle */
16
+ /** @typedef {import("./SandboxTransportService.ts").SandboxTransportService} SandboxTransportService */
17
+ /** @typedef {import("@smithers-orchestrator/observability/SmithersEvent").SmithersEvent} SmithersEvent */
18
+
19
+ const DEFAULT_MAX_CONCURRENT_SANDBOXES = 10;
20
+ /**
21
+ * @param {ConstructorParameters<typeof SmithersDb>[0]} db
22
+ * @param {SmithersEvent} event
23
+ * @returns {Promise<void>}
24
+ */
25
+ async function emitSandboxEvent(db, event) {
26
+ const adapter = new SmithersDb(db);
27
+ await adapter.insertEventWithNextSeq({
28
+ runId: event.runId,
29
+ timestampMs: event.timestampMs,
30
+ type: event.type,
31
+ payloadJson: JSON.stringify(event),
32
+ });
33
+ await Effect.runPromise(trackEvent(event));
34
+ }
35
+ /**
36
+ * @param {string} path
37
+ * @returns {Promise<number>}
38
+ */
39
+ async function directorySize(path) {
40
+ const info = await stat(path).catch(() => null);
41
+ if (!info)
42
+ return 0;
43
+ if (info.isFile())
44
+ return info.size;
45
+ return 0;
46
+ }
47
+ /**
48
+ * @template A
49
+ * @param {SandboxRuntime} runtime
50
+ * @param {Effect.Effect<A, SmithersError, SandboxTransport>} effect
51
+ * @returns {Effect.Effect<A, SmithersError, never>}
52
+ */
53
+ function runtimeServiceEffect(runtime, effect) {
54
+ return effect.pipe(Effect.provide(layerForSandboxRuntime(runtime)));
55
+ }
56
+ /**
57
+ * @template A
58
+ * @param {SandboxRuntime} runtime
59
+ * @param {Effect.Effect<A, SmithersError, SandboxTransport>} effect
60
+ * @returns {Promise<A>}
61
+ */
62
+ async function transportCall(runtime, effect) {
63
+ const started = performance.now();
64
+ const value = await Effect.runPromise(runtimeServiceEffect(runtime, effect));
65
+ await Effect.runPromise(Metric.update(sandboxTransportDurationMs, performance.now() - started));
66
+ return value;
67
+ }
68
+ /**
69
+ * @template A
70
+ * @param {(svc: SandboxTransportService) => Effect.Effect<A, SmithersError>} fn
71
+ * @returns {Effect.Effect<A, SmithersError, SandboxTransport>}
72
+ */
73
+ function sandboxTransport(fn) {
74
+ return Effect.flatMap(SandboxTransport, fn);
75
+ }
76
+ /**
77
+ * @param {SandboxHandle | null} handle
78
+ * @param {string} sandboxId
79
+ * @returns {SandboxHandle}
80
+ */
81
+ function requireSandboxHandle(handle, sandboxId) {
82
+ if (handle)
83
+ return handle;
84
+ throw new SmithersError("SANDBOX_EXECUTION_FAILED", `Sandbox ${sandboxId} did not initialize correctly.`, { sandboxId });
85
+ }
86
+ /**
87
+ * @returns {number}
88
+ */
89
+ function resolveMaxConcurrentSandboxes() {
90
+ const raw = process.env.SMITHERS_MAX_CONCURRENT_SANDBOXES;
91
+ const parsed = Number(raw);
92
+ if (!Number.isFinite(parsed) || parsed <= 0) {
93
+ return DEFAULT_MAX_CONCURRENT_SANDBOXES;
94
+ }
95
+ return Math.floor(parsed);
96
+ }
97
+ /**
98
+ * @param {unknown} status
99
+ * @returns {boolean}
100
+ */
101
+ function isSandboxActive(status) {
102
+ if (typeof status !== "string")
103
+ return false;
104
+ return status !== "finished" && status !== "failed" && status !== "cancelled";
105
+ }
106
+ /**
107
+ * @param {ExecuteSandboxOptions} options
108
+ * @returns {Promise<unknown>}
109
+ */
110
+ export async function executeSandbox(options) {
111
+ const runtime = requireTaskRuntime();
112
+ runtime.heartbeat({
113
+ sandboxId: options.sandboxId,
114
+ stage: "initializing",
115
+ progress: 0,
116
+ });
117
+ const adapter = new SmithersDb(runtime.db);
118
+ const requestedRuntime = options.runtime ?? "bubblewrap";
119
+ const selectedRuntime = resolveSandboxRuntime(requestedRuntime);
120
+ const createdAtMs = nowMs();
121
+ const configJson = JSON.stringify({
122
+ runtime: requestedRuntime,
123
+ selectedRuntime,
124
+ allowNetwork: options.allowNetwork,
125
+ maxOutputBytes: options.maxOutputBytes,
126
+ toolTimeoutMs: options.toolTimeoutMs,
127
+ reviewDiffs: options.reviewDiffs ?? true,
128
+ autoAcceptDiffs: Boolean(options.autoAcceptDiffs),
129
+ ...options.config,
130
+ });
131
+ const sandboxRoot = join(options.rootDir, ".smithers", "sandboxes", runtime.runId, options.sandboxId);
132
+ const requestBundlePath = join(sandboxRoot, "request-bundle");
133
+ /**
134
+ * @param {string} childRunId
135
+ */
136
+ const childLogPath = (childRunId) => join(options.rootDir, ".smithers", "executions", childRunId, "logs", "stream.ndjson");
137
+ let handle = null;
138
+ try {
139
+ const existingSandboxes = await adapter.listSandboxes(runtime.runId);
140
+ const activeSandboxCount = existingSandboxes.filter((row) => isSandboxActive(row?.status)).length;
141
+ const maxConcurrent = resolveMaxConcurrentSandboxes();
142
+ if (activeSandboxCount >= maxConcurrent) {
143
+ throw new SmithersError("SANDBOX_EXECUTION_FAILED", `Sandbox concurrency limit reached for run ${runtime.runId} (${maxConcurrent}).`, {
144
+ runId: runtime.runId,
145
+ maxConcurrent,
146
+ activeSandboxCount,
147
+ });
148
+ }
149
+ await adapter.upsertSandbox({
150
+ runId: runtime.runId,
151
+ sandboxId: options.sandboxId,
152
+ runtime: selectedRuntime,
153
+ remoteRunId: null,
154
+ workspaceId: null,
155
+ containerId: null,
156
+ configJson,
157
+ status: "pending",
158
+ shippedAtMs: null,
159
+ completedAtMs: null,
160
+ bundlePath: null,
161
+ });
162
+ await emitSandboxEvent(runtime.db, {
163
+ type: "SandboxCreated",
164
+ runId: runtime.runId,
165
+ sandboxId: options.sandboxId,
166
+ runtime: selectedRuntime,
167
+ configJson,
168
+ timestampMs: createdAtMs,
169
+ });
170
+ runtime.heartbeat({
171
+ sandboxId: options.sandboxId,
172
+ stage: "created",
173
+ progress: 10,
174
+ });
175
+ await mkdir(requestBundlePath, { recursive: true });
176
+ await writeFile(join(requestBundlePath, "README.md"), JSON.stringify({
177
+ status: "pending",
178
+ sandboxId: options.sandboxId,
179
+ runtime: selectedRuntime,
180
+ input: options.input ?? {},
181
+ }, null, 2), "utf8");
182
+ const transportConfig = {
183
+ runId: runtime.runId,
184
+ sandboxId: options.sandboxId,
185
+ runtime: selectedRuntime,
186
+ rootDir: options.rootDir,
187
+ image: options.config?.image ?? undefined,
188
+ };
189
+ handle = await transportCall(selectedRuntime, sandboxTransport((svc) => svc.create(transportConfig)));
190
+ const sandboxHandle = requireSandboxHandle(handle, options.sandboxId);
191
+ await transportCall(selectedRuntime, sandboxTransport((svc) => svc.ship(requestBundlePath, sandboxHandle)));
192
+ const bundleSizeBytes = await directorySize(join(requestBundlePath, "README.md"));
193
+ await emitSandboxEvent(runtime.db, {
194
+ type: "SandboxShipped",
195
+ runId: runtime.runId,
196
+ sandboxId: options.sandboxId,
197
+ runtime: selectedRuntime,
198
+ bundleSizeBytes,
199
+ timestampMs: nowMs(),
200
+ });
201
+ runtime.heartbeat({
202
+ sandboxId: options.sandboxId,
203
+ stage: "shipped",
204
+ progress: 25,
205
+ });
206
+ await adapter.upsertSandbox({
207
+ runId: runtime.runId,
208
+ sandboxId: options.sandboxId,
209
+ runtime: selectedRuntime,
210
+ remoteRunId: null,
211
+ workspaceId: sandboxHandle.workspaceId ?? null,
212
+ containerId: sandboxHandle.containerId ?? null,
213
+ configJson,
214
+ status: "shipped",
215
+ shippedAtMs: nowMs(),
216
+ completedAtMs: null,
217
+ bundlePath: null,
218
+ });
219
+ await transportCall(selectedRuntime, sandboxTransport((svc) => svc.execute("smithers up bundle.tsx", sandboxHandle)));
220
+ runtime.heartbeat({
221
+ sandboxId: options.sandboxId,
222
+ stage: "executing",
223
+ progress: 40,
224
+ });
225
+ const childStartedMs = performance.now();
226
+ const child = await executeChildWorkflow(options.parentWorkflow, {
227
+ workflow: options.workflow,
228
+ input: options.input,
229
+ parentRunId: runtime.runId,
230
+ rootDir: options.rootDir,
231
+ allowNetwork: options.allowNetwork,
232
+ maxOutputBytes: options.maxOutputBytes,
233
+ toolTimeoutMs: options.toolTimeoutMs,
234
+ signal: runtime.signal,
235
+ });
236
+ runtime.heartbeat({
237
+ sandboxId: options.sandboxId,
238
+ stage: "child-finished",
239
+ progress: 70,
240
+ childRunId: child.runId,
241
+ childStatus: child.status,
242
+ });
243
+ await emitSandboxEvent(runtime.db, {
244
+ type: "SandboxHeartbeat",
245
+ runId: runtime.runId,
246
+ sandboxId: options.sandboxId,
247
+ remoteRunId: child.runId,
248
+ progress: 1,
249
+ timestampMs: nowMs(),
250
+ });
251
+ await writeSandboxBundle({
252
+ bundlePath: sandboxHandle.resultPath,
253
+ output: child.output,
254
+ status: child.status === "finished" ? "finished" : "failed",
255
+ runId: child.runId,
256
+ streamLogPath: childLogPath(child.runId),
257
+ });
258
+ const collected = await transportCall(selectedRuntime, sandboxTransport((svc) => svc.collect(sandboxHandle)));
259
+ const validated = await validateSandboxBundle(collected.bundlePath);
260
+ runtime.heartbeat({
261
+ sandboxId: options.sandboxId,
262
+ stage: "bundle-collected",
263
+ progress: 85,
264
+ bundlePath: validated.bundlePath,
265
+ patchCount: validated.patchFiles.length,
266
+ });
267
+ await emitSandboxEvent(runtime.db, {
268
+ type: "SandboxBundleReceived",
269
+ runId: runtime.runId,
270
+ sandboxId: options.sandboxId,
271
+ bundleSizeBytes: validated.bundleSizeBytes,
272
+ patchCount: validated.patchFiles.length,
273
+ hasOutputs: validated.manifest.outputs !== undefined,
274
+ timestampMs: nowMs(),
275
+ });
276
+ const reviewDiffs = options.reviewDiffs ?? true;
277
+ if (reviewDiffs && validated.patchFiles.length > 0) {
278
+ await emitSandboxEvent(runtime.db, {
279
+ type: "SandboxDiffReviewRequested",
280
+ runId: runtime.runId,
281
+ sandboxId: options.sandboxId,
282
+ patchCount: validated.patchFiles.length,
283
+ totalDiffLines: 0,
284
+ timestampMs: nowMs(),
285
+ });
286
+ if (!options.autoAcceptDiffs) {
287
+ await emitSandboxEvent(runtime.db, {
288
+ type: "SandboxDiffRejected",
289
+ runId: runtime.runId,
290
+ sandboxId: options.sandboxId,
291
+ reason: "Diff review approval is required before applying sandbox patches.",
292
+ timestampMs: nowMs(),
293
+ });
294
+ throw new SmithersError("INVALID_INPUT", "Sandbox produced patches that require review approval.", {
295
+ sandboxId: options.sandboxId,
296
+ patchCount: validated.patchFiles.length,
297
+ });
298
+ }
299
+ await emitSandboxEvent(runtime.db, {
300
+ type: "SandboxDiffAccepted",
301
+ runId: runtime.runId,
302
+ sandboxId: options.sandboxId,
303
+ patchCount: validated.patchFiles.length,
304
+ timestampMs: nowMs(),
305
+ });
306
+ }
307
+ await adapter.upsertSandbox({
308
+ runId: runtime.runId,
309
+ sandboxId: options.sandboxId,
310
+ runtime: selectedRuntime,
311
+ remoteRunId: child.runId,
312
+ workspaceId: sandboxHandle.workspaceId ?? null,
313
+ containerId: sandboxHandle.containerId ?? null,
314
+ configJson,
315
+ status: validated.manifest.status,
316
+ shippedAtMs: createdAtMs,
317
+ completedAtMs: nowMs(),
318
+ bundlePath: validated.bundlePath,
319
+ });
320
+ await emitSandboxEvent(runtime.db, {
321
+ type: "SandboxCompleted",
322
+ runId: runtime.runId,
323
+ sandboxId: options.sandboxId,
324
+ remoteRunId: child.runId,
325
+ runtime: selectedRuntime,
326
+ status: validated.manifest.status,
327
+ durationMs: performance.now() - childStartedMs,
328
+ timestampMs: nowMs(),
329
+ });
330
+ runtime.heartbeat({
331
+ sandboxId: options.sandboxId,
332
+ stage: "completed",
333
+ progress: 100,
334
+ status: validated.manifest.status,
335
+ });
336
+ return validated.manifest.outputs;
337
+ }
338
+ catch (error) {
339
+ await adapter.upsertSandbox({
340
+ runId: runtime.runId,
341
+ sandboxId: options.sandboxId,
342
+ runtime: selectedRuntime,
343
+ remoteRunId: null,
344
+ workspaceId: handle?.workspaceId ?? null,
345
+ containerId: handle?.containerId ?? null,
346
+ configJson,
347
+ status: "failed",
348
+ shippedAtMs: createdAtMs,
349
+ completedAtMs: nowMs(),
350
+ bundlePath: handle?.resultPath ?? null,
351
+ });
352
+ await emitSandboxEvent(runtime.db, {
353
+ type: "SandboxFailed",
354
+ runId: runtime.runId,
355
+ sandboxId: options.sandboxId,
356
+ runtime: selectedRuntime,
357
+ error: errorToJson(error),
358
+ timestampMs: nowMs(),
359
+ });
360
+ runtime.heartbeat({
361
+ sandboxId: options.sandboxId,
362
+ stage: "failed",
363
+ progress: 100,
364
+ error: error instanceof Error ? error.message : String(error),
365
+ });
366
+ throw error;
367
+ }
368
+ finally {
369
+ if (handle) {
370
+ await transportCall(selectedRuntime, sandboxTransport((svc) => svc.cleanup(handle))).catch(() => undefined);
371
+ }
372
+ }
373
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,140 @@
1
+ import * as _smithers_observability_SmithersEvent from '@smithers-orchestrator/observability/SmithersEvent';
2
+ import { SmithersWorkflow } from '@smithers-orchestrator/components/SmithersWorkflow';
3
+ import { ChildWorkflowDefinition } from '@smithers-orchestrator/engine/child-workflow';
4
+ import { Context, Effect, Layer } from 'effect';
5
+ import { SmithersError } from '@smithers-orchestrator/errors/SmithersError';
6
+
7
+ type SandboxBundleManifest$1 = {
8
+ outputs: unknown;
9
+ status: "finished" | "failed" | "cancelled";
10
+ runId?: string;
11
+ patches?: string[];
12
+ };
13
+
14
+ type ValidatedSandboxBundle$1 = {
15
+ manifest: SandboxBundleManifest$1;
16
+ bundleSizeBytes: number;
17
+ patchFiles: string[];
18
+ logsPath: string | null;
19
+ bundlePath: string;
20
+ };
21
+
22
+ /**
23
+ * @param {string} bundlePath
24
+ * @returns {Promise<ValidatedSandboxBundle>}
25
+ */
26
+ declare function validateSandboxBundle(bundlePath: string): Promise<ValidatedSandboxBundle>;
27
+ /**
28
+ * @param {{ bundlePath: string; output: unknown; status: "finished" | "failed" | "cancelled"; runId?: string; streamLogPath?: string | null; patches?: Array<{ path: string; content: string }>; artifacts?: Array<{ path: string; content: string }>; }} params
29
+ */
30
+ declare function writeSandboxBundle(params: {
31
+ bundlePath: string;
32
+ output: unknown;
33
+ status: "finished" | "failed" | "cancelled";
34
+ runId?: string;
35
+ streamLogPath?: string | null;
36
+ patches?: Array<{
37
+ path: string;
38
+ content: string;
39
+ }>;
40
+ artifacts?: Array<{
41
+ path: string;
42
+ content: string;
43
+ }>;
44
+ }): Promise<void>;
45
+ /** @typedef {import("./SandboxBundleManifest.ts").SandboxBundleManifest} SandboxBundleManifest */
46
+ /** @typedef {import("./ValidatedSandboxBundle.ts").ValidatedSandboxBundle} ValidatedSandboxBundle */
47
+ declare const SANDBOX_MAX_BUNDLE_BYTES: number;
48
+ declare const SANDBOX_MAX_README_BYTES: number;
49
+ declare const SANDBOX_MAX_PATCH_FILES: 1000;
50
+ declare const SANDBOX_BUNDLE_RUN_ID_MAX_LENGTH: 256;
51
+ declare const SANDBOX_BUNDLE_PATH_MAX_LENGTH: 1024;
52
+ declare const SANDBOX_BUNDLE_OUTPUT_MAX_DEPTH: 16;
53
+ declare const SANDBOX_BUNDLE_OUTPUT_MAX_ARRAY_LENGTH: 512;
54
+ declare const SANDBOX_BUNDLE_OUTPUT_MAX_STRING_LENGTH: number;
55
+ type SandboxBundleManifest = SandboxBundleManifest$1;
56
+ type ValidatedSandboxBundle = ValidatedSandboxBundle$1;
57
+
58
+ type SandboxRuntime$1 = "bubblewrap" | "docker" | "codeplane";
59
+
60
+ type SandboxTransportConfig$1 = {
61
+ runId: string;
62
+ sandboxId: string;
63
+ runtime: SandboxRuntime$1;
64
+ rootDir: string;
65
+ image?: string;
66
+ };
67
+
68
+ type SandboxHandle = {
69
+ runtime: SandboxRuntime$1;
70
+ runId: string;
71
+ sandboxId: string;
72
+ sandboxRoot: string;
73
+ requestPath: string;
74
+ resultPath: string;
75
+ containerId?: string;
76
+ workspaceId?: string;
77
+ };
78
+
79
+ type SandboxBundleResult$1 = {
80
+ bundlePath: string;
81
+ };
82
+
83
+ type SandboxTransportService = {
84
+ readonly create: (config: SandboxTransportConfig$1) => Effect.Effect<SandboxHandle, SmithersError>;
85
+ readonly ship: (bundlePath: string, handle: SandboxHandle) => Effect.Effect<void, SmithersError>;
86
+ readonly execute: (command: string, handle: SandboxHandle) => Effect.Effect<{
87
+ exitCode: number;
88
+ }, SmithersError>;
89
+ readonly collect: (handle: SandboxHandle) => Effect.Effect<SandboxBundleResult$1, SmithersError>;
90
+ readonly cleanup: (handle: SandboxHandle) => Effect.Effect<void, SmithersError>;
91
+ };
92
+
93
+ type ExecuteSandboxOptions$1 = {
94
+ parentWorkflow?: SmithersWorkflow<unknown>;
95
+ sandboxId: string;
96
+ runtime?: SandboxRuntime$1;
97
+ workflow: ChildWorkflowDefinition;
98
+ input?: unknown;
99
+ rootDir: string;
100
+ allowNetwork: boolean;
101
+ maxOutputBytes: number;
102
+ toolTimeoutMs: number;
103
+ reviewDiffs?: boolean;
104
+ autoAcceptDiffs?: boolean;
105
+ config?: Record<string, unknown>;
106
+ };
107
+
108
+ /**
109
+ * @param {ExecuteSandboxOptions} options
110
+ * @returns {Promise<unknown>}
111
+ */
112
+ declare function executeSandbox(options: ExecuteSandboxOptions): Promise<unknown>;
113
+ type ExecuteSandboxOptions = ExecuteSandboxOptions$1;
114
+ type SmithersEvent = _smithers_observability_SmithersEvent.SmithersEvent;
115
+
116
+ declare class SandboxEntityExecutor extends Context.TagClassShape<"SandboxEntityExecutor", SandboxTransportService> {
117
+ }
118
+
119
+ /**
120
+ * @template R, E
121
+ * @param {Layer.Layer<SandboxEntityExecutor, E, R>} executorLayer
122
+ * @returns {Layer.Layer<SandboxTransport, E, R>}
123
+ */
124
+ declare function makeSandboxTransportLayer<R, E>(executorLayer: Layer.Layer<SandboxEntityExecutor, E, R>): Layer.Layer<SandboxTransport, E, R>;
125
+ /**
126
+ * @param {SandboxRuntime} runtime
127
+ */
128
+ declare function layerForSandboxRuntime(runtime: SandboxRuntime): Layer.Layer<SandboxTransport, never, never>;
129
+ /**
130
+ * @param {SandboxRuntime} requested
131
+ * @returns {SandboxRuntime}
132
+ */
133
+ declare function resolveSandboxRuntime(requested: SandboxRuntime): SandboxRuntime;
134
+ declare class SandboxTransport extends Context.TagClassShape<"SandboxTransport", SandboxTransportService> {
135
+ }
136
+ type SandboxBundleResult = SandboxBundleResult$1;
137
+ type SandboxTransportConfig = SandboxTransportConfig$1;
138
+ type SandboxRuntime = SandboxRuntime$1;
139
+
140
+ export { type ExecuteSandboxOptions, SANDBOX_BUNDLE_OUTPUT_MAX_ARRAY_LENGTH, SANDBOX_BUNDLE_OUTPUT_MAX_DEPTH, SANDBOX_BUNDLE_OUTPUT_MAX_STRING_LENGTH, SANDBOX_BUNDLE_PATH_MAX_LENGTH, SANDBOX_BUNDLE_RUN_ID_MAX_LENGTH, SANDBOX_MAX_BUNDLE_BYTES, SANDBOX_MAX_PATCH_FILES, SANDBOX_MAX_README_BYTES, type SandboxBundleManifest, type SandboxBundleResult, SandboxTransport, type SandboxTransportConfig, type SmithersEvent, type ValidatedSandboxBundle, executeSandbox, layerForSandboxRuntime, makeSandboxTransportLayer, resolveSandboxRuntime, validateSandboxBundle, writeSandboxBundle };
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./bundle.js";
2
+ export * from "./execute.js";
3
+ export * from "./transport.js";
@@ -0,0 +1,67 @@
1
+ import { resolve, isAbsolute, sep, dirname } from "node:path";
2
+ import { realpath } from "node:fs/promises";
3
+ import { Effect } from "effect";
4
+ import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
5
+ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
6
+ /**
7
+ * @param {string} rootDir
8
+ * @param {string} inputPath
9
+ * @returns {string}
10
+ */
11
+ export function resolveSandboxPath(rootDir, inputPath) {
12
+ if (!inputPath || typeof inputPath !== "string") {
13
+ throw new SmithersError("TOOL_PATH_INVALID", "Path must be a string");
14
+ }
15
+ const resolved = isAbsolute(inputPath)
16
+ ? resolve(inputPath)
17
+ : resolve(rootDir, inputPath);
18
+ const root = resolve(rootDir);
19
+ if (!resolved.startsWith(root + sep) && resolved !== root) {
20
+ throw new SmithersError("TOOL_PATH_ESCAPE", "Path escapes sandbox root");
21
+ }
22
+ return resolved;
23
+ }
24
+ /**
25
+ * @param {string} rootDir
26
+ * @param {string} resolvedPath
27
+ */
28
+ export function assertPathWithinRootEffect(rootDir, resolvedPath) {
29
+ return Effect.gen(function* () {
30
+ const root = yield* Effect.tryPromise({
31
+ try: () => realpath(resolve(rootDir)),
32
+ catch: (cause) => toSmithersError(cause, "realpath root"),
33
+ });
34
+ let current = resolvedPath;
35
+ while (true) {
36
+ const result = yield* Effect.either(Effect.tryPromise({
37
+ try: () => realpath(current),
38
+ catch: (cause) => toSmithersError(cause, "realpath check"),
39
+ }));
40
+ if (result._tag === "Right") {
41
+ const target = result.right;
42
+ if (target !== root && !target.startsWith(root + sep)) {
43
+ return yield* Effect.fail(new SmithersError("TOOL_PATH_ESCAPE", "Path escapes sandbox root (via symlink)"));
44
+ }
45
+ return;
46
+ }
47
+ const err = result.left;
48
+ const cause = err?.cause ?? err;
49
+ const code = cause?.code;
50
+ if (code && code !== "ENOENT" && code !== "ENOTDIR") {
51
+ return yield* Effect.fail(err);
52
+ }
53
+ const parent = dirname(current);
54
+ if (parent === current) {
55
+ return yield* Effect.fail(new SmithersError("TOOL_PATH_ESCAPE", "Path escapes sandbox root (via symlink)"));
56
+ }
57
+ current = parent;
58
+ }
59
+ });
60
+ }
61
+ /**
62
+ * @param {string} rootDir
63
+ * @param {string} resolvedPath
64
+ */
65
+ export async function assertPathWithinRoot(rootDir, resolvedPath) {
66
+ return Effect.runPromise(assertPathWithinRootEffect(rootDir, resolvedPath));
67
+ }
@@ -0,0 +1,51 @@
1
+ // @smithers-type-exports-begin
2
+ /** @typedef {import("./SandboxBundleResult.ts").SandboxBundleResult} SandboxBundleResult */
3
+ /** @typedef {import("./SandboxHandle.ts").SandboxHandle} SandboxHandle */
4
+ /** @typedef {import("./SandboxTransportConfig.ts").SandboxTransportConfig} SandboxTransportConfig */
5
+ /** @typedef {import("./SandboxTransportService.ts").SandboxTransportService} SandboxTransportService */
6
+ // @smithers-type-exports-end
7
+
8
+ import { Context, Effect, Layer } from "effect";
9
+ import { CodeplaneSandboxExecutorLive, DockerSandboxExecutorLive, } from "./effect/http-runner.js";
10
+ import { SandboxEntityExecutor, makeSandboxTransportServiceEffect, } from "./effect/sandbox-entity.js";
11
+ import { BubblewrapSandboxExecutorLive } from "./effect/socket-runner.js";
12
+ import {} from "@smithers-orchestrator/errors/SmithersError";
13
+ /** @typedef {import("./SandboxRuntime.ts").SandboxRuntime} SandboxRuntime */
14
+
15
+ const SandboxTransportTag = /** @type {Context.TagClass<SandboxTransport, "SandboxTransport", SandboxTransportService>} */ (
16
+ Context.Tag("SandboxTransport")()
17
+ );
18
+ export class SandboxTransport extends SandboxTransportTag {
19
+ }
20
+ /**
21
+ * @template R, E
22
+ * @param {Layer.Layer<SandboxEntityExecutor, E, R>} executorLayer
23
+ * @returns {Layer.Layer<SandboxTransport, E, R>}
24
+ */
25
+ export function makeSandboxTransportLayer(executorLayer) {
26
+ return Layer.scoped(SandboxTransport, makeSandboxTransportServiceEffect(executorLayer).pipe(Effect.map((service) => SandboxTransport.of(service))));
27
+ }
28
+ /**
29
+ * @param {SandboxRuntime} runtime
30
+ */
31
+ export function layerForSandboxRuntime(runtime) {
32
+ switch (runtime) {
33
+ case "docker":
34
+ return makeSandboxTransportLayer(DockerSandboxExecutorLive);
35
+ case "codeplane":
36
+ return makeSandboxTransportLayer(CodeplaneSandboxExecutorLive);
37
+ case "bubblewrap":
38
+ default:
39
+ return makeSandboxTransportLayer(BubblewrapSandboxExecutorLive);
40
+ }
41
+ }
42
+ /**
43
+ * @param {SandboxRuntime} requested
44
+ * @returns {SandboxRuntime}
45
+ */
46
+ export function resolveSandboxRuntime(requested) {
47
+ if (requested !== "docker")
48
+ return requested;
49
+ const hasDocker = typeof Bun !== "undefined" ? Boolean(Bun.which("docker")) : false;
50
+ return hasDocker ? "docker" : "bubblewrap";
51
+ }