@tt-a1i/openpi 0.1.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 +643 -0
- package/SETUP.md +74 -0
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-package.png +0 -0
- package/assets/readme-hero-mobile.svg +72 -0
- package/assets/readme-hero.svg +118 -0
- package/assets/readme-runtime-mobile.svg +91 -0
- package/assets/readme-runtime.svg +111 -0
- package/extensions/ask-user/handoff.ts +205 -0
- package/extensions/ask-user/index.ts +1110 -0
- package/extensions/ask-user/limits.ts +89 -0
- package/extensions/ask-user/prompt.ts +76 -0
- package/extensions/background-terminals/index.ts +653 -0
- package/extensions/background-terminals/src/domain.ts +99 -0
- package/extensions/background-terminals/src/manager.ts +989 -0
- package/extensions/background-terminals/src/output.ts +84 -0
- package/extensions/background-terminals/src/prompt.ts +195 -0
- package/extensions/background-terminals/src/result-delivery.ts +43 -0
- package/extensions/background-terminals/src/runtime.ts +36 -0
- package/extensions/background-terminals/src/ui/output-view.ts +55 -0
- package/extensions/background-terminals/src/ui/ps.ts +642 -0
- package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
- package/extensions/background-terminals/src/watch.ts +192 -0
- package/extensions/context-pivot/index.ts +222 -0
- package/extensions/copy-all/index.ts +65 -0
- package/extensions/cron/index.ts +173 -0
- package/extensions/cron/schedule.ts +127 -0
- package/extensions/file-mutation-display/index.ts +105 -0
- package/extensions/file-mutation-display/render.ts +107 -0
- package/extensions/file-search/index.ts +515 -0
- package/extensions/file-search/src/args.ts +129 -0
- package/extensions/file-search/src/binaries.ts +419 -0
- package/extensions/file-search/src/output.ts +142 -0
- package/extensions/file-search/src/process.ts +309 -0
- package/extensions/file-search/src/prompt.ts +53 -0
- package/extensions/git-info/index.ts +272 -0
- package/extensions/git-info/src/changed-files-view.ts +414 -0
- package/extensions/git-info/src/process.ts +107 -0
- package/extensions/git-info/src/refresh-coordinator.ts +13 -0
- package/extensions/git-info/src/runtime.ts +28 -0
- package/extensions/goal/controller.ts +794 -0
- package/extensions/goal/index.ts +521 -0
- package/extensions/goal/prompts.ts +122 -0
- package/extensions/goal/state.ts +763 -0
- package/extensions/goal/ui.ts +158 -0
- package/extensions/model-info/index.ts +234 -0
- package/extensions/plan-mode/bash-policy.ts +313 -0
- package/extensions/plan-mode/index.ts +539 -0
- package/extensions/post-edit/index.ts +129 -0
- package/extensions/sessions/LICENSE.upstream +21 -0
- package/extensions/sessions/git-stats.ts +226 -0
- package/extensions/sessions/index.ts +1092 -0
- package/extensions/sessions/sessions.ts +385 -0
- package/extensions/setup/index.ts +408 -0
- package/extensions/shared/activity-status.ts +65 -0
- package/extensions/shared/below-editor-navigation.ts +343 -0
- package/extensions/shared/child-session.ts +352 -0
- package/extensions/shared/context-utilization.ts +47 -0
- package/extensions/shared/dashboard-state.ts +102 -0
- package/extensions/shared/plan-mode-state.ts +65 -0
- package/extensions/shared/setup-config.ts +971 -0
- package/extensions/shared/subagent-roles.ts +22 -0
- package/extensions/shared/terminal-text.ts +38 -0
- package/extensions/shared/tool-call-timeout.ts +104 -0
- package/extensions/shared/worktree.ts +526 -0
- package/extensions/subagents/index.ts +1225 -0
- package/extensions/subagents/navigation.ts +121 -0
- package/extensions/subagents/src/agent-types.ts +543 -0
- package/extensions/subagents/src/backend.ts +63 -0
- package/extensions/subagents/src/backends/pi.ts +493 -0
- package/extensions/subagents/src/backends/stub.ts +296 -0
- package/extensions/subagents/src/by-the-way.ts +21 -0
- package/extensions/subagents/src/domain.ts +271 -0
- package/extensions/subagents/src/format.ts +48 -0
- package/extensions/subagents/src/manager.ts +769 -0
- package/extensions/subagents/src/prompt.ts +190 -0
- package/extensions/subagents/src/result-delivery.ts +20 -0
- package/extensions/subagents/src/runtime.ts +51 -0
- package/extensions/subagents/src/ui/takeover.ts +615 -0
- package/extensions/subagents/src/ui/transcript.ts +293 -0
- package/extensions/subagents/src/ui/wait-result.ts +89 -0
- package/extensions/suggestions/index.ts +172 -0
- package/extensions/suggestions/src/config.ts +12 -0
- package/extensions/suggestions/src/predictor.ts +147 -0
- package/extensions/suggestions/src/prompt.ts +20 -0
- package/extensions/suggestions/src/transcript.ts +233 -0
- package/extensions/suggestions/src/ui.ts +224 -0
- package/extensions/tasks/index.ts +512 -0
- package/extensions/tasks/tasks.ts +649 -0
- package/extensions/tasks/ui.ts +421 -0
- package/extensions/turn-time/index.ts +61 -0
- package/extensions/ui-customization/footer.ts +512 -0
- package/extensions/ui-customization/index.ts +217 -0
- package/extensions/workflows/acceptance.ts +298 -0
- package/extensions/workflows/artifacts.ts +225 -0
- package/extensions/workflows/controller.ts +210 -0
- package/extensions/workflows/dashboard.ts +1226 -0
- package/extensions/workflows/index.ts +1884 -0
- package/extensions/workflows/journal.ts +188 -0
- package/extensions/workflows/meta.ts +250 -0
- package/extensions/workflows/model.ts +423 -0
- package/extensions/workflows/navigation.ts +93 -0
- package/extensions/workflows/prompt.ts +212 -0
- package/extensions/workflows/replay-safety.ts +577 -0
- package/extensions/workflows/runner.ts +786 -0
- package/extensions/workflows/sandbox-child.cjs +402 -0
- package/extensions/workflows/sandbox.ts +397 -0
- package/extensions/workflows/serialization.ts +162 -0
- package/extensions/workflows/worktree-handoff.ts +216 -0
- package/package.json +87 -0
- package/scripts/prepare-effect-tsgo.mjs +16 -0
- package/skills/background-terminals/SKILL.md +30 -0
- package/skills/subagents/SKILL.md +15 -0
- package/themes/github-dark-default.json +89 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { MAX_WORKFLOW_AGENT_CALLS } from "../shared/setup-config.ts";
|
|
6
|
+
import { safeStringify, toSerializable } from "./serialization.ts";
|
|
7
|
+
|
|
8
|
+
const MAX_SOURCE_BYTES = 512 * 1024;
|
|
9
|
+
const MAX_ARGS_BYTES = 256 * 1024;
|
|
10
|
+
const MAX_RESULT_BYTES = 1024 * 1024;
|
|
11
|
+
const MAX_AGENT_MESSAGE_BYTES = 512 * 1024;
|
|
12
|
+
/** A narrator line is one terminal row; the host bounds it again on arrival. */
|
|
13
|
+
const MAX_LOG_MESSAGE_BYTES = 8 * 1024;
|
|
14
|
+
/**
|
|
15
|
+
* The sandbox's hard agent-request cap sits this many calls ABOVE the
|
|
16
|
+
* controller's graceful budget, so the controller rejects the (budget+1)th
|
|
17
|
+
* agent() into the script (as { ok:false }) before the sandbox would fatally
|
|
18
|
+
* kill the run. Bounded so the backstop still stops a runaway child that
|
|
19
|
+
* bypasses the controller entirely.
|
|
20
|
+
*/
|
|
21
|
+
export const AGENT_CALL_BACKSTOP_MARGIN = 8;
|
|
22
|
+
|
|
23
|
+
export interface SandboxAgentOptions {
|
|
24
|
+
agent_type?: unknown;
|
|
25
|
+
label?: unknown;
|
|
26
|
+
phase?: unknown;
|
|
27
|
+
schema?: unknown;
|
|
28
|
+
acceptance?: unknown;
|
|
29
|
+
model?: unknown;
|
|
30
|
+
provider?: unknown;
|
|
31
|
+
effort?: unknown;
|
|
32
|
+
isolation?: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SandboxAgentResult {
|
|
36
|
+
ok: boolean;
|
|
37
|
+
output: string;
|
|
38
|
+
structured?: unknown;
|
|
39
|
+
error?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RunWorkflowSandboxOptions {
|
|
43
|
+
source: string;
|
|
44
|
+
args: unknown;
|
|
45
|
+
cwd: string;
|
|
46
|
+
signal: AbortSignal;
|
|
47
|
+
onAgent: (
|
|
48
|
+
prompt: string,
|
|
49
|
+
options: SandboxAgentOptions,
|
|
50
|
+
signal: AbortSignal,
|
|
51
|
+
) => Promise<SandboxAgentResult>;
|
|
52
|
+
onPhase: (title: string) => void;
|
|
53
|
+
onLog: (text: string) => void;
|
|
54
|
+
/**
|
|
55
|
+
* Cumulative run usage, read at send time so the child's `usage()` reflects
|
|
56
|
+
* the agent that just settled rather than a value captured at launch.
|
|
57
|
+
*/
|
|
58
|
+
usageSnapshot: () => unknown;
|
|
59
|
+
maxConcurrency: number;
|
|
60
|
+
/** Same budget the controller enforces; the sandbox is the outer guard. */
|
|
61
|
+
maxAgentCalls: number;
|
|
62
|
+
/**
|
|
63
|
+
* Replayable results available to this run. A replayed call costs no
|
|
64
|
+
* controller budget but still sends one agent IPC message, so without this
|
|
65
|
+
* the backstop fires long before the controller does and kills the child
|
|
66
|
+
* mid-run — losing the aggregate that resuming exists to preserve.
|
|
67
|
+
*/
|
|
68
|
+
extraAgentRequests?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function byteLength(value: string) {
|
|
72
|
+
return Buffer.byteLength(value, "utf8");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
76
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function errorText(error: unknown) {
|
|
80
|
+
return error instanceof Error ? error.message : String(error);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function terminateChild(child: ChildProcess) {
|
|
84
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
85
|
+
child.kill("SIGTERM");
|
|
86
|
+
const force = setTimeout(() => {
|
|
87
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
88
|
+
child.kill("SIGKILL");
|
|
89
|
+
}, 1_000);
|
|
90
|
+
force.unref?.();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sanitizeAgentOptions(value: unknown): SandboxAgentOptions {
|
|
94
|
+
if (!isRecord(value)) return {};
|
|
95
|
+
return {
|
|
96
|
+
...(value.agent_type !== undefined ? { agent_type: value.agent_type } : {}),
|
|
97
|
+
...(value.label !== undefined ? { label: value.label } : {}),
|
|
98
|
+
...(value.phase !== undefined ? { phase: value.phase } : {}),
|
|
99
|
+
...(value.schema !== undefined ? { schema: value.schema } : {}),
|
|
100
|
+
...(value.acceptance !== undefined ? { acceptance: value.acceptance } : {}),
|
|
101
|
+
...(value.model !== undefined ? { model: value.model } : {}),
|
|
102
|
+
...(value.provider !== undefined ? { provider: value.provider } : {}),
|
|
103
|
+
...(value.effort !== undefined ? { effort: value.effort } : {}),
|
|
104
|
+
...(value.isolation !== undefined ? { isolation: value.isolation } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Execute orchestration code in a separate, permission-restricted Node process.
|
|
110
|
+
* The child can only invoke the narrow agent/phase IPC protocol and is always
|
|
111
|
+
* terminated on completion, cancellation, or protocol failure. The workflow
|
|
112
|
+
* itself and its agent requests have no wall-clock deadline. Active requests
|
|
113
|
+
* are aborted only when the workflow is cancelled or the sandbox is cleaned up.
|
|
114
|
+
*/
|
|
115
|
+
export function runWorkflowSandbox(options: RunWorkflowSandboxOptions) {
|
|
116
|
+
if (!process.allowedNodeEnvironmentFlags.has("--permission")) {
|
|
117
|
+
return Promise.reject(
|
|
118
|
+
new Error("This Node runtime cannot enforce workflow child permissions"),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (byteLength(options.source) > MAX_SOURCE_BYTES) {
|
|
122
|
+
return Promise.reject(
|
|
123
|
+
new Error(`Workflow script exceeds the ${MAX_SOURCE_BYTES} byte limit`),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// See AGENT_CALL_BACKSTOP_MARGIN: the sandbox hard cap sits above the
|
|
128
|
+
// controller's graceful budget so the controller rejects first and the
|
|
129
|
+
// script can still return its aggregate.
|
|
130
|
+
const controllerBudget = Math.max(
|
|
131
|
+
1,
|
|
132
|
+
Math.min(MAX_WORKFLOW_AGENT_CALLS, Math.floor(options.maxAgentCalls)),
|
|
133
|
+
);
|
|
134
|
+
const maxAgentRequests =
|
|
135
|
+
controllerBudget +
|
|
136
|
+
AGENT_CALL_BACKSTOP_MARGIN +
|
|
137
|
+
Math.max(
|
|
138
|
+
0,
|
|
139
|
+
Math.min(
|
|
140
|
+
MAX_WORKFLOW_AGENT_CALLS,
|
|
141
|
+
Math.floor(options.extraAgentRequests ?? 0),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const argsJson = safeStringify(
|
|
146
|
+
{ defined: options.args !== undefined, value: options.args },
|
|
147
|
+
{ maxBytes: MAX_ARGS_BYTES, maxDepth: 16, maxNodes: 10_000 },
|
|
148
|
+
);
|
|
149
|
+
if (byteLength(argsJson) > MAX_ARGS_BYTES) {
|
|
150
|
+
return Promise.reject(new Error("Workflow args exceed the IPC limit"));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
154
|
+
const workerPath = fileURLToPath(
|
|
155
|
+
new URL("./sandbox-child.cjs", import.meta.url),
|
|
156
|
+
);
|
|
157
|
+
const child = spawn(
|
|
158
|
+
process.execPath,
|
|
159
|
+
[
|
|
160
|
+
"--permission",
|
|
161
|
+
`--allow-fs-read=${path.dirname(workerPath)}`,
|
|
162
|
+
"--max-old-space-size=128",
|
|
163
|
+
"--stack-size=2048",
|
|
164
|
+
workerPath,
|
|
165
|
+
],
|
|
166
|
+
{
|
|
167
|
+
cwd: options.cwd,
|
|
168
|
+
env: {
|
|
169
|
+
PATH: process.env.PATH ?? "",
|
|
170
|
+
NODE_NO_WARNINGS: "1",
|
|
171
|
+
},
|
|
172
|
+
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
const token = randomBytes(24).toString("hex");
|
|
176
|
+
const requestIds = new Set<number>();
|
|
177
|
+
const activeAgentRequests = new Map<number, AbortController>();
|
|
178
|
+
let requestCount = 0;
|
|
179
|
+
let finished = false;
|
|
180
|
+
|
|
181
|
+
// The child parses this and falls back to zeros if it is ever unusable, so
|
|
182
|
+
// a broken snapshot degrades `usage()` to a zero reading instead of
|
|
183
|
+
// failing the run.
|
|
184
|
+
const usageJson = () => {
|
|
185
|
+
try {
|
|
186
|
+
return JSON.stringify(options.usageSnapshot()) ?? "{}";
|
|
187
|
+
} catch {
|
|
188
|
+
return "{}";
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const cleanup = () => {
|
|
193
|
+
for (const abortController of activeAgentRequests.values()) {
|
|
194
|
+
abortController.abort(new Error("Workflow stopped"));
|
|
195
|
+
}
|
|
196
|
+
activeAgentRequests.clear();
|
|
197
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
198
|
+
child.removeAllListeners("message");
|
|
199
|
+
child.removeAllListeners("error");
|
|
200
|
+
child.removeAllListeners("exit");
|
|
201
|
+
terminateChild(child);
|
|
202
|
+
};
|
|
203
|
+
const finish = (error?: Error, value?: unknown) => {
|
|
204
|
+
if (finished) return;
|
|
205
|
+
finished = true;
|
|
206
|
+
cleanup();
|
|
207
|
+
if (error) reject(error);
|
|
208
|
+
else resolve(value);
|
|
209
|
+
};
|
|
210
|
+
const onAbort = () => finish(new Error("Workflow was aborted"));
|
|
211
|
+
|
|
212
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
213
|
+
if (options.signal.aborted) {
|
|
214
|
+
onAbort();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
child.on("error", (error) => finish(error));
|
|
219
|
+
child.on("exit", (code, exitSignal) => {
|
|
220
|
+
if (!finished) {
|
|
221
|
+
finish(
|
|
222
|
+
new Error(
|
|
223
|
+
`Workflow sandbox exited before completion (${exitSignal ?? code ?? "unknown"})`,
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
child.on("message", (raw: unknown) => {
|
|
229
|
+
if (
|
|
230
|
+
!isRecord(raw) ||
|
|
231
|
+
raw.token !== token ||
|
|
232
|
+
typeof raw.kind !== "string"
|
|
233
|
+
) {
|
|
234
|
+
finish(new Error("Workflow sandbox sent an invalid IPC message"));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (raw.kind === "phase") {
|
|
238
|
+
// Same clip-not-kill rule as log below: a phase title is display text,
|
|
239
|
+
// and an oversized one must not cost the run its agent results.
|
|
240
|
+
if (
|
|
241
|
+
typeof raw.payloadJson !== "string" ||
|
|
242
|
+
byteLength(raw.payloadJson) > MAX_LOG_MESSAGE_BYTES
|
|
243
|
+
) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
const payload: unknown = JSON.parse(raw.payloadJson);
|
|
248
|
+
if (!isRecord(payload) || typeof payload.title !== "string") {
|
|
249
|
+
throw new Error("invalid title");
|
|
250
|
+
}
|
|
251
|
+
options.onPhase(payload.title.slice(0, 160));
|
|
252
|
+
} catch {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (raw.kind === "log") {
|
|
258
|
+
if (
|
|
259
|
+
typeof raw.payloadJson !== "string" ||
|
|
260
|
+
byteLength(raw.payloadJson) > MAX_LOG_MESSAGE_BYTES
|
|
261
|
+
) {
|
|
262
|
+
// Dropped, never fatal. A narrator line is the least important thing
|
|
263
|
+
// in a run; killing the child over one discards every completed
|
|
264
|
+
// agent's output. The byte ceiling is protocol abuse protection, and
|
|
265
|
+
// it is generous precisely so that ordinary oversized narration
|
|
266
|
+
// (a JSON.stringify of 400 paths, 3000 emoji) is clipped by
|
|
267
|
+
// appendLog rather than losing the run.
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
const payload: unknown = JSON.parse(raw.payloadJson);
|
|
272
|
+
if (!isRecord(payload) || typeof payload.text !== "string") {
|
|
273
|
+
throw new Error("invalid text");
|
|
274
|
+
}
|
|
275
|
+
options.onLog(payload.text);
|
|
276
|
+
} catch {
|
|
277
|
+
finish(new Error("Workflow sandbox sent an invalid log line"));
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (raw.kind === "agent") {
|
|
282
|
+
if (
|
|
283
|
+
typeof raw.payloadJson !== "string" ||
|
|
284
|
+
byteLength(raw.payloadJson) > MAX_AGENT_MESSAGE_BYTES
|
|
285
|
+
) {
|
|
286
|
+
finish(new Error("Workflow sandbox sent an oversized agent request"));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
let payload: unknown;
|
|
290
|
+
try {
|
|
291
|
+
payload = JSON.parse(raw.payloadJson);
|
|
292
|
+
} catch {
|
|
293
|
+
finish(new Error("Workflow sandbox sent malformed agent JSON"));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (
|
|
297
|
+
!isRecord(payload) ||
|
|
298
|
+
!Number.isSafeInteger(payload.id) ||
|
|
299
|
+
typeof payload.id !== "number" ||
|
|
300
|
+
payload.id < 1 ||
|
|
301
|
+
typeof payload.prompt !== "string" ||
|
|
302
|
+
payload.prompt.length > 100_000 ||
|
|
303
|
+
!isRecord(payload.options)
|
|
304
|
+
) {
|
|
305
|
+
finish(new Error("Workflow sandbox sent an invalid agent request"));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (requestIds.has(payload.id) || ++requestCount > maxAgentRequests) {
|
|
309
|
+
finish(
|
|
310
|
+
new Error("Workflow sandbox exceeded its agent request budget"),
|
|
311
|
+
);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
requestIds.add(payload.id);
|
|
315
|
+
const id = payload.id;
|
|
316
|
+
const abortController = new AbortController();
|
|
317
|
+
const sendResult = (result: SandboxAgentResult) => {
|
|
318
|
+
if (!activeAgentRequests.delete(id)) return;
|
|
319
|
+
if (finished || !child.connected) return;
|
|
320
|
+
const normalized = toSerializable(result, {
|
|
321
|
+
maxDepth: 16,
|
|
322
|
+
maxNodes: 10_000,
|
|
323
|
+
maxStringBytes: 128 * 1024,
|
|
324
|
+
});
|
|
325
|
+
let resultJson = JSON.stringify(normalized);
|
|
326
|
+
if (byteLength(resultJson) > MAX_AGENT_MESSAGE_BYTES) {
|
|
327
|
+
resultJson = JSON.stringify({
|
|
328
|
+
ok: false,
|
|
329
|
+
output: "",
|
|
330
|
+
error: "Agent result exceeded the workflow IPC output limit",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
child.send({
|
|
334
|
+
token,
|
|
335
|
+
kind: "agentResult",
|
|
336
|
+
id,
|
|
337
|
+
resultJson,
|
|
338
|
+
usageJson: usageJson(),
|
|
339
|
+
});
|
|
340
|
+
};
|
|
341
|
+
activeAgentRequests.set(id, abortController);
|
|
342
|
+
let agentOperation: Promise<SandboxAgentResult>;
|
|
343
|
+
try {
|
|
344
|
+
agentOperation = options.onAgent(
|
|
345
|
+
payload.prompt,
|
|
346
|
+
sanitizeAgentOptions(payload.options),
|
|
347
|
+
abortController.signal,
|
|
348
|
+
);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
sendResult({ ok: false, output: "", error: errorText(error) });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
void agentOperation.then(sendResult, (error) =>
|
|
354
|
+
sendResult({ ok: false, output: "", error: errorText(error) }),
|
|
355
|
+
);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (raw.kind === "result") {
|
|
359
|
+
if (
|
|
360
|
+
typeof raw.resultJson !== "string" ||
|
|
361
|
+
byteLength(raw.resultJson) > MAX_RESULT_BYTES
|
|
362
|
+
) {
|
|
363
|
+
finish(new Error("Workflow result exceeded the IPC limit"));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
const normalized = toSerializable(JSON.parse(raw.resultJson));
|
|
368
|
+
finish(undefined, JSON.parse(JSON.stringify(normalized)));
|
|
369
|
+
} catch (error) {
|
|
370
|
+
finish(
|
|
371
|
+
new Error(`Workflow returned invalid JSON: ${errorText(error)}`),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (raw.kind === "error" && typeof raw.error === "string") {
|
|
377
|
+
finish(new Error(raw.error.slice(0, 16 * 1024)));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
finish(new Error("Workflow sandbox sent an unknown IPC message"));
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
child.send(
|
|
384
|
+
{
|
|
385
|
+
kind: "init",
|
|
386
|
+
token,
|
|
387
|
+
source: options.source,
|
|
388
|
+
argsJson,
|
|
389
|
+
maxConcurrency: options.maxConcurrency,
|
|
390
|
+
usageJson: usageJson(),
|
|
391
|
+
},
|
|
392
|
+
(error) => {
|
|
393
|
+
if (error) finish(error);
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface SerializationOptions {
|
|
5
|
+
maxBytes?: number;
|
|
6
|
+
maxDepth?: number;
|
|
7
|
+
maxNodes?: number;
|
|
8
|
+
maxStringBytes?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DEFAULT_MAX_BYTES = 1024 * 1024;
|
|
12
|
+
const DEFAULT_MAX_DEPTH = 16;
|
|
13
|
+
const DEFAULT_MAX_NODES = 20_000;
|
|
14
|
+
const DEFAULT_MAX_STRING_BYTES = 64 * 1024;
|
|
15
|
+
|
|
16
|
+
function byteLength(value: string) {
|
|
17
|
+
return Buffer.byteLength(value, "utf8");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function truncateUtf8(value: string, maxBytes: number) {
|
|
21
|
+
if (maxBytes <= 0) return "";
|
|
22
|
+
if (byteLength(value) <= maxBytes) return value;
|
|
23
|
+
const buffer = Buffer.from(value, "utf8");
|
|
24
|
+
let end = Math.min(maxBytes, buffer.length);
|
|
25
|
+
while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
|
|
26
|
+
return buffer.subarray(0, end).toString("utf8");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Normalize arbitrary values to inert JSON data. Cycles, bigint, non-finite
|
|
31
|
+
* numbers, deep trees, throwing properties, and very large strings are all
|
|
32
|
+
* represented explicitly instead of making artifact persistence fail.
|
|
33
|
+
*/
|
|
34
|
+
export function toSerializable(
|
|
35
|
+
value: unknown,
|
|
36
|
+
options: SerializationOptions = {},
|
|
37
|
+
): unknown {
|
|
38
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
39
|
+
const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
|
|
40
|
+
const maxStringBytes = options.maxStringBytes ?? DEFAULT_MAX_STRING_BYTES;
|
|
41
|
+
const seen = new WeakMap<object, string>();
|
|
42
|
+
let nodes = 0;
|
|
43
|
+
|
|
44
|
+
const visit = (
|
|
45
|
+
current: unknown,
|
|
46
|
+
depth: number,
|
|
47
|
+
location: string,
|
|
48
|
+
): unknown => {
|
|
49
|
+
nodes++;
|
|
50
|
+
if (nodes > maxNodes) return "[truncated: node limit]";
|
|
51
|
+
if (depth > maxDepth) return "[truncated: depth limit]";
|
|
52
|
+
if (current === null || typeof current === "boolean") return current;
|
|
53
|
+
if (typeof current === "string") {
|
|
54
|
+
if (byteLength(current) <= maxStringBytes) return current;
|
|
55
|
+
return `${truncateUtf8(current, maxStringBytes)}\n[truncated: string limit]`;
|
|
56
|
+
}
|
|
57
|
+
if (typeof current === "number") {
|
|
58
|
+
return Number.isFinite(current)
|
|
59
|
+
? current
|
|
60
|
+
: `[number: ${String(current)}]`;
|
|
61
|
+
}
|
|
62
|
+
if (typeof current === "bigint") return `${current.toString()}n`;
|
|
63
|
+
if (typeof current === "undefined") return "[undefined]";
|
|
64
|
+
if (typeof current === "symbol")
|
|
65
|
+
return `[symbol: ${current.description ?? ""}]`;
|
|
66
|
+
if (typeof current === "function")
|
|
67
|
+
return `[function: ${current.name || "anonymous"}]`;
|
|
68
|
+
if (typeof current !== "object") return String(current);
|
|
69
|
+
|
|
70
|
+
const prior = seen.get(current);
|
|
71
|
+
if (prior) return `[circular: ${prior}]`;
|
|
72
|
+
seen.set(current, location);
|
|
73
|
+
|
|
74
|
+
if (Array.isArray(current)) {
|
|
75
|
+
return current.map((item, index) =>
|
|
76
|
+
visit(item, depth + 1, `${location}[${index}]`),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (current instanceof Date) {
|
|
81
|
+
return Number.isNaN(current.getTime())
|
|
82
|
+
? "[date: invalid]"
|
|
83
|
+
: current.toISOString();
|
|
84
|
+
}
|
|
85
|
+
if (current instanceof Error) {
|
|
86
|
+
return {
|
|
87
|
+
name: current.name,
|
|
88
|
+
message: current.message,
|
|
89
|
+
...(current.stack
|
|
90
|
+
? { stack: truncateUtf8(current.stack, 16 * 1024) }
|
|
91
|
+
: {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const result: Record<string, unknown> = Object.create(null);
|
|
96
|
+
let keys: string[];
|
|
97
|
+
try {
|
|
98
|
+
keys = Object.keys(current);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return `[unreadable object: ${error instanceof Error ? error.message : String(error)}]`;
|
|
101
|
+
}
|
|
102
|
+
for (const key of keys) {
|
|
103
|
+
try {
|
|
104
|
+
result[key] = visit(
|
|
105
|
+
(current as Record<string, unknown>)[key],
|
|
106
|
+
depth + 1,
|
|
107
|
+
`${location}.${key}`,
|
|
108
|
+
);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
result[key] =
|
|
111
|
+
`[unreadable property: ${error instanceof Error ? error.message : String(error)}]`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return visit(value, 0, "$root");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Serialize to valid JSON no larger than the requested cap. */
|
|
121
|
+
export function safeStringify(
|
|
122
|
+
value: unknown,
|
|
123
|
+
options: SerializationOptions = {},
|
|
124
|
+
) {
|
|
125
|
+
const maxBytes = Math.max(256, options.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
126
|
+
const normalized = toSerializable(value, options);
|
|
127
|
+
const serialized = JSON.stringify(normalized, null, 2) ?? "null";
|
|
128
|
+
if (byteLength(serialized) <= maxBytes) return serialized;
|
|
129
|
+
|
|
130
|
+
let previewBytes = Math.max(32, Math.floor(maxBytes / 3));
|
|
131
|
+
while (previewBytes > 0) {
|
|
132
|
+
const fallback = JSON.stringify(
|
|
133
|
+
{
|
|
134
|
+
truncated: true,
|
|
135
|
+
reason: `serialized value exceeded ${maxBytes} bytes`,
|
|
136
|
+
preview: truncateUtf8(serialized, previewBytes),
|
|
137
|
+
},
|
|
138
|
+
null,
|
|
139
|
+
2,
|
|
140
|
+
);
|
|
141
|
+
if (byteLength(fallback) <= maxBytes) return fallback;
|
|
142
|
+
previewBytes = Math.floor(previewBytes / 2);
|
|
143
|
+
}
|
|
144
|
+
return JSON.stringify({ truncated: true });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Durable same-directory replace: readers see either the old or new file. */
|
|
148
|
+
export function writeFileAtomic(filePath: string, content: string) {
|
|
149
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
150
|
+
const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
151
|
+
try {
|
|
152
|
+
fs.writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600 });
|
|
153
|
+
fs.renameSync(temporary, filePath);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
try {
|
|
156
|
+
fs.unlinkSync(temporary);
|
|
157
|
+
} catch {
|
|
158
|
+
// The original write error is more useful.
|
|
159
|
+
}
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|