@smithers-orchestrator/sandbox 0.20.3 → 0.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smithers-orchestrator/sandbox",
3
- "version": "0.20.3",
3
+ "version": "0.21.0",
4
4
  "description": "Sandbox bundle, execution, and transport integration for Smithers",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -23,14 +23,11 @@
23
23
  "@effect/cluster": "^0.58.0",
24
24
  "@effect/rpc": "^0.75.0",
25
25
  "effect": "^3.21.1",
26
- "@smithers-orchestrator/db": "0.20.3",
27
- "@smithers-orchestrator/driver": "0.20.3",
28
- "@smithers-orchestrator/components": "0.20.3",
29
- "@smithers-orchestrator/errors": "0.20.3",
30
- "@smithers-orchestrator/engine": "0.20.3",
31
- "@smithers-orchestrator/graph": "0.20.3",
32
- "@smithers-orchestrator/observability": "0.20.3",
33
- "@smithers-orchestrator/scheduler": "0.20.3"
26
+ "@smithers-orchestrator/db": "0.21.0",
27
+ "@smithers-orchestrator/driver": "0.21.0",
28
+ "@smithers-orchestrator/errors": "0.21.0",
29
+ "@smithers-orchestrator/observability": "0.21.0",
30
+ "@smithers-orchestrator/scheduler": "0.21.0"
34
31
  },
