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.
- package/README.md +48 -4
- package/dist/content/accessibility-tree.js +11 -0
- package/dist/content/accessibility-tree.js.map +1 -0
- package/dist/content/visual-indicator.js +111 -0
- package/dist/content/visual-indicator.js.map +1 -0
- package/dist/manifest.json +11 -2
- package/dist/options/options.js +3 -3
- package/dist/options/options.js.map +1 -1
- package/dist/service-worker/index.js +61 -261
- package/dist/service-worker/index.js.map +1 -1
- package/native/activity-journal.cjs +55 -0
- package/native/chatgpt-client.cjs +2 -0
- package/native/cli.cjs +52 -278
- package/native/do-executor.cjs +52 -475
- package/native/do-parser.cjs +8 -249
- package/native/host-helpers.cjs +6 -14
- package/native/host-sessions.cjs +5 -1
- package/native/host.cjs +199 -1
- package/native/network-export.cjs +20 -17
- package/native/network-store.cjs +38 -58
- package/native/playbook-authoring.cjs +44 -0
- package/native/playbook-cli.cjs +157 -0
- package/native/playbook-client.cjs +259 -0
- package/native/playbook-receipts.cjs +109 -0
- package/native/playbook-records.cjs +208 -0
- package/native/playbook-runtime.cjs +177 -0
- package/native/playbooks.cjs +235 -0
- package/native/private-state.cjs +156 -0
- package/native/redaction.cjs +104 -0
- package/native/workflow-definition.cjs +368 -0
- package/native/workflow-runtime.cjs +225 -0
- package/package.json +2 -1
- package/playbooks/page/ops/read.json +22 -0
- package/playbooks/page/playbook.json +7 -0
- package/skills/surf/SKILL.md +41 -1
- package/dist/content/index.js +0 -116
- package/dist/content/index.js.map +0 -1
package/native/do-parser.cjs
CHANGED
|
@@ -1,250 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
};
|
package/native/host-helpers.cjs
CHANGED
|
@@ -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
|
|
package/native/host-sessions.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|