surf-cli 2.18.0 → 2.20.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 +276 -8
- package/dist/content/index.js +4 -4
- package/dist/content/index.js.map +1 -1
- package/dist/options/options.html +1 -18
- package/dist/service-worker/index.js +44 -15
- package/dist/service-worker/index.js.map +1 -1
- package/native/cli.cjs +430 -14
- package/native/do-executor.cjs +35 -8
- package/native/doctor.cjs +211 -42
- package/native/endpoint.cjs +121 -36
- package/native/extract.cjs +362 -0
- package/native/host-helpers.cjs +133 -11
- package/native/host-sessions.cjs +10 -0
- package/native/host.cjs +53 -23
- package/native/mcp-server.cjs +25 -0
- package/native/native-host-launch-probe.cjs +69 -0
- package/native/private-state.cjs +25 -1
- package/native/script-options.cjs +33 -0
- package/native/semantic-cli.cjs +764 -0
- package/native/semantic-core.cjs +369 -0
- package/native/semantic-credentials.cjs +207 -0
- package/native/semantic-provider.cjs +61 -0
- package/native/semantic-workflow-executor.cjs +65 -0
- package/native/semantic-workflow-state.cjs +271 -0
- package/native/semantic-workflow.cjs +398 -0
- package/native/stdin-frames.cjs +33 -0
- package/native/tool-scope.cjs +3 -2
- package/native/workflow-definition.cjs +125 -1
- package/native/workflow-runtime.cjs +71 -5
- package/package.json +11 -7
- package/scripts/install-native-host.cjs +103 -60
- package/scripts/uninstall-native-host.cjs +40 -38
- package/scripts/windows-interop.cjs +89 -0
- package/skills/surf/SKILL.md +96 -1
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/** Retries use fresh tabs, so caller scripts must be read-only or idempotent. */
|
|
2
|
+
|
|
3
|
+
const { applyOptionsPrelude } = require("./script-options.cjs");
|
|
4
|
+
|
|
5
|
+
const DEFAULT_RETRY_COUNT = 1;
|
|
6
|
+
const DEFAULT_RETRY_DELAY_MS = 500;
|
|
7
|
+
const MAX_RETRY_COUNT = 5;
|
|
8
|
+
const ROW_KEY_CANDIDATES = ["rows", "items", "results", "entries", "records", "data"];
|
|
9
|
+
|
|
10
|
+
const TRANSIENT_TAB_ERROR_MARKERS = [
|
|
11
|
+
"navigated or closed",
|
|
12
|
+
"Detached while handling command",
|
|
13
|
+
"Cannot find default execution context",
|
|
14
|
+
"Execution context was destroyed",
|
|
15
|
+
"Receiving end does not exist",
|
|
16
|
+
"Content script not loaded",
|
|
17
|
+
"no longer exists",
|
|
18
|
+
"Target closed",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const RETRYABLE_ERROR_CODES = new Set(["empty_result", "page_timeout", "tab_gone", "target_gone"]);
|
|
22
|
+
|
|
23
|
+
const FATAL_READINESS_CODES = new Set(["page_login", "page_challenge", "page_not_found", "page_error"]);
|
|
24
|
+
|
|
25
|
+
class ExtractError extends Error {
|
|
26
|
+
constructor(code, message, details = {}) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "ExtractError";
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.details = details;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function errorMessageOf(error) {
|
|
35
|
+
if (error instanceof Error) return error.message;
|
|
36
|
+
if (typeof error === "string") return error;
|
|
37
|
+
if (error && typeof error === "object") {
|
|
38
|
+
const text = error.content?.[0]?.text;
|
|
39
|
+
if (typeof text === "string") return text;
|
|
40
|
+
if (typeof error.message === "string") return error.message;
|
|
41
|
+
return JSON.stringify(error);
|
|
42
|
+
}
|
|
43
|
+
return String(error);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function errorCodeOf(error) {
|
|
47
|
+
if (error && typeof error === "object" && typeof error.code === "string") return error.code;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isTransientTabError(error) {
|
|
52
|
+
const message = errorMessageOf(error);
|
|
53
|
+
return TRANSIENT_TAB_ERROR_MARKERS.some((marker) => message.includes(marker));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether a failed attempt is worth a fresh tab. Login bounces, challenges
|
|
58
|
+
* and not-found pages are not: the next tab lands on the same page.
|
|
59
|
+
*/
|
|
60
|
+
function isRetryableExtractionError(error) {
|
|
61
|
+
const code = errorCodeOf(error);
|
|
62
|
+
if (code && FATAL_READINESS_CODES.has(code)) return false;
|
|
63
|
+
if (code && RETRYABLE_ERROR_CODES.has(code)) return true;
|
|
64
|
+
return isTransientTabError(error);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function responseText(response) {
|
|
68
|
+
const text = response?.result?.content?.[0]?.text;
|
|
69
|
+
return typeof text === "string" ? text : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function responseError(response, stage) {
|
|
73
|
+
if (!response || !response.error) return null;
|
|
74
|
+
const err = response.error;
|
|
75
|
+
const code = errorCodeOf(err) || "tool_error";
|
|
76
|
+
return new ExtractError(code, errorMessageOf(err), { stage, ...(err.details || {}) });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseExtractionOutput(text) {
|
|
80
|
+
if (text === null || text === undefined || text.trim() === "" || text.trim() === "undefined") {
|
|
81
|
+
throw new ExtractError(
|
|
82
|
+
"no_output",
|
|
83
|
+
"The extraction script returned nothing. End it with `return { rows: [...] }` or `return [...]`.",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(text);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new ExtractError("invalid_output", `The extraction script did not return JSON: ${error.message}`, {
|
|
90
|
+
preview: text.slice(0, 200),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Pick the row array out of the script result: the result itself when it
|
|
97
|
+
* is an array, `--rows <key>` when given, else the first conventional key
|
|
98
|
+
* holding an array. Returns null when the result has no row concept.
|
|
99
|
+
*/
|
|
100
|
+
function selectRows(data, rowsKey) {
|
|
101
|
+
if (rowsKey) {
|
|
102
|
+
const rows = data && typeof data === "object" && !Array.isArray(data) ? data[rowsKey] : undefined;
|
|
103
|
+
if (!Array.isArray(rows)) {
|
|
104
|
+
throw new ExtractError("rows_key_missing", `The script result has no array at "${rowsKey}"`, {
|
|
105
|
+
keys: data && typeof data === "object" ? Object.keys(data) : [],
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return rows;
|
|
109
|
+
}
|
|
110
|
+
if (Array.isArray(data)) return data;
|
|
111
|
+
if (data && typeof data === "object") {
|
|
112
|
+
for (const key of ROW_KEY_CANDIDATES) {
|
|
113
|
+
if (Array.isArray(data[key])) return data[key];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Zero rows is a failure unless the caller opts in. A logged-out render,
|
|
121
|
+
* a selector miss or a half-loaded page all look like "no results"; only
|
|
122
|
+
* the caller knows whether an empty result is plausible.
|
|
123
|
+
*/
|
|
124
|
+
function enforceRowsInvariant(rows, { allowEmpty = false, readiness } = {}) {
|
|
125
|
+
if (!Array.isArray(rows) || rows.length > 0 || allowEmpty) return;
|
|
126
|
+
if (readiness?.state === "empty") return;
|
|
127
|
+
throw new ExtractError(
|
|
128
|
+
"empty_result",
|
|
129
|
+
"The extraction returned zero rows (the page may be logged out, blocked, or the selectors missed). Pass --allow-empty to accept an empty result, or --empty-text to recognise the page's own no-results message.",
|
|
130
|
+
{ rows: 0 },
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function cellText(value) {
|
|
135
|
+
let text;
|
|
136
|
+
if (value === null || value === undefined) text = "";
|
|
137
|
+
else if (typeof value === "string") text = value;
|
|
138
|
+
else text = JSON.stringify(value);
|
|
139
|
+
return text.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Markdown for humans and LLMs: metadata bullets, then a table of rows. */
|
|
143
|
+
function renderExtractionMarkdown(data, rows, { title = "Extraction" } = {}) {
|
|
144
|
+
const lines = [`# ${title}`, ""];
|
|
145
|
+
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
146
|
+
for (const [key, value] of Object.entries(data)) {
|
|
147
|
+
if (Array.isArray(value) || (value && typeof value === "object")) continue;
|
|
148
|
+
lines.push(`- ${key}: ${cellText(value)}`);
|
|
149
|
+
}
|
|
150
|
+
if (lines.length > 2) lines.push("");
|
|
151
|
+
}
|
|
152
|
+
if (!Array.isArray(rows)) {
|
|
153
|
+
lines.push("```json", JSON.stringify(data, null, 2), "```");
|
|
154
|
+
return lines.join("\n");
|
|
155
|
+
}
|
|
156
|
+
lines.push(`${rows.length} row${rows.length === 1 ? "" : "s"}`, "");
|
|
157
|
+
if (rows.length === 0) return lines.join("\n").trimEnd();
|
|
158
|
+
if (!rows.every((row) => row !== null && typeof row === "object" && !Array.isArray(row))) {
|
|
159
|
+
for (const row of rows) lines.push(`- ${cellText(row)}`);
|
|
160
|
+
return lines.join("\n");
|
|
161
|
+
}
|
|
162
|
+
const columns = [];
|
|
163
|
+
for (const row of rows) {
|
|
164
|
+
for (const key of Object.keys(row)) {
|
|
165
|
+
if (!columns.includes(key)) columns.push(key);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
lines.push(`| ${columns.join(" | ")} |`);
|
|
169
|
+
lines.push(`| ${columns.map(() => "---").join(" | ")} |`);
|
|
170
|
+
for (const row of rows) {
|
|
171
|
+
lines.push(`| ${columns.map((column) => cellText(row[column])).join(" | ")} |`);
|
|
172
|
+
}
|
|
173
|
+
return lines.join("\n");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizeRetry(retry) {
|
|
177
|
+
const count = Number.isInteger(retry?.count) ? Math.max(0, Math.min(retry.count, MAX_RETRY_COUNT)) : DEFAULT_RETRY_COUNT;
|
|
178
|
+
const delayMs = Number.isFinite(retry?.delayMs) && retry.delayMs >= 0 ? retry.delayMs : DEFAULT_RETRY_DELAY_MS;
|
|
179
|
+
return { count, delayMs };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function readinessArgs(ready = {}) {
|
|
183
|
+
const args = {};
|
|
184
|
+
if (ready.selector) args.selector = ready.selector;
|
|
185
|
+
if (ready.text) args.text = ready.text;
|
|
186
|
+
if (ready.urlPrefix) args.urlPrefix = ready.urlPrefix;
|
|
187
|
+
if (ready.emptyText) args.emptyText = ready.emptyText;
|
|
188
|
+
if (ready.timeout !== undefined) args.timeout = ready.timeout;
|
|
189
|
+
if (ready.interval !== undefined) args.interval = ready.interval;
|
|
190
|
+
return args;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Tab id from the stable structured field on a tab.new host response. */
|
|
194
|
+
function tabIdFromResponse(response) {
|
|
195
|
+
const failure = responseError(response, "tab.new");
|
|
196
|
+
if (failure) throw failure;
|
|
197
|
+
const tabId = response?.result?.tabId;
|
|
198
|
+
if (Number.isInteger(tabId) && tabId > 0) return tabId;
|
|
199
|
+
throw new ExtractError("no_tab", "tab.new did not return a structured tab id");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function parseToolJson(response, stage) {
|
|
203
|
+
const failure = responseError(response, stage);
|
|
204
|
+
if (failure) throw failure;
|
|
205
|
+
const text = responseText(response);
|
|
206
|
+
if (text === null) return null;
|
|
207
|
+
try {
|
|
208
|
+
return JSON.parse(text);
|
|
209
|
+
} catch {
|
|
210
|
+
return text;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Drop transport-only fields from the readiness metadata returned to callers. */
|
|
215
|
+
function cleanReadiness(readiness) {
|
|
216
|
+
if (!readiness || typeof readiness !== "object") return readiness;
|
|
217
|
+
const { id, _resolvedTabId, _resolvedWindowId, _hint, ...publicReadiness } = readiness;
|
|
218
|
+
return publicReadiness;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function runAttemptOnTab(executeTool, tabId, settings) {
|
|
222
|
+
const readiness = cleanReadiness(
|
|
223
|
+
parseToolJson(await executeTool("wait.ready", readinessArgs(settings.ready), tabId), "wait.ready"),
|
|
224
|
+
);
|
|
225
|
+
const code = applyOptionsPrelude(settings.code, settings.options);
|
|
226
|
+
const jsResponse = await executeTool("js", { code }, tabId);
|
|
227
|
+
const failure = responseError(jsResponse, "js");
|
|
228
|
+
if (failure) throw failure;
|
|
229
|
+
const data = parseExtractionOutput(responseText(jsResponse));
|
|
230
|
+
const rows = selectRows(data, settings.rowsKey);
|
|
231
|
+
enforceRowsInvariant(rows, { allowEmpty: settings.allowEmpty, readiness });
|
|
232
|
+
return { data, rows, readiness };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* @param {object} settings
|
|
237
|
+
* @param {(tool: string, args: object, tabId?: number) => Promise<object>} settings.executeTool
|
|
238
|
+
* Sends one tool request. `tabId` overrides the target for owned tabs; when
|
|
239
|
+
* it is undefined the caller's default target (session/tab/window) applies.
|
|
240
|
+
* @param {string} settings.code Page-side script; must `return` JSON.
|
|
241
|
+
* @param {string} [settings.url] Page to open. Required unless `target` is set.
|
|
242
|
+
* @param {object} [settings.options] Exposed to the script as SURF_OPTIONS.
|
|
243
|
+
* @param {object} [settings.ready] wait.ready expectations (selector, text, urlPrefix, emptyText, timeout, interval).
|
|
244
|
+
* @param {{count?: number, delayMs?: number}} [settings.retry]
|
|
245
|
+
* @param {boolean} [settings.keepTab] Leave the owned tab open on success.
|
|
246
|
+
* @param {boolean} [settings.allowEmpty]
|
|
247
|
+
* @param {string} [settings.rowsKey]
|
|
248
|
+
* @param {boolean} [settings.target] Use the caller's target instead of an owned tab.
|
|
249
|
+
* @param {(error: unknown) => boolean} [settings.isRetryable]
|
|
250
|
+
* @param {(ms: number) => Promise<void>} [settings.sleep]
|
|
251
|
+
* @param {(event: object) => void} [settings.onEvent]
|
|
252
|
+
*/
|
|
253
|
+
async function runExtraction(settings) {
|
|
254
|
+
const {
|
|
255
|
+
executeTool,
|
|
256
|
+
code,
|
|
257
|
+
url,
|
|
258
|
+
target = false,
|
|
259
|
+
keepTab = false,
|
|
260
|
+
isRetryable = isRetryableExtractionError,
|
|
261
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
262
|
+
onEvent = () => {},
|
|
263
|
+
} = settings;
|
|
264
|
+
if (typeof executeTool !== "function") throw new Error("runExtraction requires executeTool");
|
|
265
|
+
if (typeof code !== "string" || code.trim() === "") throw new ExtractError("no_script", "An extraction script is required (--file or --code)");
|
|
266
|
+
if (!target && !url) throw new ExtractError("no_url", "A URL is required unless --tab-id or --session names the page to read");
|
|
267
|
+
|
|
268
|
+
if (target) {
|
|
269
|
+
// Caller-supplied target: navigate once if asked, never close, never retry.
|
|
270
|
+
if (url) {
|
|
271
|
+
const navigation = await executeTool("navigate", { url });
|
|
272
|
+
const failure = responseError(navigation, "navigate");
|
|
273
|
+
if (failure) throw failure;
|
|
274
|
+
}
|
|
275
|
+
onEvent({ type: "attempt", attempt: 1, of: 1, mode: "target" });
|
|
276
|
+
const attempt = await runAttemptOnTab(executeTool, undefined, settings);
|
|
277
|
+
return { ...attempt, rowCount: Array.isArray(attempt.rows) ? attempt.rows.length : null, attempts: 1, mode: "target", url: url ?? null };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const retry = normalizeRetry(settings.retry);
|
|
281
|
+
const attempts = retry.count + 1;
|
|
282
|
+
let lastError = null;
|
|
283
|
+
let attemptsMade = 0;
|
|
284
|
+
|
|
285
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
286
|
+
attemptsMade = attempt;
|
|
287
|
+
if (attempt > 1) await sleep(retry.delayMs);
|
|
288
|
+
onEvent({ type: "attempt", attempt, of: attempts, mode: "owned-tab" });
|
|
289
|
+
let tabId = null;
|
|
290
|
+
let result;
|
|
291
|
+
try {
|
|
292
|
+
tabId = tabIdFromResponse(await executeTool("tab.new", { url }));
|
|
293
|
+
result = await runAttemptOnTab(executeTool, tabId, settings);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
lastError = error;
|
|
296
|
+
let cleanupError = null;
|
|
297
|
+
if (tabId) {
|
|
298
|
+
try {
|
|
299
|
+
const closed = await executeTool("tab.close", { id: tabId }, tabId);
|
|
300
|
+
const closeFailure = responseError(closed, "tab.close");
|
|
301
|
+
if (closeFailure) throw closeFailure;
|
|
302
|
+
} catch (closeError) {
|
|
303
|
+
cleanupError = closeError;
|
|
304
|
+
onEvent({ type: "close-failed", attempt, tabId, error: errorMessageOf(closeError) });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (cleanupError) {
|
|
308
|
+
throw new ExtractError("cleanup_failed", `Extraction failed and the owned tab could not be closed: ${errorMessageOf(cleanupError)}`, {
|
|
309
|
+
stage: "tab.close",
|
|
310
|
+
tabId,
|
|
311
|
+
attempts: attempt,
|
|
312
|
+
extractionError: { code: errorCodeOf(error), message: errorMessageOf(error) },
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
const retryable = attempt < attempts && isRetryable(error);
|
|
316
|
+
onEvent({ type: "attempt-failed", attempt, of: attempts, error: errorMessageOf(error), code: errorCodeOf(error), retryable });
|
|
317
|
+
if (!retryable) break;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (!keepTab) {
|
|
322
|
+
try {
|
|
323
|
+
const closed = await executeTool("tab.close", { id: tabId }, tabId);
|
|
324
|
+
const closeFailure = responseError(closed, "tab.close");
|
|
325
|
+
if (closeFailure) throw closeFailure;
|
|
326
|
+
} catch (error) {
|
|
327
|
+
throw new ExtractError("cleanup_failed", `Extraction succeeded but the owned tab could not be closed: ${errorMessageOf(error)}`, {
|
|
328
|
+
stage: "tab.close",
|
|
329
|
+
tabId,
|
|
330
|
+
attempts: attempt,
|
|
331
|
+
extractionSucceeded: true,
|
|
332
|
+
rowCount: Array.isArray(result.rows) ? result.rows.length : null,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
...result,
|
|
338
|
+
rowCount: Array.isArray(result.rows) ? result.rows.length : null,
|
|
339
|
+
attempts: attempt,
|
|
340
|
+
mode: "owned-tab",
|
|
341
|
+
url,
|
|
342
|
+
tabId: keepTab ? tabId : null,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
if (lastError instanceof ExtractError) {
|
|
346
|
+
lastError.details = { ...lastError.details, attempts: attemptsMade };
|
|
347
|
+
throw lastError;
|
|
348
|
+
}
|
|
349
|
+
throw new ExtractError(errorCodeOf(lastError) || "extraction_failed", errorMessageOf(lastError), { attempts: attemptsMade });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
module.exports = {
|
|
353
|
+
ExtractError,
|
|
354
|
+
enforceRowsInvariant,
|
|
355
|
+
isRetryableExtractionError,
|
|
356
|
+
isTransientTabError,
|
|
357
|
+
parseExtractionOutput,
|
|
358
|
+
renderExtractionMarkdown,
|
|
359
|
+
runExtraction,
|
|
360
|
+
selectRows,
|
|
361
|
+
tabIdFromResponse,
|
|
362
|
+
};
|
package/native/host-helpers.cjs
CHANGED
|
@@ -228,6 +228,21 @@ function formatToolContent(result, log = () => {}, options = {}) {
|
|
|
228
228
|
];
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
if (result.semanticObservation !== undefined) {
|
|
232
|
+
return text(JSON.stringify({
|
|
233
|
+
pageContent: result.pageContent,
|
|
234
|
+
viewport: result.viewport,
|
|
235
|
+
semanticObservation: result.semanticObservation,
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (
|
|
240
|
+
result.identity !== undefined && result.matches !== undefined ||
|
|
241
|
+
result.scopeToken !== undefined && result.geometry !== undefined
|
|
242
|
+
) {
|
|
243
|
+
return text(JSON.stringify(result));
|
|
244
|
+
}
|
|
245
|
+
|
|
231
246
|
if (result.pageContent !== undefined) {
|
|
232
247
|
const content = result.pageContent || "No content";
|
|
233
248
|
let output = '';
|
|
@@ -442,6 +457,11 @@ function formatToolContent(result, log = () => {}, options = {}) {
|
|
|
442
457
|
|
|
443
458
|
// Bug fix: Handle success with metrics/frames/readyState/hint in one block
|
|
444
459
|
if (result.success) {
|
|
460
|
+
// Keep the response envelope intact so the CLI can print body bytes verbatim
|
|
461
|
+
// for text output while still producing structured output under --json.
|
|
462
|
+
if (typeof result.body === "string" && Object.hasOwn(result, "base64Encoded")) {
|
|
463
|
+
return text(JSON.stringify(result));
|
|
464
|
+
}
|
|
445
465
|
if (result.metrics) {
|
|
446
466
|
return text(JSON.stringify(result.metrics, null, 2));
|
|
447
467
|
}
|
|
@@ -462,13 +482,37 @@ function formatToolContent(result, log = () => {}, options = {}) {
|
|
|
462
482
|
}
|
|
463
483
|
|
|
464
484
|
// Strip internal fields before JSON output
|
|
465
|
-
const { _resolvedTabId, _hint, ...cleanResult } = result;
|
|
485
|
+
const { _resolvedTabId, _resolvedWindowId, _hint, ...cleanResult } = result;
|
|
466
486
|
if (_hint) {
|
|
467
487
|
return text(JSON.stringify(cleanResult) + `\n[hint] ${_hint}`);
|
|
468
488
|
}
|
|
469
489
|
return text(JSON.stringify(cleanResult));
|
|
470
490
|
}
|
|
471
491
|
|
|
492
|
+
/**
|
|
493
|
+
* Readiness expectations accept both CLI flag spelling (--url-prefix) and
|
|
494
|
+
* socket API spelling (urlPrefix). Empty values are dropped.
|
|
495
|
+
*/
|
|
496
|
+
function readinessExpectations(a) {
|
|
497
|
+
const pick = (...keys) => {
|
|
498
|
+
for (const key of keys) {
|
|
499
|
+
const value = a[key];
|
|
500
|
+
if (typeof value === "string" && value.trim() !== "") return value;
|
|
501
|
+
}
|
|
502
|
+
return undefined;
|
|
503
|
+
};
|
|
504
|
+
const expect = {
|
|
505
|
+
selector: pick("selector"),
|
|
506
|
+
text: pick("text"),
|
|
507
|
+
urlPrefix: pick("urlPrefix", "url-prefix"),
|
|
508
|
+
emptyText: pick("emptyText", "empty-text"),
|
|
509
|
+
};
|
|
510
|
+
for (const key of Object.keys(expect)) {
|
|
511
|
+
if (expect[key] === undefined) delete expect[key];
|
|
512
|
+
}
|
|
513
|
+
return expect;
|
|
514
|
+
}
|
|
515
|
+
|
|
472
516
|
/**
|
|
473
517
|
* Map computer action to extension message
|
|
474
518
|
*/
|
|
@@ -477,7 +521,7 @@ function mapComputerAction(args, tabId) {
|
|
|
477
521
|
const { action, text, scroll_direction, scroll_amount,
|
|
478
522
|
start_coordinate, ref, duration, modifiers } = a;
|
|
479
523
|
const coordinate = a.coordinate || (a.x !== undefined && a.y !== undefined ? [a.x, a.y] : undefined);
|
|
480
|
-
const baseMsg = { tabId };
|
|
524
|
+
const baseMsg = { tabId, ...(Number.isInteger(a.semanticFrameId) ? { frameId: a.semanticFrameId } : {}) };
|
|
481
525
|
|
|
482
526
|
if (!action) {
|
|
483
527
|
return { type: "UNSUPPORTED_ACTION", action: null, message: "No action specified for computer tool" };
|
|
@@ -488,7 +532,7 @@ function mapComputerAction(args, tabId) {
|
|
|
488
532
|
return { type: "EXECUTE_SCREENSHOT", ...baseMsg };
|
|
489
533
|
|
|
490
534
|
case "left_click":
|
|
491
|
-
if (ref) return { type: "CLICK_REF", ref, button: "left", ...baseMsg };
|
|
535
|
+
if (ref) return { type: "CLICK_REF", ref, button: "left", expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
|
|
492
536
|
if (a.selector) return { type: "CLICK_SELECTOR", selector: a.selector, index: a.index || 0, button: "left", ...baseMsg };
|
|
493
537
|
return { type: "EXECUTE_CLICK", x: coordinate?.[0], y: coordinate?.[1], modifiers, ...baseMsg };
|
|
494
538
|
|
|
@@ -506,7 +550,7 @@ function mapComputerAction(args, tabId) {
|
|
|
506
550
|
|
|
507
551
|
case "type": {
|
|
508
552
|
if (ref) {
|
|
509
|
-
return { type: "FORM_FILL", data: [{ ref, value: text }], ...baseMsg };
|
|
553
|
+
return { type: "FORM_FILL", data: [{ ref, value: text }], expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
|
|
510
554
|
}
|
|
511
555
|
const typeSelector = a.selector || a.into;
|
|
512
556
|
if (typeSelector) {
|
|
@@ -546,7 +590,7 @@ function mapComputerAction(args, tabId) {
|
|
|
546
590
|
right: { deltaX: amount, deltaY: 0 },
|
|
547
591
|
};
|
|
548
592
|
const { deltaX, deltaY } = deltas[direction] || { deltaX: 0, deltaY: 0 };
|
|
549
|
-
return { type: "EXECUTE_SCROLL", deltaX, deltaY, x: coordinate?.[0], y: coordinate?.[1], ...baseMsg };
|
|
593
|
+
return { type: "EXECUTE_SCROLL", deltaX, deltaY, x: coordinate?.[0], y: coordinate?.[1], expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
|
|
550
594
|
}
|
|
551
595
|
|
|
552
596
|
case "scroll_to":
|
|
@@ -585,14 +629,14 @@ function mapComputerAction(args, tabId) {
|
|
|
585
629
|
* Map tool name and args to extension message
|
|
586
630
|
*/
|
|
587
631
|
function mapToolToMessage(tool, args, tabId) {
|
|
588
|
-
const baseMsg = { tabId };
|
|
589
632
|
const a = args || {};
|
|
633
|
+
const baseMsg = { tabId, ...(Number.isInteger(a.semanticFrameId) ? { frameId: a.semanticFrameId } : {}) };
|
|
590
634
|
|
|
591
635
|
switch (tool) {
|
|
592
636
|
case "computer":
|
|
593
637
|
return mapComputerAction(args, tabId);
|
|
594
638
|
case "navigate":
|
|
595
|
-
return { type: "EXECUTE_NAVIGATE", url: a.url, ...baseMsg };
|
|
639
|
+
return { type: "EXECUTE_NAVIGATE", url: a.url, expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
|
|
596
640
|
case "read_page":
|
|
597
641
|
return {
|
|
598
642
|
type: "READ_PAGE",
|
|
@@ -856,9 +900,9 @@ function mapToolToMessage(tool, args, tabId) {
|
|
|
856
900
|
case "js":
|
|
857
901
|
return { type: "EXECUTE_JAVASCRIPT", code: a.code, ...baseMsg };
|
|
858
902
|
case "scroll.top":
|
|
859
|
-
return { type: "SCROLL_TO_POSITION", position: "top", selector: a.selector, ...baseMsg };
|
|
903
|
+
return { type: "SCROLL_TO_POSITION", position: "top", selector: a.selector, expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
|
|
860
904
|
case "scroll.bottom":
|
|
861
|
-
return { type: "SCROLL_TO_POSITION", position: "bottom", selector: a.selector, ...baseMsg };
|
|
905
|
+
return { type: "SCROLL_TO_POSITION", position: "bottom", selector: a.selector, expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
|
|
862
906
|
case "scroll.info":
|
|
863
907
|
return { type: "GET_SCROLL_INFO", selector: a.selector, ...baseMsg };
|
|
864
908
|
case "scroll.to":
|
|
@@ -871,10 +915,23 @@ function mapToolToMessage(tool, args, tabId) {
|
|
|
871
915
|
return { type: "WAIT_FOR_URL", pattern: a.pattern || a.url, timeout: a.timeout, ...baseMsg };
|
|
872
916
|
case "wait.dom":
|
|
873
917
|
return { type: "WAIT_FOR_DOM_STABLE", stable: a.stable || 100, timeout: a.timeout || 5000, ...baseMsg };
|
|
918
|
+
case "wait.ready":
|
|
919
|
+
return {
|
|
920
|
+
type: "WAIT_FOR_READY",
|
|
921
|
+
expect: readinessExpectations(a),
|
|
922
|
+
timeout: a.timeout,
|
|
923
|
+
interval: a.interval,
|
|
924
|
+
accept: a.accept,
|
|
925
|
+
...baseMsg,
|
|
926
|
+
};
|
|
927
|
+
case "page.readiness":
|
|
928
|
+
return { type: "PAGE_READINESS", expect: readinessExpectations(a), ...baseMsg };
|
|
874
929
|
case "wait.load":
|
|
875
930
|
return { type: "WAIT_FOR_LOAD", timeout: a.timeout || 30000, ...baseMsg };
|
|
876
931
|
case "frame.list":
|
|
877
932
|
return { type: "GET_FRAMES", ...baseMsg };
|
|
933
|
+
case "frame.diagnose":
|
|
934
|
+
return { type: "FRAME_DIAGNOSE", ...baseMsg };
|
|
878
935
|
case "frame.switch":
|
|
879
936
|
return {
|
|
880
937
|
type: "FRAME_SWITCH",
|
|
@@ -929,7 +986,7 @@ function mapToolToMessage(tool, args, tabId) {
|
|
|
929
986
|
if (typeof fillData === "string") {
|
|
930
987
|
try { fillData = JSON.parse(fillData); } catch (e) { throw new Error("invalid --data JSON"); }
|
|
931
988
|
}
|
|
932
|
-
return { type: "FORM_FILL", data: fillData, ...baseMsg };
|
|
989
|
+
return { type: "FORM_FILL", data: fillData, expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
|
|
933
990
|
case "perf.start":
|
|
934
991
|
return { type: "PERF_START", categories: a.categories ? a.categories.split(",") : undefined, ...baseMsg };
|
|
935
992
|
case "perf.stop":
|
|
@@ -961,10 +1018,30 @@ function mapToolToMessage(tool, args, tabId) {
|
|
|
961
1018
|
compact: a.compact || false,
|
|
962
1019
|
maxBytes,
|
|
963
1020
|
forceFullSnapshot: a.compact === true || maxBytes !== undefined,
|
|
1021
|
+
...(a.semanticObservation === true ? { semanticObservation: true } : {}),
|
|
964
1022
|
},
|
|
965
1023
|
...baseMsg
|
|
966
1024
|
};
|
|
967
1025
|
}
|
|
1026
|
+
case "semantic.localCompare":
|
|
1027
|
+
return {
|
|
1028
|
+
type: "SEMANTIC_LOCAL_COMPARE",
|
|
1029
|
+
ref: a.ref,
|
|
1030
|
+
predicate: a.predicate,
|
|
1031
|
+
expectedIdentity: a.semanticExpectedIdentity,
|
|
1032
|
+
...baseMsg,
|
|
1033
|
+
};
|
|
1034
|
+
case "semantic.scrollScope":
|
|
1035
|
+
if (!["inspect", "top", "advance"].includes(a.action)) {
|
|
1036
|
+
throw new Error("semantic scroll scope action must be inspect, top, or advance");
|
|
1037
|
+
}
|
|
1038
|
+
return {
|
|
1039
|
+
type: "SEMANTIC_SCROLL_SCOPE",
|
|
1040
|
+
action: a.action,
|
|
1041
|
+
scopeToken: a.scopeToken,
|
|
1042
|
+
expectedIdentity: a.semanticExpectedIdentity,
|
|
1043
|
+
...baseMsg,
|
|
1044
|
+
};
|
|
968
1045
|
case "page.text":
|
|
969
1046
|
return { type: "GET_PAGE_TEXT", ...baseMsg };
|
|
970
1047
|
case "page.html":
|
|
@@ -1280,4 +1357,49 @@ function mapToolToMessage(tool, args, tabId) {
|
|
|
1280
1357
|
}
|
|
1281
1358
|
}
|
|
1282
1359
|
|
|
1283
|
-
|
|
1360
|
+
function applySemanticExpectedIdentity(request, extensionMessage, args) {
|
|
1361
|
+
const fail = (code, message) => {
|
|
1362
|
+
const error = new Error(message);
|
|
1363
|
+
error.code = code;
|
|
1364
|
+
throw error;
|
|
1365
|
+
};
|
|
1366
|
+
const expected = args?.semanticExpectedIdentity;
|
|
1367
|
+
const identityRequired = ["SEMANTIC_LOCAL_COMPARE", "SEMANTIC_SCROLL_SCOPE"].includes(extensionMessage?.type);
|
|
1368
|
+
if (expected === undefined) {
|
|
1369
|
+
if (identityRequired) fail("invalid_expected_identity", "semantic expected identity is required for this action");
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
const guardedTypes = [
|
|
1373
|
+
"CLICK_REF", "FORM_FILL", "EXECUTE_NAVIGATE", "EXECUTE_SCROLL", "SCROLL_TO_POSITION",
|
|
1374
|
+
"SEMANTIC_LOCAL_COMPARE", "SEMANTIC_SCROLL_SCOPE",
|
|
1375
|
+
];
|
|
1376
|
+
if (!extensionMessage || !guardedTypes.includes(extensionMessage.type)) {
|
|
1377
|
+
fail("invalid_expected_identity", "semantic expected identity is not valid for this action");
|
|
1378
|
+
}
|
|
1379
|
+
const domAction = ["CLICK_REF", "FORM_FILL", "SEMANTIC_LOCAL_COMPARE"].includes(extensionMessage.type);
|
|
1380
|
+
const stringFields = ["browserEpoch", "fullUrl", "documentToken", ...(domAction ? ["ref", "role", "name", "type"] : [])];
|
|
1381
|
+
if (!expected || typeof expected !== "object" || stringFields.some((field) => typeof expected[field] !== "string")) {
|
|
1382
|
+
fail("invalid_expected_identity", "invalid semantic expected identity");
|
|
1383
|
+
}
|
|
1384
|
+
if (!Number.isInteger(expected.tabId) || !Number.isInteger(expected.frameId)) {
|
|
1385
|
+
fail("invalid_expected_identity", "invalid semantic expected identity");
|
|
1386
|
+
}
|
|
1387
|
+
const actualFrameId = Number.isInteger(extensionMessage.frameId) ? extensionMessage.frameId : 0;
|
|
1388
|
+
if (
|
|
1389
|
+
request?.browserIdentity?.browserEpoch !== expected.browserEpoch ||
|
|
1390
|
+
request?.target?.tabId !== expected.tabId ||
|
|
1391
|
+
actualFrameId !== expected.frameId ||
|
|
1392
|
+
domAction && extensionMessage.ref && extensionMessage.ref !== expected.ref ||
|
|
1393
|
+
extensionMessage.type === "FORM_FILL" &&
|
|
1394
|
+
(!Array.isArray(extensionMessage.data) || extensionMessage.data.length !== 1 || extensionMessage.data[0]?.ref !== expected.ref)
|
|
1395
|
+
) {
|
|
1396
|
+
fail("stale_observation", "stale_observation");
|
|
1397
|
+
}
|
|
1398
|
+
extensionMessage.expectedIdentity = {
|
|
1399
|
+
fullUrl: expected.fullUrl,
|
|
1400
|
+
documentToken: expected.documentToken,
|
|
1401
|
+
...(domAction ? { ref: expected.ref, role: expected.role, name: expected.name, type: expected.type } : {}),
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
module.exports = { mapToolToMessage, mapComputerAction, formatToolContent, formatToolError, buildProviderUploadMessage, readinessExpectations, applySemanticExpectedIdentity };
|
package/native/host-sessions.cjs
CHANGED
|
@@ -15,6 +15,9 @@ const QUEUE_TIMEOUT_MS = 60000;
|
|
|
15
15
|
const DEFAULT_DEADLINE_MS = 60000;
|
|
16
16
|
const MAX_DEADLINE_MS = 50 * 60 * 1000;
|
|
17
17
|
const CLEANUP_GRACE_MS = 60000;
|
|
18
|
+
const READINESS_DEFAULT_TIMEOUT_MS = 20000;
|
|
19
|
+
const READINESS_MAX_TIMEOUT_MS = 120000;
|
|
20
|
+
const READINESS_DEADLINE_GRACE_MS = 5000;
|
|
18
21
|
const PROVIDER_DEFAULT_TIMEOUT_SECONDS = {
|
|
19
22
|
ai: 300,
|
|
20
23
|
aistudio: 300,
|
|
@@ -29,6 +32,13 @@ const PROVIDER_DEFAULT_TIMEOUT_SECONDS = {
|
|
|
29
32
|
};
|
|
30
33
|
|
|
31
34
|
function resolveRequestDeadlineMs(tool, args = {}) {
|
|
35
|
+
if (tool === "wait.ready") {
|
|
36
|
+
const requestedMs = Number(args?.timeout);
|
|
37
|
+
const timeoutMs = Number.isFinite(requestedMs) && requestedMs > 0
|
|
38
|
+
? Math.min(requestedMs, READINESS_MAX_TIMEOUT_MS)
|
|
39
|
+
: READINESS_DEFAULT_TIMEOUT_MS;
|
|
40
|
+
return timeoutMs + READINESS_DEADLINE_GRACE_MS;
|
|
41
|
+
}
|
|
32
42
|
const defaultSeconds = PROVIDER_DEFAULT_TIMEOUT_SECONDS[tool];
|
|
33
43
|
if (defaultSeconds === undefined) return DEFAULT_DEADLINE_MS;
|
|
34
44
|
const rawTimeout = tool === "playbook.run" && args && typeof args === "object" && !Array.isArray(args)
|