surf-cli 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +48 -4
  2. package/dist/content/accessibility-tree.js +11 -0
  3. package/dist/content/accessibility-tree.js.map +1 -0
  4. package/dist/content/visual-indicator.js +111 -0
  5. package/dist/content/visual-indicator.js.map +1 -0
  6. package/dist/manifest.json +11 -2
  7. package/dist/options/options.js +3 -3
  8. package/dist/options/options.js.map +1 -1
  9. package/dist/service-worker/index.js +61 -261
  10. package/dist/service-worker/index.js.map +1 -1
  11. package/native/activity-journal.cjs +55 -0
  12. package/native/chatgpt-client.cjs +2 -0
  13. package/native/cli.cjs +52 -278
  14. package/native/do-executor.cjs +52 -475
  15. package/native/do-parser.cjs +8 -249
  16. package/native/host-helpers.cjs +6 -14
  17. package/native/host-sessions.cjs +5 -1
  18. package/native/host.cjs +199 -1
  19. package/native/network-export.cjs +20 -17
  20. package/native/network-store.cjs +38 -58
  21. package/native/playbook-authoring.cjs +44 -0
  22. package/native/playbook-cli.cjs +157 -0
  23. package/native/playbook-client.cjs +259 -0
  24. package/native/playbook-receipts.cjs +109 -0
  25. package/native/playbook-records.cjs +208 -0
  26. package/native/playbook-runtime.cjs +177 -0
  27. package/native/playbooks.cjs +235 -0
  28. package/native/private-state.cjs +156 -0
  29. package/native/redaction.cjs +104 -0
  30. package/native/workflow-definition.cjs +368 -0
  31. package/native/workflow-runtime.cjs +225 -0
  32. package/package.json +2 -1
  33. package/playbooks/page/ops/read.json +22 -0
  34. package/playbooks/page/playbook.json +7 -0
  35. package/skills/surf/SKILL.md +41 -1
  36. package/dist/content/index.js +0 -116
  37. package/dist/content/index.js.map +0 -1