35
32
  "devDependencies": {
36
33
  "@types/bun": "latest",
@@ -1,4 +1,5 @@
1
1
  import type { SandboxRuntime } from "./SandboxRuntime.ts";
2
+ import type { SandboxDiffBundleLike, SandboxProvider } from "./SandboxProvider.ts";
2
3
 
3
4
  export type SandboxWorkflow = {
4
5
  db?: unknown;
@@ -31,17 +32,20 @@ export type ExecuteSandboxChildWorkflow = (
31
32
  ) => Promise<{ runId: string; status: string; output: unknown }>;
32
33
 
33
34
  export type ExecuteSandboxOptions = {
34
- parentWorkflow?: SandboxWorkflow;
35
- sandboxId: string;
36
- runtime?: SandboxRuntime;
37
- workflow: SandboxChildWorkflowDefinition;
38
- executeChildWorkflow: ExecuteSandboxChildWorkflow;
39
- input?: unknown;
40
- rootDir: string;
41
- allowNetwork: boolean;
42
- maxOutputBytes: number;
43
- toolTimeoutMs: number;
44
- reviewDiffs?: boolean;
45
- autoAcceptDiffs?: boolean;
46
- config?: Record<string, unknown>;
35
+ parentWorkflow?: SandboxWorkflow;
36
+ sandboxId: string;
37
+ provider?: SandboxProvider | string;
38
+ runtime?: SandboxRuntime;
39
+ workflow: SandboxChildWorkflowDefinition;
40
+ executeChildWorkflow: ExecuteSandboxChildWorkflow;
41
+ applyDiffBundle?: (bundle: SandboxDiffBundleLike, targetDir: string) => Promise<void>;
42
+ input?: unknown;
43
+ rootDir: string;
44
+ allowNetwork: boolean;
45
+ maxOutputBytes: number;
46
+ toolTimeoutMs: number;
47
+ reviewDiffs?: boolean;
48
+ autoAcceptDiffs?: boolean;
49
+ allowNested?: boolean;
50
+ config?: Record<string, unknown>;
47
51
  };
@@ -1,6 +1,9 @@
1
+ import type { SandboxDiffBundleLike } from "./SandboxProvider.ts";
2
+
1
3
  export type SandboxBundleManifest = {
2
- outputs: unknown;
3
- status: "finished" | "failed" | "cancelled";
4
- runId?: string;
5
- patches?: string[];
4
+ outputs: unknown;
5
+ status: "finished" | "failed" | "cancelled";
6
+ runId?: string;
7
+ patches?: string[];
8
+ diffBundle?: SandboxDiffBundleLike;
6
9
  };
@@ -1,5 +1,23 @@
1
1
  import type { SandboxRuntime } from "./SandboxRuntime.ts";
2
2
 
3
+ export type SandboxPortMapping = {
4
+ host: number;
5
+ container: number;
6
+ };
7
+
8
+ export type SandboxVolumeMount = {
9
+ host: string;
10
+ container: string;
11
+ readonly?: boolean;
12
+ };
13
+
14
+ export type SandboxWorkspaceSpec = {
15
+ name: string;
16
+ snapshotId?: string;
17
+ idleTimeoutSecs?: number;
18
+ persistence?: "ephemeral" | "sticky";
19
+ };
20
+
3
21
  export type SandboxHandle = {
4
22
  runtime: SandboxRuntime;
5
23
  runId: string;
@@ -7,6 +25,14 @@ export type SandboxHandle = {
7
25
  sandboxRoot: string;
8
26
  requestPath: string;
9
27
  resultPath: string;
28
+ image?: string;
29
+ allowNetwork?: boolean;
30
+ env?: Record<string, string>;
31
+ ports?: SandboxPortMapping[];
32
+ volumes?: SandboxVolumeMount[];
33
+ memoryLimit?: string;
34
+ cpuLimit?: string;
35
+ workspace?: SandboxWorkspaceSpec;
10
36
  containerId?: string;
11
37
  workspaceId?: string;
12
38
  };
@@ -0,0 +1,59 @@
1
+ import type { SandboxChildWorkflowDefinition, SandboxWorkflow, ExecuteSandboxChildWorkflow } from "./ExecuteSandboxOptions.ts";
2
+
3
+ export type SandboxBundleStatus = "finished" | "failed" | "cancelled";
4
+
5
+ export type SandboxDiffBundleLike = {
6
+ seq: number;
7
+ baseRef: string;
8
+ patches: Array<{
9
+ path: string;
10
+ operation: "add" | "modify" | "delete";
11
+ diff: string;
12
+ binaryContent?: string;
13
+ }>;
14
+ };
15
+
16
+ export type SandboxProviderRequest = {
17
+ runId: string;
18
+ sandboxId: string;
19
+ input?: unknown;
20
+ rootDir: string;
21
+ requestBundlePath: string;
22
+ resultBundlePath: string;
23
+ workflow: SandboxChildWorkflowDefinition;
24
+ parentWorkflow?: SandboxWorkflow;
25
+ executeChildWorkflow: ExecuteSandboxChildWorkflow;
26
+ allowNetwork: boolean;
27
+ maxOutputBytes: number;
28
+ toolTimeoutMs: number;
29
+ config: Record<string, unknown>;
30
+ signal?: AbortSignal;
31
+ heartbeat: (data?: unknown) => void;
32
+ };
33
+
34
+ export type SandboxProviderResult =
35
+ | {
36
+ bundlePath: string;
37
+ remoteRunId?: string;
38
+ workspaceId?: string;
39
+ containerId?: string;
40
+ }
41
+ | {
42
+ status: SandboxBundleStatus;
43
+ output?: unknown;
44
+ outputs?: unknown;
45
+ runId?: string;
46
+ remoteRunId?: string;
47
+ workspaceId?: string;
48
+ containerId?: string;
49
+ diffBundle?: SandboxDiffBundleLike;
50
+ patches?: Array<{ path: string; content: string }>;
51
+ artifacts?: Array<{ path: string; content: string }>;
52
+ streamLogPath?: string | null;
53
+ };
54
+
55
+ export type SandboxProvider = {
56
+ id: string;
57
+ run: (request: SandboxProviderRequest) => Promise<SandboxProviderResult> | SandboxProviderResult;
58
+ cleanup?: (request: SandboxProviderRequest) => Promise<void> | void;
59
+ };
@@ -1,4 +1,5 @@
1
1
  import type { SandboxRuntime } from "./SandboxRuntime.ts";
2
+ import type { SandboxPortMapping, SandboxVolumeMount, SandboxWorkspaceSpec } from "./SandboxHandle.ts";
2
3
 
3
4
  export type SandboxTransportConfig = {
4
5
  runId: string;
@@ -6,4 +7,11 @@ export type SandboxTransportConfig = {
6
7
  runtime: SandboxRuntime;
7
8
  rootDir: string;
8
9
  image?: string;
10
+ allowNetwork?: boolean;
11
+ env?: Record<string, string>;
12
+ ports?: SandboxPortMapping[];
13
+ volumes?: SandboxVolumeMount[];
14
+ memoryLimit?: string;
15
+ cpuLimit?: string;
16
+ workspace?: SandboxWorkspaceSpec;
9
17
  };
package/src/bundle.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
1
+ import { lstat, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
2
2
  import { dirname, join, relative } from "node:path";
3
3
  import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
4
4
  import { assertJsonPayloadWithinBounds, assertOptionalArrayMaxLength, assertOptionalStringMaxLength, } from "@smithers-orchestrator/db/input-bounds";
@@ -27,12 +27,17 @@ async function walkFiles(dir) {
27
27
  const entries = await readdir(current, { withFileTypes: true });
28
28
  for (const entry of entries) {
29
29
  const full = join(current, entry.name);
30
- if (entry.isDirectory()) {
30
+ const info = await lstat(full);
31
+ if (info.isSymbolicLink()) {
32
+ throw new SmithersError("TOOL_PATH_ESCAPE", "Sandbox bundle may not contain symlinks.", {
33
+ path: relative(dir, full),
34
+ });
35
+ }
36
+ if (info.isDirectory()) {
31
37
  pending.push(full);
32
38
  }
33
- else if (entry.isFile()) {
39
+ else if (info.isFile()) {
34
40
  files.push(full);
35
- const info = await stat(full);
36
41
  totalBytes += info.size;
37
42
  }
38
43
  }
@@ -70,6 +75,9 @@ function parseReadmeJson(readme) {
70
75
  patches: Array.isArray(manifest.patches)
71
76
  ? manifest.patches.filter((v) => typeof v === "string")
72
77
  : undefined,
78
+ diffBundle: manifest.diffBundle && typeof manifest.diffBundle === "object"
79
+ ? manifest.diffBundle
80
+ : undefined,
73
81
  };
74
82
  }
75
83
  /**
@@ -85,7 +93,7 @@ function assertPatchPathSafe(bundlePath, patchPath) {
85
93
  }
86
94
  }
87
95
  /**
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
96
+ * @param {{ output: unknown; patches?: Array<{ path: string; content: string }>; artifacts?: Array<{ path: string; content: string }>; runId?: string; status: "finished" | "failed" | "cancelled"; streamLogPath?: string | null; diffBundle?: unknown; }} params
89
97
  */
90
98
  async function estimateBundleWriteBytes(params) {
91
99
  const readmeBytes = Buffer.byteLength(JSON.stringify({
@@ -93,6 +101,7 @@ async function estimateBundleWriteBytes(params) {
93
101
  status: params.status,
94
102
  runId: params.runId,
95
103
  patches: (params.patches ?? []).map((patch) => patch.path),
104
+ diffBundle: params.diffBundle,
96
105
  }, null, 2), "utf8");
97
106
  const patchBytes = (params.patches ?? []).reduce((total, patch) => total + Buffer.byteLength(patch.content, "utf8"), 0);
98
107
  const artifactBytes = (params.artifacts ?? []).reduce((total, artifact) => total + Buffer.byteLength(artifact.content, "utf8"), 0);
@@ -102,7 +111,7 @@ async function estimateBundleWriteBytes(params) {
102
111
  return readmeBytes + patchBytes + artifactBytes + streamLogBytes;
103
112
  }
104
113
  /**
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
114
+ * @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 }>; diffBundle?: unknown; }} params
106
115
  */
107
116
  async function validateSandboxBundleWriteParams(params) {
108
117
  assertOptionalStringMaxLength("bundlePath", params.bundlePath, SANDBOX_BUNDLE_PATH_MAX_LENGTH);
@@ -133,7 +142,10 @@ async function validateSandboxBundleWriteParams(params) {
133
142
  */
134
143
  export async function validateSandboxBundle(bundlePath) {
135
144
  const resolvedReadme = resolveSandboxPath(bundlePath, "README.md");
136
- const readmeStats = await stat(resolvedReadme).catch(() => null);
145
+ const readmeStats = await lstat(resolvedReadme).catch(() => null);
146
+ if (readmeStats?.isSymbolicLink()) {
147
+ throw new SmithersError("TOOL_PATH_ESCAPE", "Sandbox bundle README.md may not be a symlink.", { bundlePath });
148
+ }
137
149
  if (!readmeStats?.isFile()) {
138
150
  throw new SmithersError("INVALID_INPUT", "Sandbox bundle is missing README.md", { bundlePath });
139
151
  }
@@ -160,7 +172,10 @@ export async function validateSandboxBundle(bundlePath) {
160
172
  assertPatchPathSafe(bundlePath, patchPath);
161
173
  }
162
174
  const logsPath = resolveSandboxPath(bundlePath, "logs/stream.ndjson");
163
- const logsStats = await stat(logsPath).catch(() => null);
175
+ const logsStats = await lstat(logsPath).catch(() => null);
176
+ if (logsStats?.isSymbolicLink()) {
177
+ throw new SmithersError("TOOL_PATH_ESCAPE", "Sandbox bundle logs may not be a symlink.", { bundlePath });
178
+ }
164
179
  return {
165
180
  manifest,
166
181
  bundleSizeBytes: walked.totalBytes,
@@ -170,7 +185,7 @@ export async function validateSandboxBundle(bundlePath) {
170
185
  };
171
186
  }
172
187
  /**
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
188
+ * @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 }>; diffBundle?: unknown; }} params
174
189
  */
175
190
  export async function writeSandboxBundle(params) {
176
191
  await validateSandboxBundleWriteParams(params);
@@ -197,5 +212,6 @@ export async function writeSandboxBundle(params) {
197
212
  status: params.status,
198
213
  runId: params.runId,
199
214
  patches: (params.patches ?? []).map((p) => p.path),
215
+ diffBundle: params.diffBundle,
200
216
  }, null, 2), "utf8");
201
217
  }
@@ -6,6 +6,7 @@ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
6
6
  import { spawnCaptureEffect } from "@smithers-orchestrator/driver/child-process";
7
7
  import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
8
8
  import { SandboxEntityExecutor } from "./sandbox-entity.js";
9
+ import { dockerArgs, normalizeSandboxHandleControls, sandboxRunnerEnv, spawnSandboxCommand } from "./process-runner.js";
9
10
  /** @typedef {import("../SandboxTransportConfig.ts").SandboxTransportConfig} SandboxTransportConfig */
10
11
  /** @typedef {import("../SandboxHandle.ts").SandboxHandle} SandboxHandle */
11
12
  /**
@@ -14,6 +15,7 @@ import { SandboxEntityExecutor } from "./sandbox-entity.js";
14
15
  */
15
16
  function baseHandle(config) {
16
17
  const sandboxRoot = join(config.rootDir, ".smithers", "sandboxes", config.runId, config.sandboxId);
18
+ const controls = normalizeSandboxHandleControls(config);
17
19
  return {
18
20
  runtime: config.runtime,
19
21
  runId: config.runId,
@@ -21,6 +23,9 @@ function baseHandle(config) {
21
23
  sandboxRoot,
22
24
  requestPath: join(sandboxRoot, "request"),
23
25
  resultPath: join(sandboxRoot, "result"),
26
+ image: config.image,
27
+ allowNetwork: Boolean(config.allowNetwork),
28
+ ...controls,
24
29
  };
25
30
  }
26
31
  /** @type {Layer.Layer<SandboxEntityExecutor, never, never>} */
@@ -29,7 +34,7 @@ export const DockerSandboxExecutorLive = Layer.succeed(SandboxEntityExecutor, Sa
29
34
  const handle = baseHandle(config);
30
35
  yield* spawnCaptureEffect("docker", ["info"], {
31
36
  cwd: config.rootDir,
32
- env: process.env,
37
+ env: sandboxRunnerEnv(),
33
38
  timeoutMs: 10_000,
34
39
  maxOutputBytes: 200_000,
35
40
  }).pipe(Effect.catchAll(() => Effect.fail(new SmithersError("PROCESS_SPAWN_FAILED", "Docker daemon not reachable.", { runtime: "docker" }))));
@@ -50,9 +55,15 @@ export const DockerSandboxExecutorLive = Layer.succeed(SandboxEntityExecutor, Sa
50
55
  },
51
56
  catch: (cause) => toSmithersError(cause, "ship docker bundle"),
52
57
  }),
53
- execute: (_command, _handle) => Effect.succeed({ exitCode: 0 }),
58
+ execute: (command, handle) => spawnSandboxCommand("docker", dockerArgs(command, handle), {
59
+ cwd: handle.requestPath,
60
+ runtime: "docker",
61
+ }),
54
62
  collect: (handle) => Effect.succeed({ bundlePath: handle.resultPath }),
55
- cleanup: (_handle) => Effect.void,
63
+ cleanup: (handle) => Effect.tryPromise({
64
+ try: () => rm(handle.requestPath, { recursive: true, force: true }),
65
+ catch: (cause) => toSmithersError(cause, "cleanup docker sandbox workspace"),
66
+ }),
56
67
  }));
57
68
  /** @type {Layer.Layer<SandboxEntityExecutor, never, never>} */
58
69
  export const CodeplaneSandboxExecutorLive = Layer.succeed(SandboxEntityExecutor, SandboxEntityExecutor.of({
@@ -83,8 +94,15 @@ export const CodeplaneSandboxExecutorLive = Layer.succeed(SandboxEntityExecutor,
83
94
  },
84
95
  catch: (cause) => toSmithersError(cause, "ship codeplane bundle"),
85
96
  }),
86
- execute: (_command, _handle) => Effect.succeed({ exitCode: 0 }),
97
+ execute: (command, handle) => Effect.fail(new SmithersError("SANDBOX_EXECUTION_FAILED", "Codeplane sandbox command execution requires the remote Codeplane worker integration.", {
98
+ runtime: "codeplane",
99
+ command,
100
+ workspaceId: handle.workspaceId ?? null,
101
+ })),
87
102
  collect: (handle) => Effect.succeed({ bundlePath: handle.resultPath }),
88
- cleanup: (_handle) => Effect.void,
103
+ cleanup: (handle) => Effect.tryPromise({
104
+ try: () => rm(handle.requestPath, { recursive: true, force: true }),
105
+ catch: (cause) => toSmithersError(cause, "cleanup codeplane sandbox workspace"),
106
+ }),
89
107
  }));
90
108
  export const SandboxHttpRunner = HttpRunner;