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.
- package/README.md +43 -18
- package/dist/agent/claudeAdapter.js +15 -1
- package/dist/agent/claudeTools.d.ts +13 -0
- package/dist/agent/claudeTools.js +98 -0
- package/dist/agent/piAdapter.js +38 -16
- package/dist/agent/sandboxPiTools.d.ts +99 -0
- package/dist/agent/sandboxPiTools.js +109 -0
- package/dist/config/hostConfig.js +2 -0
- package/dist/config/loadTask.js +34 -1
- package/dist/mcp/catalogTools.js +11 -1
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/runtime/gitCheckoutCapability.d.ts +1 -0
- package/dist/runtime/gitCheckoutCapability.js +1 -1
- package/dist/runtime/gitWorktreeCheckout.d.ts +22 -0
- package/dist/runtime/gitWorktreeCheckout.js +120 -0
- package/dist/runtime/sandboxContainer.d.ts +62 -0
- package/dist/runtime/sandboxContainer.js +151 -0
- package/dist/runtime/sandboxDesk.d.ts +28 -0
- package/dist/runtime/sandboxDesk.js +242 -0
- package/dist/runtime/sandboxFiles.d.ts +20 -0
- package/dist/runtime/sandboxFiles.js +150 -0
- package/dist/runtime/stageEnvironment.js +2 -0
- package/dist/runtime/stageRoots.d.ts +9 -0
- package/dist/runtime/stageRoots.js +14 -0
- package/dist/runtime/stageRunner.d.ts +5 -0
- package/dist/runtime/stageRunner.js +38 -4
- package/dist/runtime/stageSandbox.d.ts +16 -0
- package/dist/runtime/stageSandbox.js +54 -0
- package/dist/runtime/stageWorker.js +138 -105
- package/dist/runtime/workspaceBinding.d.ts +2 -1
- package/dist/runtime/workspaceBinding.js +20 -4
- package/dist/tools/readEmail.d.ts +3 -3
- package/dist/types/task.d.ts +11 -1
- package/examples/memory/README.md +2 -0
- package/examples/memory/probe-bash.task.yaml +3 -0
- package/examples/memory/sandbox-memory-bash.pipeline.yaml +28 -0
- package/examples/ship-feature/README.md +15 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,9 +53,43 @@ The scheduler owns orchestration semantics: DAG readiness, bounded parallelism,
|
|
|
53
53
|
|
|
54
54
|
See [Architecture](docs/architecture.md) for component boundaries, execution flow, persistence, recovery behavior, and the tradeoffs behind fresh sessions and explicit handoffs.
|
|
55
55
|
|
|
56
|
-
##
|
|
56
|
+
## Get started
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
### Docker (recommended)
|
|
59
|
+
|
|
60
|
+
Run Stageflow from the published image. It is [`ghcr.io/tejasghutukade/stageflow`](https://github.com/tejasghutukade/stageflow/pkgs/container/stageflow). `0.39` is the current minor tag (`0.39.0` is that exact release).
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
docker run --rm --name stageflow \
|
|
64
|
+
-e STAGEFLOW_ALLOW_UNAUTHENTICATED_BIND=1 \
|
|
65
|
+
-p 127.0.0.1:3847:3847 \
|
|
66
|
+
-v stageflow-data:/data \
|
|
67
|
+
ghcr.io/tejasghutukade/stageflow:0.39 \
|
|
68
|
+
sf ui
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Open [http://127.0.0.1:3847](http://127.0.0.1:3847). Connect a provider under **Settings → Providers**, then **New pipeline** on the Pipelines page. The `stageflow-data` volume keeps that state across runs.
|
|
72
|
+
|
|
73
|
+
The process inside the container binds `0.0.0.0`, which is what Docker port publish needs. The `-p` address keeps the port on this machine, and the allow flag lets it start without a bearer. Do not publish the port on `0.0.0.0` with that flag set. `sf ui` is required because the image command is `sf mcp`.
|
|
74
|
+
|
|
75
|
+
To show the console a project directory:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
docker run --rm --name stageflow \
|
|
79
|
+
-e STAGEFLOW_ALLOW_UNAUTHENTICATED_BIND=1 \
|
|
80
|
+
-e STAGEFLOW_ENSURE_PROJECT_ROOTS=/project \
|
|
81
|
+
-p 127.0.0.1:3847:3847 \
|
|
82
|
+
-v stageflow-data:/data \
|
|
83
|
+
-v "$PWD":/project \
|
|
84
|
+
ghcr.io/tejasghutukade/stageflow:0.39 \
|
|
85
|
+
sf ui
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Building from this checkout, instead of pulling, is [Docker — Local try](docs/docker.md#local-try-compose).
|
|
89
|
+
|
|
90
|
+
### Node
|
|
91
|
+
|
|
92
|
+
Install the `sf` command on the machine when you want it in the shell. Requires **Node.js ≥ 20**.
|
|
59
93
|
|
|
60
94
|
**Quick install (macOS / Linux):**
|
|
61
95
|
|
|
@@ -75,37 +109,28 @@ npm i -g ./stageflow-*.tgz
|
|
|
75
109
|
|
|
76
110
|
`better-sqlite3` ships prebuilds for common platforms. `--ignore-scripts` is fine when a prebuild exists. Benign `node-gyp` warnings during install can be ignored if `require("better-sqlite3")` works.
|
|
77
111
|
|
|
78
|
-
**Harness skills** (Cursor, Claude Code, Codex, Pi, OpenCode) — from a consumer project:
|
|
79
|
-
|
|
80
|
-
```bash
|
|
81
|
-
npx skills add tejasghutukade/stageflow
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
Then ask the agent to set up Stageflow. Details: [docs/skills-suite.md](docs/skills-suite.md).
|
|
85
|
-
|
|
86
|
-
## Quick start
|
|
87
|
-
|
|
88
112
|
In a project directory (preferably a git repo):
|
|
89
113
|
|
|
90
114
|
```bash
|
|
91
115
|
sf init
|
|
116
|
+
sf ui
|
|
117
|
+
sf run --pipeline pipelines/hello.pipeline.yaml --task tasks/hello.task.yaml
|
|
92
118
|
```
|
|
93
119
|
|
|
94
|
-
|
|
120
|
+
`sf init` scaffolds `stageflow.yaml`, `pipelines/hello.pipeline.yaml`, and `tasks/hello.task.yaml`. Connect a provider before `sf run` — see below. The console is [http://127.0.0.1:3847](http://127.0.0.1:3847).
|
|
95
121
|
|
|
96
|
-
|
|
122
|
+
**Harness skills** (Cursor, Claude Code, Codex, Pi, OpenCode) — from a consumer project:
|
|
97
123
|
|
|
98
124
|
```bash
|
|
99
|
-
|
|
100
|
-
sf run --pipeline pipelines/hello.pipeline.yaml --task tasks/hello.task.yaml
|
|
125
|
+
npx skills add tejasghutukade/stageflow
|
|
101
126
|
```
|
|
102
127
|
|
|
128
|
+
Then ask the agent to set up Stageflow. Details: [docs/skills-suite.md](docs/skills-suite.md).
|
|
129
|
+
|
|
103
130
|
**Workshop** (**New pipeline** on the Pipelines page, or `#/workshop`; it has no rail item) drafts a pipeline in chat and saves it under `workshop/<pipeline-id>/` so it shows up on Run. Guide: [docs/workshop.md](docs/workshop.md).
|
|
104
131
|
|
|
105
132
|
Expanded walkthrough: [docs/quickstart.md](docs/quickstart.md)
|
|
106
133
|
|
|
107
|
-
**Docker Compose** (UI + MCP in one Host): copy [`.env.example`](.env.example) → `.env`, then `docker compose up --build -d`. The shipped compose file publishes port 3847 on localhost only and does not require a control token. Checklist: [docs/docker.md](docs/docker.md#local-try-compose).
|
|
108
|
-
|
|
109
134
|
## Connect a model provider
|
|
110
135
|
|
|
111
136
|
Each stage sets a **`model`** id in YAML (e.g. `anthropic/claude-sonnet-4-5`). The matching provider must be authenticated before runs succeed — `sf validate` does not check auth.
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
42
42
|
import { isAdvancingEnvelope } from "../envelope/check.js";
|
|
43
43
|
import { assertAnswerMatchesPrompt, parseAskOperatorAnswer, summarizeAnswer, } from "../tools/askOperator.js";
|
|
44
|
-
import { buildStageflowMcpServer, EMIT_STAGE_ENVELOPE_TOOL_NAME, STAGEFLOW_MCP_SERVER_NAME, WRITE_STAGE_ARTIFACT_TOOL_NAME, ASK_OPERATOR_TOOL_NAME, } from "./claudeTools.js";
|
|
44
|
+
import { buildStageflowMcpServer, EMIT_STAGE_ENVELOPE_TOOL_NAME, STAGEFLOW_MCP_SERVER_NAME, WRITE_STAGE_ARTIFACT_TOOL_NAME, ASK_OPERATOR_TOOL_NAME, SANDBOX_BASH_TOOL_NAME, SANDBOX_EDIT_TOOL_NAME, SANDBOX_READ_TOOL_NAME, SANDBOX_WRITE_TOOL_NAME, } from "./claudeTools.js";
|
|
45
45
|
import { createClaudeActivityMapper } from "./claudeActivity.js";
|
|
46
46
|
import { DEFAULT_EMIT_REMINDERS, emitReminderPrompt, formatProviderError } from "./stageTurnDriver.js";
|
|
47
47
|
import { readActivityVerbose } from "./activity.js";
|
|
@@ -215,6 +215,7 @@ async function runTurn(input, model, markerPath, promptText, resumeSessionId, us
|
|
|
215
215
|
input.onActivity?.({ event: "turn_start" });
|
|
216
216
|
let sessionId = resumeSessionId;
|
|
217
217
|
let providerError;
|
|
218
|
+
const containerName = input.roots.containerName;
|
|
218
219
|
try {
|
|
219
220
|
const mcpServer = buildStageflowMcpServer({
|
|
220
221
|
capture: emitCapture,
|
|
@@ -238,6 +239,9 @@ async function runTurn(input, model, markerPath, promptText, resumeSessionId, us
|
|
|
238
239
|
},
|
|
239
240
|
}
|
|
240
241
|
: {}),
|
|
242
|
+
...(containerName !== undefined
|
|
243
|
+
? { sandboxBash: { containerName, dockerBin: input.roots.containerDockerBin } }
|
|
244
|
+
: {}),
|
|
241
245
|
});
|
|
242
246
|
const passedServers = Object.fromEntries(Object.entries(input.resolvedMcpServers ?? {}).map(([name, config]) => [
|
|
243
247
|
name,
|
|
@@ -258,6 +262,16 @@ async function runTurn(input, model, markerPath, promptText, resumeSessionId, us
|
|
|
258
262
|
persistSession: true,
|
|
259
263
|
strictMcpConfig: true,
|
|
260
264
|
...(resumeSessionId !== undefined ? { resume: resumeSessionId } : {}),
|
|
265
|
+
...(containerName !== undefined
|
|
266
|
+
? {
|
|
267
|
+
toolAliases: {
|
|
268
|
+
Bash: SANDBOX_BASH_TOOL_NAME,
|
|
269
|
+
Read: SANDBOX_READ_TOOL_NAME,
|
|
270
|
+
Write: SANDBOX_WRITE_TOOL_NAME,
|
|
271
|
+
Edit: SANDBOX_EDIT_TOOL_NAME,
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
: {}),
|
|
261
275
|
},
|
|
262
276
|
});
|
|
263
277
|
for await (const message of stream) {
|
|
@@ -31,10 +31,15 @@ import { type WriteStageArtifactOptions } from "../tools/writeStageArtifact.js";
|
|
|
31
31
|
import { type AskOperatorKind, type AskOperatorPrompt } from "../tools/askOperator.js";
|
|
32
32
|
import type { CloneEmitContext, ForkEmitContext } from "../types/forkChoice.js";
|
|
33
33
|
import type { PreEmitCheckOptions } from "../envelope/preEmitChecks.js";
|
|
34
|
+
import { execInSandboxContainer as defaultExecInSandboxContainer } from "../runtime/sandboxContainer.js";
|
|
34
35
|
export declare const STAGEFLOW_MCP_SERVER_NAME = "stageflow";
|
|
35
36
|
export declare const EMIT_STAGE_ENVELOPE_TOOL_NAME = "mcp__stageflow__emit_stage_envelope";
|
|
36
37
|
export declare const WRITE_STAGE_ARTIFACT_TOOL_NAME = "mcp__stageflow__write_stage_artifact";
|
|
37
38
|
export declare const ASK_OPERATOR_TOOL_NAME = "mcp__stageflow__ask_operator";
|
|
39
|
+
export declare const SANDBOX_BASH_TOOL_NAME = "mcp__stageflow__sandbox_bash";
|
|
40
|
+
export declare const SANDBOX_READ_TOOL_NAME = "mcp__stageflow__sandbox_read";
|
|
41
|
+
export declare const SANDBOX_WRITE_TOOL_NAME = "mcp__stageflow__sandbox_write";
|
|
42
|
+
export declare const SANDBOX_EDIT_TOOL_NAME = "mcp__stageflow__sandbox_edit";
|
|
38
43
|
export type AskOperatorCapture = {
|
|
39
44
|
prompt?: AskOperatorPrompt;
|
|
40
45
|
};
|
|
@@ -72,6 +77,12 @@ export declare const writeStageArtifactShape: {
|
|
|
72
77
|
path: z.ZodString;
|
|
73
78
|
content: z.ZodString;
|
|
74
79
|
};
|
|
80
|
+
type SandboxExecFn = typeof defaultExecInSandboxContainer;
|
|
81
|
+
export type SandboxBashOptions = {
|
|
82
|
+
containerName: string;
|
|
83
|
+
dockerBin?: string;
|
|
84
|
+
execute?: SandboxExecFn;
|
|
85
|
+
};
|
|
75
86
|
export declare function buildStageflowMcpServer(options: {
|
|
76
87
|
capture: EmitCapture;
|
|
77
88
|
payloadSchema?: unknown;
|
|
@@ -83,4 +94,6 @@ export declare function buildStageflowMcpServer(options: {
|
|
|
83
94
|
capture: AskOperatorCapture;
|
|
84
95
|
allowedKinds?: readonly AskOperatorKind[];
|
|
85
96
|
};
|
|
97
|
+
sandboxBash?: SandboxBashOptions;
|
|
86
98
|
}): McpServerConfig;
|
|
99
|
+
export {};
|
|
@@ -29,10 +29,16 @@ import { z } from "zod";
|
|
|
29
29
|
import { createEmitStageEnvelopeTool, } from "../tools/emitStageEnvelope.js";
|
|
30
30
|
import { createWriteStageArtifactTool, } from "../tools/writeStageArtifact.js";
|
|
31
31
|
import { ASK_OPERATOR_KINDS, AskOperatorError, normalizePromptIds, parseAskOperatorParams, } from "../tools/askOperator.js";
|
|
32
|
+
import { execInSandboxContainer as defaultExecInSandboxContainer } from "../runtime/sandboxContainer.js";
|
|
33
|
+
import { editSandboxFile, formatSandboxBashResult, readSandboxFile, sliceSandboxLines, writeSandboxFile, } from "../runtime/sandboxFiles.js";
|
|
32
34
|
export const STAGEFLOW_MCP_SERVER_NAME = "stageflow";
|
|
33
35
|
export const EMIT_STAGE_ENVELOPE_TOOL_NAME = `mcp__${STAGEFLOW_MCP_SERVER_NAME}__emit_stage_envelope`;
|
|
34
36
|
export const WRITE_STAGE_ARTIFACT_TOOL_NAME = `mcp__${STAGEFLOW_MCP_SERVER_NAME}__write_stage_artifact`;
|
|
35
37
|
export const ASK_OPERATOR_TOOL_NAME = `mcp__${STAGEFLOW_MCP_SERVER_NAME}__ask_operator`;
|
|
38
|
+
export const SANDBOX_BASH_TOOL_NAME = `mcp__${STAGEFLOW_MCP_SERVER_NAME}__sandbox_bash`;
|
|
39
|
+
export const SANDBOX_READ_TOOL_NAME = `mcp__${STAGEFLOW_MCP_SERVER_NAME}__sandbox_read`;
|
|
40
|
+
export const SANDBOX_WRITE_TOOL_NAME = `mcp__${STAGEFLOW_MCP_SERVER_NAME}__sandbox_write`;
|
|
41
|
+
export const SANDBOX_EDIT_TOOL_NAME = `mcp__${STAGEFLOW_MCP_SERVER_NAME}__sandbox_edit`;
|
|
36
42
|
/**
|
|
37
43
|
* Exported so a parity test can assert this stays field-for-field aligned
|
|
38
44
|
* with `tools/emitStageEnvelope.ts`'s typebox schema — the two are hand
|
|
@@ -74,6 +80,9 @@ const askOperatorShape = {
|
|
|
74
80
|
questions: z.array(z.record(z.string(), z.unknown())).optional(),
|
|
75
81
|
artifacts: z.array(z.string()).optional(),
|
|
76
82
|
};
|
|
83
|
+
const sandboxBashShape = {
|
|
84
|
+
command: z.string().min(1),
|
|
85
|
+
};
|
|
77
86
|
/** Adapts a `{content, details?, isError?, terminate?}` tool result to plain MCP CallToolResult. */
|
|
78
87
|
function toCallToolResult(result) {
|
|
79
88
|
return {
|
|
@@ -111,6 +120,92 @@ function buildAskOperatorTool(capture, allowedKinds) {
|
|
|
111
120
|
};
|
|
112
121
|
});
|
|
113
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* When a sandbox container is set, Read/Write/Edit/Bash are aliased onto
|
|
125
|
+
* these tools. They run against the attempt desk at /workspace. Other stage
|
|
126
|
+
* tools stay on the host.
|
|
127
|
+
*/
|
|
128
|
+
function buildSandboxBashTool(options) {
|
|
129
|
+
const execute = options.execute ?? defaultExecInSandboxContainer;
|
|
130
|
+
return tool("sandbox_bash", "Run a shell command inside this stage attempt's sandbox. The working directory is /workspace. The host filesystem is not mounted.", sandboxBashShape, async (args) => {
|
|
131
|
+
let result;
|
|
132
|
+
try {
|
|
133
|
+
result = await execute({
|
|
134
|
+
containerName: options.containerName,
|
|
135
|
+
command: args.command,
|
|
136
|
+
dockerBin: options.dockerBin,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: "text", text: `sandbox_bash could not run: ${message}` }],
|
|
143
|
+
isError: true,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
content: [{ type: "text", text: formatSandboxBashResult(result) }],
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const sandboxReadShape = {
|
|
152
|
+
file_path: z.string().min(1),
|
|
153
|
+
offset: z.number().optional(),
|
|
154
|
+
limit: z.number().optional(),
|
|
155
|
+
};
|
|
156
|
+
const sandboxWriteShape = {
|
|
157
|
+
file_path: z.string().min(1),
|
|
158
|
+
content: z.string(),
|
|
159
|
+
};
|
|
160
|
+
const sandboxEditShape = {
|
|
161
|
+
file_path: z.string().min(1),
|
|
162
|
+
old_string: z.string(),
|
|
163
|
+
new_string: z.string(),
|
|
164
|
+
};
|
|
165
|
+
function sandboxFileError(err, label) {
|
|
166
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
167
|
+
return {
|
|
168
|
+
content: [{ type: "text", text: `${label} could not run: ${message}` }],
|
|
169
|
+
isError: true,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function buildSandboxReadTool(options) {
|
|
173
|
+
return tool("sandbox_read", "Read a file inside this stage attempt's sandbox. Paths are relative to /workspace or absolute under /workspace. Host paths are rejected.", sandboxReadShape, async (args) => {
|
|
174
|
+
try {
|
|
175
|
+
const text = await readSandboxFile(options, args.file_path);
|
|
176
|
+
return {
|
|
177
|
+
content: [{ type: "text", text: sliceSandboxLines(text, args.offset, args.limit) }],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
return sandboxFileError(err, "sandbox_read");
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
function buildSandboxWriteTool(options) {
|
|
186
|
+
return tool("sandbox_write", "Write a file inside this stage attempt's sandbox. Paths are relative to /workspace or absolute under /workspace. Host paths are rejected.", sandboxWriteShape, async (args) => {
|
|
187
|
+
try {
|
|
188
|
+
await writeSandboxFile(options, args.file_path, args.content);
|
|
189
|
+
return { content: [{ type: "text", text: `Successfully wrote to ${args.file_path}` }] };
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
return sandboxFileError(err, "sandbox_write");
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
function buildSandboxEditTool(options) {
|
|
197
|
+
return tool("sandbox_edit", "Edit a file inside this stage attempt's sandbox. old_string must match exactly once. Paths stay under /workspace.", sandboxEditShape, async (args) => {
|
|
198
|
+
try {
|
|
199
|
+
await editSandboxFile(options, args.file_path, [
|
|
200
|
+
{ oldText: args.old_string, newText: args.new_string },
|
|
201
|
+
]);
|
|
202
|
+
return { content: [{ type: "text", text: `Successfully edited ${args.file_path}` }] };
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
return sandboxFileError(err, "sandbox_edit");
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
114
209
|
export function buildStageflowMcpServer(options) {
|
|
115
210
|
const emitDef = createEmitStageEnvelopeTool(options.capture, options.payloadSchema, options.forkEmitContext, options.cloneEmitContext, options.preEmitCheckOptions);
|
|
116
211
|
const artifactDef = createWriteStageArtifactTool(options.writeStageArtifact);
|
|
@@ -123,6 +218,9 @@ export function buildStageflowMcpServer(options) {
|
|
|
123
218
|
if (options.askOperator) {
|
|
124
219
|
tools.push(buildAskOperatorTool(options.askOperator.capture, options.askOperator.allowedKinds));
|
|
125
220
|
}
|
|
221
|
+
if (options.sandboxBash) {
|
|
222
|
+
tools.push(buildSandboxReadTool(options.sandboxBash), buildSandboxWriteTool(options.sandboxBash), buildSandboxEditTool(options.sandboxBash), buildSandboxBashTool(options.sandboxBash));
|
|
223
|
+
}
|
|
126
224
|
return createSdkMcpServer({
|
|
127
225
|
name: STAGEFLOW_MCP_SERVER_NAME,
|
|
128
226
|
tools,
|
package/dist/agent/piAdapter.js
CHANGED
|
@@ -48,6 +48,7 @@ import { createEmitStageEnvelopeTool, } from "../tools/emitStageEnvelope.js";
|
|
|
48
48
|
import { remindUntilEmitted } from "./stageTurnDriver.js";
|
|
49
49
|
import { createAskOperatorTool, } from "../tools/askOperator.js";
|
|
50
50
|
import { createWriteStageArtifactTool } from "../tools/writeStageArtifact.js";
|
|
51
|
+
import { createSandboxBashTool, createSandboxEditTool, createSandboxReadTool, createSandboxWriteTool, } from "./sandboxPiTools.js";
|
|
51
52
|
import { createSendEmailTool } from "../tools/sendEmail.js";
|
|
52
53
|
import { createSendMessageTool } from "../tools/sendMessage.js";
|
|
53
54
|
import { createMemoryStoreBashDenyExtension } from "../memory/bashDeny.js";
|
|
@@ -462,6 +463,7 @@ function buildUserPrompt(input, emitToolName, emitHintOverride, artifactToolName
|
|
|
462
463
|
}
|
|
463
464
|
const attempt = input.roots.attempt ?? 1;
|
|
464
465
|
const attemptArtifactsPath = `stages/${runtimeStageId(input)}/attempts/${attempt}/artifacts/`;
|
|
466
|
+
const sandboxed = input.roots.containerName !== undefined;
|
|
465
467
|
const skillBaseDirs = [
|
|
466
468
|
input.stage.skill !== undefined && input.skillFilePath !== undefined
|
|
467
469
|
? path.dirname(input.skillFilePath)
|
|
@@ -476,25 +478,36 @@ function buildUserPrompt(input, emitToolName, emitHintOverride, artifactToolName
|
|
|
476
478
|
(input.stage.email?.length ?? 0) > 0
|
|
477
479
|
? `Large tool results are saved automatically as stage artifacts under ${attemptArtifactsPath}${TOOL_OUTPUT_ARTIFACT_DIR}/. The tool result names the path. Read or grep that file; do not copy tool output into ${artifactToolName ?? "an artifact"}.`
|
|
478
480
|
: "";
|
|
479
|
-
const artifactGuidance =
|
|
481
|
+
const artifactGuidance = sandboxed
|
|
480
482
|
? [
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
]
|
|
490
|
-
|
|
491
|
-
.join("\n")
|
|
492
|
-
: artifactToolName !== undefined
|
|
483
|
+
"Sandbox desk: /workspace. read, bash, write, and edit run only inside that desk.",
|
|
484
|
+
input.roots.checkoutRoot
|
|
485
|
+
? `Checkout: /workspace/checkout. The host checkout path is not mounted.`
|
|
486
|
+
: "This stage has no checkout. Scratch files go in /workspace/work.",
|
|
487
|
+
"Attempt artifacts are at /workspace/artifacts. Scratch space is /workspace/work.",
|
|
488
|
+
artifactToolName !== undefined
|
|
489
|
+
? `Create factory stage artifacts only with ${artifactToolName} (path relative to ${attemptArtifactsPath}). Use the returned run-relative path in ${emitToolName}.`
|
|
490
|
+
: `Create factory stage artifacts under ${attemptArtifactsPath} relative to the run folder.`,
|
|
491
|
+
].join("\n")
|
|
492
|
+
: input.roots.mode === "bound" && artifactToolName !== undefined
|
|
493
493
|
? [
|
|
494
|
-
`
|
|
494
|
+
`Project checkout (agent cwd): ${input.roots.checkoutRoot}`,
|
|
495
|
+
`Factory run workspace: ${input.roots.runWorkspaceDir}`,
|
|
496
|
+
`Builtin tools (read, bash, write, edit) are for work inside the project checkout.`,
|
|
497
|
+
skillBaseDir !== undefined
|
|
498
|
+
? `Absolute read and bash against ${skillBaseDir} are in bounds for the linked skill.`
|
|
499
|
+
: undefined,
|
|
500
|
+
`Create factory stage artifacts only with ${artifactToolName} (path relative to ${attemptArtifactsPath}). Use the returned run-relative path in ${emitToolName}.`,
|
|
495
501
|
`This is soft guidance: prefer ${artifactToolName} for factory files under the run workspace; do not rely on relative builtin writes for those files.`,
|
|
496
|
-
]
|
|
497
|
-
|
|
502
|
+
]
|
|
503
|
+
.filter(Boolean)
|
|
504
|
+
.join("\n")
|
|
505
|
+
: artifactToolName !== undefined
|
|
506
|
+
? [
|
|
507
|
+
`Create factory stage artifacts with ${artifactToolName} (path relative to ${attemptArtifactsPath}). Use the returned run-relative path in ${emitToolName}.`,
|
|
508
|
+
`This is soft guidance: prefer ${artifactToolName} for factory files under the run workspace; do not rely on relative builtin writes for those files.`,
|
|
509
|
+
].join("\n")
|
|
510
|
+
: `Create factory stage artifacts under ${attemptArtifactsPath} relative to the run folder.`;
|
|
498
511
|
return [
|
|
499
512
|
...(input.stage.channels?.length ? [
|
|
500
513
|
`Stageflow channels: ${JSON.stringify(input.stage.channels.map(channel => ({ channel: channel.alias ?? channel.connection, operations: channel.operations })))}. Use send_message with the channel name. Recipients are fixed by configuration and you cannot change them. Use a stable operationKey for each intended message. Provider acceptance does not prove delivery. Never resend an unknown submission automatically.`,
|
|
@@ -1041,6 +1054,15 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
|
|
|
1041
1054
|
tools.push(definition.name);
|
|
1042
1055
|
}
|
|
1043
1056
|
}
|
|
1057
|
+
if (roots.containerName !== undefined) {
|
|
1058
|
+
const sandboxTools = {
|
|
1059
|
+
containerName: roots.containerName,
|
|
1060
|
+
...(roots.containerDockerBin !== undefined
|
|
1061
|
+
? { dockerBin: roots.containerDockerBin }
|
|
1062
|
+
: {}),
|
|
1063
|
+
};
|
|
1064
|
+
customTools.push(defineTool(createSandboxReadTool(sandboxTools)), defineTool(createSandboxWriteTool(sandboxTools)), defineTool(createSandboxEditTool(sandboxTools)), defineTool(createSandboxBashTool(sandboxTools)));
|
|
1065
|
+
}
|
|
1044
1066
|
return {
|
|
1045
1067
|
sessionManager,
|
|
1046
1068
|
modelRuntime,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { type SandboxFileTarget } from "../runtime/sandboxFiles.js";
|
|
3
|
+
export type SandboxPiToolOptions = SandboxFileTarget;
|
|
4
|
+
export declare function createSandboxReadTool(options: SandboxPiToolOptions): {
|
|
5
|
+
name: string;
|
|
6
|
+
label: string;
|
|
7
|
+
description: string;
|
|
8
|
+
promptSnippet: string;
|
|
9
|
+
promptGuidelines: string[];
|
|
10
|
+
parameters: Type.TObject<{
|
|
11
|
+
path: Type.TString;
|
|
12
|
+
offset: Type.TOptional<Type.TNumber>;
|
|
13
|
+
limit: Type.TOptional<Type.TNumber>;
|
|
14
|
+
}>;
|
|
15
|
+
execute: (_toolCallId: string, params: {
|
|
16
|
+
path: string;
|
|
17
|
+
offset?: number;
|
|
18
|
+
limit?: number;
|
|
19
|
+
}) => Promise<{
|
|
20
|
+
content: {
|
|
21
|
+
type: "text";
|
|
22
|
+
text: string;
|
|
23
|
+
}[];
|
|
24
|
+
details: undefined;
|
|
25
|
+
isError?: boolean | undefined;
|
|
26
|
+
}>;
|
|
27
|
+
};
|
|
28
|
+
export declare function createSandboxWriteTool(options: SandboxPiToolOptions): {
|
|
29
|
+
name: string;
|
|
30
|
+
label: string;
|
|
31
|
+
description: string;
|
|
32
|
+
promptSnippet: string;
|
|
33
|
+
promptGuidelines: string[];
|
|
34
|
+
parameters: Type.TObject<{
|
|
35
|
+
path: Type.TString;
|
|
36
|
+
content: Type.TString;
|
|
37
|
+
}>;
|
|
38
|
+
execute: (_toolCallId: string, params: {
|
|
39
|
+
path: string;
|
|
40
|
+
content: string;
|
|
41
|
+
}) => Promise<{
|
|
42
|
+
content: {
|
|
43
|
+
type: "text";
|
|
44
|
+
text: string;
|
|
45
|
+
}[];
|
|
46
|
+
details: undefined;
|
|
47
|
+
isError?: boolean | undefined;
|
|
48
|
+
}>;
|
|
49
|
+
};
|
|
50
|
+
export declare function createSandboxEditTool(options: SandboxPiToolOptions): {
|
|
51
|
+
name: string;
|
|
52
|
+
label: string;
|
|
53
|
+
description: string;
|
|
54
|
+
promptSnippet: string;
|
|
55
|
+
promptGuidelines: string[];
|
|
56
|
+
parameters: Type.TObject<{
|
|
57
|
+
path: Type.TString;
|
|
58
|
+
edits: Type.TArray<Type.TObject<{
|
|
59
|
+
oldText: Type.TString;
|
|
60
|
+
newText: Type.TString;
|
|
61
|
+
}>>;
|
|
62
|
+
}>;
|
|
63
|
+
execute: (_toolCallId: string, params: {
|
|
64
|
+
path: string;
|
|
65
|
+
edits: Array<{
|
|
66
|
+
oldText: string;
|
|
67
|
+
newText: string;
|
|
68
|
+
}>;
|
|
69
|
+
}) => Promise<{
|
|
70
|
+
content: {
|
|
71
|
+
type: "text";
|
|
72
|
+
text: string;
|
|
73
|
+
}[];
|
|
74
|
+
details: undefined;
|
|
75
|
+
isError?: boolean | undefined;
|
|
76
|
+
}>;
|
|
77
|
+
};
|
|
78
|
+
export declare function createSandboxBashTool(options: SandboxPiToolOptions): {
|
|
79
|
+
name: string;
|
|
80
|
+
label: string;
|
|
81
|
+
description: string;
|
|
82
|
+
promptSnippet: string;
|
|
83
|
+
promptGuidelines: string[];
|
|
84
|
+
parameters: Type.TObject<{
|
|
85
|
+
command: Type.TString;
|
|
86
|
+
timeout: Type.TOptional<Type.TNumber>;
|
|
87
|
+
}>;
|
|
88
|
+
execute: (_toolCallId: string, params: {
|
|
89
|
+
command: string;
|
|
90
|
+
timeout?: number;
|
|
91
|
+
}) => Promise<{
|
|
92
|
+
content: {
|
|
93
|
+
type: "text";
|
|
94
|
+
text: string;
|
|
95
|
+
}[];
|
|
96
|
+
details: undefined;
|
|
97
|
+
isError?: boolean | undefined;
|
|
98
|
+
}>;
|
|
99
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { editSandboxFile, readSandboxFile, runSandboxBash, sliceSandboxLines, writeSandboxFile, } from "../runtime/sandboxFiles.js";
|
|
3
|
+
function textResult(text, isError = false) {
|
|
4
|
+
return {
|
|
5
|
+
content: [{ type: "text", text }],
|
|
6
|
+
details: undefined,
|
|
7
|
+
...(isError ? { isError: true } : {}),
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function failure(err) {
|
|
11
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
12
|
+
return textResult(message, true);
|
|
13
|
+
}
|
|
14
|
+
export function createSandboxReadTool(options) {
|
|
15
|
+
return {
|
|
16
|
+
name: "read",
|
|
17
|
+
label: "read",
|
|
18
|
+
description: "Read a file inside this stage's sandbox. Paths are relative to /workspace (artifacts/, checkout/, grants/, work/) or absolute under /workspace. Host paths are rejected.",
|
|
19
|
+
promptSnippet: "Read file contents inside /workspace",
|
|
20
|
+
promptGuidelines: [
|
|
21
|
+
"Use read to examine files under /workspace instead of cat or sed.",
|
|
22
|
+
],
|
|
23
|
+
parameters: Type.Object({
|
|
24
|
+
path: Type.String({ description: "Path to the file to read, relative to /workspace or absolute under /workspace" }),
|
|
25
|
+
offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
|
|
26
|
+
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
|
|
27
|
+
}),
|
|
28
|
+
execute: async (_toolCallId, params) => {
|
|
29
|
+
try {
|
|
30
|
+
const text = await readSandboxFile(options, params.path);
|
|
31
|
+
return textResult(sliceSandboxLines(text, params.offset, params.limit));
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
return failure(err);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function createSandboxWriteTool(options) {
|
|
40
|
+
return {
|
|
41
|
+
name: "write",
|
|
42
|
+
label: "write",
|
|
43
|
+
description: "Write a file inside this stage's sandbox. Paths are relative to /workspace or absolute under /workspace. Parent directories are created. Host paths are rejected.",
|
|
44
|
+
promptSnippet: "Create or overwrite files inside /workspace",
|
|
45
|
+
promptGuidelines: ["Use write only for new files or complete rewrites under /workspace."],
|
|
46
|
+
parameters: Type.Object({
|
|
47
|
+
path: Type.String({ description: "Path to the file to write, relative to /workspace or absolute under /workspace" }),
|
|
48
|
+
content: Type.String({ description: "Content to write to the file" }),
|
|
49
|
+
}),
|
|
50
|
+
execute: async (_toolCallId, params) => {
|
|
51
|
+
try {
|
|
52
|
+
await writeSandboxFile(options, params.path, params.content);
|
|
53
|
+
return textResult(`Successfully wrote to ${params.path}`);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
return failure(err);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function createSandboxEditTool(options) {
|
|
62
|
+
return {
|
|
63
|
+
name: "edit",
|
|
64
|
+
label: "edit",
|
|
65
|
+
description: "Edit a file inside this stage's sandbox with exact text replacement. Paths are relative to /workspace or absolute under /workspace. Each oldText must match exactly once in the original file.",
|
|
66
|
+
promptSnippet: "Make precise file edits inside /workspace",
|
|
67
|
+
promptGuidelines: [
|
|
68
|
+
"Use edit for precise changes under /workspace (edits[].oldText must match exactly once).",
|
|
69
|
+
],
|
|
70
|
+
parameters: Type.Object({
|
|
71
|
+
path: Type.String({ description: "Path to the file to edit, relative to /workspace or absolute under /workspace" }),
|
|
72
|
+
edits: Type.Array(Type.Object({
|
|
73
|
+
oldText: Type.String({ description: "Exact text to replace. It must be unique in the original file." }),
|
|
74
|
+
newText: Type.String({ description: "Replacement text." }),
|
|
75
|
+
}), { description: "One or more targeted replacements against the original file." }),
|
|
76
|
+
}),
|
|
77
|
+
execute: async (_toolCallId, params) => {
|
|
78
|
+
try {
|
|
79
|
+
await editSandboxFile(options, params.path, params.edits);
|
|
80
|
+
return textResult(`Successfully edited ${params.path}`);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
return failure(err);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function createSandboxBashTool(options) {
|
|
89
|
+
return {
|
|
90
|
+
name: "bash",
|
|
91
|
+
label: "bash",
|
|
92
|
+
description: "Execute a bash command in this stage's sandbox. The working directory is /workspace. The host filesystem is not mounted.",
|
|
93
|
+
promptSnippet: "Execute bash commands in /workspace",
|
|
94
|
+
promptGuidelines: ["The shell starts in /workspace. Do not use host paths."],
|
|
95
|
+
parameters: Type.Object({
|
|
96
|
+
command: Type.String({ description: "Shell command to execute" }),
|
|
97
|
+
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional)" })),
|
|
98
|
+
}),
|
|
99
|
+
execute: async (_toolCallId, params) => {
|
|
100
|
+
try {
|
|
101
|
+
const text = await runSandboxBash(options, params.command, params.timeout !== undefined ? params.timeout * 1000 : undefined);
|
|
102
|
+
return textResult(text);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return failure(err);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -56,6 +56,8 @@ export const KNOWN_STAGEFLOW_ENV_KEYS = new Set([
|
|
|
56
56
|
"STAGEFLOW_MAX_ACTIVE_STAGES_PER_RUN",
|
|
57
57
|
"STAGEFLOW_MAX_ACTIVE_STAGE_PROCESSES",
|
|
58
58
|
"STAGEFLOW_STAGE_EXECUTION",
|
|
59
|
+
"STAGEFLOW_STAGE_CONTAINER_IMAGE",
|
|
60
|
+
"STAGEFLOW_STAGE_CONTAINER_DOCKER_BIN",
|
|
59
61
|
"STAGEFLOW_STAGE_ENV_ALLOW",
|
|
60
62
|
"STAGEFLOW_STAGE_ENV_PASSTHROUGH",
|
|
61
63
|
"STAGEFLOW_GC_INTERVAL_MS",
|
package/dist/config/loadTask.js
CHANGED
|
@@ -6,6 +6,16 @@ import { yamlParsePosition } from "./yamlParsePosition.js";
|
|
|
6
6
|
function isPlainObject(value) {
|
|
7
7
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8
8
|
}
|
|
9
|
+
function parseCheckout(value) {
|
|
10
|
+
if (typeof value === "string")
|
|
11
|
+
return value;
|
|
12
|
+
if (isPlainObject(value)) {
|
|
13
|
+
const branch = typeof value.branch === "string" ? value.branch : undefined;
|
|
14
|
+
const base = typeof value.base === "string" ? value.base : undefined;
|
|
15
|
+
return { ...(branch !== undefined ? { branch } : {}), ...(base !== undefined ? { base } : {}) };
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
9
19
|
function parseGitIdentity(raw) {
|
|
10
20
|
if (!isPlainObject(raw))
|
|
11
21
|
return undefined;
|
|
@@ -30,7 +40,7 @@ export function coerceTaskFile(raw) {
|
|
|
30
40
|
goal: record.goal,
|
|
31
41
|
context: typeof record.context === "string" ? record.context : undefined,
|
|
32
42
|
constraints: typeof record.constraints === "string" ? record.constraints : undefined,
|
|
33
|
-
checkout:
|
|
43
|
+
checkout: parseCheckout(record.checkout),
|
|
34
44
|
...(typeof record.repository === "string" ? { repository: record.repository } : {}),
|
|
35
45
|
...(typeof record.ref === "string" ? { ref: record.ref } : {}),
|
|
36
46
|
...(typeof record.run_branch_template === "string"
|
|
@@ -73,6 +83,29 @@ export function parseTaskFile(raw, source = "task") {
|
|
|
73
83
|
return { ...issue, taskId: task.id };
|
|
74
84
|
}));
|
|
75
85
|
}
|
|
86
|
+
if (isPlainObject(record?.checkout)) {
|
|
87
|
+
const { branch, base } = record.checkout;
|
|
88
|
+
if (branch !== undefined && typeof branch !== "string") {
|
|
89
|
+
return loadFailure([
|
|
90
|
+
{
|
|
91
|
+
code: "task.invalid_shape",
|
|
92
|
+
message: `Invalid ${source}: checkout.branch must be a string`,
|
|
93
|
+
category: "task",
|
|
94
|
+
taskId: task.id,
|
|
95
|
+
},
|
|
96
|
+
]);
|
|
97
|
+
}
|
|
98
|
+
if (base !== undefined && typeof base !== "string") {
|
|
99
|
+
return loadFailure([
|
|
100
|
+
{
|
|
101
|
+
code: "task.invalid_shape",
|
|
102
|
+
message: `Invalid ${source}: checkout.base must be a string`,
|
|
103
|
+
category: "task",
|
|
104
|
+
taskId: task.id,
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
76
109
|
return loadSuccess(task);
|
|
77
110
|
}
|
|
78
111
|
export function loadTaskFromYamlOutcome(yamlText, source = "task yaml") {
|