@@ -1,250 +1,9 @@
1
- /**
2
- * Parser for surf `do` workflow commands
3
- *
4
- * Parses newline-separated commands into structured step arrays:
5
- *
6
- * Input:
7
- * 'go "https://example.com"
8
- * click e5
9
- * screenshot'
10
- *
11
- * Output:
12
- * [
13
- * { cmd: 'navigate', args: { url: 'https://example.com' } },
14
- * { cmd: 'click', args: { ref: 'e5' } },
15
- * { cmd: 'screenshot', args: {} }
16
- * ]
17
- */
18
-
19
- // Aliases mapping (matches cli.cjs)
20
- const ALIASES = {
21
- snap: "screenshot",
22
- read: "page.read",
23
- find: "search",
24
- go: "navigate",
25
- net: "network",
26
- "network.dump": "network.get",
27
- };
28
-
29
- // Primary argument mapping for positional args (matches cli.cjs)
30
- const PRIMARY_ARG_MAP = {
31
- ai: "query",
32
- gemini: "query",
33
- chatgpt: "query",
34
- perplexity: "query",
35
- grok: "query",
36
- navigate: "url",
37
- go: "url",
38
- js: "code",
39
- javascript_tool: "code",
40
- key: "key",
41
- wait: "duration",
42
- health: "url",
43
- new_tab: "url",
44
- "tab.new": "url",
45
- switch_tab: "tab_id",
46
- "tab.switch": "id",
47
- close_tab: "tab_id",
48
- "tab.close": "id",
49
- "tab.name": "name",
50
- "tab.unname": "name",
51
- scroll_to_position: "position",
52
- type: "text",
53
- smart_type: "text",
54
- "emulate.network": "preset",
55
- "emulate.cpu": "rate",
56
- search: "term",
57
- find: "term",
58
- "wait.element": "selector",
59
- "wait.url": "pattern",
60
- zoom: "level",
61
- "history.search": "query",
62
- "network.get": "id",
63
- "network.body": "id",
64
- "network.curl": "id",
65
- "network.path": "id",
66
- "window.new": "url",
67
- "window.focus": "id",
68
- "window.close": "id",
69
- "locate.role": "role",
70
- "locate.text": "text",
71
- "locate.label": "label",
72
- "emulate.device": "device",
73
- "frame.js": "code",
74
- "element.styles": "selector",
75
- "select": "selector",
76
- };
77
-
78
- /**
79
- * Tokenize a command line, respecting single and double quotes
80
- * @param {string} line - Single line to tokenize
81
- * @returns {string[]} - Array of tokens
82
- */
83
- function tokenize(line) {
84
- const tokens = [];
85
- let current = '';
86
- let inQuote = null;
87
-
88
- for (let i = 0; i < line.length; i++) {
89
- const ch = line[i];
90
-
91
- if (inQuote) {
92
- if (ch === inQuote) {
93
- // End of quoted string
94
- inQuote = null;
95
- } else {
96
- current += ch;
97
- }
98
- } else if (ch === '"' || ch === "'") {
99
- // Start of quoted string
100
- inQuote = ch;
101
- } else if (ch === ' ' || ch === '\t') {
102
- // Whitespace separator
103
- if (current) {
104
- tokens.push(current);
105
- current = '';
106
- }
107
- } else {
108
- current += ch;
109
- }
110
- }
111
-
112
- // Don't forget last token
113
- if (current) {
114
- tokens.push(current);
115
- }
116
-
117
- return tokens;
118
- }
119
-
120
- /**
121
- * Parse a single command line into a step object
122
- * @param {string} line - Single command line
123
- * @returns {{ cmd: string, args: object } | null}
124
- */
125
- function parseCommandLine(line) {
126
- const tokens = tokenize(line);
127
- if (tokens.length === 0) return null;
128
-
129
- // Get command and apply alias
130
- let cmd = tokens[0];
131
- cmd = ALIASES[cmd] || cmd;
132
-
133
- const args = {};
134
- let i = 1;
135
-
136
- // Handle first positional argument based on command type
137
- if (i < tokens.length && !tokens[i].startsWith('--')) {
138
- const firstArg = tokens[i];
139
-
140
- // Special handling for click command
141
- if (cmd === 'click') {
142
- if (/^e\d+$/.test(firstArg)) {
143
- // Element reference: e5 -> ref
144
- args.ref = firstArg;
145
- i++;
146
- } else if (/^\d+$/.test(firstArg) && tokens[i + 1] && /^\d+$/.test(tokens[i + 1])) {
147
- // Coordinates: 100 200 -> x, y
148
- args.x = parseInt(firstArg, 10);
149
- args.y = parseInt(tokens[i + 1], 10);
150
- i += 2;
151
- }
152
- } else if (cmd === 'select') {
153
- // Select takes selector + one or more values: select e5 "US" or select e5 "opt1" "opt2"
154
- args.selector = firstArg;
155
- i++;
156
- // Collect remaining positional args as values
157
- const values = [];
158
- while (i < tokens.length && !tokens[i].startsWith('--')) {
159
- values.push(tokens[i]);
160
- i++;
161
- }
162
- // Host expects 'values' (always), matching CLI behavior
163
- if (values.length === 1) {
164
- args.values = values[0]; // Single value as string (host will wrap in array)
165
- } else if (values.length > 1) {
166
- args.values = values; // Multiple values as array
167
- }
168
- } else if (cmd === 'scroll') {
169
- if (firstArg === 'top' || firstArg === 'bottom') {
170
- cmd = `scroll.${firstArg}`;
171
- i++;
172
- } else if (['up', 'down', 'left', 'right'].includes(firstArg)) {
173
- args.direction = firstArg;
174
- i++;
175
- if (i < tokens.length && /^-?\d+$/.test(tokens[i])) {
176
- args.scroll_pixels = parseInt(tokens[i], 10);
177
- i++;
178
- }
179
- }
180
- } else {
181
- // Use PRIMARY_ARG_MAP for other commands
182
- const primaryKey = PRIMARY_ARG_MAP[cmd];
183
- if (primaryKey) {
184
- args[primaryKey] = firstArg;
185
- i++;
186
- }
187
- }
188
- }
189
-
190
- // Parse --flag value pairs
191
- while (i < tokens.length) {
192
- const token = tokens[i];
193
- if (token.startsWith('--')) {
194
- const key = token.slice(2);
195
- const next = tokens[i + 1];
196
- if (next && !next.startsWith('--')) {
197
- // Flag with value
198
- let val = next;
199
- // Type coercion
200
- if (val === "true") val = true;
201
- else if (val === "false") val = false;
202
- else if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
203
- else if (/^-?\d+\.\d+$/.test(val)) val = parseFloat(val);
204
- args[key] = val;
205
- i += 2;
206
- } else {
207
- // Boolean flag
208
- args[key] = true;
209
- i++;
210
- }
211
- } else {
212
- // Skip unrecognized positional (shouldn't happen normally)
213
- i++;
214
- }
215
- }
216
-
217
- return { cmd, args };
218
- }
219
-
220
- /**
221
- * Parse a workflow string into step array
222
- * Supports pipe-separated (inline) or newline-separated (file) commands
223
- * @param {string} input - Workflow string
224
- * @returns {Array<{ cmd: string, args: object }>}
225
- */
226
- function parseDoCommands(input) {
227
- // Determine separator: use pipe if present, otherwise newlines
228
- // Pipe is preferred for inline: 'go "url" | click e5 | screenshot'
229
- // Newlines for files or heredocs
230
- const hasPipe = input.includes('|');
231
- const separator = hasPipe ? '|' : '\n';
232
-
233
- // Also handle literal \n for backwards compatibility
234
- const normalized = hasPipe ? input : input.replace(/\\n/g, '\n');
235
-
236
- return normalized
237
- .split(separator)
238
- .map(line => line.trim())
239
- .filter(line => line && !line.startsWith('#'))
240
- .map(line => parseCommandLine(line))
241
- .filter(step => step !== null);
242
- }
243
-
244
- module.exports = {
245
- parseDoCommands,
246
- parseCommandLine,
247
- tokenize,
248
- ALIASES,
249
- PRIMARY_ARG_MAP
1
+ const definition = require("./workflow-definition.cjs");
2
+
3
+ module.exports = {
4
+ ALIASES: definition.ALIASES,
5
+ PRIMARY_ARG_MAP: definition.PRIMARY_ARG_MAP,
6
+ parseCommandLine: definition.parseCommandLine,
7
+ parseDoCommands: definition.parseDoCommands,
8
+ tokenize: definition.tokenize,
250
9
  };
@@ -1,6 +1,5 @@
1
1
  const fs = require("fs");
2
2
  const networkFormatters = require("./formatters/network.cjs");
3
- const networkStore = require("./network-store.cjs");
4
3
 
5
4
  function buildProviderUploadMessage(provider, tabId, filePaths, id) {
6
5
  const normalizedProvider = String(provider || "").toLowerCase();
@@ -123,19 +122,6 @@ function formatToolContent(result, log = () => {}, options = {}) {
123
122
  // Handle both requests (basic) and entries (full) formats
124
123
  const items = result.requests || result.entries;
125
124
  if (items && Array.isArray(items)) {
126
- // Persist entries with full data to disk
127
- if (result.entries && items.length > 0) {
128
- (async () => {
129
- for (const entry of items) {
130
- try {
131
- await networkStore.appendEntry(entry);
132
- } catch (err) {
133
- log(`Failed to persist network entry: ${err.message}`);
134
- }
135
- }
136
- })();
137
- }
138
-
139
125
  if (items.length === 0) {
140
126
  return text("No network requests captured");
141
127
  }
@@ -679,6 +665,9 @@ function mapToolToMessage(tool, args, tabId) {
679
665
  limit: a.limit || a.last,
680
666
  format: a.format,
681
667
  verbose: a.v ? 1 : (a.vv ? 2 : 0),
668
+ bodyMode: a["body-mode"] || a.bodyMode,
669
+ perBodyBytes: a["per-body-bytes"] || a.perBodyBytes,
670
+ totalBodyBytes: a["total-body-bytes"] || a.totalBodyBytes,
682
671
  ...baseMsg
683
672
  };
684
673
 
@@ -732,6 +721,9 @@ function mapToolToMessage(tool, args, tabId) {
732
721
  type: "EXPORT_NETWORK_REQUESTS",
733
722
  har: a.har,
734
723
  jsonl: a.jsonl,
724
+ bodyMode: a["body-mode"] || a.bodyMode,
725
+ perBodyBytes: a["per-body-bytes"] || a.perBodyBytes,
726
+ totalBodyBytes: a["total-body-bytes"] || a.totalBodyBytes,
735
727
  ...baseMsg
736
728
  };
737
729
 
@@ -23,13 +23,17 @@ const PROVIDER_DEFAULT_TIMEOUT_SECONDS = {
23
23
  gemini: 300,
24
24
  grok: 300,
25
25
  perplexity: 120,
26
+ "playbook.run": 600,
26
27
  };
27
28
 
28
29
  function resolveRequestDeadlineMs(tool, args = {}) {
29
30
  const defaultSeconds = PROVIDER_DEFAULT_TIMEOUT_SECONDS[tool];
30
31
  if (defaultSeconds === undefined) return DEFAULT_DEADLINE_MS;
32
+ const rawTimeout = tool === "playbook.run" && args && typeof args === "object" && !Array.isArray(args)
33
+ ? args.timeout ?? args.args?.timeout
34
+ : args?.timeout;
31
35
  const requestedSeconds = Number(
32
- args && typeof args === "object" && !Array.isArray(args) ? args.timeout : undefined,
36
+ rawTimeout,
33
37
  );
34
38
  const seconds = Number.isFinite(requestedSeconds) && requestedSeconds > 0
35
39
  ? requestedSeconds
package/native/host.cjs CHANGED
@@ -22,11 +22,30 @@ const { parseListenEndpoint } = require("./listener.cjs");
22
22
  const { getStateDir } = require("./remote-auth.cjs");
23
23
  const { createFrameParser, createServerAuthSession, createSocketWriter, isClientAuthorized, writeFrame, MAX_FRAME_BYTES } = require("./remote-transport.cjs");
24
24
  const { HostSessionManager, resolveRequestDeadlineMs } = require("./host-sessions.cjs");
25
- const { abortError, throwIfAborted } = require("./abort.cjs");
25
+ const { abortError, abortableDelay, throwIfAborted } = require("./abort.cjs");
26
26
  const { BoundedAiQueue } = require("./ai-queue.cjs");
27
27
  const { RequestPendingMap } = require("./request-pending.cjs");
28
28
  const { cleanupFilePaths, createStagingDirectory, createTransferState, materializeRemoteTool, rewriteTransferPaths, streamFileDownload, transferError } = require("./file-transfer.cjs");
29
29
  const { writeNetworkExport } = require("./network-export.cjs");
30
+ const networkStore = require("./network-store.cjs");
31
+ const { redactUrlSecrets } = require("./redaction.cjs");
32
+ const { appendActivity, journalCommand } = require("./activity-journal.cjs");
33
+ const { reserveReceipt, updateReceipt } = require("./playbook-receipts.cjs");
34
+ const {
35
+ activeRecord,
36
+ appendRecordEvent,
37
+ attachNetworkTrace,
38
+ discardRecord,
39
+ markRecord,
40
+ pauseRecord,
41
+ resumeRecord,
42
+ startRecord,
43
+ stopRecord,
44
+ updateRecordContext,
45
+ } = require("./playbook-records.cjs");
46
+ const { resolveArgs, runPlaybookOp } = require("./playbook-runtime.cjs");
47
+ const { resolveOp } = require("./playbooks.cjs");
48
+ const { commandMetadata, redactCommandArgs } = require("./workflow-definition.cjs");
30
49
  const MAX_CLIENT_FRAME_BYTES = MAX_FRAME_BYTES;
31
50
  const TEST_REQUEST_DEADLINE_MS = process.env.SURF_TEST_MODE === "1" && Number.isFinite(Number(process.env.SURF_TEST_REQUEST_DEADLINE_MS))
32
51
  ? Number(process.env.SURF_TEST_REQUEST_DEADLINE_MS)
@@ -428,6 +447,131 @@ function requestCallExtension(request, tool, message, timeoutMs = 30000, cleanup
428
447
  });
429
448
  }
430
449
 
450
+ async function executeMappedHostTool(request, tool, args, tabId) {
451
+ const extensionMsg = mapToolToMessage(tool, args, tabId);
452
+ if (!extensionMsg) throw new Error(`Unknown tool: ${tool}`);
453
+ if (extensionMsg.type === "UNSUPPORTED_ACTION") throw new Error(extensionMsg.message);
454
+ if (extensionMsg.type === "LOCAL_WAIT") {
455
+ await abortableDelay(extensionMsg.seconds * 1000, request.signal);
456
+ return { success: true };
457
+ }
458
+ if (extensionMsg.type === "BATCH_EXECUTE" || extensionMsg.type.endsWith("_QUERY")) {
459
+ throw new Error(`tool ${tool} is not available inside a host-owned workflow`);
460
+ }
461
+ return requestCallExtension(request, tool, extensionMsg, resolveRequestDeadlineMs(tool, args));
462
+ }
463
+
464
+ async function executeNativePlaybook(request, handler, args, options = {}) {
465
+ if (handler !== "chatgpt.ask") throw new Error(`unknown native playbook handler: ${handler}`);
466
+ const result = await chatgptClient.query({
467
+ prompt: args.prompt,
468
+ signal: request.signal,
469
+ model: args.model,
470
+ timeout: args.timeout ? Number(args.timeout) * 1000 : undefined,
471
+ getCookies: () => requestCallExtension(request, "get_cookies", { type: "GET_CHATGPT_COOKIES" }),
472
+ createTab: () => requestCallExtension(request, "create_tab", { type: "CHATGPT_NEW_TAB" }),
473
+ closeTab: (tabId) => requestCallExtension(request, "close_tab", { type: "CHATGPT_CLOSE_TAB", tabId }, 45000, true),
474
+ cdpEvaluate: (tabId, expression) => requestCallExtension(request, "cdp_evaluate", { type: "CHATGPT_EVALUATE", tabId, expression }),
475
+ cdpCommand: (tabId, method, params) => requestCallExtension(request, "cdp_command", { type: "CHATGPT_CDP_COMMAND", tabId, method, params }),
476
+ beforeSubmit: options.markDispatched,
477
+ log: (message) => log(`[playbook:chatgpt] ${message}`),
478
+ });
479
+ return { response: result.response, model: result.model, tookMs: result.tookMs };
480
+ }
481
+
482
+ async function runHostPlaybook(msg, request) {
483
+ const params = msg.params?.args || {};
484
+ const { playbook, op } = resolveOp(params.playbook, params.op, {
485
+ cwd: request.context?.isRemote ? process.cwd() : params.projectDir || process.cwd(),
486
+ pinBuiltIn: params.pinBuiltIn === true,
487
+ });
488
+ const runArgs = resolveArgs(op, params.args || {});
489
+ if (op.effect === "write" && op.safety.authorization === "explicit" && params.write !== true) {
490
+ throw new Error(`write op ${playbook.id} ${op.id} requires --write`);
491
+ }
492
+ const receipt = reserveReceipt({
493
+ playbookId: playbook.id,
494
+ op,
495
+ args: runArgs,
496
+ repeat: params.repeat === true,
497
+ retryAttempt: params.retryAttempt,
498
+ overrideInDoubt: params.overrideInDoubt === true,
499
+ });
500
+ const report = (event) => {
501
+ appendActivity(event);
502
+ appendRecordEvent(event);
503
+ };
504
+ return runPlaybookOp({
505
+ playbook,
506
+ op,
507
+ args: runArgs,
508
+ attemptId: receipt?.attemptId,
509
+ signal: request.signal,
510
+ executeTool: (tool, args) => executeMappedHostTool(request, tool, args, msg.tabId),
511
+ executeNative: (handler, args, options) => executeNativePlaybook(request, handler, args, options),
512
+ sleep: (ms) => abortableDelay(ms, request.signal),
513
+ onEvent: report,
514
+ beforeDispatch: async () => updateReceipt(receipt, "dispatched"),
515
+ afterDispatch: async ({ status, error }) => updateReceipt(receipt, status, { error }),
516
+ });
517
+ }
518
+
519
+ async function handleRecordRequest(tool, args, msg, request) {
520
+ if (tool === "playbook.record.start") {
521
+ let record = startRecord({ ...args, tabId: msg.tabId });
522
+ try {
523
+ const context = await requestCallExtension(request, tool, {
524
+ type: "GET_PLAYBOOK_RECORD_CONTEXT",
525
+ tabId: msg.tabId,
526
+ });
527
+ record = updateRecordContext({
528
+ tabId: context._resolvedTabId || msg.tabId,
529
+ origin: context.origin,
530
+ });
531
+ if (record.capture.network) {
532
+ const result = await requestCallExtension(request, tool, { type: "START_NETWORK_CAPTURE", tabId: record.tabId, bodyMode: "text" });
533
+ record = updateRecordContext({ tabId: result._resolvedTabId || record.tabId });
534
+ }
535
+ if (record.capture.watch) {
536
+ const result = await requestCallExtension(request, tool, { type: "START_PLAYBOOK_WATCH", tabId: record.tabId || msg.tabId, includeInputValues: record.redaction.includeInputValues });
537
+ record = updateRecordContext({ tabId: result._resolvedTabId || record.tabId || msg.tabId });
538
+ }
539
+ return record;
540
+ } catch (error) {
541
+ if (record?.capture.network) await requestCallExtension(request, tool, { type: "STOP_NETWORK_CAPTURE", tabId: record.tabId }, 30000, true).catch(() => {});
542
+ if (record?.capture.watch) await requestCallExtension(request, tool, { type: "STOP_PLAYBOOK_WATCH", tabId: record.tabId }, 30000, true).catch(() => {});
543
+ discardRecord();
544
+ throw error;
545
+ }
546
+ }
547
+ if (tool === "playbook.record.status") return activeRecord() || { status: "idle" };
548
+ if (tool === "playbook.record.mark") return markRecord(args.label);
549
+ if (tool === "playbook.record.pause") return pauseRecord();
550
+ if (tool === "playbook.record.resume") return resumeRecord();
551
+ if (tool === "playbook.record.discard") {
552
+ const record = activeRecord();
553
+ if (record?.capture.network) await requestCallExtension(request, tool, { type: "STOP_NETWORK_CAPTURE", tabId: record.tabId }, 30000, true).catch(() => {});
554
+ if (record?.capture.watch) await requestCallExtension(request, tool, { type: "STOP_PLAYBOOK_WATCH", tabId: record.tabId }, 30000, true).catch(() => {});
555
+ return discardRecord();
556
+ }
557
+ if (tool === "playbook.record.stop") {
558
+ const record = activeRecord();
559
+ if (!record) throw new Error("no active playbook record");
560
+ if (record.capture.network) {
561
+ try {
562
+ const result = await requestCallExtension(request, tool, { type: "READ_NETWORK_REQUESTS", tabId: record.tabId, full: true, limit: 500 });
563
+ const cutoff = Date.parse(record.startedAt);
564
+ attachNetworkTrace(record.id, (result.entries || []).filter((entry) => entry.ts >= cutoff));
565
+ } finally {
566
+ await requestCallExtension(request, tool, { type: "STOP_NETWORK_CAPTURE", tabId: record.tabId }, 30000, true).catch(() => {});
567
+ }
568
+ }
569
+ if (record.capture.watch) await requestCallExtension(request, tool, { type: "STOP_PLAYBOOK_WATCH", tabId: record.tabId }, 30000, true).catch(() => {});
570
+ return stopRecord({ draft: args.draft === true });
571
+ }
572
+ throw new Error(`Unknown record command: ${tool}`);
573
+ }
574
+
431
575
  const sessionManager = new HostSessionManager({
432
576
  audit: auditSession,
433
577
  onTimeout(context, request) {
@@ -531,6 +675,21 @@ function sendToolResponse(socket, id, result, error) {
531
675
  if (finalError && request) {
532
676
  finalError = rewriteTransferPaths(finalError, request.pathRewrites || []);
533
677
  }
678
+ if (request?.tool && !request.tool.startsWith("playbook.")) {
679
+ const metadata = commandMetadata(request.tool);
680
+ if (metadata.recordable) {
681
+ const event = {
682
+ type: finalError ? "tool.failed" : "tool.completed",
683
+ command: metadata.name,
684
+ argsRedacted: redactCommandArgs(request.tool, request.args || {}),
685
+ startedAt: request.activityStartedAt || new Date().toISOString(),
686
+ endedAt: new Date().toISOString(),
687
+ resultSummary: finalError ? "failed" : "success",
688
+ };
689
+ appendActivity(event);
690
+ appendRecordEvent(event);
691
+ }
692
+ }
534
693
  await cleanupRequestTransfers(request);
535
694
  if (request?.settled) return;
536
695
  const outcome = request?.signal.aborted
@@ -607,6 +766,22 @@ function handleToolRequest(msg, socket, requestContext = requestStorage.getStore
607
766
  sendToolResponse(socket, originalId, null, "No tool specified");
608
767
  return;
609
768
  }
769
+
770
+ requestContext.args = args || {};
771
+ requestContext.activityStartedAt = new Date().toISOString();
772
+ if (!tool.startsWith("playbook.")) journalCommand(tool, args || {}, { tabId });
773
+ if (tool === "playbook.run") {
774
+ runHostPlaybook(msg, requestContext)
775
+ .then((result) => sendToolResponse(socket, originalId, { output: JSON.stringify(result) }, null))
776
+ .catch((error) => sendToolResponse(socket, originalId, null, error.message));
777
+ return;
778
+ }
779
+ if (tool.startsWith("playbook.record.")) {
780
+ handleRecordRequest(tool, args || {}, msg, requestContext)
781
+ .then((result) => sendToolResponse(socket, originalId, result, null))
782
+ .catch((error) => sendToolResponse(socket, originalId, null, error.message));
783
+ return;
784
+ }
610
785
 
611
786
  const extensionMsg = mapToolToMessage(tool, args, tabId);
612
787
  if (!extensionMsg) {
@@ -1264,7 +1439,9 @@ function handleToolRequest(msg, socket, requestContext = requestStorage.getStore
1264
1439
  autoScreenshot: args?.autoScreenshot === true,
1265
1440
  autoScreenshotOutput: args?.autoScreenshotOutput,
1266
1441
  networkExport: extensionMsg.type === "EXPORT_NETWORK_REQUESTS",
1442
+ persistNetwork: extensionMsg.type === "READ_NETWORK_REQUESTS" && extensionMsg.full && args?.["no-save"] !== true,
1267
1443
  networkExportPath: args?.output,
1444
+ networkPath: args?.["network-path"],
1268
1445
  networkExportFormat: extensionMsg.har ? "har" : extensionMsg.jsonl ? "jsonl" : "json",
1269
1446
  fullRes: extensionMsg.fullRes || args?.fullRes,
1270
1447
  maxSize: extensionMsg.maxSize || args?.maxSize,
@@ -1431,6 +1608,19 @@ function processInput() {
1431
1608
  handleApiRequest(msg, writeMessage);
1432
1609
  return;
1433
1610
  }
1611
+
1612
+ if (msg.type === "PLAYBOOK_WATCH_EVENT") {
1613
+ appendRecordEvent({
1614
+ type: "browser.event",
1615
+ event: msg.event,
1616
+ selector: msg.selector,
1617
+ value: msg.value,
1618
+ url: redactUrlSecrets(msg.url),
1619
+ tabId: msg.tabId,
1620
+ timestamp: msg.timestamp || new Date().toISOString(),
1621
+ });
1622
+ return;
1623
+ }
1434
1624
 
1435
1625
  if (msg.type === "STREAM_EVENT") {
1436
1626
  const stream = activeStreams.get(msg.streamId);
@@ -1489,6 +1679,14 @@ function processInput() {
1489
1679
  } catch (error) {
1490
1680
  sendToolResponse(socket, originalId, null, `Failed to export network requests: ${error.message}`);
1491
1681
  }
1682
+ } else if (pending.persistNetwork && Array.isArray(msg.entries)) {
1683
+ try {
1684
+ for (const entry of msg.entries) networkStore.appendEntrySync(entry, pending.networkPath);
1685
+ networkStore.maybeAutoCleanup();
1686
+ sendToolResponse(socket, originalId, msg, null);
1687
+ } catch (error) {
1688
+ sendToolResponse(socket, originalId, null, `Failed to persist network requests: ${error.message}`);
1689
+ }
1492
1690
  } else if (savePath && msg.base64) {
1493
1691
  try {
1494
1692
  const dir = path.dirname(savePath);
@@ -1,7 +1,7 @@
1
- const crypto = require("crypto");
2
1
  const fs = require("fs");
3
2
  const path = require("path");
4
3
  const { version: PACKAGE_VERSION } = require("../package.json");
4
+ const { atomicWriteFile } = require("./private-state.cjs");
5
5
 
6
6
  const MAX_NETWORK_EXPORT_FILE_BYTES = 256 * 1024 * 1024;
7
7
  const INTERNAL_FIELDS = new Set(["_requestId", "_responseReceived", "_loadingFinished"]);
@@ -22,6 +22,20 @@ function headerList(headers) {
22
22
  return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }));
23
23
  }
24
24
 
25
+ function headerValue(headers, name) {
26
+ if (!headers || typeof headers !== "object") return "";
27
+ const match = Object.entries(headers).find(([key]) => key.toLowerCase() === name);
28
+ return match ? String(match[1]) : "";
29
+ }
30
+
31
+ function queryList(url) {
32
+ try {
33
+ return [...new URL(url).searchParams.entries()].map(([name, value]) => ({ name, value }));
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
25
39
  function harEntry(entry) {
26
40
  const requestBody = entry.requestBody;
27
41
  const responseBody = entry.responseBody;
@@ -37,11 +51,11 @@ function harEntry(entry) {
37
51
  url: entry.url || "",
38
52
  httpVersion: "HTTP/1.1",
39
53
  headers: headerList(requestHeaders),
40
- queryString: [],
54
+ queryString: queryList(entry.url || ""),
41
55
  cookies: [],
42
56
  headersSize: -1,
43
57
  bodySize: Number.isFinite(entry.requestBodySize) ? entry.requestBodySize : requestBody ? Buffer.byteLength(String(requestBody)) : -1,
44
- ...(requestBody !== undefined ? { postData: { mimeType: "application/octet-stream", text: String(requestBody) } } : {}),
58
+ ...(requestBody !== undefined ? { postData: { mimeType: headerValue(requestHeaders, "content-type") || "application/octet-stream", text: String(requestBody) } } : {}),
45
59
  },
46
60
  response: {
47
61
  status: Number.isFinite(entry.status) ? entry.status : 0,
@@ -53,6 +67,8 @@ function harEntry(entry) {
53
67
  size: Number.isFinite(entry.responseBodySize) ? entry.responseBodySize : responseBody ? Buffer.byteLength(String(responseBody)) : 0,
54
68
  mimeType: entry.mimeType || "",
55
69
  ...(responseBody !== undefined ? { text: String(responseBody) } : {}),
70
+ ...(entry.responseBodyEncoding === "base64" ? { encoding: "base64" } : {}),
71
+ _surfBodyCapture: entry.bodyCapture || { mode: "none", complete: responseBody === undefined ? false : true },
56
72
  },
57
73
  redirectURL: "",
58
74
  headersSize: -1,
@@ -87,20 +103,7 @@ function writeNetworkExport(outputPath, entries, format = "json") {
87
103
  const bytes = Buffer.byteLength(content);
88
104
  if (bytes > MAX_NETWORK_EXPORT_FILE_BYTES) throw new Error("network export exceeds the 256 MiB file limit");
89
105
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
90
- const temporaryPath = path.join(path.dirname(outputPath), `.${path.basename(outputPath)}.surf-${crypto.randomBytes(12).toString("hex")}.tmp`);
91
- try {
92
- const fd = fs.openSync(temporaryPath, "wx", 0o600);
93
- try {
94
- fs.writeFileSync(fd, content, "utf8");
95
- fs.fchmodSync(fd, 0o600);
96
- } finally {
97
- fs.closeSync(fd);
98
- }
99
- fs.renameSync(temporaryPath, outputPath);
100
- } catch (error) {
101
- try { fs.rmSync(temporaryPath, { force: true }); } catch {}
102
- throw error;
103
- }
106
+ atomicWriteFile(outputPath, content, { encoding: "utf8" });
104
107
  return { path: outputPath, format, count: entries.length, bytes };
105
108
  }
106
109