@melaya/runner 1.1.67 → 1.1.69
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/dist/assistantHost.py +5 -0
- package/dist/browserBridge.js +178 -0
- package/dist/browserFast.d.ts +144 -0
- package/dist/browserFast.js +363 -0
- package/dist/browserProvisioner.d.ts +39 -0
- package/dist/browserProvisioner.js +96 -0
- package/dist/cli.js +180 -66
- package/dist/computerHelperManifest.d.ts +5 -0
- package/dist/computerHelperManifest.js +9 -0
- package/dist/computerHostCli.d.ts +233 -0
- package/dist/computerHostCli.js +808 -0
- package/dist/computerRelay.d.ts +73 -0
- package/dist/computerRelay.js +280 -0
- package/dist/connection.d.ts +12 -2
- package/dist/connection.js +299 -82
- package/dist/detect.d.ts +40 -1
- package/dist/detect.js +194 -36
- package/dist/hostChannel.d.ts +122 -0
- package/dist/hostChannel.js +117 -0
- package/dist/hostFiles.d.ts +25 -0
- package/dist/hostFiles.js +90 -0
- package/dist/linkedinLogin.js +13 -17
- package/dist/lumaLogin.js +11 -17
- package/dist/processTree.d.ts +65 -0
- package/dist/processTree.js +237 -0
- package/dist/publishGate.js +14 -4
- package/dist/pythonEnv.js +6 -7
- package/dist/sharedVendor.js +3 -1
- package/dist/singleInstance.js +10 -1
- package/dist/startupCache.d.ts +22 -0
- package/dist/startupCache.js +45 -0
- package/dist/startupTimings.d.ts +34 -0
- package/dist/startupTimings.js +81 -0
- package/dist/uvBootstrap.js +32 -24
- package/dist/vaults.js +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// packages/runner/src/browserFast.ts
|
|
2
|
+
//
|
|
3
|
+
// browser_fast on the runner's OWN browser (the desktop app's dedicated
|
|
4
|
+
// Playwright browser, browserBridge.ts). Design: Obsidian/37 System One
|
|
5
|
+
// Models/System One Model & Browser Extension.md, section 2.
|
|
6
|
+
//
|
|
7
|
+
// The fast EXECUTOR stays on the server (server/src/services/fast/fastRun.ts):
|
|
8
|
+
// one plan call, deterministic grounding first, Laya as a gated fallback with
|
|
9
|
+
// the per-user and box budgets, verification after every step, STOP, no blind
|
|
10
|
+
// retry. Only the transport differs from the extension path. The server
|
|
11
|
+
// cannot reach this machine's loopback bridge, so the runner opens the
|
|
12
|
+
// channel itself:
|
|
13
|
+
//
|
|
14
|
+
// browser.py browser_fast --POST /browser/fast--> bridge (per-run bearer,
|
|
15
|
+
// grant expiry) --POST /api/v1/browser/runner/fast {op:"start"}--> server
|
|
16
|
+
// (runner token, session ownership, grant not revoked) runs fastToolRun
|
|
17
|
+
// with runnerBrowserFastHost (server/src/services/fast/fastRunnerBrowser.ts)
|
|
18
|
+
//
|
|
19
|
+
// meanwhile the bridge pumps commands: POST /api/v1/browser/runner/fast/pump
|
|
20
|
+
// carries the previous command's outcome and long-polls the next one.
|
|
21
|
+
// Every command runs HERE, under the SAME run registration (the signed
|
|
22
|
+
// grant the bridge verified), through performAct: effect ceiling, origin
|
|
23
|
+
// policy, takeover pause, the publish gate and its approval card.
|
|
24
|
+
//
|
|
25
|
+
// This module holds the parts that need no browser: stable refs, the fast
|
|
26
|
+
// screen rendering, command execution over an injected ops interface, the
|
|
27
|
+
// pump loop and the HTTP client. browserBridge.ts supplies the real ops.
|
|
28
|
+
import { request as httpRequest } from "node:http";
|
|
29
|
+
import { request as httpsRequest } from "node:https";
|
|
30
|
+
// ---------------------------------------------------------------------
|
|
31
|
+
// Stable refs
|
|
32
|
+
// ---------------------------------------------------------------------
|
|
33
|
+
//
|
|
34
|
+
// The bridge renumbers @eN on every snapshot and refuses a ref from an older
|
|
35
|
+
// one. The executor compares refs ACROSS reads (a control that appeared since
|
|
36
|
+
// the last action wins a tie between identical labels; a picker's options are
|
|
37
|
+
// the elements that were not there before), so it needs refs that stay put
|
|
38
|
+
// while the element lives, as the extension's do. A stable ref is keyed on
|
|
39
|
+
// the DOM node (frame + backendNodeId), numbered per lease and never reused:
|
|
40
|
+
// a ref from an earlier document can never land on a node of a later one.
|
|
41
|
+
// They carry their own prefix ("r@e12") so the per-step tools, whose @eN are
|
|
42
|
+
// per snapshot, refuse them instead of clicking whatever @e12 is now.
|
|
43
|
+
export const STABLE_REF_PREFIX = "r";
|
|
44
|
+
const STABLE_REF_RE = /^r@e(\d+)$/;
|
|
45
|
+
const SNAPSHOT_REF_RE = /^@e\d+$/;
|
|
46
|
+
export class StableRefs {
|
|
47
|
+
documentGeneration = -1;
|
|
48
|
+
next = 0;
|
|
49
|
+
byKey = new Map();
|
|
50
|
+
byNum = new Map();
|
|
51
|
+
static keyOf(b) {
|
|
52
|
+
return `${b.frameKey}:${b.backendNodeId}`;
|
|
53
|
+
}
|
|
54
|
+
sync(documentGeneration) {
|
|
55
|
+
if (documentGeneration === this.documentGeneration)
|
|
56
|
+
return;
|
|
57
|
+
// A new document: forget every node, keep counting (never reuse a number).
|
|
58
|
+
this.documentGeneration = documentGeneration;
|
|
59
|
+
this.byKey.clear();
|
|
60
|
+
this.byNum.clear();
|
|
61
|
+
}
|
|
62
|
+
/** Stable ref for every binding of the current snapshot (snapshot ref ->
|
|
63
|
+
* stable ref). Records each node's latest role and label. */
|
|
64
|
+
assign(documentGeneration, bindings) {
|
|
65
|
+
this.sync(documentGeneration);
|
|
66
|
+
const out = new Map();
|
|
67
|
+
for (const b of bindings) {
|
|
68
|
+
const key = StableRefs.keyOf(b);
|
|
69
|
+
let num = this.byKey.get(key);
|
|
70
|
+
if (num === undefined) {
|
|
71
|
+
num = ++this.next;
|
|
72
|
+
this.byKey.set(key, num);
|
|
73
|
+
}
|
|
74
|
+
this.byNum.set(num, { key, frameKey: b.frameKey, backendNodeId: b.backendNodeId, role: b.role, name: b.name });
|
|
75
|
+
out.set(b.ref, `${STABLE_REF_PREFIX}@e${num}`);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** Stable ref of a binding already numbered (no new number). */
|
|
80
|
+
stableOf(documentGeneration, b) {
|
|
81
|
+
if (documentGeneration !== this.documentGeneration)
|
|
82
|
+
return null;
|
|
83
|
+
const num = this.byKey.get(StableRefs.keyOf(b));
|
|
84
|
+
return num === undefined ? null : `${STABLE_REF_PREFIX}@e${num}`;
|
|
85
|
+
}
|
|
86
|
+
/** The current snapshot's ref for a stable ref, or null when the element is
|
|
87
|
+
* gone, the document changed, or the node no longer carries the role and
|
|
88
|
+
* label it was read with. Never a guess. */
|
|
89
|
+
resolve(stableRef, documentGeneration, current) {
|
|
90
|
+
const m = STABLE_REF_RE.exec(String(stableRef || "").trim());
|
|
91
|
+
if (!m || documentGeneration !== this.documentGeneration)
|
|
92
|
+
return null;
|
|
93
|
+
const e = this.byNum.get(Number(m[1]));
|
|
94
|
+
if (!e)
|
|
95
|
+
return null;
|
|
96
|
+
for (const b of current) {
|
|
97
|
+
if (b.frameKey !== e.frameKey || b.backendNodeId !== e.backendNodeId)
|
|
98
|
+
continue;
|
|
99
|
+
if (b.role !== e.role || b.name !== e.name)
|
|
100
|
+
return null;
|
|
101
|
+
return b.ref;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function isStableRef(ref) {
|
|
107
|
+
return typeof ref === "string" && STABLE_REF_RE.test(ref.trim());
|
|
108
|
+
}
|
|
109
|
+
const clean = (s, max) => String(s || "").replace(/\s+/g, " ").replace(/"/g, "'").trim().slice(0, max);
|
|
110
|
+
/** The screen in the line format the server's parseBrowserTree reads:
|
|
111
|
+
* `r@e12 <role> [click] [edit] [COVERED] "name" ="value" ~aria #id @href <l,t,r,b>`.
|
|
112
|
+
* A COVERED element (something else is painted over its centre) is not
|
|
113
|
+
* offered as clickable: a click there lands on whatever covers it. */
|
|
114
|
+
export function renderFastLines(items, toStable) {
|
|
115
|
+
const lines = [];
|
|
116
|
+
for (const n of items) {
|
|
117
|
+
const ref = toStable.get(n.ref);
|
|
118
|
+
if (!ref)
|
|
119
|
+
continue;
|
|
120
|
+
const flags = `${n.click && !n.covered ? " click" : ""}${n.edit ? " edit" : ""}${n.covered ? " COVERED" : ""}`;
|
|
121
|
+
const name = ` "${clean(n.name, 240)}"`;
|
|
122
|
+
const value = n.edit && n.value ? ` ="${clean(n.value, 200)}"` : "";
|
|
123
|
+
const aria = n.aria ? ` ~${clean(n.aria, 40).replace(/\s@/g, " ")}` : "";
|
|
124
|
+
const domId = n.domId ? ` #${clean(n.domId, 40).replace(/\s/g, "")}` : "";
|
|
125
|
+
const href = n.href ? ` @${clean(n.href, 200).replace(/\s/g, "")}` : "";
|
|
126
|
+
lines.push(`${ref} <${n.role || "node"}>${flags}${name}${value}${aria}${domId}${href} <${Math.round(n.l)},${Math.round(n.t)},${Math.round(n.r)},${Math.round(n.b)}>`);
|
|
127
|
+
}
|
|
128
|
+
return lines;
|
|
129
|
+
}
|
|
130
|
+
// ---------------------------------------------------------------------
|
|
131
|
+
// Steps that quote the per-step tree's refs
|
|
132
|
+
// ---------------------------------------------------------------------
|
|
133
|
+
/** The LLM usually read the page with browser_get_screen_tree before planning,
|
|
134
|
+
* so its steps may quote that snapshot's @eN (a target's `ref`, a list's
|
|
135
|
+
* `in`). The run's first read takes a new snapshot, which would orphan them:
|
|
136
|
+
* translate them to stable refs now, while that snapshot is still current.
|
|
137
|
+
* A ref that no longer resolves is dropped; the step falls back to its label. */
|
|
138
|
+
export function translateStepRefs(steps, toStable) {
|
|
139
|
+
if (!Array.isArray(steps))
|
|
140
|
+
return steps;
|
|
141
|
+
return steps.map((raw) => {
|
|
142
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
143
|
+
return raw;
|
|
144
|
+
const s = { ...raw };
|
|
145
|
+
for (const k of ["ref", "in"]) {
|
|
146
|
+
const v = s[k];
|
|
147
|
+
if (typeof v === "string" && SNAPSHOT_REF_RE.test(v.trim())) {
|
|
148
|
+
const stable = toStable(v.trim());
|
|
149
|
+
if (stable)
|
|
150
|
+
s[k] = stable;
|
|
151
|
+
else
|
|
152
|
+
delete s[k];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (Array.isArray(s.steps))
|
|
156
|
+
s.steps = translateStepRefs(s.steps, toStable);
|
|
157
|
+
return s;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// ---------------------------------------------------------------------
|
|
161
|
+
// Autonomy
|
|
162
|
+
// ---------------------------------------------------------------------
|
|
163
|
+
const MODE_RANK = { safe: 0, payments_only: 1, autonomous: 2 };
|
|
164
|
+
const asMode = (m) => m === "autonomous" || m === "payments_only" ? m : "safe";
|
|
165
|
+
/** The run's autonomy: the registered (server-clamped) mode, which a caller
|
|
166
|
+
* holding the bridge token may TIGHTEN but never loosen (same rule as the
|
|
167
|
+
* bridge's publish gate). No registered mode: the caller's, default safe. */
|
|
168
|
+
export function clampHitlMode(specMode, bodyMode) {
|
|
169
|
+
if (specMode == null || specMode === "")
|
|
170
|
+
return asMode(bodyMode);
|
|
171
|
+
const spec = asMode(specMode);
|
|
172
|
+
if (bodyMode == null || bodyMode === "")
|
|
173
|
+
return spec;
|
|
174
|
+
const body = asMode(bodyMode);
|
|
175
|
+
return MODE_RANK[body] < MODE_RANK[spec] ? body : spec;
|
|
176
|
+
}
|
|
177
|
+
const ACT_KINDS = new Set(["click", "input_text", "press_key", "scroll", "select_option", "back", "navigate"]);
|
|
178
|
+
function errOf(e) {
|
|
179
|
+
const x = e;
|
|
180
|
+
return {
|
|
181
|
+
code: typeof x?.code === "string" && x.code ? x.code : "bridge_error",
|
|
182
|
+
message: String(x?.message ?? e ?? "failed").slice(0, 600),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
export class FastCommandError extends Error {
|
|
186
|
+
code;
|
|
187
|
+
constructor(code, message) {
|
|
188
|
+
super(message);
|
|
189
|
+
this.code = code;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function screenOf(ops) {
|
|
193
|
+
const obs = await ops.observe();
|
|
194
|
+
const refs = await ops.stableRefs();
|
|
195
|
+
const toStable = refs.assign(obs.documentGeneration, obs.items);
|
|
196
|
+
const lines = renderFastLines(obs.items, toStable);
|
|
197
|
+
return { url: obs.url, title: obs.title, generation: obs.generation, tree: lines.join("\n"), nodes: lines.length };
|
|
198
|
+
}
|
|
199
|
+
async function snapshotRefOf(ops, ref) {
|
|
200
|
+
const r = typeof ref === "string" ? ref.trim() : "";
|
|
201
|
+
if (!isStableRef(r))
|
|
202
|
+
throw new FastCommandError("stale_ref", `ref '${r.slice(0, 40)}' is not a fast-mode ref of this page`);
|
|
203
|
+
const [b, refs] = await Promise.all([ops.bindings(), ops.stableRefs()]);
|
|
204
|
+
const snap = refs.resolve(r, b.documentGeneration, b.refs);
|
|
205
|
+
if (!snap)
|
|
206
|
+
throw new FastCommandError("stale_ref", `the element ${r} is no longer on the page as it was read`);
|
|
207
|
+
return snap;
|
|
208
|
+
}
|
|
209
|
+
/** Run one command the server's executor sent, under this run's grant and
|
|
210
|
+
* autonomy (`mode`, clamped on this side: the server cannot loosen it). */
|
|
211
|
+
export async function execFastCommand(ops, cmd, mode) {
|
|
212
|
+
try {
|
|
213
|
+
const st = ops.state();
|
|
214
|
+
if (st.cancelled)
|
|
215
|
+
return { ok: false, code: "canceled", message: "the browser run was stopped or torn down" };
|
|
216
|
+
if (st.expired)
|
|
217
|
+
return { ok: false, code: "grant_expired", message: "the browser grant for this run has expired" };
|
|
218
|
+
const body = cmd.body ?? {};
|
|
219
|
+
switch (cmd.kind) {
|
|
220
|
+
case "observe":
|
|
221
|
+
return { ok: true, data: await screenOf(ops) };
|
|
222
|
+
case "location":
|
|
223
|
+
return { ok: true, data: await ops.location() };
|
|
224
|
+
case "text": {
|
|
225
|
+
const ref = body.ref ? await snapshotRefOf(ops, body.ref) : undefined;
|
|
226
|
+
const out = (await ops.act({ kind: "get_text", ...(ref ? { ref } : {}), hitl_mode: mode }));
|
|
227
|
+
return { ok: true, data: { text: String(out?.text ?? "") } };
|
|
228
|
+
}
|
|
229
|
+
case "act": {
|
|
230
|
+
const action = String(body.action ?? "");
|
|
231
|
+
if (!ACT_KINDS.has(action))
|
|
232
|
+
return { ok: false, code: "act_kind_unknown", message: `fast mode does not send '${action.slice(0, 40)}'` };
|
|
233
|
+
// The user took over: stop here, before anything is acted on.
|
|
234
|
+
if (st.paused)
|
|
235
|
+
return { ok: false, code: "paused_by_user", message: "the user has taken manual control of the browser" };
|
|
236
|
+
const args = { kind: action, hitl_mode: mode };
|
|
237
|
+
if (body.ref !== undefined)
|
|
238
|
+
args.ref = await snapshotRefOf(ops, body.ref);
|
|
239
|
+
let result;
|
|
240
|
+
switch (action) {
|
|
241
|
+
case "input_text": {
|
|
242
|
+
const text = String(body.text ?? "");
|
|
243
|
+
// A submitting type is a commit (Enter): its approval is settled
|
|
244
|
+
// before a single character is typed, so a pending or declined
|
|
245
|
+
// card leaves the field untouched.
|
|
246
|
+
if (body.submit === true)
|
|
247
|
+
await ops.gateSubmit(mode);
|
|
248
|
+
result = await ops.act({ ...args, text, ...(body.mode === "human" ? { mode: "human" } : {}) });
|
|
249
|
+
if (body.submit === true)
|
|
250
|
+
await ops.act({ kind: "press_key", key: "Enter", hitl_mode: mode });
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
case "press_key":
|
|
254
|
+
result = await ops.act({ ...args, key: String(body.key ?? "") });
|
|
255
|
+
break;
|
|
256
|
+
case "scroll":
|
|
257
|
+
result = await ops.act({ ...args, text: body.direction === "up" ? "up" : "down", value: "medium" });
|
|
258
|
+
// Let an infinite feed load its next batch before the page is read.
|
|
259
|
+
if (body.direction !== "up" && body.feed !== false)
|
|
260
|
+
await ops.wait(700);
|
|
261
|
+
break;
|
|
262
|
+
case "select_option":
|
|
263
|
+
result = await ops.act({ ...args, value: String(body.option ?? ""), match_label: true });
|
|
264
|
+
break;
|
|
265
|
+
case "navigate":
|
|
266
|
+
result = await ops.act({ ...args, url: String(body.url ?? "") });
|
|
267
|
+
break;
|
|
268
|
+
default:
|
|
269
|
+
result = await ops.act(args);
|
|
270
|
+
}
|
|
271
|
+
return { ok: true, data: { result: result ?? null, ...(await screenOf(ops)) } };
|
|
272
|
+
}
|
|
273
|
+
default:
|
|
274
|
+
return { ok: false, code: "unknown_command", message: `unknown fast command '${String(cmd.kind).slice(0, 40)}'` };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
return { ok: false, ...errOf(e) };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** Pull the server executor's commands and run them, one at a time, until the
|
|
282
|
+
* server ends the run or this run is torn down. A command is executed once:
|
|
283
|
+
* its outcome is carried by the next poll, resent unchanged if that poll
|
|
284
|
+
* fails, and the server ignores an outcome it already has. Nothing is ever
|
|
285
|
+
* re-run here. */
|
|
286
|
+
export async function runFastPump(o) {
|
|
287
|
+
const sleep = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
288
|
+
const maxFailures = o.maxFailures ?? 5;
|
|
289
|
+
let outcome = null;
|
|
290
|
+
let failures = 0;
|
|
291
|
+
const executed = new Set();
|
|
292
|
+
for (;;) {
|
|
293
|
+
const cancelled = o.isCancelled();
|
|
294
|
+
let reply;
|
|
295
|
+
try {
|
|
296
|
+
reply = await o.post({ fast_id: o.fastId, ...(outcome ? { result: outcome } : {}), ...(cancelled ? { abort: true } : {}) });
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
if (cancelled || ++failures >= maxFailures) {
|
|
300
|
+
o.log?.(`[fast] pump ${o.fastId.slice(0, 8)} gave up: ${e?.message || e}`);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
await sleep(Math.min(5_000, 500 * failures));
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
failures = 0;
|
|
307
|
+
outcome = null;
|
|
308
|
+
if (cancelled || reply.end)
|
|
309
|
+
return;
|
|
310
|
+
const cmd = reply.command;
|
|
311
|
+
if (!cmd || typeof cmd.seq !== "number")
|
|
312
|
+
continue;
|
|
313
|
+
if (executed.has(cmd.seq)) {
|
|
314
|
+
// Never run a command twice, whatever the channel did.
|
|
315
|
+
outcome = { seq: cmd.seq, outcome: { ok: false, code: "unknown_outcome", message: "this command was already delivered once; it is not run again" } };
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
executed.add(cmd.seq);
|
|
319
|
+
outcome = { seq: cmd.seq, outcome: await o.exec(cmd) };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// ---------------------------------------------------------------------
|
|
323
|
+
// HTTP to the server (same base URL + runner token the publish gate uses)
|
|
324
|
+
// ---------------------------------------------------------------------
|
|
325
|
+
export function postServerJson(serverBase, path, authHeader, body, timeoutMs) {
|
|
326
|
+
return new Promise((resolve, reject) => {
|
|
327
|
+
let u;
|
|
328
|
+
try {
|
|
329
|
+
u = new URL(serverBase.replace(/\/$/, "") + path);
|
|
330
|
+
}
|
|
331
|
+
catch (e) {
|
|
332
|
+
reject(e);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const isHttps = u.protocol === "https:";
|
|
336
|
+
const data = JSON.stringify(body);
|
|
337
|
+
const req = (isHttps ? httpsRequest : httpRequest)({
|
|
338
|
+
hostname: u.hostname,
|
|
339
|
+
port: u.port || (isHttps ? 443 : 80),
|
|
340
|
+
path: u.pathname + u.search,
|
|
341
|
+
method: "POST",
|
|
342
|
+
headers: {
|
|
343
|
+
"Content-Type": "application/json",
|
|
344
|
+
"Content-Length": Buffer.byteLength(data),
|
|
345
|
+
"Authorization": authHeader,
|
|
346
|
+
},
|
|
347
|
+
}, (res) => {
|
|
348
|
+
const chunks = [];
|
|
349
|
+
res.on("data", (c) => chunks.push(Buffer.from(c)));
|
|
350
|
+
res.on("end", () => {
|
|
351
|
+
let json = {};
|
|
352
|
+
try {
|
|
353
|
+
json = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
354
|
+
}
|
|
355
|
+
catch { /* non-JSON body */ }
|
|
356
|
+
resolve({ status: res.statusCode || 0, json });
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
req.setTimeout(timeoutMs, () => req.destroy(new Error(`no answer from the server in ${Math.round(timeoutMs / 1000)}s`)));
|
|
360
|
+
req.on("error", reject);
|
|
361
|
+
req.end(data);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
@@ -31,3 +31,42 @@ export declare function ensureEngine(engine: BrowserEngineId, opts?: {
|
|
|
31
31
|
allowDownload?: boolean;
|
|
32
32
|
log?: (m: string) => void;
|
|
33
33
|
}): Promise<InstalledEngine>;
|
|
34
|
+
/** Preference order for headed flows. */
|
|
35
|
+
export declare const INSTALLED_BROWSER_PREFERENCE: readonly BrowserEngineId[];
|
|
36
|
+
/** Actionable error when no supported browser is installed. */
|
|
37
|
+
export declare function noInstalledBrowserError(): ProvisionError;
|
|
38
|
+
/** `npx` on PATH (Windows: npx.cmd). File checks only, no subprocess. */
|
|
39
|
+
export declare function npxOnPath(env?: NodeJS.ProcessEnv): boolean;
|
|
40
|
+
/** May this process download Playwright's Chromium? Only the CLI, with npx present. */
|
|
41
|
+
export declare function canDownloadBundledChromium(opts?: {
|
|
42
|
+
hosted?: boolean;
|
|
43
|
+
npx?: boolean;
|
|
44
|
+
}): boolean;
|
|
45
|
+
/** Installed engines in preference order (Chrome, Edge, Brave, on-disk Chromium). */
|
|
46
|
+
export declare function installedBrowsersByPreference(engines?: InstalledEngine[]): Promise<InstalledEngine[]>;
|
|
47
|
+
type LaunchFn<B> = (o: {
|
|
48
|
+
headless: boolean;
|
|
49
|
+
channel?: string;
|
|
50
|
+
executablePath?: string;
|
|
51
|
+
args?: string[];
|
|
52
|
+
}) => Promise<B>;
|
|
53
|
+
/**
|
|
54
|
+
* Launch a headed browser for a sign-in flow from the installed set. Tries each
|
|
55
|
+
* installed engine in preference order, then Playwright's own Chrome / Edge
|
|
56
|
+
* channel lookup. When nothing launches: in the CLI with npx, `downloadChromium`
|
|
57
|
+
* (if given) installs Playwright's Chromium and it is launched; otherwise a
|
|
58
|
+
* ProvisionError with install instructions is thrown. Never spawns npx in the
|
|
59
|
+
* desktop app.
|
|
60
|
+
*/
|
|
61
|
+
export declare function launchInstalledBrowser<B>(launch: LaunchFn<B>, opts?: {
|
|
62
|
+
args?: string[];
|
|
63
|
+
progress?: (m: string) => void;
|
|
64
|
+
downloadChromium?: () => Promise<void>;
|
|
65
|
+
engines?: InstalledEngine[];
|
|
66
|
+
hosted?: boolean;
|
|
67
|
+
npx?: boolean;
|
|
68
|
+
}): Promise<{
|
|
69
|
+
browser: B;
|
|
70
|
+
engine: BrowserEngineId | "channel";
|
|
71
|
+
}>;
|
|
72
|
+
export {};
|
|
@@ -239,6 +239,12 @@ export async function ensureEngine(engine, opts = {}) {
|
|
|
239
239
|
};
|
|
240
240
|
throw new ProvisionError("engine_not_installed", `${engine} is not installed on this machine`, `Install ${engine} from ${urls[engine]} and reconnect the runner.`);
|
|
241
241
|
}
|
|
242
|
+
// Desktop app: process.execPath is the Electron binary, which cannot run
|
|
243
|
+
// Playwright's CLI (Electron-as-Node is fused off), and there is no npx.
|
|
244
|
+
// Never shell out; ask for an installed browser instead (audit P1.4).
|
|
245
|
+
if (hostedByDesktopApp()) {
|
|
246
|
+
throw noInstalledBrowserError();
|
|
247
|
+
}
|
|
242
248
|
if (!opts.allowDownload) {
|
|
243
249
|
throw new ProvisionError("download_not_authorized", "bundled Chromium is not on disk and download was not authorized", "Re-run with the download flag enabled, or run: npx playwright install chromium");
|
|
244
250
|
}
|
|
@@ -258,6 +264,96 @@ export async function ensureEngine(engine, opts = {}) {
|
|
|
258
264
|
throw new ProvisionError("download_failed", "Chromium installed but executable not found");
|
|
259
265
|
return post;
|
|
260
266
|
}
|
|
267
|
+
// ---------------------------------------------------------------------
|
|
268
|
+
// Installed-browser selection (one provisioner for every headed flow)
|
|
269
|
+
// ---------------------------------------------------------------------
|
|
270
|
+
//
|
|
271
|
+
// LinkedIn / Luma sign-in and any other headed flow pick a browser here:
|
|
272
|
+
// installed Chrome, then Edge, then Brave, then a Playwright Chromium that is
|
|
273
|
+
// already on disk. Downloading Chromium is a CLI-only last resort (needs npx
|
|
274
|
+
// or a real Node), never in the desktop app.
|
|
275
|
+
/** Preference order for headed flows. */
|
|
276
|
+
export const INSTALLED_BROWSER_PREFERENCE = ["chrome", "edge", "brave", "chromium"];
|
|
277
|
+
/** Actionable error when no supported browser is installed. */
|
|
278
|
+
export function noInstalledBrowserError() {
|
|
279
|
+
return new ProvisionError("engine_not_installed", "No supported browser found on this computer (Chrome, Edge or Brave).", "Install Google Chrome (https://www.google.com/chrome/) or Microsoft Edge (https://www.microsoft.com/edge), then try again.");
|
|
280
|
+
}
|
|
281
|
+
function hostedByDesktopApp() {
|
|
282
|
+
const p = process.parentPort;
|
|
283
|
+
return !!p && typeof p.postMessage === "function";
|
|
284
|
+
}
|
|
285
|
+
/** `npx` on PATH (Windows: npx.cmd). File checks only, no subprocess. */
|
|
286
|
+
export function npxOnPath(env = process.env) {
|
|
287
|
+
const sep = process.platform === "win32" ? ";" : ":";
|
|
288
|
+
const names = process.platform === "win32" ? ["npx.cmd", "npx.exe", "npx"] : ["npx"];
|
|
289
|
+
for (const dir of String(env.PATH ?? env.Path ?? "").split(sep)) {
|
|
290
|
+
if (!dir)
|
|
291
|
+
continue;
|
|
292
|
+
for (const n of names) {
|
|
293
|
+
try {
|
|
294
|
+
if (existsSync(join(dir, n)))
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
catch { /* unreadable */ }
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
/** May this process download Playwright's Chromium? Only the CLI, with npx present. */
|
|
303
|
+
export function canDownloadBundledChromium(opts = {}) {
|
|
304
|
+
const hosted = opts.hosted ?? hostedByDesktopApp();
|
|
305
|
+
if (hosted)
|
|
306
|
+
return false;
|
|
307
|
+
return opts.npx ?? npxOnPath();
|
|
308
|
+
}
|
|
309
|
+
/** Installed engines in preference order (Chrome, Edge, Brave, on-disk Chromium). */
|
|
310
|
+
export async function installedBrowsersByPreference(engines) {
|
|
311
|
+
const list = engines ?? await discoverEngines();
|
|
312
|
+
return INSTALLED_BROWSER_PREFERENCE
|
|
313
|
+
.map((id) => list.find((e) => e.engine === id))
|
|
314
|
+
.filter((e) => !!e);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Launch a headed browser for a sign-in flow from the installed set. Tries each
|
|
318
|
+
* installed engine in preference order, then Playwright's own Chrome / Edge
|
|
319
|
+
* channel lookup. When nothing launches: in the CLI with npx, `downloadChromium`
|
|
320
|
+
* (if given) installs Playwright's Chromium and it is launched; otherwise a
|
|
321
|
+
* ProvisionError with install instructions is thrown. Never spawns npx in the
|
|
322
|
+
* desktop app.
|
|
323
|
+
*/
|
|
324
|
+
export async function launchInstalledBrowser(launch, opts = {}) {
|
|
325
|
+
const args = opts.args ?? [];
|
|
326
|
+
const tried = new Set();
|
|
327
|
+
for (const e of await installedBrowsersByPreference(opts.engines)) {
|
|
328
|
+
const key = e.channel ? `channel:${e.channel}` : `path:${e.executablePath}`;
|
|
329
|
+
if (tried.has(key))
|
|
330
|
+
continue;
|
|
331
|
+
tried.add(key);
|
|
332
|
+
try {
|
|
333
|
+
const browser = e.channel
|
|
334
|
+
? await launch({ headless: false, channel: e.channel, args })
|
|
335
|
+
: await launch({ headless: false, executablePath: e.executablePath, args });
|
|
336
|
+
return { browser, engine: e.engine };
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
opts.progress?.(`${e.engine} did not start (${String(err?.message || err).split("\n")[0]}); trying the next browser.`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Installs outside the well-known paths: Playwright's own channel lookup.
|
|
343
|
+
for (const channel of ["chrome", "msedge"]) {
|
|
344
|
+
if (tried.has(`channel:${channel}`))
|
|
345
|
+
continue;
|
|
346
|
+
try {
|
|
347
|
+
return { browser: await launch({ headless: false, channel, args }), engine: "channel" };
|
|
348
|
+
}
|
|
349
|
+
catch { /* not installed */ }
|
|
350
|
+
}
|
|
351
|
+
if (opts.downloadChromium && canDownloadBundledChromium({ hosted: opts.hosted, npx: opts.npx })) {
|
|
352
|
+
await opts.downloadChromium();
|
|
353
|
+
return { browser: await launch({ headless: false, args }), engine: "chromium" };
|
|
354
|
+
}
|
|
355
|
+
throw noInstalledBrowserError();
|
|
356
|
+
}
|
|
261
357
|
function packageRootGuess() {
|
|
262
358
|
// dist/ layout: this file compiles to dist/browserProvisioner.js; the
|
|
263
359
|
// package root (with node_modules/playwright) is one level up. In the
|