@ai-hero/sandcastle 0.2.4 → 0.3.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/README.md +26 -13
- package/dist/SandboxFactory.d.ts +15 -4
- package/dist/SandboxFactory.d.ts.map +1 -1
- package/dist/SandboxFactory.js +156 -203
- package/dist/SandboxFactory.js.map +1 -1
- package/dist/SandboxProvider.d.ts +114 -0
- package/dist/SandboxProvider.d.ts.map +1 -0
- package/dist/SandboxProvider.js +25 -0
- package/dist/SandboxProvider.js.map +1 -0
- package/dist/cli.d.ts +7 -5
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +10 -9
- package/dist/cli.js.map +1 -1
- package/dist/createSandbox.d.ts +3 -2
- package/dist/createSandbox.d.ts.map +1 -1
- package/dist/createSandbox.js +19 -32
- package/dist/createSandbox.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/run.d.ts +4 -9
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +3 -15
- package/dist/run.js.map +1 -1
- package/dist/sandboxExec.d.ts +12 -0
- package/dist/sandboxExec.d.ts.map +1 -0
- package/dist/sandboxExec.js +26 -0
- package/dist/sandboxExec.js.map +1 -0
- package/dist/sandboxes/docker.d.ts +26 -0
- package/dist/sandboxes/docker.d.ts.map +1 -0
- package/dist/sandboxes/docker.js +133 -0
- package/dist/sandboxes/docker.js.map +1 -0
- package/dist/sandboxes/test-isolated.d.ts +17 -0
- package/dist/sandboxes/test-isolated.d.ts.map +1 -0
- package/dist/sandboxes/test-isolated.js +86 -0
- package/dist/sandboxes/test-isolated.js.map +1 -0
- package/dist/syncIn.d.ts +22 -0
- package/dist/syncIn.d.ts.map +1 -0
- package/dist/syncIn.js +57 -0
- package/dist/syncIn.js.map +1 -0
- package/dist/syncOut.d.ts +25 -0
- package/dist/syncOut.d.ts.map +1 -0
- package/dist/syncOut.js +192 -0
- package/dist/syncOut.js.map +1 -0
- package/dist/templates/blank/main.mts +2 -0
- package/dist/templates/parallel-planner/main.mts +4 -0
- package/dist/templates/sequential-reviewer/main.mts +3 -0
- package/dist/templates/simple-loop/main.mts +4 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -50,9 +50,11 @@ npx tsx .sandcastle/main.ts
|
|
|
50
50
|
```typescript
|
|
51
51
|
// 3. Run the agent via the JS API
|
|
52
52
|
import { run, claudeCode } from "@ai-hero/sandcastle";
|
|
53
|
+
import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
|
|
53
54
|
|
|
54
55
|
await run({
|
|
55
56
|
agent: claudeCode("claude-opus-4-6"),
|
|
57
|
+
sandbox: docker(),
|
|
56
58
|
promptFile: ".sandcastle/prompt.md",
|
|
57
59
|
});
|
|
58
60
|
```
|
|
@@ -63,9 +65,11 @@ Sandcastle exports a programmatic `run()` function for use in scripts, CI pipeli
|
|
|
63
65
|
|
|
64
66
|
```typescript
|
|
65
67
|
import { run, claudeCode } from "@ai-hero/sandcastle";
|
|
68
|
+
import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
|
|
66
69
|
|
|
67
70
|
const result = await run({
|
|
68
71
|
agent: claudeCode("claude-opus-4-6"),
|
|
72
|
+
sandbox: docker(),
|
|
69
73
|
promptFile: ".sandcastle/prompt.md",
|
|
70
74
|
});
|
|
71
75
|
|
|
@@ -78,12 +82,17 @@ console.log(result.branch); // target branch name
|
|
|
78
82
|
|
|
79
83
|
```typescript
|
|
80
84
|
import { run, claudeCode } from "@ai-hero/sandcastle";
|
|
85
|
+
import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
|
|
81
86
|
|
|
82
87
|
const result = await run({
|
|
83
88
|
// Agent provider — required. Pass a model string to claudeCode().
|
|
84
89
|
// Optional second arg for provider-specific options like effort level.
|
|
85
90
|
agent: claudeCode("claude-opus-4-6", { effort: "high" }),
|
|
86
91
|
|
|
92
|
+
// Sandbox provider — required. Import from "@ai-hero/sandcastle/sandboxes/docker".
|
|
93
|
+
// Provider-specific config (like imageName) lives inside the provider factory call.
|
|
94
|
+
sandbox: docker({ imageName: "sandcastle:local" }),
|
|
95
|
+
|
|
87
96
|
// Prompt source — provide one of these, not both:
|
|
88
97
|
promptFile: ".sandcastle/prompt.md", // path to a prompt file
|
|
89
98
|
// prompt: "Fix issue #42 in this repo", // OR an inline prompt string
|
|
@@ -102,9 +111,6 @@ const result = await run({
|
|
|
102
111
|
// { mode: 'branch', branch } — create a worktree on an explicit branch.
|
|
103
112
|
worktree: { mode: "branch", branch: "agent/fix-42" },
|
|
104
113
|
|
|
105
|
-
// Docker image used for the sandbox. Default: "sandcastle:<repo-dir-name>"
|
|
106
|
-
imageName: "sandcastle:local",
|
|
107
|
-
|
|
108
114
|
// Display name for this run, shown as a prefix in log output.
|
|
109
115
|
name: "fix-issue-42",
|
|
110
116
|
|
|
@@ -145,9 +151,11 @@ Use `run()` instead when you only need a single one-shot invocation — it handl
|
|
|
145
151
|
|
|
146
152
|
```typescript
|
|
147
153
|
import { createSandbox, claudeCode } from "@ai-hero/sandcastle";
|
|
154
|
+
import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
|
|
148
155
|
|
|
149
156
|
await using sandbox = await createSandbox({
|
|
150
157
|
branch: "agent/fix-42",
|
|
158
|
+
sandbox: docker(),
|
|
151
159
|
});
|
|
152
160
|
|
|
153
161
|
const result = await sandbox.run({
|
|
@@ -162,9 +170,11 @@ console.log(result.commits); // [{ sha: "abc123" }]
|
|
|
162
170
|
|
|
163
171
|
```typescript
|
|
164
172
|
import { createSandbox, claudeCode } from "@ai-hero/sandcastle";
|
|
173
|
+
import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
|
|
165
174
|
|
|
166
175
|
await using sandbox = await createSandbox({
|
|
167
176
|
branch: "agent/fix-42",
|
|
177
|
+
sandbox: docker(),
|
|
168
178
|
hooks: { onSandboxReady: [{ command: "npm install" }] },
|
|
169
179
|
});
|
|
170
180
|
|
|
@@ -191,7 +201,10 @@ Commits from all `run()` calls accumulate on the same branch. The sandbox contai
|
|
|
191
201
|
#### Manual `close()` with `CloseResult`
|
|
192
202
|
|
|
193
203
|
```typescript
|
|
194
|
-
const sandbox = await createSandbox({
|
|
204
|
+
const sandbox = await createSandbox({
|
|
205
|
+
branch: "agent/fix-42",
|
|
206
|
+
sandbox: docker(),
|
|
207
|
+
});
|
|
195
208
|
// ... run agents ...
|
|
196
209
|
const closeResult = await sandbox.close();
|
|
197
210
|
if (closeResult.preservedWorktreePath) {
|
|
@@ -201,12 +214,12 @@ if (closeResult.preservedWorktreePath) {
|
|
|
201
214
|
|
|
202
215
|
#### `CreateSandboxOptions`
|
|
203
216
|
|
|
204
|
-
| Option | Type
|
|
205
|
-
| --------------- |
|
|
206
|
-
| `branch` | string
|
|
207
|
-
| `
|
|
208
|
-
| `hooks` | object
|
|
209
|
-
| `copyToSandbox` | string[]
|
|
217
|
+
| Option | Type | Default | Description |
|
|
218
|
+
| --------------- | --------------- | ------- | ------------------------------------------------------------------- |
|
|
219
|
+
| `branch` | string | — | **Required.** Explicit branch for the worktree |
|
|
220
|
+
| `sandbox` | SandboxProvider | — | **Required.** Sandbox provider (e.g. `docker()`) |
|
|
221
|
+
| `hooks` | object | — | Lifecycle hooks (`onSandboxReady`) — run once at creation time |
|
|
222
|
+
| `copyToSandbox` | string[] | — | Host-relative file paths to copy into the worktree at creation time |
|
|
210
223
|
|
|
211
224
|
#### `Sandbox`
|
|
212
225
|
|
|
@@ -397,7 +410,7 @@ Creates the following files:
|
|
|
397
410
|
|
|
398
411
|
Errors if `.sandcastle/` already exists to prevent overwriting customizations.
|
|
399
412
|
|
|
400
|
-
### `sandcastle build-image`
|
|
413
|
+
### `sandcastle docker build-image`
|
|
401
414
|
|
|
402
415
|
Rebuilds the Docker image from an existing `.sandcastle/` directory. Use this after modifying the Dockerfile.
|
|
403
416
|
|
|
@@ -406,7 +419,7 @@ Rebuilds the Docker image from an existing `.sandcastle/` directory. Use this af
|
|
|
406
419
|
| `--image-name` | No | `sandcastle:<repo-dir-name>` | Docker image name |
|
|
407
420
|
| `--dockerfile` | No | — | Path to a custom Dockerfile (build context will be the current working directory) |
|
|
408
421
|
|
|
409
|
-
### `sandcastle remove-image`
|
|
422
|
+
### `sandcastle docker remove-image`
|
|
410
423
|
|
|
411
424
|
Removes the Docker image.
|
|
412
425
|
|
|
@@ -419,12 +432,12 @@ Removes the Docker image.
|
|
|
419
432
|
| Option | Type | Default | Description |
|
|
420
433
|
| -------------------- | ------------------ | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
421
434
|
| `agent` | AgentProvider | — | **Required.** Agent provider (e.g. `claudeCode("claude-opus-4-6")`, `pi("claude-sonnet-4-6")`, `codex("gpt-5.4-mini")`) |
|
|
435
|
+
| `sandbox` | SandboxProvider | — | **Required.** Sandbox provider (e.g. `docker()`, `docker({ imageName: "sandcastle:local" })`) |
|
|
422
436
|
| `prompt` | string | — | Inline prompt (mutually exclusive with `promptFile`) |
|
|
423
437
|
| `promptFile` | string | — | Path to prompt file (mutually exclusive with `prompt`) |
|
|
424
438
|
| `maxIterations` | number | `1` | Maximum iterations to run |
|
|
425
439
|
| `hooks` | object | — | Lifecycle hooks (`onSandboxReady`) |
|
|
426
440
|
| `worktree` | WorktreeMode | `{ mode: 'temp-branch' }` | Worktree mode: `{ mode: 'none' }`, `{ mode: 'temp-branch' }`, or `{ mode: 'branch', branch }` |
|
|
427
|
-
| `imageName` | string | `sandcastle:<repo-dir-name>` | Docker image name for the sandbox |
|
|
428
441
|
| `name` | string | — | Display name for the run, shown as a prefix in log output |
|
|
429
442
|
| `promptArgs` | PromptArgs | — | Key-value map for `{{KEY}}` placeholder substitution |
|
|
430
443
|
| `copyToSandbox` | string[] | — | Host-relative file paths to copy into the worktree before start (not supported with `mode: 'none'`) |
|
package/dist/SandboxFactory.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { FileSystem } from "@effect/platform";
|
|
|
3
3
|
import type { PlatformError } from "@effect/platform/Error";
|
|
4
4
|
import { CopyError, ExecError, WorktreeError, type DockerError } from "./errors.js";
|
|
5
5
|
import { Display } from "./Display.js";
|
|
6
|
+
import type { SandboxProvider, BindMountSandboxHandle, IsolatedSandboxHandle } from "./SandboxProvider.js";
|
|
6
7
|
export interface ExecResult {
|
|
7
8
|
readonly stdout: string;
|
|
8
9
|
readonly stderr: string;
|
|
@@ -21,7 +22,12 @@ export interface SandboxService {
|
|
|
21
22
|
declare const Sandbox_base: Context.TagClass<Sandbox, "Sandbox", SandboxService>;
|
|
22
23
|
export declare class Sandbox extends Sandbox_base {
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Wrap a Promise-based sandbox handle into an Effect-based SandboxService layer.
|
|
27
|
+
* Works with both bind-mount handles (copyIn/copyOut unsupported) and
|
|
28
|
+
* isolated handles (copyIn/copyOut delegated to the handle).
|
|
29
|
+
*/
|
|
30
|
+
export declare const makeSandboxLayerFromHandle: (handle: BindMountSandboxHandle | IsolatedSandboxHandle) => Layer.Layer<Sandbox, never, never>;
|
|
25
31
|
/** The mount point inside the container where the project worktree is bound. */
|
|
26
32
|
export declare const SANDBOX_WORKSPACE_DIR = "/home/agent/workspace";
|
|
27
33
|
export interface SandboxInfo {
|
|
@@ -39,7 +45,6 @@ declare const SandboxFactory_base: Context.TagClass<SandboxFactory, "SandboxFact
|
|
|
39
45
|
export declare class SandboxFactory extends SandboxFactory_base {
|
|
40
46
|
}
|
|
41
47
|
declare const WorktreeSandboxConfig_base: Context.TagClass<WorktreeSandboxConfig, "WorktreeSandboxConfig", {
|
|
42
|
-
readonly imageName: string;
|
|
43
48
|
readonly env: Record<string, string>;
|
|
44
49
|
readonly hostRepoDir: string;
|
|
45
50
|
/** Worktree mode: none, temp-branch (default), or explicit branch. */
|
|
@@ -48,15 +53,21 @@ declare const WorktreeSandboxConfig_base: Context.TagClass<WorktreeSandboxConfig
|
|
|
48
53
|
readonly copyToSandbox?: string[] | undefined;
|
|
49
54
|
/** When specified, the run name is included in the auto-generated branch and worktree names. */
|
|
50
55
|
readonly name?: string | undefined;
|
|
56
|
+
/** Sandbox provider — delegates container lifecycle to the provider. */
|
|
57
|
+
readonly sandboxProvider: SandboxProvider;
|
|
51
58
|
}>;
|
|
52
59
|
export declare class WorktreeSandboxConfig extends WorktreeSandboxConfig_base {
|
|
53
60
|
}
|
|
61
|
+
export interface MountEntry {
|
|
62
|
+
readonly hostPath: string;
|
|
63
|
+
readonly sandboxPath: string;
|
|
64
|
+
}
|
|
54
65
|
/**
|
|
55
|
-
* Resolves the git-related
|
|
66
|
+
* Resolves the git-related mounts needed for the sandbox.
|
|
56
67
|
* Handles both normal repos (where .git is a directory) and worktrees
|
|
57
68
|
* (where .git is a file pointing to the parent repo's .git/worktrees/<name>).
|
|
58
69
|
*/
|
|
59
|
-
export declare const
|
|
70
|
+
export declare const resolveGitMounts: (gitPath: string) => Effect.Effect<MountEntry[], PlatformError, FileSystem.FileSystem>;
|
|
60
71
|
export declare const WorktreeDockerSandboxFactory: {
|
|
61
72
|
layer: Layer.Layer<SandboxFactory, never, Display | FileSystem.FileSystem | WorktreeSandboxConfig>;
|
|
62
73
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SandboxFactory.d.ts","sourceRoot":"","sources":["../src/SandboxFactory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAQ,KAAK,EAAE,MAAM,QAAQ,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"SandboxFactory.d.ts","sourceRoot":"","sources":["../src/SandboxFactory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAQ,KAAK,EAAE,MAAM,QAAQ,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAG9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAC5D,OAAO,EAEL,SAAS,EACT,SAAS,EAET,aAAa,EACb,KAAK,WAAW,EACjB,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,KAAK,EACV,eAAe,EAEf,sBAAsB,EAEtB,qBAAqB,EACtB,MAAM,sBAAsB,CAAC;AAI9B,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,CACb,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,KACvB,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,SAAS,CAAC,CAAC;IAE1C,QAAQ,CAAC,aAAa,EAAE,CACtB,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,EACpC,OAAO,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,KACvB,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,SAAS,CAAC,CAAC;IAE1C,QAAQ,CAAC,MAAM,EAAE,CACf,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,KAChB,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;IAEpC,QAAQ,CAAC,OAAO,EAAE,CAChB,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,KACb,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;CACrC;;AAED,qBAAa,OAAQ,SAAQ,YAG1B;CAAG;AAEN;;;;GAIG;AACH,eAAO,MAAM,0BAA0B,gGAwDnC,CAAC;AAEL,gFAAgF;AAChF,eAAO,MAAM,qBAAqB,0BAA0B,CAAC;AAE7D,MAAM,WAAW,WAAW;IAC1B,qEAAqE;IACrE,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACpC;AAED,MAAM,WAAW,iBAAiB,CAAC,CAAC;IAClC,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC;IAClB,6GAA6G;IAC7G,QAAQ,CAAC,qBAAqB,CAAC,EAAE,MAAM,CAAC;CACzC;;2BAK0B,CAAC,EAAE,CAAC,EAAE,CAAC;;AAHlC,qBAAa,cAAe,SAAQ,mBAWjC;CAAG;;;;IAOF,sEAAsE;;IAEtE,6FAA6F;;IAE7F,gGAAgG;;IAEhG,wEAAwE;;;AAX5E,qBAAa,qBAAsB,SAAQ,0BAcxC;CAAG;AAcN,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,wFAwBzB,CAAC;AAuGL,eAAO,MAAM,4BAA4B;;CAiQxC,CAAC"}
|
package/dist/SandboxFactory.js
CHANGED
|
@@ -1,140 +1,60 @@
|
|
|
1
1
|
import { Context, Effect, Exit, Layer } from "effect";
|
|
2
2
|
import { FileSystem } from "@effect/platform";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
6
|
-
import { dirname, join, resolve } from "node:path";
|
|
7
|
-
import { createInterface } from "node:readline";
|
|
8
|
-
import { startContainer, removeContainer, chownInContainer, } from "./DockerLifecycle.js";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
9
5
|
import { AgentError, CopyError, ExecError, TimeoutError, WorktreeError, } from "./errors.js";
|
|
10
6
|
import * as WorktreeManager from "./WorktreeManager.js";
|
|
11
7
|
import { copyToSandbox } from "./CopyToSandbox.js";
|
|
12
8
|
import { Display } from "./Display.js";
|
|
9
|
+
import { syncIn } from "./syncIn.js";
|
|
10
|
+
import { syncOut } from "./syncOut.js";
|
|
13
11
|
export class Sandbox extends Context.Tag("Sandbox")() {
|
|
14
12
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
resume(Effect.fail(new ExecError({
|
|
27
|
-
command,
|
|
28
|
-
message: `docker exec failed: ${error.message}`,
|
|
29
|
-
})));
|
|
30
|
-
}
|
|
31
|
-
else {
|
|
32
|
-
resume(Effect.succeed({
|
|
33
|
-
stdout: stdout.toString(),
|
|
34
|
-
stderr: stderr.toString(),
|
|
35
|
-
exitCode: typeof error?.code === "number"
|
|
36
|
-
? error.code
|
|
37
|
-
: 0,
|
|
38
|
-
}));
|
|
39
|
-
}
|
|
40
|
-
});
|
|
13
|
+
/**
|
|
14
|
+
* Wrap a Promise-based sandbox handle into an Effect-based SandboxService layer.
|
|
15
|
+
* Works with both bind-mount handles (copyIn/copyOut unsupported) and
|
|
16
|
+
* isolated handles (copyIn/copyOut delegated to the handle).
|
|
17
|
+
*/
|
|
18
|
+
export const makeSandboxLayerFromHandle = (handle) => Layer.succeed(Sandbox, {
|
|
19
|
+
exec: (command, options) => Effect.tryPromise({
|
|
20
|
+
try: () => handle.exec(command, options),
|
|
21
|
+
catch: (e) => new ExecError({
|
|
22
|
+
command,
|
|
23
|
+
message: `exec failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
41
24
|
}),
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const proc = spawn("docker", args, {
|
|
49
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
50
|
-
});
|
|
51
|
-
const stdoutChunks = [];
|
|
52
|
-
const stderrChunks = [];
|
|
53
|
-
const rl = createInterface({ input: proc.stdout });
|
|
54
|
-
rl.on("line", (line) => {
|
|
55
|
-
stdoutChunks.push(line);
|
|
56
|
-
onStdoutLine(line);
|
|
57
|
-
});
|
|
58
|
-
proc.stderr.on("data", (chunk) => {
|
|
59
|
-
stderrChunks.push(chunk.toString());
|
|
60
|
-
});
|
|
61
|
-
proc.on("error", (error) => {
|
|
62
|
-
resume(Effect.fail(new ExecError({
|
|
63
|
-
command,
|
|
64
|
-
message: `docker exec streaming failed: ${error.message}`,
|
|
65
|
-
})));
|
|
66
|
-
});
|
|
67
|
-
proc.on("close", (code) => {
|
|
68
|
-
resume(Effect.succeed({
|
|
69
|
-
stdout: stdoutChunks.join("\n"),
|
|
70
|
-
stderr: stderrChunks.join(""),
|
|
71
|
-
exitCode: code ?? 0,
|
|
72
|
-
}));
|
|
73
|
-
});
|
|
25
|
+
}),
|
|
26
|
+
execStreaming: (command, onStdoutLine, options) => Effect.tryPromise({
|
|
27
|
+
try: () => handle.execStreaming(command, onStdoutLine, options),
|
|
28
|
+
catch: (e) => new ExecError({
|
|
29
|
+
command,
|
|
30
|
+
message: `exec streaming failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
74
31
|
}),
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
else {
|
|
97
|
-
resume(Effect.succeed(undefined));
|
|
98
|
-
}
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
}),
|
|
102
|
-
copyOut: (sandboxPath, hostPath) => Effect.gen(function* () {
|
|
103
|
-
yield* fs.makeDirectory(dirname(hostPath), { recursive: true }).pipe(Effect.mapError((error) => new CopyError({
|
|
104
|
-
message: `Failed to create host dir ${dirname(hostPath)}: ${error}`,
|
|
105
|
-
})));
|
|
106
|
-
yield* Effect.async((resume) => {
|
|
107
|
-
execFile("docker", ["cp", `${containerName}:${sandboxPath}`, hostPath], (error) => {
|
|
108
|
-
if (error) {
|
|
109
|
-
resume(Effect.fail(new CopyError({
|
|
110
|
-
message: `Failed to copy ${containerName}:${sandboxPath} -> ${hostPath}: ${error.message}`,
|
|
111
|
-
})));
|
|
112
|
-
}
|
|
113
|
-
else {
|
|
114
|
-
resume(Effect.succeed(undefined));
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
}),
|
|
119
|
-
};
|
|
32
|
+
}),
|
|
33
|
+
copyIn: "copyIn" in handle
|
|
34
|
+
? (hostPath, sandboxPath) => Effect.tryPromise({
|
|
35
|
+
try: () => handle.copyIn(hostPath, sandboxPath),
|
|
36
|
+
catch: (e) => new CopyError({
|
|
37
|
+
message: `copyIn failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
38
|
+
}),
|
|
39
|
+
})
|
|
40
|
+
: () => Effect.fail(new CopyError({
|
|
41
|
+
message: "copyIn is not supported for bind-mount sandbox providers",
|
|
42
|
+
})),
|
|
43
|
+
copyOut: "copyOut" in handle
|
|
44
|
+
? (sandboxPath, hostPath) => Effect.tryPromise({
|
|
45
|
+
try: () => handle.copyOut(sandboxPath, hostPath),
|
|
46
|
+
catch: (e) => new CopyError({
|
|
47
|
+
message: `copyOut failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
48
|
+
}),
|
|
49
|
+
})
|
|
50
|
+
: () => Effect.fail(new CopyError({
|
|
51
|
+
message: "copyOut is not supported for bind-mount sandbox providers",
|
|
52
|
+
})),
|
|
120
53
|
});
|
|
121
|
-
export const makeDockerSandboxLayer = (containerName) => Layer.effect(Sandbox, makeDockerSandbox(containerName)).pipe(Layer.provide(NodeFileSystem.layer));
|
|
122
54
|
/** The mount point inside the container where the project worktree is bound. */
|
|
123
55
|
export const SANDBOX_WORKSPACE_DIR = "/home/agent/workspace";
|
|
124
56
|
export class SandboxFactory extends Context.Tag("SandboxFactory")() {
|
|
125
57
|
}
|
|
126
|
-
/**
|
|
127
|
-
* Synchronously force-remove a Docker container.
|
|
128
|
-
* Used in process exit handlers where async operations are not possible.
|
|
129
|
-
*/
|
|
130
|
-
const forceRemoveContainerSync = (containerName) => {
|
|
131
|
-
try {
|
|
132
|
-
execFileSync("docker", ["rm", "-f", containerName], { stdio: "ignore" });
|
|
133
|
-
}
|
|
134
|
-
catch {
|
|
135
|
-
// Best-effort — container may already be gone
|
|
136
|
-
}
|
|
137
|
-
};
|
|
138
58
|
export class WorktreeSandboxConfig extends Context.Tag("WorktreeSandboxConfig")() {
|
|
139
59
|
}
|
|
140
60
|
/**
|
|
@@ -146,93 +66,141 @@ const printWorktreePreservedMessage = (worktreePath, reason) => {
|
|
|
146
66
|
console.error(` To clean up: git worktree remove --force ${worktreePath}`);
|
|
147
67
|
};
|
|
148
68
|
/**
|
|
149
|
-
*
|
|
150
|
-
* Shared between worktree and none modes.
|
|
151
|
-
*/
|
|
152
|
-
const startSandboxContainer = (containerName, imageName, env, volumeMounts) => {
|
|
153
|
-
const cleanupContainerOnly = () => {
|
|
154
|
-
forceRemoveContainerSync(containerName);
|
|
155
|
-
};
|
|
156
|
-
const onSignal = () => {
|
|
157
|
-
cleanupContainerOnly();
|
|
158
|
-
process.exit(1);
|
|
159
|
-
};
|
|
160
|
-
const hostUid = process.getuid?.() ?? 1000;
|
|
161
|
-
const hostGid = process.getgid?.() ?? 1000;
|
|
162
|
-
return startContainer(containerName, imageName, { ...env, HOME: "/home/agent" }, {
|
|
163
|
-
volumeMounts,
|
|
164
|
-
workdir: SANDBOX_WORKSPACE_DIR,
|
|
165
|
-
user: `${hostUid}:${hostGid}`,
|
|
166
|
-
}).pipe(Effect.andThen(chownInContainer(containerName, `${hostUid}:${hostGid}`, "/home/agent")), Effect.tap(() => Effect.sync(() => {
|
|
167
|
-
process.on("exit", cleanupContainerOnly);
|
|
168
|
-
process.on("SIGINT", onSignal);
|
|
169
|
-
process.on("SIGTERM", onSignal);
|
|
170
|
-
})), Effect.map(() => ({ cleanupContainerOnly, onSignal })));
|
|
171
|
-
};
|
|
172
|
-
/**
|
|
173
|
-
* Resolves the git-related volume mounts needed for the Docker container.
|
|
69
|
+
* Resolves the git-related mounts needed for the sandbox.
|
|
174
70
|
* Handles both normal repos (where .git is a directory) and worktrees
|
|
175
71
|
* (where .git is a file pointing to the parent repo's .git/worktrees/<name>).
|
|
176
72
|
*/
|
|
177
|
-
export const
|
|
73
|
+
export const resolveGitMounts = (gitPath) => Effect.gen(function* () {
|
|
178
74
|
const fs = yield* FileSystem.FileSystem;
|
|
179
75
|
const stat = yield* fs.stat(gitPath);
|
|
180
76
|
if (stat.type === "Directory") {
|
|
181
|
-
return [
|
|
77
|
+
return [{ hostPath: gitPath, sandboxPath: gitPath }];
|
|
182
78
|
}
|
|
183
79
|
// Worktree: .git is a file with "gitdir: <path>"
|
|
184
80
|
const content = (yield* fs.readFileString(gitPath)).trim();
|
|
185
81
|
const match = content.match(/^gitdir:\s*(.+)$/);
|
|
186
82
|
if (!match) {
|
|
187
83
|
// Unrecognized format — fall back to mounting the file as-is
|
|
188
|
-
return [
|
|
84
|
+
return [{ hostPath: gitPath, sandboxPath: gitPath }];
|
|
189
85
|
}
|
|
190
86
|
const gitdirPath = match[1];
|
|
191
87
|
// gitdirPath is like /path/to/repo/.git/worktrees/<name>
|
|
192
88
|
// Mount both the .git file and the parent .git directory
|
|
193
89
|
const parentGitDir = resolve(gitdirPath, "..", "..");
|
|
194
|
-
return [
|
|
90
|
+
return [
|
|
91
|
+
{ hostPath: gitPath, sandboxPath: gitPath },
|
|
92
|
+
{ hostPath: parentGitDir, sandboxPath: parentGitDir },
|
|
93
|
+
];
|
|
195
94
|
});
|
|
95
|
+
/**
|
|
96
|
+
* Start a sandbox using the provider abstraction.
|
|
97
|
+
* Returns the handle, sandbox layer, and workspace path.
|
|
98
|
+
*/
|
|
99
|
+
const startProviderSandbox = (provider, worktreeOrRepoPath, hostRepoDir, env, gitMounts, workspaceDir) => Effect.tryPromise({
|
|
100
|
+
try: () => {
|
|
101
|
+
const mounts = [
|
|
102
|
+
{
|
|
103
|
+
hostPath: worktreeOrRepoPath,
|
|
104
|
+
sandboxPath: workspaceDir,
|
|
105
|
+
},
|
|
106
|
+
...gitMounts,
|
|
107
|
+
];
|
|
108
|
+
return provider.create({
|
|
109
|
+
worktreePath: worktreeOrRepoPath,
|
|
110
|
+
hostRepoPath: hostRepoDir,
|
|
111
|
+
mounts,
|
|
112
|
+
env,
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
catch: (e) => new WorktreeError({
|
|
116
|
+
message: `Provider '${provider.name}' create failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
117
|
+
}),
|
|
118
|
+
}).pipe(Effect.map((handle) => ({
|
|
119
|
+
handle,
|
|
120
|
+
sandboxLayer: makeSandboxLayerFromHandle(handle),
|
|
121
|
+
workspacePath: handle.workspacePath,
|
|
122
|
+
})));
|
|
123
|
+
/**
|
|
124
|
+
* Start an isolated sandbox: create handle, sync host repo via git bundle.
|
|
125
|
+
* Returns the handle, sandbox layer, and workspace path.
|
|
126
|
+
*/
|
|
127
|
+
const startIsolatedProviderSandbox = (provider, hostRepoDir, env, copyPaths) => Effect.tryPromise({
|
|
128
|
+
try: async () => {
|
|
129
|
+
const handle = await provider.create({ env });
|
|
130
|
+
await syncIn(hostRepoDir, handle);
|
|
131
|
+
if (copyPaths && copyPaths.length > 0) {
|
|
132
|
+
for (const relativePath of copyPaths) {
|
|
133
|
+
const hostPath = join(hostRepoDir, relativePath);
|
|
134
|
+
if (!existsSync(hostPath)) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const sandboxPath = join(handle.workspacePath, relativePath);
|
|
138
|
+
await handle.copyIn(hostPath, sandboxPath);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return handle;
|
|
142
|
+
},
|
|
143
|
+
catch: (e) => new WorktreeError({
|
|
144
|
+
message: `Isolated provider '${provider.name}' setup failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
145
|
+
}),
|
|
146
|
+
}).pipe(Effect.map((handle) => ({
|
|
147
|
+
handle,
|
|
148
|
+
sandboxLayer: makeSandboxLayerFromHandle(handle),
|
|
149
|
+
workspacePath: handle.workspacePath,
|
|
150
|
+
})));
|
|
196
151
|
export const WorktreeDockerSandboxFactory = {
|
|
197
152
|
layer: Layer.effect(SandboxFactory, Effect.gen(function* () {
|
|
198
|
-
const {
|
|
153
|
+
const { env, hostRepoDir, worktree: worktreeMode, copyToSandbox: copyPaths, name, sandboxProvider, } = yield* WorktreeSandboxConfig;
|
|
199
154
|
const isNoneMode = worktreeMode?.mode === "none";
|
|
200
155
|
const branch = worktreeMode?.mode === "branch" ? worktreeMode.branch : undefined;
|
|
201
156
|
const fileSystem = yield* FileSystem.FileSystem;
|
|
202
157
|
const display = yield* Display;
|
|
203
158
|
return {
|
|
204
159
|
withSandbox: (makeEffect) => {
|
|
205
|
-
|
|
160
|
+
// Isolated providers: skip worktree, sync via git bundle
|
|
161
|
+
if (sandboxProvider.tag === "isolated") {
|
|
162
|
+
return Effect.acquireUseRelease(startIsolatedProviderSandbox(sandboxProvider, hostRepoDir, env, copyPaths),
|
|
163
|
+
// Use
|
|
164
|
+
({ sandboxLayer }) => makeEffect({}).pipe(Effect.provide(sandboxLayer)),
|
|
165
|
+
// Release: sync commits back to host, then close
|
|
166
|
+
({ handle }) => Effect.tryPromise({
|
|
167
|
+
try: () => syncOut(hostRepoDir, handle),
|
|
168
|
+
catch: (e) => new WorktreeError({
|
|
169
|
+
message: `syncOut failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
170
|
+
}),
|
|
171
|
+
}).pipe(Effect.catchAll((e) => Effect.sync(() => {
|
|
172
|
+
console.error(`[sandcastle] Warning: syncOut failed: ${e.message}`);
|
|
173
|
+
})), Effect.andThen(Effect.tryPromise({
|
|
174
|
+
try: () => handle.close(),
|
|
175
|
+
catch: () => undefined,
|
|
176
|
+
})), Effect.orDie)).pipe(Effect.map((value) => ({
|
|
177
|
+
value,
|
|
178
|
+
preservedWorktreePath: undefined,
|
|
179
|
+
})));
|
|
180
|
+
}
|
|
206
181
|
if (isNoneMode) {
|
|
207
182
|
// None mode: bind-mount host directory directly, no worktree
|
|
208
183
|
const gitPath = join(hostRepoDir, ".git");
|
|
209
|
-
return
|
|
184
|
+
return resolveGitMounts(gitPath).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.mapError((e) => new WorktreeError({
|
|
210
185
|
message: `Failed to resolve git mounts: ${e}`,
|
|
211
|
-
})), Effect.flatMap((gitMounts) =>
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
process.removeListener("SIGINT", onSignal);
|
|
223
|
-
process.removeListener("SIGTERM", onSignal);
|
|
224
|
-
}).pipe(Effect.andThen(removeContainer(containerName)), Effect.orDie)).pipe(Effect.map((value) => ({
|
|
225
|
-
value,
|
|
226
|
-
preservedWorktreePath: undefined,
|
|
227
|
-
})));
|
|
228
|
-
}));
|
|
186
|
+
})), Effect.flatMap((gitMounts) => Effect.acquireUseRelease(startProviderSandbox(sandboxProvider, hostRepoDir, hostRepoDir, env, gitMounts, SANDBOX_WORKSPACE_DIR),
|
|
187
|
+
// Use
|
|
188
|
+
({ sandboxLayer }) => makeEffect({}).pipe(Effect.provide(sandboxLayer)),
|
|
189
|
+
// Release
|
|
190
|
+
({ handle }) => Effect.tryPromise({
|
|
191
|
+
try: () => handle.close(),
|
|
192
|
+
catch: () => undefined,
|
|
193
|
+
}).pipe(Effect.orDie)).pipe(Effect.map((value) => ({
|
|
194
|
+
value,
|
|
195
|
+
preservedWorktreePath: undefined,
|
|
196
|
+
})))));
|
|
229
197
|
}
|
|
230
198
|
// Worktree mode (temp-branch or explicit branch)
|
|
231
199
|
// Populated by the release phase when a worktree is preserved on failure,
|
|
232
200
|
// so we can attach the path to recognized error types before they propagate.
|
|
233
201
|
let preservedWorktreePath;
|
|
234
202
|
return Effect.acquireUseRelease(
|
|
235
|
-
// Acquire: prune stale worktrees (best-effort), create worktree, then start
|
|
203
|
+
// Acquire: prune stale worktrees (best-effort), create worktree, then start sandbox
|
|
236
204
|
WorktreeManager.pruneStale(hostRepoDir)
|
|
237
205
|
.pipe(Effect.catchAll((e) => Effect.sync(() => {
|
|
238
206
|
console.error("[sandcastle] Warning: failed to prune stale worktrees:", e.message);
|
|
@@ -246,39 +214,24 @@ export const WorktreeDockerSandboxFactory = {
|
|
|
246
214
|
: Effect.succeed(undefined)).pipe(Effect.map(() => worktreeInfo))))
|
|
247
215
|
.pipe(Effect.flatMap((worktreeInfo) => {
|
|
248
216
|
const gitPath = join(hostRepoDir, ".git");
|
|
249
|
-
return
|
|
217
|
+
return resolveGitMounts(gitPath).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.mapError((e) => new WorktreeError({
|
|
250
218
|
message: `Failed to resolve git mounts: ${e}`,
|
|
251
|
-
})), Effect.flatMap((gitMounts) =>
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
process.removeListener("SIGTERM", onSignal);
|
|
260
|
-
const onSignalWithWorktree = () => {
|
|
261
|
-
cleanupContainerOnly();
|
|
262
|
-
printWorktreePreservedMessage(worktreeInfo.path, `Worktree preserved at ${worktreeInfo.path}`);
|
|
263
|
-
process.exit(1);
|
|
264
|
-
};
|
|
265
|
-
process.on("SIGINT", onSignalWithWorktree);
|
|
266
|
-
process.on("SIGTERM", onSignalWithWorktree);
|
|
267
|
-
})), Effect.map(({ cleanupContainerOnly, onSignal }) => ({
|
|
268
|
-
worktreeInfo,
|
|
269
|
-
cleanupContainerOnly,
|
|
270
|
-
onSignal,
|
|
271
|
-
})));
|
|
272
|
-
}));
|
|
219
|
+
})), Effect.flatMap((gitMounts) =>
|
|
220
|
+
// sandboxProvider is guaranteed bind-mount here
|
|
221
|
+
// (isolated providers return early above)
|
|
222
|
+
startProviderSandbox(sandboxProvider, worktreeInfo.path, hostRepoDir, env, gitMounts, SANDBOX_WORKSPACE_DIR).pipe(Effect.map(({ handle, sandboxLayer }) => ({
|
|
223
|
+
worktreeInfo,
|
|
224
|
+
handle,
|
|
225
|
+
sandboxLayer,
|
|
226
|
+
})))));
|
|
273
227
|
})),
|
|
274
228
|
// Use
|
|
275
|
-
({ worktreeInfo }) => makeEffect({ hostWorktreePath: worktreeInfo.path }).pipe(Effect.provide(
|
|
276
|
-
// Release:
|
|
277
|
-
({ worktreeInfo,
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}).pipe(Effect.andThen(removeContainer(containerName)), Effect.andThen(WorktreeManager.hasUncommittedChanges(worktreeInfo.path).pipe(Effect.catchAll(() => Effect.succeed(false)), Effect.flatMap((isDirty) => {
|
|
229
|
+
({ worktreeInfo, sandboxLayer }) => makeEffect({ hostWorktreePath: worktreeInfo.path }).pipe(Effect.provide(sandboxLayer)),
|
|
230
|
+
// Release: close provider handle, then remove/preserve worktree based on dirty state.
|
|
231
|
+
({ worktreeInfo, handle }, exit) => Effect.tryPromise({
|
|
232
|
+
try: () => handle.close(),
|
|
233
|
+
catch: () => undefined,
|
|
234
|
+
}).pipe(Effect.asVoid, Effect.andThen(WorktreeManager.hasUncommittedChanges(worktreeInfo.path).pipe(Effect.catchAll(() => Effect.succeed(false)), Effect.flatMap((isDirty) => {
|
|
282
235
|
if (isDirty) {
|
|
283
236
|
preservedWorktreePath = worktreeInfo.path;
|
|
284
237
|
printWorktreePreservedMessage(worktreeInfo.path, Exit.isSuccess(exit)
|