@melaya/runner 1.0.118 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ import { type KeyObject } from "node:crypto";
2
+ export declare const BROWSER_EFFECT_CLASSES: readonly ["read", "navigate", "message", "publish", "consent", "upload", "download", "purchase", "account_change", "data_export", "destructive"];
3
+ export type BrowserEffectClass = (typeof BROWSER_EFFECT_CLASSES)[number];
4
+ export declare const BROWSER_EFFECT_RANK: Readonly<Record<BrowserEffectClass, number>>;
5
+ export declare const BROWSER_GRANT_ISSUER = "melaya-control";
6
+ export declare const BROWSER_GRANT_ALG: "EdDSA";
7
+ /** The algorithm allowlist is exactly one entry by design. */
8
+ export declare const ALLOWED_ALGS: readonly string[];
9
+ export interface BrowserGrantClaims {
10
+ sub: string;
11
+ aud: string[];
12
+ tenant: string;
13
+ project: string;
14
+ run: string;
15
+ runnerDevice: string;
16
+ socketGeneration: number;
17
+ browserSession: string;
18
+ target: {
19
+ ref: string;
20
+ };
21
+ profile?: string;
22
+ space?: string;
23
+ originScopes: string[];
24
+ actionScopes: BrowserEffectClass[];
25
+ effectCeiling: BrowserEffectClass;
26
+ }
27
+ export interface BrowserGrant extends BrowserGrantClaims {
28
+ iss: typeof BROWSER_GRANT_ISSUER;
29
+ kid: string;
30
+ jti: string;
31
+ iat: number;
32
+ nbf: number;
33
+ exp: number;
34
+ }
35
+ export type BrowserGrantErrorCode = "malformed" | "alg_not_allowed" | "kid_unknown" | "bad_signature" | "iss_mismatch" | "aud_mismatch" | "expired" | "not_yet_valid" | "claims_invalid" | "revoked" | "replayed" | "replay_store_unavailable";
36
+ export declare class BrowserGrantError extends Error {
37
+ readonly code: BrowserGrantErrorCode;
38
+ constructor(code: BrowserGrantErrorCode, message: string);
39
+ }
40
+ export interface ReplayStore {
41
+ /** Atomically register a jti. Returns true exactly once per jti
42
+ * (first caller wins); false means the grant was already used.
43
+ * Implementations MUST throw (not return true) when the store is
44
+ * unreachable: the verifier maps that to replay_store_unavailable
45
+ * and refuses the grant (fail closed). */
46
+ registerOnce(jti: string, ttlMs: number): Promise<boolean>;
47
+ }
48
+ /** In-process replay store for the single-runner case. The runner is
49
+ * one OS process, and every grant is bound to THIS runner device via
50
+ * `aud`, so process-local single-use is sufficient on this side; the
51
+ * server keeps the authoritative Redis store for its own paths. */
52
+ export declare function createInMemoryReplayStore(): ReplayStore;
53
+ /** Accepts base64 of a 32-byte raw Ed25519 public key (the wire form
54
+ * the server ships via getBrowserGrantPublicKeys) or of an SPKI DER
55
+ * key, or an already-built KeyObject. Throws BrowserGrantError
56
+ * ("kid_unknown") on anything that does not decode to Ed25519. */
57
+ export declare function toPublicKeyObject(key: string | KeyObject, kid: string): KeyObject;
58
+ /** Optional operator pinning: parse MEL_BROWSER_GRANT_PUBLIC_KEYS
59
+ * ("<kid>=<base64>,..." in the same format the server env uses) into
60
+ * a publicKeysByKid map. The normal delivery path is the server
61
+ * pushing getBrowserGrantPublicKeys() output over the socket; this
62
+ * env var lets a security-conscious operator pin keys locally so a
63
+ * compromised socket cannot swap them. */
64
+ export declare function loadPinnedPublicKeysFromEnv(): Record<string, string>;
65
+ export declare const DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
66
+ export interface VerifyBrowserGrantOptions {
67
+ /** This verifier's own identity (runner device id, or extension
68
+ * instance id on the extension path). Must appear in `aud`. */
69
+ expectedAud: string;
70
+ /** kid -> public key (base64 raw 32-byte / base64 SPKI DER /
71
+ * KeyObject). Delivered by the server or pinned via env. */
72
+ publicKeysByKid: Record<string, string | KeyObject>;
73
+ /** Single-use jti store. Use createInMemoryReplayStore() on the
74
+ * runner. REQUIRED: single-use is part of the trust contract. */
75
+ replayStore: ReplayStore;
76
+ /** Optional revocation hook (server RPC or local cache). Returning
77
+ * true, or throwing, rejects the grant. When omitted, revocation
78
+ * is enforced only by expiry (acceptable for 120s grants when the
79
+ * transport also delivers explicit stop signals). */
80
+ isRevoked?: (jti: string) => Promise<boolean>;
81
+ /** Clock tolerance in seconds for exp/nbf/iat. Default 30. */
82
+ clockToleranceSeconds?: number;
83
+ /** Test hook: current time in epoch ms. Default Date.now(). */
84
+ nowMs?: number;
85
+ }
86
+ /** Verify a browserGrant compact JWS. Resolves to the typed, verified
87
+ * BrowserGrant on success; throws BrowserGrantError on EVERY reject
88
+ * path (fail closed). The jti is consumed in the replay store as the
89
+ * final step, so a grant that fails any earlier check is NOT burned
90
+ * and a retried delivery of a valid token still works exactly once. */
91
+ export declare function verifyBrowserGrant(token: string, opts: VerifyBrowserGrantOptions): Promise<BrowserGrant>;
@@ -0,0 +1,353 @@
1
+ // packages/runner/src/browserGrantVerify.ts
2
+ //
3
+ // Melaya Browser, Phase 0 (plan Section 0.6): runner-side VERIFIER for
4
+ // the signed `browserGrant` capability token minted by the server
5
+ // (server/src/services/browser/browserGrant.ts).
6
+ //
7
+ // Verification order (all checks fail closed with typed errors):
8
+ // 1. compact-JWS shape and JSON decoding -> "malformed"
9
+ // 2. alg allowlist (exactly ["EdDSA"]) -> "alg_not_allowed"
10
+ // 3. kid -> public key lookup -> "kid_unknown"
11
+ // 4. Ed25519 signature over header.payload -> "bad_signature"
12
+ // 5. iss === "melaya-control" -> "iss_mismatch"
13
+ // 6. expectedAud member of aud[] -> "aud_mismatch"
14
+ // 7. exp / nbf / iat with small tolerance (30s) -> "expired" / "not_yet_valid"
15
+ // 8. structural claim checks + scope <= ceiling -> "claims_invalid"
16
+ // 9. revocation hook (if provided) -> "revoked"
17
+ // 10. single-use jti via the replay store -> "replayed"
18
+ //
19
+ // Zero dependencies: node:crypto supports Ed25519 natively on every
20
+ // Node version the runner supports (engines >= 18).
21
+ //
22
+ // -- Keep in sync -------------------------------------------------------
23
+ // `BrowserGrantClaims`, `BrowserGrant`, and the effect-class vocabulary
24
+ // are DUPLICATED from server/src/services/browser/browserGrant.ts
25
+ // because this published package cannot import server sources. Any
26
+ // change on either side MUST be mirrored on the other.
27
+ import { createPublicKey, verify as cryptoVerify } from "node:crypto";
28
+ // ---------------------------------------------------------------------
29
+ // Effect-class vocabulary (KEEP IN SYNC with server browserGrant.ts)
30
+ // ---------------------------------------------------------------------
31
+ export const BROWSER_EFFECT_CLASSES = [
32
+ "read",
33
+ "navigate",
34
+ "message",
35
+ "publish",
36
+ "consent",
37
+ "upload",
38
+ "download",
39
+ "purchase",
40
+ "account_change",
41
+ "data_export",
42
+ "destructive",
43
+ ];
44
+ export const BROWSER_EFFECT_RANK = Object.fromEntries(BROWSER_EFFECT_CLASSES.map((e, i) => [e, i]));
45
+ // ---------------------------------------------------------------------
46
+ // Types (KEEP IN SYNC with server browserGrant.ts)
47
+ // ---------------------------------------------------------------------
48
+ export const BROWSER_GRANT_ISSUER = "melaya-control";
49
+ export const BROWSER_GRANT_ALG = "EdDSA";
50
+ /** The algorithm allowlist is exactly one entry by design. */
51
+ export const ALLOWED_ALGS = [BROWSER_GRANT_ALG];
52
+ export class BrowserGrantError extends Error {
53
+ code;
54
+ constructor(code, message) {
55
+ super(`[browserGrant:${code}] ${message}`);
56
+ this.name = "BrowserGrantError";
57
+ this.code = code;
58
+ }
59
+ }
60
+ /** In-process replay store for the single-runner case. The runner is
61
+ * one OS process, and every grant is bound to THIS runner device via
62
+ * `aud`, so process-local single-use is sufficient on this side; the
63
+ * server keeps the authoritative Redis store for its own paths. */
64
+ export function createInMemoryReplayStore() {
65
+ const seen = new Map(); // jti -> expiry epoch ms
66
+ let lastSweep = 0;
67
+ function sweep(now) {
68
+ // Amortized cleanup: at most once per 30s.
69
+ if (now - lastSweep < 30_000)
70
+ return;
71
+ lastSweep = now;
72
+ for (const [jti, until] of seen) {
73
+ if (until <= now)
74
+ seen.delete(jti);
75
+ }
76
+ }
77
+ return {
78
+ async registerOnce(jti, ttlMs) {
79
+ const now = Date.now();
80
+ sweep(now);
81
+ const existing = seen.get(jti);
82
+ if (existing !== undefined && existing > now)
83
+ return false;
84
+ seen.set(jti, now + Math.max(1000, ttlMs));
85
+ return true;
86
+ },
87
+ };
88
+ }
89
+ // ---------------------------------------------------------------------
90
+ // Public-key handling
91
+ // ---------------------------------------------------------------------
92
+ // RFC 8410 fixed DER prefix for an Ed25519 SPKI public key.
93
+ const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
94
+ const RAW_PUB_LEN = 32;
95
+ /** Accepts base64 of a 32-byte raw Ed25519 public key (the wire form
96
+ * the server ships via getBrowserGrantPublicKeys) or of an SPKI DER
97
+ * key, or an already-built KeyObject. Throws BrowserGrantError
98
+ * ("kid_unknown") on anything that does not decode to Ed25519. */
99
+ export function toPublicKeyObject(key, kid) {
100
+ if (typeof key !== "string") {
101
+ if (key.asymmetricKeyType !== "ed25519") {
102
+ throw new BrowserGrantError("kid_unknown", `public key for kid '${kid}' is not ed25519`);
103
+ }
104
+ return key;
105
+ }
106
+ let raw;
107
+ try {
108
+ raw = Buffer.from(key, "base64");
109
+ }
110
+ catch {
111
+ throw new BrowserGrantError("kid_unknown", `public key for kid '${kid}' is not valid base64`);
112
+ }
113
+ try {
114
+ const der = raw.length === RAW_PUB_LEN ? Buffer.concat([SPKI_PREFIX, raw]) : raw;
115
+ const obj = createPublicKey({ key: der, format: "der", type: "spki" });
116
+ if (obj.asymmetricKeyType !== "ed25519") {
117
+ throw new Error("not ed25519");
118
+ }
119
+ return obj;
120
+ }
121
+ catch {
122
+ throw new BrowserGrantError("kid_unknown", `public key for kid '${kid}' must be base64 of a 32-byte raw or SPKI DER Ed25519 key`);
123
+ }
124
+ }
125
+ /** Optional operator pinning: parse MEL_BROWSER_GRANT_PUBLIC_KEYS
126
+ * ("<kid>=<base64>,..." in the same format the server env uses) into
127
+ * a publicKeysByKid map. The normal delivery path is the server
128
+ * pushing getBrowserGrantPublicKeys() output over the socket; this
129
+ * env var lets a security-conscious operator pin keys locally so a
130
+ * compromised socket cannot swap them. */
131
+ export function loadPinnedPublicKeysFromEnv() {
132
+ const out = {};
133
+ const raw = process.env.MEL_BROWSER_GRANT_PUBLIC_KEYS || "";
134
+ for (const entry of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
135
+ const eq = entry.indexOf("=");
136
+ if (eq <= 0)
137
+ continue;
138
+ out[entry.slice(0, eq)] = entry.slice(eq + 1);
139
+ }
140
+ return out;
141
+ }
142
+ // ---------------------------------------------------------------------
143
+ // Verifier
144
+ // ---------------------------------------------------------------------
145
+ export const DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
146
+ /** Sanity ceiling: matches the server's MAX_TTL_SECONDS. A "valid"
147
+ * token claiming a longer life than the issuer can mint is forged or
148
+ * corrupt, so it is rejected regardless of signature. */
149
+ const MAX_LIFETIME_SECONDS = 900;
150
+ function b64urlJson(part, what) {
151
+ let buf;
152
+ try {
153
+ buf = Buffer.from(part, "base64url");
154
+ }
155
+ catch {
156
+ throw new BrowserGrantError("malformed", `${what} is not valid base64url`);
157
+ }
158
+ try {
159
+ return JSON.parse(buf.toString("utf8"));
160
+ }
161
+ catch {
162
+ throw new BrowserGrantError("malformed", `${what} is not valid JSON`);
163
+ }
164
+ }
165
+ function isNonEmptyString(v) {
166
+ return typeof v === "string" && v.length > 0;
167
+ }
168
+ function validateClaimStructure(p) {
169
+ const bad = (name) => {
170
+ throw new BrowserGrantError("claims_invalid", `claim '${name}' is missing or malformed`);
171
+ };
172
+ if (!isNonEmptyString(p.sub))
173
+ bad("sub");
174
+ if (!isNonEmptyString(p.jti))
175
+ bad("jti");
176
+ if (!isNonEmptyString(p.tenant))
177
+ bad("tenant");
178
+ if (!isNonEmptyString(p.project))
179
+ bad("project");
180
+ if (!isNonEmptyString(p.run))
181
+ bad("run");
182
+ if (!isNonEmptyString(p.runnerDevice))
183
+ bad("runnerDevice");
184
+ if (!isNonEmptyString(p.browserSession))
185
+ bad("browserSession");
186
+ if (!Number.isInteger(p.socketGeneration) || p.socketGeneration < 0)
187
+ bad("socketGeneration");
188
+ const target = p.target;
189
+ if (!target || typeof target !== "object" || !isNonEmptyString(target.ref))
190
+ bad("target.ref");
191
+ if (p.profile !== undefined && !isNonEmptyString(p.profile))
192
+ bad("profile");
193
+ if (p.space !== undefined && !isNonEmptyString(p.space))
194
+ bad("space");
195
+ if (!Array.isArray(p.originScopes) || p.originScopes.some((o) => !isNonEmptyString(o))) {
196
+ bad("originScopes");
197
+ }
198
+ if (!isNonEmptyString(p.effectCeiling) || !(p.effectCeiling in BROWSER_EFFECT_RANK))
199
+ bad("effectCeiling");
200
+ const ceiling = BROWSER_EFFECT_RANK[p.effectCeiling];
201
+ if (!Array.isArray(p.actionScopes) || p.actionScopes.length === 0)
202
+ bad("actionScopes");
203
+ for (const scope of p.actionScopes) {
204
+ if (!isNonEmptyString(scope) || !(scope in BROWSER_EFFECT_RANK))
205
+ bad("actionScopes");
206
+ if (BROWSER_EFFECT_RANK[scope] > ceiling) {
207
+ throw new BrowserGrantError("claims_invalid", `actionScope '${scope}' exceeds effectCeiling '${String(p.effectCeiling)}'`);
208
+ }
209
+ }
210
+ }
211
+ /** Verify a browserGrant compact JWS. Resolves to the typed, verified
212
+ * BrowserGrant on success; throws BrowserGrantError on EVERY reject
213
+ * path (fail closed). The jti is consumed in the replay store as the
214
+ * final step, so a grant that fails any earlier check is NOT burned
215
+ * and a retried delivery of a valid token still works exactly once. */
216
+ export async function verifyBrowserGrant(token, opts) {
217
+ if (!opts || !isNonEmptyString(opts.expectedAud)) {
218
+ throw new BrowserGrantError("aud_mismatch", "verifier misconfigured: expectedAud is required");
219
+ }
220
+ if (!opts.replayStore) {
221
+ throw new BrowserGrantError("replay_store_unavailable", "verifier misconfigured: replayStore is required");
222
+ }
223
+ // 1. Shape
224
+ if (typeof token !== "string" || token.length === 0 || token.length > 16_384) {
225
+ throw new BrowserGrantError("malformed", "token is empty, not a string, or oversized");
226
+ }
227
+ const parts = token.split(".");
228
+ if (parts.length !== 3) {
229
+ throw new BrowserGrantError("malformed", "token is not a three-part compact JWS");
230
+ }
231
+ const header = b64urlJson(parts[0], "header");
232
+ // 2. Algorithm allowlist (never trust the header beyond routing)
233
+ if (!ALLOWED_ALGS.includes(header.alg)) {
234
+ throw new BrowserGrantError("alg_not_allowed", `alg '${String(header.alg)}' is not in [${ALLOWED_ALGS.join(", ")}]`);
235
+ }
236
+ // 3. kid -> public key
237
+ if (!isNonEmptyString(header.kid)) {
238
+ throw new BrowserGrantError("kid_unknown", "header has no kid");
239
+ }
240
+ const kid = header.kid;
241
+ const keyMaterial = opts.publicKeysByKid[kid];
242
+ if (keyMaterial === undefined) {
243
+ throw new BrowserGrantError("kid_unknown", `no public key for kid '${kid}'`);
244
+ }
245
+ const publicKey = toPublicKeyObject(keyMaterial, kid);
246
+ // 4. Signature over the exact signing input
247
+ let sig;
248
+ try {
249
+ sig = Buffer.from(parts[2], "base64url");
250
+ }
251
+ catch {
252
+ throw new BrowserGrantError("malformed", "signature is not valid base64url");
253
+ }
254
+ const signingInput = Buffer.from(parts[0] + "." + parts[1], "utf8");
255
+ let ok = false;
256
+ try {
257
+ ok = cryptoVerify(null, signingInput, publicKey, sig);
258
+ }
259
+ catch {
260
+ ok = false;
261
+ }
262
+ if (!ok) {
263
+ throw new BrowserGrantError("bad_signature", `signature verification failed under kid '${kid}'`);
264
+ }
265
+ // Only now is the payload trustworthy enough to inspect.
266
+ const p = b64urlJson(parts[1], "payload");
267
+ // 5. Issuer
268
+ if (p.iss !== BROWSER_GRANT_ISSUER) {
269
+ throw new BrowserGrantError("iss_mismatch", `iss '${String(p.iss)}' is not '${BROWSER_GRANT_ISSUER}'`);
270
+ }
271
+ // 6. Audience: our own id must be in the list
272
+ if (!Array.isArray(p.aud) || p.aud.some((a) => !isNonEmptyString(a))) {
273
+ throw new BrowserGrantError("aud_mismatch", "aud is not a string array");
274
+ }
275
+ if (!p.aud.includes(opts.expectedAud)) {
276
+ throw new BrowserGrantError("aud_mismatch", `aud does not include '${opts.expectedAud}'`);
277
+ }
278
+ // 7. Time window with tolerance
279
+ const tol = Math.max(0, opts.clockToleranceSeconds ?? DEFAULT_CLOCK_TOLERANCE_SECONDS);
280
+ const now = Math.floor((opts.nowMs ?? Date.now()) / 1000);
281
+ const { iat, nbf, exp } = p;
282
+ if (!Number.isFinite(iat) || !Number.isFinite(nbf) || !Number.isFinite(exp)) {
283
+ throw new BrowserGrantError("claims_invalid", "iat/nbf/exp must be numeric");
284
+ }
285
+ if (exp <= now - tol) {
286
+ throw new BrowserGrantError("expired", `exp ${exp} is in the past (now ${now}, tolerance ${tol}s)`);
287
+ }
288
+ if (nbf > now + tol) {
289
+ throw new BrowserGrantError("not_yet_valid", `nbf ${nbf} is in the future (now ${now}, tolerance ${tol}s)`);
290
+ }
291
+ if (iat > now + tol) {
292
+ throw new BrowserGrantError("not_yet_valid", `iat ${iat} is in the future (now ${now}, tolerance ${tol}s)`);
293
+ }
294
+ if (exp - iat > MAX_LIFETIME_SECONDS + tol) {
295
+ throw new BrowserGrantError("claims_invalid", `lifetime exceeds ${MAX_LIFETIME_SECONDS}s issuer maximum`);
296
+ }
297
+ if (p.kid !== undefined && p.kid !== kid) {
298
+ throw new BrowserGrantError("claims_invalid", "payload kid disagrees with header kid");
299
+ }
300
+ // 8. Structural claims + scope/ceiling relation
301
+ validateClaimStructure(p);
302
+ const jti = p.jti;
303
+ // 9. Revocation hook (before burning the jti). A throwing hook
304
+ // rejects: unreachable revocation infrastructure means we cannot
305
+ // prove the grant is still live.
306
+ if (opts.isRevoked) {
307
+ let revoked;
308
+ try {
309
+ revoked = await opts.isRevoked(jti);
310
+ }
311
+ catch (e) {
312
+ throw new BrowserGrantError("revoked", `revocation check failed, refusing grant: ${e?.message || e}`);
313
+ }
314
+ if (revoked) {
315
+ throw new BrowserGrantError("revoked", `jti '${jti}' is revoked`);
316
+ }
317
+ }
318
+ // 10. Single-use consumption, last so failed earlier checks never
319
+ // burn a valid token. TTL covers remaining life + tolerance.
320
+ const replayTtlMs = Math.max(1000, (exp - now + tol) * 1000);
321
+ let first;
322
+ try {
323
+ first = await opts.replayStore.registerOnce(jti, replayTtlMs);
324
+ }
325
+ catch (e) {
326
+ throw new BrowserGrantError("replay_store_unavailable", `replay store unreachable, refusing grant: ${e?.message || e}`);
327
+ }
328
+ if (!first) {
329
+ throw new BrowserGrantError("replayed", `jti '${jti}' was already used`);
330
+ }
331
+ return {
332
+ iss: BROWSER_GRANT_ISSUER,
333
+ kid,
334
+ jti,
335
+ iat: iat,
336
+ nbf: nbf,
337
+ exp: exp,
338
+ sub: p.sub,
339
+ aud: p.aud,
340
+ tenant: p.tenant,
341
+ project: p.project,
342
+ run: p.run,
343
+ runnerDevice: p.runnerDevice,
344
+ socketGeneration: p.socketGeneration,
345
+ browserSession: p.browserSession,
346
+ target: { ref: p.target.ref },
347
+ profile: p.profile,
348
+ space: p.space,
349
+ originScopes: p.originScopes,
350
+ actionScopes: p.actionScopes,
351
+ effectCeiling: p.effectCeiling,
352
+ };
353
+ }
@@ -0,0 +1,33 @@
1
+ export type BrowserEngineId = "chrome" | "edge" | "brave" | "chromium";
2
+ export interface InstalledEngine {
3
+ engine: BrowserEngineId;
4
+ executablePath: string;
5
+ /** Best-effort version ("139.0.7258.67" style); "" when undetectable. */
6
+ version: string;
7
+ /** Playwright channel name when launchable via channel, else null
8
+ * (brave has no Playwright channel: launched via executablePath). */
9
+ channel: "chrome" | "msedge" | null;
10
+ /** Engine-specific caveats the caller should surface to the user. */
11
+ quirks: string[];
12
+ }
13
+ export interface BrowserCapabilityReport {
14
+ engines: InstalledEngine[];
15
+ cdp: boolean;
16
+ provisioner: "v1";
17
+ platform: NodeJS.Platform;
18
+ }
19
+ export declare function discoverEngines(forceRefresh?: boolean): Promise<InstalledEngine[]>;
20
+ export declare function capabilityReport(): Promise<BrowserCapabilityReport>;
21
+ export declare class ProvisionError extends Error {
22
+ readonly code: "engine_not_installed" | "download_not_authorized" | "download_failed" | "engine_unsupported";
23
+ readonly instructions?: string;
24
+ constructor(code: ProvisionError["code"], message: string, instructions?: string);
25
+ }
26
+ /** Resolve an engine to a launchable executable. NEVER downloads unless
27
+ * allowDownload is explicitly true, and even then only the
28
+ * Playwright-bundled Chromium is downloadable (Chrome/Edge/Brave are
29
+ * user-installed products we will not fetch on the user's behalf). */
30
+ export declare function ensureEngine(engine: BrowserEngineId, opts?: {
31
+ allowDownload?: boolean;
32
+ log?: (m: string) => void;
33
+ }): Promise<InstalledEngine>;