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
@@ -0,0 +1,20 @@
1
+ import { type SandboxExecResult } from "./sandboxContainer.js";
2
+ export declare class SandboxPathError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export declare function shellQuote(value: string): string;
6
+ /** Resolve a tool path onto the sandbox mount. Host absolutes and `..` escapes are rejected. */
7
+ export declare function confineSandboxPath(inputPath: string): string;
8
+ export type SandboxFileTarget = {
9
+ containerName: string;
10
+ dockerBin?: string;
11
+ };
12
+ export declare function readSandboxFile(target: SandboxFileTarget, inputPath: string): Promise<string>;
13
+ export declare function writeSandboxFile(target: SandboxFileTarget, inputPath: string, content: string): Promise<void>;
14
+ export declare function editSandboxFile(target: SandboxFileTarget, inputPath: string, edits: readonly {
15
+ oldText: string;
16
+ newText: string;
17
+ }[]): Promise<void>;
18
+ export declare function sliceSandboxLines(text: string, offset?: number, limit?: number): string;
19
+ export declare function formatSandboxBashResult(result: SandboxExecResult): string;
20
+ export declare function runSandboxBash(target: SandboxFileTarget, command: string, timeoutMs?: number): Promise<string>;
@@ -0,0 +1,150 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { copyToSandboxContainer, execInSandboxContainer, SANDBOX_MOUNT_PATH, } from "./sandboxContainer.js";
5
+ export class SandboxPathError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "SandboxPathError";
9
+ }
10
+ }
11
+ export function shellQuote(value) {
12
+ return `'${value.replaceAll("'", `'\\''`)}'`;
13
+ }
14
+ /** Resolve a tool path onto the sandbox mount. Host absolutes and `..` escapes are rejected. */
15
+ export function confineSandboxPath(inputPath) {
16
+ if (typeof inputPath !== "string" || inputPath.trim() === "") {
17
+ throw new SandboxPathError("path must be a non-empty string");
18
+ }
19
+ if (inputPath.includes("\0") || inputPath.startsWith("~")) {
20
+ throw new SandboxPathError("path escapes the sandbox");
21
+ }
22
+ const slashes = inputPath.replaceAll("\\", "/");
23
+ const joined = slashes.startsWith("/")
24
+ ? path.posix.normalize(slashes)
25
+ : path.posix.normalize(path.posix.join(SANDBOX_MOUNT_PATH, slashes));
26
+ if (joined !== SANDBOX_MOUNT_PATH && !joined.startsWith(`${SANDBOX_MOUNT_PATH}/`)) {
27
+ throw new SandboxPathError("path escapes the sandbox");
28
+ }
29
+ return joined;
30
+ }
31
+ function assertFilePath(containerPath) {
32
+ if (containerPath === SANDBOX_MOUNT_PATH) {
33
+ throw new SandboxPathError("path must name a file under /workspace");
34
+ }
35
+ }
36
+ async function requireExec(result, fallback) {
37
+ if (result.exitCode === 0)
38
+ return;
39
+ const detail = result.stderr.trim() || result.stdout.trim();
40
+ throw new Error(detail || fallback);
41
+ }
42
+ export async function readSandboxFile(target, inputPath) {
43
+ const containerPath = confineSandboxPath(inputPath);
44
+ assertFilePath(containerPath);
45
+ const quoted = shellQuote(containerPath);
46
+ const result = await execInSandboxContainer({
47
+ containerName: target.containerName,
48
+ dockerBin: target.dockerBin,
49
+ command: `if [ ! -f ${quoted} ]; then echo "No such file: ${containerPath}" >&2; exit 1; fi; base64 < ${quoted}`,
50
+ });
51
+ await requireExec(result, `failed to read ${containerPath}`);
52
+ return Buffer.from(result.stdout, "base64").toString("utf8");
53
+ }
54
+ export async function writeSandboxFile(target, inputPath, content) {
55
+ const containerPath = confineSandboxPath(inputPath);
56
+ assertFilePath(containerPath);
57
+ const parent = path.posix.dirname(containerPath);
58
+ const made = await execInSandboxContainer({
59
+ containerName: target.containerName,
60
+ dockerBin: target.dockerBin,
61
+ command: `mkdir -p -- ${shellQuote(parent)}`,
62
+ });
63
+ await requireExec(made, `failed to write ${containerPath}`);
64
+ const scratch = await mkdtemp(path.join(tmpdir(), "sf-sandbox-write-"));
65
+ const payload = path.join(scratch, "payload");
66
+ try {
67
+ await writeFile(payload, content);
68
+ await copyToSandboxContainer({
69
+ containerName: target.containerName,
70
+ dockerBin: target.dockerBin,
71
+ hostPath: payload,
72
+ containerPath,
73
+ });
74
+ }
75
+ finally {
76
+ await rm(scratch, { recursive: true, force: true });
77
+ }
78
+ }
79
+ export async function editSandboxFile(target, inputPath, edits) {
80
+ if (edits.length === 0) {
81
+ throw new Error("edits must contain at least one replacement");
82
+ }
83
+ const original = await readSandboxFile(target, inputPath);
84
+ const ranges = [];
85
+ for (const edit of edits) {
86
+ if (edit.oldText.length === 0) {
87
+ throw new Error("oldText must not be empty");
88
+ }
89
+ const count = countOccurrences(original, edit.oldText);
90
+ if (count !== 1) {
91
+ throw new Error(`oldText matched ${count} times`);
92
+ }
93
+ const start = original.indexOf(edit.oldText);
94
+ ranges.push({ start, end: start + edit.oldText.length, newText: edit.newText });
95
+ }
96
+ ranges.sort((a, b) => b.start - a.start);
97
+ for (let i = 0; i < ranges.length; i += 1) {
98
+ const later = ranges[i];
99
+ for (let j = i + 1; j < ranges.length; j += 1) {
100
+ const earlier = ranges[j];
101
+ if (earlier.end > later.start) {
102
+ throw new Error("edits overlap");
103
+ }
104
+ }
105
+ }
106
+ let next = original;
107
+ for (const range of ranges) {
108
+ next = next.slice(0, range.start) + range.newText + next.slice(range.end);
109
+ }
110
+ await writeSandboxFile(target, inputPath, next);
111
+ }
112
+ export function sliceSandboxLines(text, offset, limit) {
113
+ if (offset === undefined && limit === undefined)
114
+ return text;
115
+ const lines = text.split("\n");
116
+ const start = offset !== undefined ? Math.max(0, Math.floor(offset) - 1) : 0;
117
+ if (start >= lines.length) {
118
+ throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`);
119
+ }
120
+ const end = limit !== undefined ? start + Math.max(0, Math.floor(limit)) : lines.length;
121
+ return lines.slice(start, end).join("\n");
122
+ }
123
+ export function formatSandboxBashResult(result) {
124
+ const parts = [result.stdout];
125
+ if (result.stderr.trim().length > 0)
126
+ parts.push(result.stderr);
127
+ parts.push(`Exit code: ${result.exitCode}`);
128
+ return parts.join("\n");
129
+ }
130
+ export async function runSandboxBash(target, command, timeoutMs) {
131
+ const result = await execInSandboxContainer({
132
+ containerName: target.containerName,
133
+ dockerBin: target.dockerBin,
134
+ command,
135
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
136
+ });
137
+ return formatSandboxBashResult(result);
138
+ }
139
+ function countOccurrences(haystack, needle) {
140
+ let count = 0;
141
+ let from = 0;
142
+ while (from <= haystack.length) {
143
+ const at = haystack.indexOf(needle, from);
144
+ if (at < 0)
145
+ return count;
146
+ count += 1;
147
+ from = at + needle.length;
148
+ }
149
+ return count;
150
+ }
@@ -26,6 +26,8 @@ export const STAGE_ENV_ALLOWLIST = [
26
26
  "no_proxy",
27
27
  "STAGEFLOW_HOME",
28
28
  "STAGEFLOW_CURSOR_EXTENSION",
29
+ "STAGEFLOW_STAGE_CONTAINER_IMAGE",
30
+ "STAGEFLOW_STAGE_CONTAINER_DOCKER_BIN",
29
31
  ];
