surf-cli 2.10.0 → 2.12.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.
@@ -0,0 +1,253 @@
1
+ const crypto = require("crypto");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const {
5
+ assertNotSymlink,
6
+ atomicWriteFile,
7
+ atomicWriteJson,
8
+ ensurePrivateDir,
9
+ getPrivateStateRoot,
10
+ readPrivateFile,
11
+ readPrivateJson,
12
+ } = require("./private-state.cjs");
13
+
14
+ const JOB_ID_PATTERN = /^\d{8}-\d{6}-[0-9a-f]{4}$/;
15
+ const TERMINAL_STATES = new Set(["captured", "failed"]);
16
+ const TRANSITIONS = {
17
+ created: new Set(["dispatched", "failed"]),
18
+ dispatched: new Set(["awaiting", "failed"]),
19
+ awaiting: new Set(["captured", "failed"]),
20
+ };
21
+
22
+ function oracleRoot(root = getPrivateStateRoot()) {
23
+ return path.join(root, "oracle");
24
+ }
25
+
26
+ function jobDirectory(id, root = getPrivateStateRoot()) {
27
+ if (!JOB_ID_PATTERN.test(id)) throw codedError("not_found", `oracle job not found: ${id}`);
28
+ return path.join(oracleRoot(root), id);
29
+ }
30
+
31
+ function codedError(code, message, details = {}) {
32
+ const error = new Error(message);
33
+ error.code = code;
34
+ Object.assign(error, details);
35
+ return error;
36
+ }
37
+
38
+ function readJobs(root = getPrivateStateRoot()) {
39
+ const base = oracleRoot(root);
40
+ if (!fs.existsSync(base)) return [];
41
+ const stat = assertNotSymlink(base, false);
42
+ if (!stat.isDirectory()) throw new Error(`oracle state path is not a directory: ${base}`);
43
+ return fs.readdirSync(base)
44
+ .filter((id) => JOB_ID_PATTERN.test(id))
45
+ .sort((a, b) => b.localeCompare(a))
46
+ .map((id) => readPrivateJson(path.join(base, id, "job.json"), null, { root }))
47
+ .filter(Boolean);
48
+ }
49
+
50
+ function createJob({ prompt, contextManifest = {}, model = null, effortRequested = null, follow = null }) {
51
+ const root = getPrivateStateRoot();
52
+ const base = oracleRoot(root);
53
+ ensurePrivateDir(base, root);
54
+ const inFlight = readJobs(root).find((job) => !TERMINAL_STATES.has(job.state));
55
+ if (inFlight) {
56
+ throw codedError(
57
+ "capacity",
58
+ `oracle job capacity reached; in-flight job: ${inFlight.id}`,
59
+ { jobId: inFlight.id },
60
+ );
61
+ }
62
+
63
+ const now = new Date();
64
+ const compactTimestamp = now.toISOString().replace(/\D/g, "").slice(0, 14);
65
+ const timestamp = `${compactTimestamp.slice(0, 8)}-${compactTimestamp.slice(8)}`;
66
+ let id;
67
+ let directory;
68
+ for (;;) {
69
+ id = `${timestamp}-${crypto.randomBytes(2).toString("hex")}`;
70
+ directory = path.join(base, id);
71
+ try {
72
+ fs.mkdirSync(directory, { mode: 0o700 });
73
+ break;
74
+ } catch (error) {
75
+ if (error?.code !== "EEXIST") throw error;
76
+ }
77
+ }
78
+
79
+ try {
80
+ ensurePrivateDir(path.join(directory, "turns"), root);
81
+ atomicWriteFile(path.join(directory, "request.md"), prompt, { root, encoding: "utf8" });
82
+ atomicWriteJson(path.join(directory, "context-manifest.json"), contextManifest, { root });
83
+ const job = {
84
+ id,
85
+ state: "created",
86
+ model,
87
+ effortRequested,
88
+ effortVerified: null,
89
+ createdAt: now.toISOString(),
90
+ dispatchedAt: null,
91
+ awaitingAt: null,
92
+ capturedAt: null,
93
+ failedAt: null,
94
+ tabId: null,
95
+ conversationUrl: null,
96
+ promptEcho: null,
97
+ error: null,
98
+ turns: [],
99
+ ...(follow ? { follow } : {}),
100
+ };
101
+ atomicWriteJson(path.join(directory, "job.json"), job, { root });
102
+ return job;
103
+ } catch (error) {
104
+ fs.rmSync(directory, { recursive: true, force: true });
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ function getJob(id) {
110
+ const root = getPrivateStateRoot();
111
+ const job = readPrivateJson(path.join(jobDirectory(id, root), "job.json"), null, { root });
112
+ if (!job) throw codedError("not_found", `oracle job not found: ${id}`);
113
+ return job;
114
+ }
115
+
116
+ function getResponse(id) {
117
+ const root = getPrivateStateRoot();
118
+ getJob(id);
119
+ return readPrivateFile(path.join(jobDirectory(id, root), "response.md"), {
120
+ root,
121
+ encoding: "utf8",
122
+ });
123
+ }
124
+
125
+ function transition(id, state, updates) {
126
+ const job = getJob(id);
127
+ if (!TRANSITIONS[job.state]?.has(state)) {
128
+ throw codedError(
129
+ "invalid_transition",
130
+ `oracle job ${id} cannot transition from ${job.state} to ${state}`,
131
+ );
132
+ }
133
+ const updated = { ...job, state, ...updates };
134
+ const root = getPrivateStateRoot();
135
+ atomicWriteJson(path.join(jobDirectory(id, root), "job.json"), updated, { root });
136
+ return updated;
137
+ }
138
+
139
+ function markDispatched(id, { tabId, promptEcho, modelVerified, effortVerified }) {
140
+ return transition(id, "dispatched", {
141
+ dispatchedAt: new Date().toISOString(),
142
+ tabId,
143
+ ...(promptEcho ? { promptEcho } : {}),
144
+ ...(modelVerified ? { model: modelVerified } : {}),
145
+ ...(effortVerified ? { effortVerified } : {}),
146
+ });
147
+ }
148
+
149
+ function markAwaiting(id, { conversationUrl, promptEcho }) {
150
+ return transition(id, "awaiting", {
151
+ awaitingAt: new Date().toISOString(),
152
+ conversationUrl,
153
+ promptEcho,
154
+ });
155
+ }
156
+
157
+ function markCaptured(id, { response }) {
158
+ const job = getJob(id);
159
+ if (!TRANSITIONS[job.state]?.has("captured")) {
160
+ throw codedError(
161
+ "invalid_transition",
162
+ `oracle job ${id} cannot transition from ${job.state} to captured`,
163
+ );
164
+ }
165
+ const root = getPrivateStateRoot();
166
+ atomicWriteFile(path.join(jobDirectory(id, root), "response.md"), response, {
167
+ root,
168
+ encoding: "utf8",
169
+ });
170
+ return transition(id, "captured", { capturedAt: new Date().toISOString() });
171
+ }
172
+
173
+ function markFailed(id, { code, message }) {
174
+ return transition(id, "failed", {
175
+ failedAt: new Date().toISOString(),
176
+ error: { code, message },
177
+ });
178
+ }
179
+
180
+ function updateTabId(id, tabId) {
181
+ const job = getJob(id);
182
+ if (TERMINAL_STATES.has(job.state)) {
183
+ throw codedError(
184
+ "invalid_transition",
185
+ `oracle job ${id} cannot transition from ${job.state} to update tab`,
186
+ );
187
+ }
188
+ const updated = { ...job, tabId };
189
+ const root = getPrivateStateRoot();
190
+ atomicWriteJson(path.join(jobDirectory(id, root), "job.json"), updated, { root });
191
+ return updated;
192
+ }
193
+
194
+ function appendTurn(id, turn) {
195
+ const job = getJob(id);
196
+ const storedTurn = {
197
+ prompt: turn.prompt,
198
+ dispatchedAt: turn.dispatchedAt ?? null,
199
+ capturedAt: turn.capturedAt ?? null,
200
+ };
201
+ const root = getPrivateStateRoot();
202
+ const directory = jobDirectory(id, root);
203
+ const turnName = `${String(job.turns.length + 1).padStart(4, "0")}.json`;
204
+ atomicWriteJson(path.join(directory, "turns", turnName), storedTurn, { root });
205
+ const updated = { ...job, turns: [...job.turns, storedTurn] };
206
+ atomicWriteJson(path.join(directory, "job.json"), updated, { root });
207
+ return updated;
208
+ }
209
+
210
+ function markTurnCaptured(id, { dispatchedAt, capturedAt }) {
211
+ const job = getJob(id);
212
+ const turnIndex = job.turns.findIndex((turn) => turn.dispatchedAt === dispatchedAt);
213
+ if (turnIndex === -1) {
214
+ throw codedError(
215
+ "invalid_transition",
216
+ `oracle job ${id} has no follow turn dispatched at ${dispatchedAt}`,
217
+ );
218
+ }
219
+ const turns = [...job.turns];
220
+ turns[turnIndex] = { ...turns[turnIndex], capturedAt };
221
+ const root = getPrivateStateRoot();
222
+ const directory = jobDirectory(id, root);
223
+ const turnName = `${String(turnIndex + 1).padStart(4, "0")}.json`;
224
+ atomicWriteJson(path.join(directory, "turns", turnName), turns[turnIndex], { root });
225
+ const updated = { ...job, turns };
226
+ atomicWriteJson(path.join(directory, "job.json"), updated, { root });
227
+ return updated;
228
+ }
229
+
230
+ function listJobs({ limit } = {}) {
231
+ const jobs = readJobs();
232
+ return limit === undefined ? jobs : jobs.slice(0, Math.max(0, limit));
233
+ }
234
+
235
+ function adoptOrphans() {
236
+ return listJobs({}).filter((job) => !TERMINAL_STATES.has(job.state));
237
+ }
238
+
239
+ module.exports = {
240
+ adoptOrphans,
241
+ appendTurn,
242
+ createJob,
243
+ getJob,
244
+ getResponse,
245
+ listJobs,
246
+ markAwaiting,
247
+ markCaptured,
248
+ markDispatched,
249
+ markFailed,
250
+ markTurnCaptured,
251
+ oracleRoot,
252
+ updateTabId,
253
+ };
@@ -48,7 +48,7 @@ function runSpec(argv) {
48
48
  const parsed = parseCommandArgs(argv.slice(offset));
49
49
  const [playbook, op] = parsed.positional;
50
50
  if (!playbook || !op) throw new Error(direct ? "Usage: surf use <playbook> <op> [--arg value]" : "Usage: surf pb run <playbook> <op> [--arg value]");
51
- const reserved = new Set(["json", "no-lock", "tab-id", "write", "repeat", "retry-attempt", "override-in-doubt", "pin-built-in"]);
51
+ const reserved = new Set(["json", "no-lock", "tab-id", "write", "repeat", "retry-attempt", "override-in-doubt", "pin-built-in", "allow-script"]);
52
52
  const args = Object.fromEntries(Object.entries(parsed.options).filter(([name]) => !reserved.has(name)));
53
53
  return { playbook, op, args, options: parsed.options };
54
54
  }
@@ -89,6 +89,7 @@ async function handlePlaybookCli(argv, { endpoint, cwd = process.cwd() }) {
89
89
  retryAttempt: spec.options["retry-attempt"],
90
90
  overrideInDoubt: spec.options["override-in-doubt"] === true,
91
91
  pinBuiltIn: spec.options["pin-built-in"] === true,
92
+ allowScript: spec.options["allow-script"] === true,
92
93
  };
93
94
  const value = await requestHost(endpoint, "playbook.run", args, {
94
95
  tabId: spec.options["tab-id"],
@@ -98,7 +99,7 @@ async function handlePlaybookCli(argv, { endpoint, cwd = process.cwd() }) {
98
99
  }
99
100
  const command = argv[1];
100
101
  const parsed = parseCommandArgs(argv.slice(2));
101
- if (!command || command === "help") return { handled: true, value: "Usage: surf playbook|pb <list|show|ops|run|record|suggest|save|client|trace|export|import>" };
102
+ if (!command || command === "help") return { handled: true, value: "Usage: surf playbook|pb <list|show|ops|run|record|suggest|save|client|trace|export|import>\nRun trusted script strategies with: surf use <playbook> <op> --allow-script" };
102
103
  if (endpoint?.kind === "remote" && ["list", "show", "ops"].includes(command)) throw new Error(`playbook ${command} is local-only with --remote because runs resolve on the browser host`);
103
104
  if (command === "list") return { handled: true, value: listPlaybooks({ cwd }), json: parsed.options.json === true };
104
105
  if (command === "show") {
@@ -1,4 +1,5 @@
1
- const { executeWorkflow } = require("./workflow-runtime.cjs");
1
+ const { executeSingleStep, executeWorkflow } = require("./workflow-runtime.cjs");
2
+ const { runWorkflowScript } = require("./workflow-script-runtime.cjs");
2
3
 
3
4
  function applyTemplate(value, args) {
4
5
  if (typeof value === "string") {
@@ -93,6 +94,31 @@ return { status: response.status, ok: response.ok, url: response.url, headers: O
93
94
  }
94
95
 
95
96
  async function runStrategy(strategy, context) {
97
+ if (strategy.using === "script") {
98
+ if (context.allowScript !== true) throw new Error("script strategy requires --allow-script");
99
+ const vars = { ...context.args };
100
+ const result = await runWorkflowScript({
101
+ script: strategy.script,
102
+ input: context.args,
103
+ timeoutMs: strategy.timeoutMs ?? 10 * 60 * 1000,
104
+ signal: context.signal,
105
+ onEvent: context.onEvent,
106
+ executeTool: async (tool, args, options = {}) => {
107
+ await context.markDispatched?.();
108
+ const step = await executeSingleStep({ cmd: tool, args, as: "value" }, vars, {
109
+ autoWait: strategy.autoWait !== false,
110
+ executeTool: context.executeTool,
111
+ onEvent: context.onEvent,
112
+ signal: options.signal || context.signal,
113
+ sleep: context.sleep,
114
+ stepDelay: strategy.stepDelay ?? 100,
115
+ });
116
+ if (!step.success) throw new Error(step.error || `tool ${tool} failed`);
117
+ return step.output;
118
+ },
119
+ });
120
+ return result.value;
121
+ }
96
122
  if (strategy.using === "workflow") {
97
123
  const result = await executeWorkflow(applyTemplate(strategy.steps, context.args), {
98
124
  autoWait: strategy.autoWait !== false,
@@ -130,7 +156,7 @@ async function runStrategy(strategy, context) {
130
156
  throw new Error(`unsupported strategy: ${strategy.using}`);
131
157
  }
132
158
 
133
- async function runPlaybookOp({ playbook, op, args: providedArgs = {}, attemptId, executeTool, executeNative, signal, sleep, onEvent = () => {}, beforeDispatch = async () => {}, afterDispatch = async () => {} }) {
159
+ async function runPlaybookOp({ playbook, op, args: providedArgs = {}, attemptId, executeTool, executeNative, signal, sleep, onEvent = () => {}, beforeDispatch = async () => {}, afterDispatch = async () => {}, allowScript = false }) {
134
160
  const args = resolveArgs(op, providedArgs);
135
161
  const attempts = [];
136
162
  for (let index = 0; index < op.run.length; index++) {
@@ -154,6 +180,7 @@ async function runPlaybookOp({ playbook, op, args: providedArgs = {}, attemptId,
154
180
  allowedOrigins: op.origins || playbook.origins || [],
155
181
  markDispatched: op.effect === "write" ? markDispatched : undefined,
156
182
  serverIdempotency: op.effect === "write" ? op.safety?.serverIdempotency : undefined,
183
+ allowScript,
157
184
  });
158
185
  const value = extractResult(raw, strategy.extract);
159
186
  verifyResult(value, strategy.verify || strategy.expect || op.on?.success?.expect, raw);
@@ -75,8 +75,21 @@ function validateWriteWorkflowSteps(steps, opId) {
75
75
  }
76
76
  }
77
77
 
78
+ function normalizeScript(value) {
79
+ if (typeof value === "string") return value;
80
+ if (Array.isArray(value) && value.every((line) => typeof line === "string")) return value.join("\n");
81
+ return null;
82
+ }
83
+
78
84
  function validateStrategy(strategy, effect, opId) {
79
85
  if (!strategy || typeof strategy !== "object" || Array.isArray(strategy)) throw new Error("playbook strategy must be an object");
86
+ if (strategy.using === "script") {
87
+ if (effect === "write") throw new Error(`write op ${opId} script strategy is not supported`);
88
+ const script = normalizeScript(strategy.script);
89
+ if (!script?.trim()) throw new Error("script strategy requires script");
90
+ if (strategy.timeoutMs !== undefined && (!Number.isInteger(strategy.timeoutMs) || strategy.timeoutMs < 1)) throw new Error("script strategy timeoutMs must be a positive integer");
91
+ return { ...strategy, script };
92
+ }
80
93
  if (strategy.using === "workflow") {
81
94
  if (!Array.isArray(strategy.steps) || strategy.steps.length === 0) throw new Error("workflow strategy requires steps");
82
95
  const steps = strategy.steps.map(normalizeStep);
@@ -7,6 +7,7 @@ const COMMANDS = {
7
7
  ai: { primaryArg: "query", effect: "read", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
8
8
  gemini: { primaryArg: "query", effect: "page-write", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
9
9
  chatgpt: { primaryArg: "query", effect: "page-write", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
10
+ "oracle.ask": { primaryArg: "prompt", effect: "page-write", argKinds: { prompt: "user-input" }, sensitiveArgs: ["prompt"] },
10
11
  perplexity: { primaryArg: "query", effect: "page-write", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
11
12
  grok: { primaryArg: "query", effect: "page-write", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
12
13
  navigate: { primaryArg: "url", effect: "navigation", argKinds: { url: "url" } },
@@ -0,0 +1,294 @@
1
+ const { Worker } = require("node:worker_threads");
2
+
3
+ const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
4
+
5
+ const WORKER_SOURCE = String.raw`
6
+ const { parentPort } = require("node:worker_threads");
7
+ const vm = require("node:vm");
8
+ const { inspect } = require("node:util");
9
+
10
+ let nextCallId = 0;
11
+ const pending = new Map();
12
+ const keyPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
13
+ const fingerprints = new Map();
14
+ let contextObjectPrototype;
15
+
16
+ function stableJson(value) {
17
+ if (Array.isArray(value)) return "[" + value.map(stableJson).join(",") + "]";
18
+ if (value && typeof value === "object") return "{" + Object.keys(value).sort().map((key) => JSON.stringify(key) + ":" + stableJson(value[key])).join(",") + "}";
19
+ return JSON.stringify(value) ?? "undefined";
20
+ }
21
+
22
+ function assertJsonValue(value, path = "value", seen = new Set()) {
23
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
24
+ if (typeof value === "number") {
25
+ if (!Number.isFinite(value)) throw new Error(path + " must contain only finite JSON numbers.");
26
+ return;
27
+ }
28
+ if (typeof value !== "object") throw new Error(path + " must be a JSON value; received " + typeof value + ".");
29
+ if (seen.has(value)) throw new Error(path + " must not contain cycles.");
30
+ seen.add(value);
31
+ if (Array.isArray(value)) {
32
+ for (let index = 0; index < value.length; index++) {
33
+ if (!Object.prototype.hasOwnProperty.call(value, index)) throw new Error(path + " must not contain sparse array entries.");
34
+ assertJsonValue(value[index], path + "[" + index + "]", seen);
35
+ }
36
+ } else {
37
+ const prototype = Object.getPrototypeOf(value);
38
+ if (prototype !== null && prototype !== Object.prototype && prototype !== contextObjectPrototype) throw new Error(path + " must contain only plain JSON objects.");
39
+ if (Object.getOwnPropertySymbols(value).length > 0) throw new Error(path + " must not contain symbol keys.");
40
+ for (const [key, entry] of Object.entries(value)) assertJsonValue(entry, path + "." + key, seen);
41
+ }
42
+ seen.delete(value);
43
+ }
44
+
45
+ function hostCall(method, args) {
46
+ return new Promise((resolve, reject) => {
47
+ const callId = ++nextCallId;
48
+ pending.set(callId, { resolve, reject });
49
+ parentPort.postMessage({ type: "call", callId, method, args });
50
+ });
51
+ }
52
+
53
+ function validateRunCall(key, params, label, nextFingerprints = fingerprints) {
54
+ if (typeof key !== "string" || !keyPattern.test(key)) throw new Error(label + " has an invalid key.");
55
+ if (!params || typeof params !== "object" || Array.isArray(params)) throw new Error(label + " requires a params object.");
56
+ const tool = params.tool ?? params.cmd;
57
+ if (typeof tool !== "string" || !tool) throw new Error(label + " requires a tool string.");
58
+ if (params.args !== undefined && (!params.args || typeof params.args !== "object" || Array.isArray(params.args))) throw new Error(label + " args must be an object.");
59
+ assertJsonValue(params, label + " params");
60
+ const fingerprint = stableJson(params);
61
+ const existing = nextFingerprints.get(key);
62
+ if (existing !== undefined && existing !== fingerprint) throw new Error("Duplicate script key '" + key + "' used with incompatible tool params.");
63
+ nextFingerprints.set(key, fingerprint);
64
+ }
65
+
66
+ const tools = Object.freeze({
67
+ run(key, params) {
68
+ validateRunCall(key, params, "tools.run");
69
+ return hostCall("run", { key, params });
70
+ },
71
+ all(items) {
72
+ if (!Array.isArray(items)) throw new Error("tools.all(items) requires an array.");
73
+ const nextFingerprints = new Map(fingerprints);
74
+ const calls = [];
75
+ for (let index = 0; index < items.length; index++) {
76
+ if (!Object.prototype.hasOwnProperty.call(items, index)) throw new Error("tools.all items must not contain sparse entries.");
77
+ const item = items[index];
78
+ if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("tools.all item " + index + " must be an object.");
79
+ const { key, ...params } = item;
80
+ validateRunCall(key, params, "tools.all item " + index, nextFingerprints);
81
+ calls.push({ key, params });
82
+ }
83
+ for (const { key, params } of calls) fingerprints.set(key, stableJson(params));
84
+ return Promise.all(calls.map(({ key, params }) => hostCall("run", { key, params, collectFailure: true })));
85
+ },
86
+ ref(result) {
87
+ if (!result || typeof result !== "object") throw new Error("tools.ref(result) requires a tool result object.");
88
+ return "[tool " + (result.key || "unknown") + "]";
89
+ },
90
+ refs(results) {
91
+ if (!Array.isArray(results)) throw new Error("tools.refs(results) requires an array.");
92
+ return results.map(tools.ref).join("\n");
93
+ },
94
+ });
95
+
96
+ const capturedConsole = Object.freeze(Object.fromEntries(
97
+ ["log", "info", "warn", "error"].map((level) => [level, (...args) => {
98
+ parentPort.postMessage({ type: "console", level, text: args.map((value) => typeof value === "string" ? value : inspect(value, { depth: 4, breakLength: 120 })).join(" ") });
99
+ }]),
100
+ ));
101
+
102
+ parentPort.on("message", async (message) => {
103
+ if (message.type === "response") {
104
+ const entry = pending.get(message.callId);
105
+ if (!entry) return;
106
+ pending.delete(message.callId);
107
+ if (message.ok) entry.resolve(message.value);
108
+ else entry.reject(new Error(message.error));
109
+ return;
110
+ }
111
+ if (message.type !== "start") return;
112
+ try {
113
+ const sandbox = {
114
+ input: Object.freeze(message.input ?? {}),
115
+ tools,
116
+ surf: tools,
117
+ emit(value) { assertJsonValue(value, "emit"); parentPort.postMessage({ type: "emit", value }); },
118
+ console: capturedConsole,
119
+ };
120
+ const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
121
+ contextObjectPrototype = vm.runInContext("Object.prototype", context);
122
+ const compiled = new vm.Script("(async () => {\n" + message.script + "\n})()", { filename: "surf-workflow-script.js" });
123
+ const value = await compiled.runInContext(context);
124
+ const persistedValue = value === undefined ? null : value;
125
+ assertJsonValue(persistedValue, "return");
126
+ parentPort.postMessage({ type: "complete", value: persistedValue });
127
+ } catch (error) {
128
+ parentPort.postMessage({ type: "error", error: error && error.stack ? error.stack : String(error) });
129
+ }
130
+ });
131
+ `;
132
+
133
+ function isRecord(value) {
134
+ return !!value && typeof value === "object" && !Array.isArray(value);
135
+ }
136
+
137
+ function assertJsonValue(value, path = "value", seen = new Set()) {
138
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
139
+ if (typeof value === "number") {
140
+ if (!Number.isFinite(value)) throw new Error(`${path} must contain only finite JSON numbers.`);
141
+ return;
142
+ }
143
+ if (typeof value !== "object") throw new Error(`${path} must be a JSON value; received ${typeof value}.`);
144
+ if (seen.has(value)) throw new Error(`${path} must not contain cycles.`);
145
+ seen.add(value);
146
+ if (Array.isArray(value)) {
147
+ for (let index = 0; index < value.length; index++) {
148
+ if (!Object.hasOwn(value, index)) throw new Error(`${path} must not contain sparse array entries.`);
149
+ assertJsonValue(value[index], `${path}[${index}]`, seen);
150
+ }
151
+ } else {
152
+ const prototype = Object.getPrototypeOf(value);
153
+ if (prototype !== null && prototype !== Object.prototype) throw new Error(`${path} must contain only plain JSON objects.`);
154
+ if (Object.getOwnPropertySymbols(value).length > 0) throw new Error(`${path} must not contain symbol keys.`);
155
+ for (const [key, entry] of Object.entries(value)) assertJsonValue(entry, `${path}.${key}`, seen);
156
+ }
157
+ seen.delete(value);
158
+ }
159
+
160
+ function omitUndefined(value, seen = new Set()) {
161
+ if (value === null || typeof value !== "object") return value;
162
+ if (seen.has(value)) return value;
163
+ seen.add(value);
164
+ const next = Array.isArray(value)
165
+ ? value.map((entry) => entry === undefined ? null : omitUndefined(entry, seen))
166
+ : Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => entry === undefined ? [] : [[key, omitUndefined(entry, seen)]]));
167
+ seen.delete(value);
168
+ return next;
169
+ }
170
+
171
+ function validateKey(value) {
172
+ if (typeof value !== "string" || !KEY_PATTERN.test(value)) {
173
+ throw new Error("tool key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.");
174
+ }
175
+ return value;
176
+ }
177
+
178
+ async function runWorkflowScript({ script, input = {}, timeoutMs = 10 * 60 * 1000, signal, executeTool, onEvent = () => {}, onEmit = () => {}, onConsole = () => {} }) {
179
+ if (typeof script !== "string" || !script.trim()) throw new Error("script strategy requires a non-empty script string");
180
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1) throw new Error("script timeoutMs must be a positive integer");
181
+ if (typeof executeTool !== "function") throw new Error("script strategy requires executeTool");
182
+ assertJsonValue(input, "input");
183
+
184
+ const worker = new Worker(WORKER_SOURCE, { eval: true });
185
+ const emits = [];
186
+ const consoleEntries = [];
187
+ const trace = [];
188
+ const childController = new AbortController();
189
+ let settled = false;
190
+
191
+ return await new Promise((resolve, reject) => {
192
+ const finish = (outcome) => {
193
+ if (settled) return;
194
+ settled = true;
195
+ clearTimeout(timer);
196
+ signal?.removeEventListener("abort", onAbort);
197
+ void worker.terminate();
198
+ childController.abort(outcome.error || new Error("Script strategy completed."));
199
+ if (outcome.error) reject(outcome.error);
200
+ else resolve({ value: outcome.value, emits, console: consoleEntries, trace });
201
+ };
202
+ const onAbort = () => finish({ error: new Error(signal.reason instanceof Error ? signal.reason.message : String(signal.reason || "Script strategy aborted")) });
203
+ const timer = setTimeout(() => finish({ error: new Error(`Script strategy timed out after ${timeoutMs}ms.`) }), timeoutMs);
204
+ signal?.addEventListener("abort", onAbort, { once: true });
205
+ if (signal?.aborted) return onAbort();
206
+
207
+ const respond = (callId, promise) => {
208
+ void promise.then(
209
+ (value) => {
210
+ try {
211
+ const clean = omitUndefined(value);
212
+ assertJsonValue(clean, "tool result");
213
+ worker.postMessage({ type: "response", callId, ok: true, value: clean });
214
+ } catch (error) {
215
+ worker.postMessage({ type: "response", callId, ok: false, error: error instanceof Error ? error.message : String(error) });
216
+ }
217
+ },
218
+ (error) => worker.postMessage({ type: "response", callId, ok: false, error: error instanceof Error ? error.message : String(error) }),
219
+ );
220
+ };
221
+
222
+ worker.on("error", (error) => finish({ error: new Error(`Script worker failed: ${error.message}`) }));
223
+ worker.on("exit", (code) => {
224
+ if (!settled && code !== 0) finish({ error: new Error(`Script worker exited with code ${code}.`) });
225
+ });
226
+ worker.on("message", (message) => {
227
+ if (message.type === "emit") {
228
+ try {
229
+ assertJsonValue(message.value, "emit");
230
+ emits.push(message.value);
231
+ onEmit([...emits]);
232
+ } catch (error) {
233
+ finish({ error: new Error(`Script emit could not be persisted: ${error.message}`) });
234
+ }
235
+ return;
236
+ }
237
+ if (message.type === "console") {
238
+ if (["log", "info", "warn", "error"].includes(message.level) && typeof message.text === "string") {
239
+ const entry = { level: message.level, text: message.text };
240
+ consoleEntries.push(entry);
241
+ onConsole(entry);
242
+ }
243
+ return;
244
+ }
245
+ if (message.type === "complete") {
246
+ try {
247
+ assertJsonValue(message.value, "return");
248
+ finish({ value: message.value });
249
+ } catch (error) {
250
+ finish({ error: new Error(`Script return could not be persisted: ${error.message}`) });
251
+ }
252
+ return;
253
+ }
254
+ if (message.type === "error") return finish({ error: new Error(typeof message.error === "string" ? message.error : "Script strategy failed.") });
255
+ if (message.type !== "call" || typeof message.callId !== "number" || message.method !== "run" || !isRecord(message.args)) return;
256
+ let key;
257
+ try {
258
+ key = validateKey(message.args.key);
259
+ } catch (error) {
260
+ return respond(message.callId, Promise.reject(error));
261
+ }
262
+ const params = message.args.params;
263
+ if (!isRecord(params)) return respond(message.callId, Promise.reject(new Error(`tools.run('${key}', params) requires a params object.`)));
264
+ const tool = params.tool ?? params.cmd;
265
+ if (typeof tool !== "string" || !tool) return respond(message.callId, Promise.reject(new Error(`tools.run('${key}') requires a tool string.`)));
266
+ const args = params.args === undefined ? {} : params.args;
267
+ if (!isRecord(args)) return respond(message.callId, Promise.reject(new Error(`tools.run('${key}') args must be an object.`)));
268
+ const collectFailure = message.args.collectFailure === true;
269
+ const startedAt = Date.now();
270
+ trace.push({ key, tool, state: "started" });
271
+ onEvent({ type: "script.tool.started", key, tool, startedAt: new Date().toISOString() });
272
+ respond(message.callId, Promise.resolve().then(async () => {
273
+ try {
274
+ const output = await executeTool(tool, args, { signal: childController.signal });
275
+ const result = { key, tool, ok: true, output };
276
+ trace.push({ key, tool, state: "completed", durationMs: Date.now() - startedAt });
277
+ onEvent({ type: "script.tool.completed", key, tool, endedAt: new Date().toISOString() });
278
+ return result;
279
+ } catch (error) {
280
+ const text = error instanceof Error ? error.message : String(error);
281
+ const result = { key, tool, ok: false, error: text };
282
+ trace.push({ key, tool, state: "failed", durationMs: Date.now() - startedAt, error: text });
283
+ onEvent({ type: "script.tool.failed", key, tool, error: text, endedAt: new Date().toISOString() });
284
+ if (!collectFailure) throw new Error(`Tool '${key}' failed: ${text}`);
285
+ return result;
286
+ }
287
+ }));
288
+ });
289
+
290
+ worker.postMessage({ type: "start", script, input });
291
+ });
292
+ }
293
+
294
+ module.exports = { runWorkflowScript };