stageflow 0.39.0 → 0.40.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.
Files changed (39) hide show
  1. package/README.md +43 -18
  2. package/dist/agent/claudeAdapter.js +15 -1
  3. package/dist/agent/claudeTools.d.ts +13 -0
  4. package/dist/agent/claudeTools.js +98 -0
  5. package/dist/agent/piAdapter.js +38 -16
  6. package/dist/agent/sandboxPiTools.d.ts +99 -0
  7. package/dist/agent/sandboxPiTools.js +109 -0
  8. package/dist/config/hostConfig.js +2 -0
  9. package/dist/config/loadTask.js +34 -1
  10. package/dist/mcp/catalogTools.js +11 -1
  11. package/dist/package-meta.d.ts +1 -1
  12. package/dist/package-meta.js +1 -1
  13. package/dist/runtime/gitCheckoutCapability.d.ts +1 -0
  14. package/dist/runtime/gitCheckoutCapability.js +1 -1
  15. package/dist/runtime/gitWorktreeCheckout.d.ts +22 -0
  16. package/dist/runtime/gitWorktreeCheckout.js +120 -0
  17. package/dist/runtime/sandboxContainer.d.ts +62 -0
  18. package/dist/runtime/sandboxContainer.js +151 -0
  19. package/dist/runtime/sandboxDesk.d.ts +28 -0
  20. package/dist/runtime/sandboxDesk.js +242 -0
  21. package/dist/runtime/sandboxFiles.d.ts +20 -0
  22. package/dist/runtime/sandboxFiles.js +150 -0
  23. package/dist/runtime/stageEnvironment.js +2 -0
  24. package/dist/runtime/stageRoots.d.ts +9 -0
  25. package/dist/runtime/stageRoots.js +14 -0
  26. package/dist/runtime/stageRunner.d.ts +5 -0
  27. package/dist/runtime/stageRunner.js +38 -4
  28. package/dist/runtime/stageSandbox.d.ts +16 -0
  29. package/dist/runtime/stageSandbox.js +54 -0
  30. package/dist/runtime/stageWorker.js +138 -105
  31. package/dist/runtime/workspaceBinding.d.ts +2 -1
  32. package/dist/runtime/workspaceBinding.js +20 -4
  33. package/dist/tools/readEmail.d.ts +3 -3
  34. package/dist/types/task.d.ts +11 -1
  35. package/examples/memory/README.md +2 -0
  36. package/examples/memory/probe-bash.task.yaml +3 -0
  37. package/examples/memory/sandbox-memory-bash.pipeline.yaml +28 -0
  38. package/examples/ship-feature/README.md +15 -0
  39. package/package.json +1 -1