30
32
  export const PROVIDER_AND_CLOUD_NAMES = [
31
33
  "ANTHROPIC_API_KEY",
@@ -11,6 +11,14 @@ export type StageRoots = {
11
11
  attempt?: number;
12
12
  /** Durable Pi auth.json path for ModelRuntime.create({ authPath }). */
13
13
  authPath?: string;
14
+ /**
15
+ * Sandbox container `stageWorker.ts` started for this attempt, if any.
16
+ * When set, read/write/edit/bash run inside that container's /workspace
17
+ * desk. Undefined leaves those tools on the host.
18
+ */
19
+ containerName?: string;
20
+ /** The docker binary the container above was started with (see STAGEFLOW_STAGE_CONTAINER_DOCKER_BIN). */
21
+ containerDockerBin?: string;
14
22
  };
15
23
  export declare const STAGEFLOW_CHECKOUT = "STAGEFLOW_CHECKOUT";
16
24
  export declare const STAGEFLOW_RUN_WORKSPACE = "STAGEFLOW_RUN_WORKSPACE";
@@ -51,6 +59,7 @@ export declare function stageBindingEnvFromRun(options: {
51
59
  };
52
60
  export declare function withResolvedAuthPath(roots: StageRoots, factoryCwd: string): StageRoots;
53
61
  export declare function rootsForStageWorker(runWorkspaceDir: string, stageId: string, model: string, checkoutRoot?: string, attemptCtx?: StageAttemptContext): StageRoots;
62
+ export declare function withContainerName(roots: StageRoots, containerName: string | undefined, containerDockerBin?: string): StageRoots;
54
63
  export declare function bindPiAgentDirEnv(agentDirPath: string): () => void;
55
64
  export declare function resolveCheckoutPath(declared: string, cwd: string): string;
56
65
  export declare function resolveAndValidateCheckout(task: TaskFile, override: string | undefined, factoryCwd: string): Promise<string | undefined>;
@@ -4,6 +4,7 @@ import { isCursorModelRef } from "../agent/cursorProvider.js";
4
4
  import { stageDir } from "../runstore/paths.js";
5
5
  import { noAttemptContext } from "./stageAttemptContext.js";
6
6
  import { resolveCredentialBinding, STAGEFLOW_AGENT_AUTH_PATH_ENV, currentExplicitAuthPath, } from "./credentialBinding.js";
7
+ import { ensureWorktreeCheckout } from "./gitWorktreeCheckout.js";
7
8
  import { SF_STAGE_WORKER } from "./stageWorkerProtocol.js";
8
9
  export const STAGEFLOW_CHECKOUT = "STAGEFLOW_CHECKOUT";
9
10
  export const STAGEFLOW_RUN_WORKSPACE = "STAGEFLOW_RUN_WORKSPACE";
@@ -151,6 +152,11 @@ export function rootsForStageWorker(runWorkspaceDir, stageId, model, checkoutRoo
151
152
  cwd: stageDir(runWorkspaceDir, stageId),
152
153
  };
153
154
  }
155
+ export function withContainerName(roots, containerName, containerDockerBin) {
156
+ return containerName === undefined
157
+ ? roots
158
+ : { ...roots, containerName, containerDockerBin };
159
+ }
154
160
  export function bindPiAgentDirEnv(agentDirPath) {
155
161
  const prev = process.env[PI_CODING_AGENT_DIR_ENV];
156
162
  process.env[PI_CODING_AGENT_DIR_ENV] = agentDirPath;
@@ -170,6 +176,14 @@ export async function resolveAndValidateCheckout(task, override, factoryCwd) {
170
176
  const raw = override ?? task.checkout;
171
177
  if (raw === undefined)
172
178
  return undefined;
179
+ if (typeof raw !== "string") {
180
+ return ensureWorktreeCheckout({
181
+ projectRoot: factoryCwd,
182
+ branch: raw.branch,
183
+ base: raw.base,
184
+ taskId: task.id,
185
+ });
186
+ }
173
187
  if (raw.trim() === "") {
174
188
  throw new Error("Invalid checkout: path is empty or whitespace-only");
175
189
  }
@@ -53,6 +53,11 @@ export type RunStageOptions = {
53
53
  stageEnv?: Record<string, string>;
54
54
  /** Test seam for the Host login check run by a `browser_login` verify item. */
55
55
  browser?: Pick<StageBrowserSupport, "runner" | "loginCheck">;
56
+ /**
57
+ * Runs after the agent turn and before completion verification records a
58
+ * verdict. Used to copy a sandbox desk back onto the host tree.
59
+ */
60
+ beforeVerify?: () => Promise<void>;
56
61
  };
57
62
  /**
58
63
  * Yield loop: next() until completed; on wait, coordinator enterWait (KTD6).
@@ -47,6 +47,17 @@ async function appendLifecycleEventAndPatchExecution(options) {
47
47
  // no execution row yet
48
48
  }
49
49
  }
50
+ async function runBeforeVerify(beforeVerify) {
51
+ if (beforeVerify === undefined)
52
+ return undefined;
53
+ try {
54
+ await beforeVerify();
55
+ return undefined;
56
+ }
57
+ catch (error) {
58
+ return error instanceof Error ? error.message : String(error);
59
+ }
60
+ }
50
61
  async function finalizeStageResult(options) {
51
62
  const { store, runId, stageId, result, activityChain, attemptCtx, browser } = options;
52
63
  const attemptOpt = attemptCtx?.eventOptions();
@@ -125,7 +136,7 @@ export async function runStageYieldLoop(options) {
125
136
  }
126
137
  }
127
138
  export async function runStage(options) {
128
- const { agent, store, runId, stage, task, dag, checkoutRoot, workspaceDir, hitl, skipStarted, existingHandle, workerMode, roots: rootsOverride, attemptCtx, factoryCwd, operatorCatalog, completedEnvelopes, skipGates, sessionMode, feedbackLoopContext, resumeToken, stageEnv, browser: browserSeams, } = options;
139
+ const { agent, store, runId, stage, task, dag, checkoutRoot, workspaceDir, hitl, skipStarted, existingHandle, workerMode, roots: rootsOverride, attemptCtx, factoryCwd, operatorCatalog, completedEnvelopes, skipGates, sessionMode, feedbackLoopContext, resumeToken, stageEnv, browser: browserSeams, beforeVerify, } = options;
129
140
  const stageId = options.stageId ?? stage.id;
130
141
  const attemptOpt = attemptCtx?.eventOptions();
131
142
  const baseRoots = rootsOverride ??
@@ -242,20 +253,43 @@ export async function runStage(options) {
242
253
  }
243
254
  catch (err) {
244
255
  const reason = err instanceof Error ? err.message : String(err);
256
+ const synced = await runBeforeVerify(beforeVerify);
257
+ const fullReason = synced === undefined ? reason : `${reason}; ${synced}`;
245
258
  await activityChain;
246
259
  hitl?.clearLiveWait(runId, stageId);
247
260
  await appendLifecycleEventAndPatchExecution({
248
261
  store,
249
262
  runId,
250
263
  stageId,
251
- event: { event: "failed", reason },
264
+ event: { event: "failed", reason: fullReason },
252
265
  attemptCtx,
253
266
  });
254
- console.error(`Stage ${stageId} failed: ${reason}`);
267
+ console.error(`Stage ${stageId} failed: ${fullReason}`);
255
268
  await handle
256
269
  .close(isStageTimeoutReason(reason) ? { park: true } : undefined)
257
270
  .catch(() => undefined);
258
- return { ok: false, reason };
271
+ return { ok: false, reason: fullReason };
272
+ }
273
+ const syncError = await runBeforeVerify(beforeVerify);
274
+ if (syncError !== undefined) {
275
+ const reason = syncError;
276
+ if (isRunStageWaiting(outcome)) {
277
+ await activityChain;
278
+ hitl?.clearLiveWait(runId, stageId);
279
+ await appendLifecycleEventAndPatchExecution({
280
+ store,
281
+ runId,
282
+ stageId,
283
+ event: { event: "failed", reason },
284
+ attemptCtx,
285
+ });
286
+ console.error(`Stage ${stageId} failed: ${reason}`);
287
+ await handle.close().catch(() => undefined);
288
+ return { ok: false, reason };
289
+ }
290
+ outcome = outcome.ok
291
+ ? { ok: false, reason }
292
+ : { ok: false, reason: `${outcome.reason}; ${reason}` };
259
293
  }
260
294
  if (isRunStageWaiting(outcome)) {
261
295
  await activityChain;
@@ -0,0 +1,16 @@
1
+ import { type StageRoots } from "./stageRoots.js";
2
+ export type StageSandbox = {
3
+ roots: StageRoots;
4
+ beforeVerify?: () => Promise<void>;
5
+ close: () => Promise<void>;
6
+ failure?: string;
7
+ };
8
+ export declare function openStageSandbox(input: {
9
+ env: NodeJS.ProcessEnv;
10
+ roots: StageRoots;
11
+ runId: string;
12
+ stageId: string;
13
+ attempt: number;
14
+ attemptDir: string;
15
+ checkoutRoot?: string;
16
+ }): Promise<StageSandbox>;
@@ -0,0 +1,54 @@
1
+ import { resolveSandboxContainerOptions, startSandboxContainer, stopSandboxContainer, } from "./sandboxContainer.js";
2
+ import { buildSandboxDesk, populateSandboxDesk, syncSandboxDesk, } from "./sandboxDesk.js";
3
+ import { withContainerName } from "./stageRoots.js";
4
+ export async function openStageSandbox(input) {
5
+ const options = resolveSandboxContainerOptions(input.env);
6
+ if (!options) {
7
+ return { roots: input.roots, close: async () => { } };
8
+ }
9
+ const started = await startSandboxContainer({
10
+ image: options.image,
11
+ dockerBin: options.dockerBin,
12
+ runId: input.runId,
13
+ stageId: input.stageId,
14
+ attempt: input.attempt,
15
+ });
16
+ const roots = withContainerName(input.roots, started.containerName, options.dockerBin);
17
+ const checkout = input.checkoutRoot?.trim();
18
+ const desk = buildSandboxDesk({
19
+ attemptDir: input.attemptDir,
20
+ ...(checkout ? { checkoutRoot: checkout } : {}),
21
+ });
22
+ const session = {
23
+ roots,
24
+ close: () => stopSandboxContainer({
25
+ containerName: started.containerName,
26
+ dockerBin: options.dockerBin,
27
+ }).catch((err) => {
28
+ const message = err instanceof Error ? err.message : String(err);
29
+ process.stderr.write(`[stageWorker] failed to stop sandbox container ${started.containerName}: ${message}\n`);
30
+ }),
31
+ };
32
+ try {
33
+ await populateSandboxDesk({
34
+ containerName: started.containerName,
35
+ dockerBin: options.dockerBin,
36
+ desk,
37
+ });
38
+ }
39
+ catch (err) {
40
+ session.failure = err instanceof Error ? err.message : String(err);
41
+ return session;
42
+ }
43
+ session.beforeVerify = syncOnce(started.containerName, options.dockerBin, desk);
44
+ return session;
45
+ }
46
+ function syncOnce(containerName, dockerBin, desk) {
47
+ let synced = false;
48
+ return async () => {
49
+ if (synced)
50
+ return;
51
+ synced = true;
52
+ await syncSandboxDesk({ containerName, dockerBin, desk });
53
+ };
54
+ }