@@ -42,7 +42,17 @@ const taskFileSchema = z.object({
42
42
  goal: z.string(),
43
43
  context: z.string().optional(),
44
44
  constraints: z.string().optional(),
45
- checkout: z.string().optional(),
45
+ checkout: z
46
+ .union([
47
+ z.string(),
48
+ z
49
+ .object({
50
+ branch: z.string().optional(),
51
+ base: z.string().optional(),
52
+ })
53
+ .strict(),
54
+ ])
55
+ .optional(),
46
56
  repository: z.string().optional(),
47
57
  ref: z.string().optional(),
48
58
  run_branch_template: z.string().optional(),
@@ -1,3 +1,3 @@
1
1
  export declare const PACKAGE_NAME = "stageflow";
2
- export declare const PACKAGE_VERSION = "0.39.0";
2
+ export declare const PACKAGE_VERSION = "0.40.0";
3
3
  export declare const BUILD_SHA: string;
@@ -1,3 +1,3 @@
1
1
  export const PACKAGE_NAME = "stageflow";
2
- export const PACKAGE_VERSION = "0.39.0";
2
+ export const PACKAGE_VERSION = "0.40.0";
3
3
  export const BUILD_SHA = process.env.STAGEFLOW_BUILD_SHA ?? "unknown";
@@ -1,4 +1,5 @@
1
1
  import type { CheckoutCapability } from "./completionCheckRunner.js";
2
+ export declare function runGit(root: string, args: string[]): Promise<Buffer>;
2
3
  /**
3
4
  * Git-backed checkout observation. It fingerprints tracked and untracked
4
5
  * files before the agent runs, so pre-existing changes are not attributed to
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { lstat, readFile, readlink } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { runGit as execGit } from "../git/exec.js";
5
- async function runGit(root, args) {
5
+ export async function runGit(root, args) {
6
6
  const result = await execGit({
7
7
  cwd: root,
8
8
  args,
@@ -0,0 +1,22 @@
1
+ import { sanitizeContainerNameSegment } from "./sandboxContainer.js";
2
+ export declare const sanitizeBranchForPath: typeof sanitizeContainerNameSegment;
3
+ export declare function defaultBranchNameForTask(taskId: string): string;
4
+ export declare function worktreePathFor(projectRoot: string, branch: string): string;
5
+ /**
6
+ * Ensures a git worktree checked out to `branch` (creating the branch from
7
+ * `base` if it doesn't exist yet) exists at a deterministic path derived
8
+ * from the branch name, and returns that path.
9
+ *
10
+ * Deterministic (not per-run/random) on purpose: `resolveAndValidateCheckout`
11
+ * is called twice per run start (runManager.ts, for the checkout lease key,
12
+ * then pipelineRunner.ts, for the durable run record) — a random path would
13
+ * create two worktrees for one run. A stable path also means rerunning the
14
+ * same task/branch reuses the same worktree, matching the "leave it in
15
+ * place" behavior an operator gets from making one by hand.
16
+ */
17
+ export declare function ensureWorktreeCheckout(params: {
18
+ projectRoot: string;
19
+ branch?: string;
20
+ base?: string;
21
+ taskId: string;
22
+ }): Promise<string>;
@@ -0,0 +1,120 @@
1
+ import { access, mkdir, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { runGit } from "./gitCheckoutCapability.js";
4
+ import { sanitizeContainerNameSegment } from "./sandboxContainer.js";
5
+ /**
6
+ * Auto-created worktrees live as siblings of the project root (not nested
7
+ * inside it) — the same place `examples/ship-feature/README.md` already
8
+ * tells operators to put a hand-made one, just automated.
9
+ */
10
+ const WORKTREES_DIR_NAME = ".stageflow-worktrees";
11
+ export const sanitizeBranchForPath = sanitizeContainerNameSegment;
12
+ export function defaultBranchNameForTask(taskId) {
13
+ return `stageflow/${sanitizeBranchForPath(taskId)}`;
14
+ }
15
+ export function worktreePathFor(projectRoot, branch) {
16
+ return path.join(path.dirname(projectRoot), WORKTREES_DIR_NAME, sanitizeBranchForPath(branch));
17
+ }
18
+ async function git(projectRoot, args) {
19
+ const stdout = await runGit(projectRoot, args);
20
+ return stdout.toString("utf8");
21
+ }
22
+ async function registeredWorktreePaths(projectRoot) {
23
+ const stdout = await git(projectRoot, ["worktree", "list", "--porcelain"]);
24
+ const paths = new Set();
25
+ for (const line of stdout.split("\n")) {
26
+ const match = /^worktree (.+)$/.exec(line);
27
+ if (match)
28
+ paths.add(match[1]);
29
+ }
30
+ return paths;
31
+ }
32
+ async function branchExists(projectRoot, branch) {
33
+ try {
34
+ await git(projectRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
35
+ return true;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ async function pathExists(target) {
42
+ try {
43
+ await access(target);
44
+ return true;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ // A ref/branch value starting with `-` can get reinterpreted by git as a
51
+ // flag instead of a literal name (e.g. `-b --upload-pack=...`) — task YAML
52
+ // is operator-authored, not attacker input, but rejecting this outright is
53
+ // free and closes the class of mistake/footgun entirely.
54
+ function assertNotFlagLike(value, label) {
55
+ if (value.startsWith("-")) {
56
+ throw new Error(`Invalid ${label} "${value}": must not start with "-"`);
57
+ }
58
+ }
59
+ /**
60
+ * Ensures a git worktree checked out to `branch` (creating the branch from
61
+ * `base` if it doesn't exist yet) exists at a deterministic path derived
62
+ * from the branch name, and returns that path.
63
+ *
64
+ * Deterministic (not per-run/random) on purpose: `resolveAndValidateCheckout`
65
+ * is called twice per run start (runManager.ts, for the checkout lease key,
66
+ * then pipelineRunner.ts, for the durable run record) — a random path would
67
+ * create two worktrees for one run. A stable path also means rerunning the
68
+ * same task/branch reuses the same worktree, matching the "leave it in
69
+ * place" behavior an operator gets from making one by hand.
70
+ */
71
+ export async function ensureWorktreeCheckout(params) {
72
+ // `git worktree list --porcelain` reports canonicalized paths (symlinks
73
+ // resolved, e.g. macOS's /var -> /private/var) — realpath the project
74
+ // root up front so every path this function builds or compares matches
75
+ // what git itself reports, instead of drifting apart on a symlinked tmp
76
+ // dir or similar.
77
+ const projectRoot = await realpath(params.projectRoot);
78
+ const branch = params.branch ?? defaultBranchNameForTask(params.taskId);
79
+ assertNotFlagLike(branch, "checkout.branch");
80
+ if (params.base !== undefined)
81
+ assertNotFlagLike(params.base, "checkout.base");
82
+ const targetPath = worktreePathFor(projectRoot, branch);
83
+ const registered = await registeredWorktreePaths(projectRoot);
84
+ if (registered.has(targetPath)) {
85
+ if (await pathExists(targetPath)) {
86
+ return targetPath;
87
+ }
88
+ // Registered but the directory is gone (e.g. deleted with `rm -rf`
89
+ // instead of `git worktree remove`) — clear the stale registration so
90
+ // `worktree add` below doesn't refuse to reuse the path.
91
+ await git(projectRoot, ["worktree", "prune"]);
92
+ }
93
+ else if (await pathExists(targetPath)) {
94
+ throw new Error(`Worktree path already exists but is not a registered git worktree: ${targetPath}. Remove it or use a different branch name.`);
95
+ }
96
+ await mkdir(path.dirname(targetPath), { recursive: true });
97
+ if (await branchExists(projectRoot, branch)) {
98
+ await git(projectRoot, ["worktree", "add", targetPath, branch]);
99
+ }
100
+ else {
101
+ await git(projectRoot, [
102
+ "worktree",
103
+ "add",
104
+ targetPath,
105
+ "-b",
106
+ branch,
107
+ params.base ?? "HEAD",
108
+ ]);
109
+ }
110
+ // Two concurrent run-starts auto-creating the same branch/worktree can
111
+ // race inside `git worktree add` itself — for the "branch already
112
+ // exists" path this has been observed to exit 0 while leaving no
113
+ // directory behind (the loser silently no-ops instead of erroring).
114
+ // Turn that into a loud, unambiguous failure rather than handing back a
115
+ // "successful" path that doesn't exist.
116
+ if (!(await pathExists(targetPath))) {
117
+ throw new Error(`git worktree add reported success but ${targetPath} does not exist — likely a concurrent worktree creation for the same branch ("${branch}"); retry.`);
118
+ }
119
+ return targetPath;
120
+ }
@@ -0,0 +1,62 @@
1
+ export declare const SANDBOX_CONTAINER_IMAGE_ENV = "STAGEFLOW_STAGE_CONTAINER_IMAGE";
2
+ export declare const SANDBOX_CONTAINER_DOCKER_BIN_ENV = "STAGEFLOW_STAGE_CONTAINER_DOCKER_BIN";
3
+ /** Docker container names allow only `[a-zA-Z0-9_.-]`. */
4
+ export declare function sanitizeContainerNameSegment(value: string): string;
5
+ export declare function buildContainerName(input: {
6
+ runId: string;
7
+ stageId: string;
8
+ attempt?: number;
9
+ }, suffix: string): string;
10
+ export type SandboxContainerOptions = {
11
+ image: string;
12
+ dockerBin: string;
13
+ };
14
+ export declare function resolveSandboxContainerOptions(env: Record<string, string | undefined>, override?: {
15
+ image: string;
16
+ dockerBin?: string;
17
+ }): SandboxContainerOptions | undefined;
18
+ /** Fixed path inside the sandbox. The daemon owns the volume; we never pass it a path from this process. */
19
+ export declare const SANDBOX_MOUNT_PATH = "/workspace";
20
+ export declare function sandboxVolumeName(containerName: string): string;
21
+ export declare function buildStartContainerArgs(params: {
22
+ image: string;
23
+ containerName: string;
24
+ }): string[];
25
+ export declare function startSandboxContainer(params: {
26
+ image: string;
27
+ runId: string;
28
+ stageId: string;
29
+ attempt?: number;
30
+ dockerBin?: string;
31
+ }): Promise<{
32
+ containerName: string;
33
+ }>;
34
+ export declare function stopSandboxContainer(params: {
35
+ containerName: string;
36
+ dockerBin?: string;
37
+ }): Promise<void>;
38
+ export type SandboxExecResult = {
39
+ stdout: string;
40
+ stderr: string;
41
+ exitCode: number;
42
+ };
43
+ export declare function execInSandboxContainer(params: {
44
+ containerName: string;
45
+ command: string;
46
+ dockerBin?: string;
47
+ timeoutMs?: number;
48
+ }): Promise<SandboxExecResult>;
49
+ export declare function copyToSandboxContainer(params: {
50
+ containerName: string;
51
+ hostPath: string;
52
+ containerPath: string;
53
+ dockerBin?: string;
54
+ contents?: boolean;
55
+ }): Promise<void>;
56
+ export declare function copyFromSandboxContainer(params: {
57
+ containerName: string;
58
+ containerPath: string;
59
+ hostPath: string;
60
+ dockerBin?: string;
61
+ contents?: boolean;
62
+ }): Promise<void>;
@@ -0,0 +1,151 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { promisify } from "node:util";
4
+ export const SANDBOX_CONTAINER_IMAGE_ENV = "STAGEFLOW_STAGE_CONTAINER_IMAGE";
5
+ export const SANDBOX_CONTAINER_DOCKER_BIN_ENV = "STAGEFLOW_STAGE_CONTAINER_DOCKER_BIN";
6
+ const execFileAsync = promisify(execFile);
7
+ /** Docker container names allow only `[a-zA-Z0-9_.-]`. */
8
+ export function sanitizeContainerNameSegment(value) {
9
+ return value.replace(/[^a-zA-Z0-9_.-]/g, "-");
10
+ }
11
+ export function buildContainerName(input, suffix) {
12
+ const runId = sanitizeContainerNameSegment(input.runId);
13
+ const stageId = sanitizeContainerNameSegment(input.stageId);
14
+ const attempt = input.attempt ?? 1;
15
+ return `stageflow-${runId}-${stageId}-${attempt}-${suffix}`;
16
+ }
17
+ export function resolveSandboxContainerOptions(env, override) {
18
+ if (override) {
19
+ if (!override.image || override.image.trim() === "") {
20
+ throw new Error("sandboxContainer: image must be a non-empty string");
21
+ }
22
+ return { image: override.image, dockerBin: override.dockerBin || "docker" };
23
+ }
24
+ const image = env[SANDBOX_CONTAINER_IMAGE_ENV];
25
+ if (!image || image.trim() === "")
26
+ return undefined;
27
+ return {
28
+ image,
29
+ dockerBin: env[SANDBOX_CONTAINER_DOCKER_BIN_ENV] || "docker",
30
+ };
31
+ }
32
+ /** Fixed path inside the sandbox. The daemon owns the volume; we never pass it a path from this process. */
33
+ export const SANDBOX_MOUNT_PATH = "/workspace";
34
+ export function sandboxVolumeName(containerName) {
35
+ const safe = containerName.replace(/[^A-Za-z0-9_.-]/g, "_");
36
+ return `sf-bash-${safe}`;
37
+ }
38
+ export function buildStartContainerArgs(params) {
39
+ const { image, containerName } = params;
40
+ return [
41
+ "run",
42
+ "-d",
43
+ "--name",
44
+ containerName,
45
+ "-v",
46
+ `${sandboxVolumeName(containerName)}:${SANDBOX_MOUNT_PATH}`,
47
+ "-w",
48
+ SANDBOX_MOUNT_PATH,
49
+ image,
50
+ "sleep",
51
+ "infinity",
52
+ ];
53
+ }
54
+ function errorMessage(err) {
55
+ if (err && typeof err === "object" && "stderr" in err) {
56
+ const stderr = err.stderr;
57
+ if (typeof stderr === "string" && stderr.trim().length > 0)
58
+ return stderr;
59
+ }
60
+ return err instanceof Error ? err.message : String(err);
61
+ }
62
+ export async function startSandboxContainer(params) {
63
+ const containerName = buildContainerName({ runId: params.runId, stageId: params.stageId, attempt: params.attempt }, randomUUID().slice(0, 8));
64
+ const args = buildStartContainerArgs({
65
+ image: params.image,
66
+ containerName,
67
+ });
68
+ try {
69
+ await execFileAsync(params.dockerBin ?? "docker", args);
70
+ }
71
+ catch (err) {
72
+ throw new Error(`failed to start sandbox container: ${errorMessage(err)}`);
73
+ }
74
+ return { containerName };
75
+ }
76
+ const NO_SUCH_CONTAINER_PATTERN = /no such container/i;
77
+ const NO_SUCH_VOLUME_PATTERN = /no such volume/i;
78
+ export async function stopSandboxContainer(params) {
79
+ try {
80
+ await execFileAsync(params.dockerBin ?? "docker", [
81
+ "rm",
82
+ "-f",
83
+ params.containerName,
84
+ ]);
85
+ }
86
+ catch (err) {
87
+ const stderr = stderrOf(err);
88
+ if (!NO_SUCH_CONTAINER_PATTERN.test(stderr)) {
89
+ throw new Error(`failed to stop sandbox container: ${errorMessage(err)}`);
90
+ }
91
+ }
92
+ try {
93
+ await execFileAsync(params.dockerBin ?? "docker", [
94
+ "volume",
95
+ "rm",
96
+ sandboxVolumeName(params.containerName),
97
+ ]);
98
+ }
99
+ catch (err) {
100
+ const stderr = stderrOf(err);
101
+ if (NO_SUCH_VOLUME_PATTERN.test(stderr))
102
+ return;
103
+ throw new Error(`failed to remove sandbox volume: ${errorMessage(err)}`);
104
+ }
105
+ }
106
+ function stderrOf(err) {
107
+ if (err && typeof err === "object" && "stderr" in err) {
108
+ return String(err.stderr ?? "");
109
+ }
110
+ return "";
111
+ }
112
+ export async function execInSandboxContainer(params) {
113
+ const args = ["exec", "-w", SANDBOX_MOUNT_PATH, params.containerName, "bash", "-c", params.command];
114
+ try {
115
+ const { stdout, stderr } = await execFileAsync(params.dockerBin ?? "docker", args, {
116
+ maxBuffer: 8 * 1024 * 1024,
117
+ ...(params.timeoutMs !== undefined ? { timeout: params.timeoutMs } : {}),
118
+ });
119
+ return { stdout, stderr, exitCode: 0 };
120
+ }
121
+ catch (err) {
122
+ const e = err;
123
+ // `code` is a number only for a completed exec that exited non-zero.
124
+ // Anything else (ENOENT for a missing docker binary, the container
125
+ // having been removed mid-exec, etc.) is an infra failure, not a
126
+ // command result — rethrow so callers don't mistake it for "the
127
+ // command ran and exited 1".
128
+ if (typeof e.code !== "number") {
129
+ throw new Error(`failed to exec in sandbox container: ${errorMessage(err)}`);
130
+ }
131
+ return { stdout: e.stdout ?? "", stderr: e.stderr ?? "", exitCode: e.code };
132
+ }
133
+ }
134
+ export async function copyToSandboxContainer(params) {
135
+ const src = params.contents ? `${params.hostPath}/.` : params.hostPath;
136
+ await dockerCp(params.dockerBin, src, `${params.containerName}:${params.containerPath}`, "into");
137
+ }
138
+ export async function copyFromSandboxContainer(params) {
139
+ const src = params.contents
140
+ ? `${params.containerName}:${params.containerPath}/.`
141
+ : `${params.containerName}:${params.containerPath}`;
142
+ await dockerCp(params.dockerBin, src, params.hostPath, "from");
143
+ }
144
+ async function dockerCp(dockerBin, src, dest, direction) {
145
+ try {
146
+ await execFileAsync(dockerBin ?? "docker", ["cp", src, dest]);
147
+ }
148
+ catch (err) {
149
+ throw new Error(`failed to copy ${direction} sandbox: ${errorMessage(err)}`);
150
+ }
151
+ }
@@ -0,0 +1,28 @@
1
+ export type SandboxDeskRole = "artifacts" | "envelope" | "checkout" | "grant" | "work";
2
+ export type SandboxDeskBinding = {
3
+ role: SandboxDeskRole;
4
+ hostPath: string;
5
+ containerPath: string;
6
+ shape: "directory" | "file" | "mirror";
7
+ copyIn: boolean;
8
+ copyOut: boolean;
9
+ optional: boolean;
10
+ };
11
+ export type SandboxDesk = {
12
+ bindings: readonly SandboxDeskBinding[];
13
+ };
14
+ export declare function buildSandboxDesk(input: {
15
+ attemptDir: string;
16
+ checkoutRoot?: string;
17
+ grantedPaths?: readonly string[];
18
+ }): SandboxDesk;
19
+ export declare function populateSandboxDesk(params: {
20
+ containerName: string;
21
+ dockerBin?: string;
22
+ desk: SandboxDesk;
23
+ }): Promise<void>;
24
+ export declare function syncSandboxDesk(params: {
25
+ containerName: string;
26
+ dockerBin?: string;
27
+ desk: SandboxDesk;
28
+ }): Promise<void>;
@@ -0,0 +1,242 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { access, mkdir, rename, rm, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { copyFromSandboxContainer, copyToSandboxContainer, execInSandboxContainer, SANDBOX_MOUNT_PATH, } from "./sandboxContainer.js";
5
+ import { shellQuote } from "./sandboxFiles.js";
6
+ export function buildSandboxDesk(input) {
7
+ const attemptDir = input.attemptDir;
8
+ const bindings = [
9
+ {
10
+ role: "artifacts",
11
+ hostPath: path.join(attemptDir, "artifacts"),
12
+ containerPath: `${SANDBOX_MOUNT_PATH}/artifacts`,
13
+ shape: "directory",
14
+ copyIn: true,
15
+ copyOut: false,
16
+ optional: true,
17
+ },
18
+ {
19
+ role: "envelope",
20
+ hostPath: path.join(attemptDir, "envelope.json"),
21
+ containerPath: `${SANDBOX_MOUNT_PATH}/envelope.json`,
22
+ shape: "file",
23
+ copyIn: true,
24
+ copyOut: false,
25
+ optional: true,
26
+ },
27
+ ];
28
+ const checkout = input.checkoutRoot?.trim();
29
+ if (checkout) {
30
+ bindings.push({
31
+ role: "checkout",
32
+ hostPath: checkout,
33
+ containerPath: `${SANDBOX_MOUNT_PATH}/checkout`,
34
+ shape: "directory",
35
+ copyIn: true,
36
+ copyOut: true,
37
+ optional: false,
38
+ });
39
+ }
40
+ const grants = input.grantedPaths ?? [];
41
+ for (let index = 0; index < grants.length; index += 1) {
42
+ const hostPath = grants[index];
43
+ bindings.push({
44
+ role: "grant",
45
+ hostPath,
46
+ containerPath: `${SANDBOX_MOUNT_PATH}/grants/${grantSegment(index, hostPath)}`,
47
+ shape: "mirror",
48
+ copyIn: true,
49
+ copyOut: true,
50
+ optional: false,
51
+ });
52
+ }
53
+ bindings.push({
54
+ role: "work",
55
+ hostPath: path.join(attemptDir, "work"),
56
+ containerPath: `${SANDBOX_MOUNT_PATH}/work`,
57
+ shape: "directory",
58
+ copyIn: false,
59
+ copyOut: true,
60
+ optional: true,
61
+ });
62
+ return { bindings };
63
+ }
64
+ function grantSegment(index, hostPath) {
65
+ const base = path.basename(hostPath).replace(/[^A-Za-z0-9_.-]/g, "_");
66
+ const safe = base.length > 0 && base !== "." && base !== ".." ? base : "path";
67
+ return `${index}-${safe}`;
68
+ }
69
+ async function pathExists(target) {
70
+ try {
71
+ await access(target);
72
+ return true;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ export async function populateSandboxDesk(params) {
79
+ const dirs = new Set([
80
+ `${SANDBOX_MOUNT_PATH}/artifacts`,
81
+ `${SANDBOX_MOUNT_PATH}/work`,
82
+ `${SANDBOX_MOUNT_PATH}/grants`,
83
+ ]);
84
+ for (const binding of params.desk.bindings) {
85
+ if (binding.role === "checkout" || binding.shape === "mirror") {
86
+ dirs.add(binding.containerPath);
87
+ }
88
+ }
89
+ const made = await execInSandboxContainer({
90
+ containerName: params.containerName,
91
+ dockerBin: params.dockerBin,
92
+ command: `mkdir -p -- ${[...dirs].map(shellQuote).join(" ")}`,
93
+ });
94
+ if (made.exitCode !== 0) {
95
+ throw new Error(`failed to prepare sandbox desk: ${made.stderr.trim() || made.stdout.trim() || `exit ${made.exitCode}`}`);
96
+ }
97
+ for (const binding of params.desk.bindings) {
98
+ if (!binding.copyIn)
99
+ continue;
100
+ if (!(await pathExists(binding.hostPath))) {
101
+ if (binding.optional)
102
+ continue;
103
+ throw new Error(`sandbox desk source missing: ${binding.hostPath}`);
104
+ }
105
+ await copyBindingIn(params, binding);
106
+ }
107
+ }
108
+ export async function syncSandboxDesk(params) {
109
+ for (const binding of params.desk.bindings) {
110
+ if (!binding.copyOut)
111
+ continue;
112
+ await copyBindingOut(params, binding);
113
+ }
114
+ }
115
+ async function copyBindingIn(params, binding) {
116
+ if (binding.shape === "file") {
117
+ await copyToSandboxContainer({
118
+ containerName: params.containerName,
119
+ dockerBin: params.dockerBin,
120
+ hostPath: binding.hostPath,
121
+ containerPath: binding.containerPath,
122
+ });
123
+ return;
124
+ }
125
+ if (binding.shape === "directory") {
126
+ await copyToSandboxContainer({
127
+ containerName: params.containerName,
128
+ dockerBin: params.dockerBin,
129
+ hostPath: binding.hostPath,
130
+ containerPath: binding.containerPath,
131
+ contents: true,
132
+ });
133
+ return;
134
+ }
135
+ const info = await stat(binding.hostPath);
136
+ if (info.isDirectory()) {
137
+ await copyToSandboxContainer({
138
+ containerName: params.containerName,
139
+ dockerBin: params.dockerBin,
140
+ hostPath: binding.hostPath,
141
+ containerPath: binding.containerPath,
142
+ contents: true,
143
+ });
144
+ return;
145
+ }
146
+ await copyToSandboxContainer({
147
+ containerName: params.containerName,
148
+ dockerBin: params.dockerBin,
149
+ hostPath: binding.hostPath,
150
+ containerPath: path.posix.join(binding.containerPath, path.basename(binding.hostPath)),
151
+ });
152
+ }
153
+ async function copyBindingOut(params, binding) {
154
+ if (binding.role === "checkout" || binding.role === "work") {
155
+ await mirrorDirectoryFromContainer(params, binding.containerPath, binding.hostPath);
156
+ return;
157
+ }
158
+ if (binding.shape === "directory") {
159
+ await mkdir(binding.hostPath, { recursive: true });
160
+ await copyFromSandboxContainer({
161
+ containerName: params.containerName,
162
+ dockerBin: params.dockerBin,
163
+ containerPath: binding.containerPath,
164
+ hostPath: binding.hostPath,
165
+ contents: true,
166
+ });
167
+ return;
168
+ }
169
+ const info = await stat(binding.hostPath);
170
+ if (info.isDirectory()) {
171
+ await mkdir(binding.hostPath, { recursive: true });
172
+ await copyFromSandboxContainer({
173
+ containerName: params.containerName,
174
+ dockerBin: params.dockerBin,
175
+ containerPath: binding.containerPath,
176
+ hostPath: binding.hostPath,
177
+ contents: true,
178
+ });
179
+ return;
180
+ }
181
+ await mkdir(path.dirname(binding.hostPath), { recursive: true });
182
+ await copyFromSandboxContainer({
183
+ containerName: params.containerName,
184
+ dockerBin: params.dockerBin,
185
+ containerPath: path.posix.join(binding.containerPath, path.basename(binding.hostPath)),
186
+ hostPath: binding.hostPath,
187
+ });
188
+ }
189
+ async function mirrorDirectoryFromContainer(params, containerPath, hostPath) {
190
+ let probe;
191
+ try {
192
+ probe = await execInSandboxContainer({
193
+ containerName: params.containerName,
194
+ dockerBin: params.dockerBin,
195
+ command: `test -d ${shellQuote(containerPath)}`,
196
+ });
197
+ }
198
+ catch (err) {
199
+ throw new Error(`sandbox desk directory unavailable, not replacing ${hostPath}: ${err instanceof Error ? err.message : String(err)}`);
200
+ }
201
+ if (probe.exitCode !== 0) {
202
+ throw new Error(`sandbox desk directory missing, not replacing ${hostPath}`);
203
+ }
204
+ await mkdir(path.dirname(hostPath), { recursive: true });
205
+ const staging = path.join(path.dirname(hostPath), `.${path.basename(hostPath)}.sf-sync-${randomBytes(4).toString("hex")}`);
206
+ await mkdir(staging);
207
+ try {
208
+ await copyFromSandboxContainer({
209
+ containerName: params.containerName,
210
+ dockerBin: params.dockerBin,
211
+ containerPath,
212
+ hostPath: staging,
213
+ contents: true,
214
+ });
215
+ await replaceDirectory(hostPath, staging);
216
+ }
217
+ catch (err) {
218
+ await rm(staging, { recursive: true, force: true });
219
+ throw err;
220
+ }
221
+ }
222
+ async function replaceDirectory(hostPath, staging) {
223
+ const backup = path.join(path.dirname(hostPath), `.${path.basename(hostPath)}.sf-sync-bak-${randomBytes(4).toString("hex")}`);
224
+ let hostMoved = false;
225
+ try {
226
+ if (await pathExists(hostPath)) {
227
+ await rename(hostPath, backup);
228
+ hostMoved = true;
229
+ }
230
+ await rename(staging, hostPath);
231
+ hostMoved = false;
232
+ }
233
+ catch (err) {
234
+ if (hostMoved && !(await pathExists(hostPath)) && (await pathExists(backup))) {
235
+ await rename(backup, hostPath);
236
+ }
237
+ throw err;
238
+ }
239
+ if (await pathExists(backup)) {
240
+ await rm(backup, { recursive: true, force: true });
241
+ }
242
+ }