@yeaft/webchat-agent 1.0.413 → 1.0.414

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,497 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { hostname } from 'node:os';
3
+ import {
4
+ access,
5
+ chmod,
6
+ mkdir,
7
+ mkdtemp,
8
+ open,
9
+ readFile,
10
+ readdir,
11
+ rename,
12
+ rm,
13
+ stat,
14
+ writeFile,
15
+ } from 'node:fs/promises';
16
+
17
+ import { constants } from 'node:fs';
18
+ import { basename, dirname, join } from 'node:path';
19
+ import { runProcess } from '../yeaft/tools/process-runner.js';
20
+ import { readWindowsBrowserExecutableVersion } from './windows-version.js';
21
+
22
+ // Chrome 151 is the first pinned Chrome for Testing build in this project that
23
+ // exposes the Extensions CDP domain required for safe action activation.
24
+ export const BROWSER_RUNTIME_CHROME_BUILD = '151.0.7922.71';
25
+ const MANIFEST_FILE = '.yeaft-browser-manifest.json';
26
+ const INSTALL_LOCK_WAIT_MS = 120_000;
27
+ const INSTALL_LOCK_STALE_MS = 30 * 60_000;
28
+ const INSTALL_RETRY_MS = 100;
29
+
30
+ export const BROWSER_RUNTIME_CHROME_ARCHIVES = Object.freeze({
31
+ linux: Object.freeze({
32
+ fileName: 'chrome-linux64.zip',
33
+ sha256: '6bd04aab53fba1544ce6027d9daddb24137295033124a61ecdf9840d785792e9',
34
+ }),
35
+ mac: Object.freeze({
36
+ fileName: 'chrome-mac-x64.zip',
37
+ sha256: 'bedcd79ae533fed218c26232b74e73cffec2a7277fce42cafcf5ec7280e4f81c',
38
+ }),
39
+ mac_arm: Object.freeze({
40
+ fileName: 'chrome-mac-arm64.zip',
41
+ sha256: '1c516b5d6c00a074034d5ce03dc1cc9bd2cde2a09293d9613244e0bc153cb80f',
42
+ }),
43
+ win32: Object.freeze({
44
+ fileName: 'chrome-win32.zip',
45
+ sha256: '338f15dcf19d457f93f692c279843477a92324f0f91f78bf5380d3fe00a9796f',
46
+ }),
47
+ win64: Object.freeze({
48
+ fileName: 'chrome-win64.zip',
49
+ sha256: '7ea2e94833ef710026c8cb08d0d2dafcb13f5d304d9c475ac07a3fa8c11d846c',
50
+ }),
51
+ });
52
+
53
+ export function defaultBrowserCacheDir(yeaftDir) {
54
+ if (!yeaftDir) throw new Error('yeaftDir required');
55
+ return join(yeaftDir, 'managed-browser');
56
+ }
57
+
58
+ export async function isExecutable(path) {
59
+ if (!path) return false;
60
+ try {
61
+ const details = await stat(path);
62
+ if (!details.isFile()) return false;
63
+ await access(path, process.platform === 'win32' ? constants.F_OK : constants.X_OK);
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ async function hashFile(path) {
71
+ const handle = await open(path, 'r');
72
+ try {
73
+ const hash = createHash('sha256');
74
+ for await (const chunk of handle.createReadStream()) hash.update(chunk);
75
+ return hash.digest('hex');
76
+ } finally {
77
+ await handle.close().catch(() => {});
78
+ }
79
+ }
80
+
81
+ function archiveForPlatform(platform, archives = BROWSER_RUNTIME_CHROME_ARCHIVES) {
82
+ const archive = archives[platform];
83
+ if (!archive) throw new Error(`Managed Chrome is unsupported on platform ${platform}`);
84
+ const folder = {
85
+ linux: 'linux64',
86
+ mac: 'mac-x64',
87
+ mac_arm: 'mac-arm64',
88
+ win32: 'win32',
89
+ win64: 'win64',
90
+ }[platform];
91
+ return {
92
+ ...archive,
93
+ platform,
94
+ url: `https://storage.googleapis.com/chrome-for-testing-public/${BROWSER_RUNTIME_CHROME_BUILD}/${folder}/${archive.fileName}`,
95
+ };
96
+ }
97
+
98
+ async function readManifest(cacheDir) {
99
+ try {
100
+ const parsed = JSON.parse(await readFile(join(cacheDir, MANIFEST_FILE), 'utf8'));
101
+ return parsed && typeof parsed === 'object' ? parsed : null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ async function writeManifest(cacheDir, manifest) {
108
+ const target = join(cacheDir, MANIFEST_FILE);
109
+ const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
110
+ try {
111
+ await writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
112
+ await rename(temporary, target);
113
+ } finally {
114
+ await rm(temporary, { force: true }).catch(() => {});
115
+ }
116
+ }
117
+
118
+ async function inspectManagedBrowser(cacheDir, dependencies = {}) {
119
+ const browsers = dependencies.browsers || await import('@puppeteer/browsers');
120
+ const platform = dependencies.platform || browsers.detectBrowserPlatform();
121
+ if (!platform) return { valid: false, reason: 'platform_unsupported' };
122
+ const archive = archiveForPlatform(platform, dependencies.archives);
123
+ let executablePath = dependencies.executablePath || null;
124
+ if (!executablePath) {
125
+ const installed = await browsers.getInstalledBrowsers({ cacheDir });
126
+ const browser = installed.find(candidate => (
127
+ candidate.browser === browsers.Browser.CHROME
128
+ && candidate.buildId === BROWSER_RUNTIME_CHROME_BUILD
129
+ && candidate.platform === platform
130
+ ));
131
+ executablePath = browser?.executablePath || null;
132
+ }
133
+ if (!await isExecutable(executablePath)) {
134
+ return { valid: false, reason: 'executable_missing', executablePath, platform, archive };
135
+ }
136
+ const manifest = await readManifest(cacheDir);
137
+ if (!manifest
138
+ || manifest.buildId !== BROWSER_RUNTIME_CHROME_BUILD
139
+ || manifest.platform !== platform
140
+ || manifest.archiveSha256 !== archive.sha256
141
+ || manifest.executablePath !== executablePath
142
+ || typeof manifest.executableSha256 !== 'string') {
143
+ return { valid: false, reason: 'manifest_missing', executablePath, platform, archive };
144
+ }
145
+ const executableSha256 = await hashFile(executablePath);
146
+ if (executableSha256 !== manifest.executableSha256) {
147
+ return { valid: false, reason: 'executable_digest_mismatch', executablePath, platform, archive };
148
+ }
149
+ return { valid: true, executablePath, platform, archive, executableSha256 };
150
+ }
151
+
152
+ /** Resolve only a verified exact managed Chrome for Testing build. */
153
+ export async function findManagedBrowser(cacheDir, dependencies = {}) {
154
+ const inspected = await inspectManagedBrowser(cacheDir, dependencies);
155
+ return inspected.valid ? inspected.executablePath : null;
156
+ }
157
+
158
+ /** Explicit executables are version-fenced by the probe before extension launch. */
159
+ export async function resolveBrowserExecutable({ executablePath, cacheDir }) {
160
+ if (executablePath) return await isExecutable(executablePath) ? executablePath : null;
161
+ return findManagedBrowser(cacheDir);
162
+ }
163
+
164
+ function delay(ms) {
165
+ return new Promise(resolve => setTimeout(resolve, ms));
166
+ }
167
+
168
+ function processIsAlive(pid) {
169
+ if (!Number.isInteger(pid) || pid <= 0) return false;
170
+ try {
171
+ process.kill(pid, 0);
172
+ return true;
173
+ } catch (error) {
174
+ return error?.code === 'EPERM';
175
+ }
176
+ }
177
+
178
+ async function readInstallLockOwner(lockDir) {
179
+ const details = await stat(lockDir);
180
+ if (!details.isDirectory()) throw new Error('Managed Chrome install lock is not a directory');
181
+ try {
182
+ return { owner: JSON.parse(await readFile(join(lockDir, 'owner.json'), 'utf8')), details };
183
+ } catch {
184
+ return { owner: null, details };
185
+ }
186
+ }
187
+
188
+ async function removeOrphanedInstallStaging(cacheDir) {
189
+ const prefix = `.chrome-${BROWSER_RUNTIME_CHROME_BUILD}-staging-`;
190
+ const entries = await readdir(cacheDir, { withFileTypes: true });
191
+ await Promise.all(entries
192
+ .filter(entry => entry.isDirectory() && entry.name.startsWith(prefix))
193
+ .map(async entry => {
194
+ const path = join(cacheDir, entry.name);
195
+ try {
196
+ const owner = JSON.parse(await readFile(join(path, 'owner.json'), 'utf8'));
197
+ if (owner?.host !== hostname() || processIsAlive(Number(owner.pid))) return;
198
+ await rm(path, { recursive: true, force: true });
199
+ } catch {}
200
+ }));
201
+ }
202
+
203
+ async function installLockCanBeTaken(lockDir, staleMs) {
204
+ const { owner, details } = await readInstallLockOwner(lockDir);
205
+ if (owner?.host === hostname()) return !processIsAlive(Number(owner.pid));
206
+ if (owner) return false;
207
+ return Date.now() - details.mtimeMs > staleMs;
208
+ }
209
+
210
+ async function installLockIsOwned(lockDir, token) {
211
+ try {
212
+ const owner = JSON.parse(await readFile(join(lockDir, 'owner.json'), 'utf8'));
213
+ return owner?.token === token;
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+
219
+ async function releaseInstallLock(lockDir, token) {
220
+ if (!await installLockIsOwned(lockDir, token)) return false;
221
+ const claimed = `${lockDir}.release-${token}`;
222
+ try {
223
+ await rename(lockDir, claimed);
224
+ } catch (error) {
225
+ if (error?.code === 'ENOENT') return false;
226
+ throw error;
227
+ }
228
+ if (!await installLockIsOwned(claimed, token)) {
229
+ await rename(claimed, lockDir).catch(() => {});
230
+ return false;
231
+ }
232
+ await rm(claimed, { recursive: true, force: true });
233
+ return true;
234
+ }
235
+
236
+ function installLockOwnerIdentity(owner) {
237
+ if (!owner) return null;
238
+ if (typeof owner.token === 'string' && owner.token) return `token:${owner.token}`;
239
+ return `legacy:${owner.host || ''}:${Number(owner.pid) || 0}:${Number(owner.startedAt) || 0}`;
240
+ }
241
+
242
+ async function takeInstallLock(lockDir, staleMs) {
243
+ const observed = (await readInstallLockOwner(lockDir)).owner;
244
+ if (!await installLockCanBeTaken(lockDir, staleMs)) return false;
245
+ const observedIdentity = installLockOwnerIdentity(observed);
246
+ const claimed = `${lockDir}.stale-${randomUUID()}`;
247
+ try {
248
+ await rename(lockDir, claimed);
249
+ } catch (error) {
250
+ if (error?.code === 'ENOENT') return true;
251
+ return false;
252
+ }
253
+ const claimedOwner = (await readInstallLockOwner(claimed)).owner;
254
+ const ownerChanged = installLockOwnerIdentity(claimedOwner) !== observedIdentity;
255
+ const ownerRevived = claimedOwner?.host === hostname()
256
+ && processIsAlive(Number(claimedOwner.pid));
257
+ if (ownerChanged || ownerRevived) {
258
+ await rename(claimed, lockDir).catch(() => {});
259
+ return false;
260
+ }
261
+ await rm(claimed, { recursive: true, force: true });
262
+ return true;
263
+ }
264
+
265
+ async function acquireInstallLock(cacheDir, {
266
+ waitMs = INSTALL_LOCK_WAIT_MS,
267
+ staleMs = INSTALL_LOCK_STALE_MS,
268
+ ready = null,
269
+ } = {}) {
270
+ await mkdir(cacheDir, { recursive: true, mode: 0o700 });
271
+ const lockDir = join(cacheDir, `.chrome-${BROWSER_RUNTIME_CHROME_BUILD}.lock`);
272
+ const deadline = Date.now() + waitMs;
273
+ for (;;) {
274
+ if (typeof ready === 'function' && await ready()) return null;
275
+ const token = randomUUID();
276
+ try {
277
+ await mkdir(lockDir, { mode: 0o700 });
278
+ await writeFile(join(lockDir, 'owner.json'), JSON.stringify({
279
+ pid: process.pid,
280
+ host: hostname(),
281
+ token,
282
+ startedAt: Date.now(),
283
+ }), { flag: 'wx', mode: 0o600 });
284
+ return {
285
+ token,
286
+ release: () => releaseInstallLock(lockDir, token),
287
+ };
288
+ } catch (error) {
289
+ if (error?.code !== 'EEXIST') throw error;
290
+ try {
291
+ if (await takeInstallLock(lockDir, staleMs)) continue;
292
+ } catch (inspectionError) {
293
+ if (inspectionError?.code === 'ENOENT') continue;
294
+ throw inspectionError;
295
+ }
296
+ if (Date.now() >= deadline) throw new Error('Managed Chrome install is busy');
297
+ await delay(Math.min(INSTALL_RETRY_MS, Math.max(1, deadline - Date.now())));
298
+ }
299
+ }
300
+ }
301
+
302
+ async function downloadVerifiedArchive(asset, destination, { fetchFn, onProgress, signal }) {
303
+ const requestOptions = {
304
+ redirect: 'follow',
305
+ signal,
306
+ headers: { 'User-Agent': 'yeaft-agent-browser-runtime' },
307
+ };
308
+ let response = await fetchFn(asset.url, requestOptions);
309
+ if (!response.ok) throw new Error(`Managed Chrome download returned HTTP ${response.status}`);
310
+ if (response.body && typeof response.body[Symbol.asyncIterator] !== 'function') {
311
+ if (!response.url || response.url === asset.url) {
312
+ throw new Error('Managed Chrome downloader returned an unreadable response body');
313
+ }
314
+ const resolved = await fetchFn(response.url, requestOptions);
315
+ if (!resolved.ok) throw new Error(`Managed Chrome download returned HTTP ${resolved.status}`);
316
+ response = resolved;
317
+ }
318
+ const handle = await open(destination, 'wx', 0o600);
319
+ const hash = createHash('sha256');
320
+ let downloaded = 0;
321
+ const total = Number(response.headers?.get?.('content-length')) || 0;
322
+ try {
323
+ for await (const raw of response.body || []) {
324
+ const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
325
+ downloaded += chunk.length;
326
+ hash.update(chunk);
327
+ await handle.write(chunk);
328
+ onProgress?.(downloaded, total);
329
+ }
330
+ await handle.sync();
331
+ } finally {
332
+ await handle.close();
333
+ }
334
+ const actual = hash.digest('hex');
335
+ if (actual !== asset.sha256) throw new Error(`Managed Chrome archive checksum mismatch for ${asset.fileName}`);
336
+ return actual;
337
+ }
338
+
339
+ export async function readBrowserExecutableVersion(executablePath, {
340
+ versionCheck = null,
341
+ signal = null,
342
+ gracefulTerminationDeadline = null,
343
+ terminationDeadline = null,
344
+ processOptions = null,
345
+ windowsVersionReader = readWindowsBrowserExecutableVersion,
346
+ } = {}) {
347
+ if (typeof versionCheck === 'function') return versionCheck(executablePath, { signal });
348
+ const startedAt = Date.now();
349
+ const resolvedGracefulDeadline = Number.isFinite(gracefulTerminationDeadline)
350
+ ? gracefulTerminationDeadline
351
+ : startedAt + 5_000;
352
+ const resolvedTerminationDeadline = Number.isFinite(terminationDeadline)
353
+ ? terminationDeadline
354
+ : resolvedGracefulDeadline + 500;
355
+ const platform = processOptions?.platform || process.platform;
356
+ if (platform === 'win32') {
357
+ return windowsVersionReader(executablePath, {
358
+ signal,
359
+ terminationDeadline: resolvedTerminationDeadline,
360
+ ...(processOptions?.windowsVersionOptions || {}),
361
+ });
362
+ }
363
+ const result = await runProcess(executablePath, ['--version'], {
364
+ signal,
365
+ timeoutMs: Math.max(1, resolvedGracefulDeadline - Date.now()),
366
+ maxBytes: 64 * 1024,
367
+ killGraceMs: 100,
368
+ gracefulTerminationDeadline: resolvedGracefulDeadline,
369
+ terminationDeadline: resolvedTerminationDeadline,
370
+ forceSettleMs: 500,
371
+ treeKillTimeoutMs: 500,
372
+ requireExitConfirmation: true,
373
+ requireProcessGroupExit: true,
374
+ ...(processOptions || {}),
375
+ platform,
376
+ });
377
+ if (result.code !== 0) {
378
+ const termination = result.terminationError ? ` ${result.terminationError}` : '';
379
+ throw new Error(`Managed Chrome version check failed (${result.code}): ${result.stderr.slice(0, 200)}${termination}`);
380
+ }
381
+ return result.stdout.trim();
382
+ }
383
+
384
+ async function executableVersion(executablePath, dependencies = {}) {
385
+ return readBrowserExecutableVersion(executablePath, dependencies);
386
+ }
387
+
388
+ function installedDirectory(cacheDir, platform) {
389
+ return join(cacheDir, 'chrome', `${platform}-${BROWSER_RUNTIME_CHROME_BUILD}`);
390
+ }
391
+
392
+ export async function installManagedBrowser({
393
+ cacheDir,
394
+ onProgress,
395
+ fetchFn = globalThis.fetch,
396
+ signal = null,
397
+ dependencies = {},
398
+ } = {}) {
399
+ if (!cacheDir) throw new Error('cacheDir required');
400
+ if (typeof fetchFn !== 'function') throw new Error('fetch is unavailable');
401
+ const lock = await acquireInstallLock(cacheDir, {
402
+ ...dependencies,
403
+ ready: async () => (await inspectManagedBrowser(cacheDir, dependencies)).valid,
404
+ });
405
+ if (!lock) {
406
+ const existing = await inspectManagedBrowser(cacheDir, dependencies);
407
+ if (!existing.valid) throw new Error('Managed Chrome install completed without a verified browser');
408
+ return {
409
+ buildId: BROWSER_RUNTIME_CHROME_BUILD,
410
+ executablePath: existing.executablePath,
411
+ executableSha256: existing.executableSha256,
412
+ cacheDir,
413
+ status: 'available',
414
+ };
415
+ }
416
+ let stagingRoot = null;
417
+ try {
418
+ if (lock) await removeOrphanedInstallStaging(cacheDir);
419
+ const existing = await inspectManagedBrowser(cacheDir, dependencies);
420
+ if (existing.valid) {
421
+ return {
422
+ buildId: BROWSER_RUNTIME_CHROME_BUILD,
423
+ executablePath: existing.executablePath,
424
+ executableSha256: existing.executableSha256,
425
+ cacheDir,
426
+ status: 'available',
427
+ };
428
+ }
429
+
430
+ const browsers = dependencies.browsers || await import('@puppeteer/browsers');
431
+ const platform = dependencies.platform || browsers.detectBrowserPlatform();
432
+ if (!platform) throw new Error('Cannot detect a supported Browser Runtime platform');
433
+ const asset = archiveForPlatform(platform, dependencies.archives);
434
+ const finalDir = installedDirectory(cacheDir, platform);
435
+
436
+ stagingRoot = await mkdtemp(join(
437
+ cacheDir,
438
+ `.chrome-${BROWSER_RUNTIME_CHROME_BUILD}-staging-${lock.token}-`,
439
+ ));
440
+ if (process.platform !== 'win32') await chmod(stagingRoot, 0o700);
441
+ await writeFile(join(stagingRoot, 'owner.json'), JSON.stringify({
442
+ pid: process.pid,
443
+ host: hostname(),
444
+ token: lock.token,
445
+ startedAt: Date.now(),
446
+ }), { flag: 'wx', mode: 0o600 });
447
+ const archivePath = join(stagingRoot, asset.fileName);
448
+ await downloadVerifiedArchive(asset, archivePath, { fetchFn, onProgress, signal });
449
+
450
+ const stagingCache = join(stagingRoot, 'cache');
451
+ await mkdir(join(stagingCache, 'chrome'), { recursive: true });
452
+ await rename(
453
+ archivePath,
454
+ join(stagingCache, 'chrome', `${BROWSER_RUNTIME_CHROME_BUILD}-${basename(asset.fileName)}`),
455
+ );
456
+ const installBrowser = dependencies.install || browsers.install;
457
+ const installed = await installBrowser({
458
+ browser: browsers.Browser.CHROME,
459
+ buildId: BROWSER_RUNTIME_CHROME_BUILD,
460
+ platform,
461
+ cacheDir: stagingCache,
462
+ installDeps: false,
463
+ });
464
+ if (!await isExecutable(installed.executablePath)) throw new Error('Managed Chrome executable missing after extraction');
465
+ const version = await executableVersion(installed.executablePath, dependencies);
466
+ if (!version.includes(BROWSER_RUNTIME_CHROME_BUILD)) {
467
+ throw new Error(`Managed Chrome build mismatch: expected ${BROWSER_RUNTIME_CHROME_BUILD}, got ${version}`);
468
+ }
469
+ const executableSha256 = await hashFile(installed.executablePath);
470
+ await mkdir(dirname(finalDir), { recursive: true });
471
+ await rm(finalDir, { recursive: true, force: true });
472
+ await rename(installedDirectory(stagingCache, platform), finalDir);
473
+ const executablePath = installed.executablePath.replace(
474
+ installedDirectory(stagingCache, platform),
475
+ finalDir,
476
+ );
477
+ await writeManifest(cacheDir, {
478
+ version: 1,
479
+ buildId: BROWSER_RUNTIME_CHROME_BUILD,
480
+ platform,
481
+ archiveFileName: asset.fileName,
482
+ archiveSha256: asset.sha256,
483
+ executablePath,
484
+ executableSha256,
485
+ });
486
+ return {
487
+ buildId: BROWSER_RUNTIME_CHROME_BUILD,
488
+ executablePath,
489
+ executableSha256,
490
+ cacheDir,
491
+ status: 'installed',
492
+ };
493
+ } finally {
494
+ if (stagingRoot) await rm(stagingRoot, { recursive: true, force: true }).catch(() => {});
495
+ await lock.release();
496
+ }
497
+ }
@@ -0,0 +1,88 @@
1
+ import {
2
+ resolveManagedYeaftDir,
3
+ resolveServiceInstanceId,
4
+ warnDeprecatedInstanceArg,
5
+ } from '../service/config.js';
6
+
7
+ /**
8
+ * Execute an Agent-instance-scoped Browser Runtime management command.
9
+ * Dependencies are injectable so tests never import the executable Agent CLI.
10
+ */
11
+ export async function handleBrowserCommand(args, dependencies = {}) {
12
+ const action = args[0];
13
+ const options = {};
14
+ const identityArgs = [];
15
+ for (let index = 1; index < args.length; index += 1) {
16
+ const arg = args[index];
17
+ if (arg === '--headful') {
18
+ options.headless = false;
19
+ continue;
20
+ }
21
+ if (arg === '--executable' || arg === '--name' || arg === '--instance' || arg === '--yeaft-dir') {
22
+ const value = args[index + 1];
23
+ if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value`);
24
+ if (arg === '--executable') options.executablePath = value;
25
+ else identityArgs.push(arg, value);
26
+ index += 1;
27
+ continue;
28
+ }
29
+ throw new Error(`Unexpected browser argument: ${arg}`);
30
+ }
31
+
32
+ const env = dependencies.env || process.env;
33
+ const warn = dependencies.warn || console.warn;
34
+ warnDeprecatedInstanceArg(identityArgs, warn);
35
+ const instanceId = resolveServiceInstanceId(identityArgs, env, { management: true });
36
+ const resolveManagementRoot = dependencies.resolveManagedYeaftDir || resolveManagedYeaftDir;
37
+ const yeaftDir = resolveManagementRoot(identityArgs, env, instanceId, {
38
+ loadServiceConfig: dependencies.loadServiceConfig,
39
+ getDefaultYeaftDir: dependencies.getDefaultYeaftDir,
40
+ });
41
+ const browser = dependencies.browserModule || await import('./index.js');
42
+ const configApi = dependencies.configApi || await import('../yeaft/config-api.js');
43
+ const current = configApi.getBrowserRuntimeSettings(yeaftDir);
44
+ if (current.error) throw new Error(current.error);
45
+ const cacheDir = current.cacheDir || browser.defaultBrowserCacheDir(yeaftDir);
46
+ const log = dependencies.log || console.log;
47
+
48
+ if (action === 'install') {
49
+ const result = await browser.installManagedBrowser({ cacheDir });
50
+ log(JSON.stringify({ ok: true, ...result }, null, 2));
51
+ return;
52
+ }
53
+ if (action === 'probe') {
54
+ const result = await browser.probeBrowserRuntime({
55
+ executablePath: options.executablePath || current.executablePath,
56
+ cacheDir,
57
+ headless: options.headless ?? current.headless,
58
+ timeoutMs: current.startupProbeTimeoutMs,
59
+ profileParent: `${cacheDir}-profiles`,
60
+ });
61
+ log(JSON.stringify(result, null, 2));
62
+ if (!result.ok) {
63
+ if (dependencies.onProbeFailure) dependencies.onProbeFailure(result);
64
+ else process.exitCode = 1;
65
+ }
66
+ return;
67
+ }
68
+ if (action === 'enable' || action === 'disable') {
69
+ const result = configApi.updateBrowserRuntimeSettings({ enabled: action === 'enable' }, yeaftDir);
70
+ if (result.error) throw new Error(result.error);
71
+ log(JSON.stringify(result, null, 2));
72
+ log('Restart the selected Agent instance to run the startup probe. Phase 0 does not advertise Browser capability.');
73
+ return;
74
+ }
75
+ if (action === 'status') {
76
+ const executablePath = await browser.findManagedBrowser(cacheDir);
77
+ log(JSON.stringify({
78
+ instanceId,
79
+ yeaftDir,
80
+ config: current,
81
+ managedBuildId: browser.BROWSER_RUNTIME_CHROME_BUILD,
82
+ managedExecutablePath: executablePath,
83
+ installed: !!executablePath,
84
+ }, null, 2));
85
+ return;
86
+ }
87
+ throw new Error('Usage: yeaft-agent browser install|probe|enable|disable|status [--name <id>] [--yeaft-dir <path>] [--executable <path>] [--headful]');
88
+ }
@@ -0,0 +1,116 @@
1
+ const MIB = 1024 * 1024;
2
+
3
+ export const BROWSER_RUNTIME_DEFAULTS = Object.freeze({
4
+ enabled: false,
5
+ executablePath: null,
6
+ cacheDir: null,
7
+ headless: true,
8
+ maxSessions: 2,
9
+ maxPeersPerSession: 2,
10
+ maxWidth: 1920,
11
+ maxHeight: 1080,
12
+ maxFps: 30,
13
+ maxBitrate: 4_000_000,
14
+ maxQueuedActionsPerSession: 128,
15
+ maxQueuedActionsPerProducer: 32,
16
+ maxActionQueueBytes: MIB,
17
+ maxActionRuntimeMs: 30_000,
18
+ producerCreditBurst: 16,
19
+ producerCreditRefillPerSecond: 8,
20
+ noViewerIdleMs: 120_000,
21
+ interactiveIdleMs: 2_100_000,
22
+ maxDownloadsBytes: 512 * MIB,
23
+ startupProbeTimeoutMs: 20_000,
24
+ });
25
+
26
+ const INTEGER_LIMITS = Object.freeze({
27
+ maxSessions: [1, 4],
28
+ maxPeersPerSession: [1, 4],
29
+ maxWidth: [320, 3840],
30
+ maxHeight: [240, 2160],
31
+ maxFps: [1, 60],
32
+ maxBitrate: [100_000, 8_000_000],
33
+ maxQueuedActionsPerSession: [1, 256],
34
+ maxQueuedActionsPerProducer: [1, 64],
35
+ maxActionQueueBytes: [64 * 1024, 4 * MIB],
36
+ maxActionRuntimeMs: [1_000, 120_000],
37
+ producerCreditBurst: [1, 64],
38
+ producerCreditRefillPerSecond: [1, 64],
39
+ noViewerIdleMs: [10_000, 30 * 60_000],
40
+ interactiveIdleMs: [60_000, 8 * 60 * 60_000],
41
+ maxDownloadsBytes: [0, 2 * 1024 * MIB],
42
+ startupProbeTimeoutMs: [5_000, 60_000],
43
+ });
44
+
45
+ export const BROWSER_RUNTIME_SETTING_KEYS = Object.freeze([
46
+ 'enabled',
47
+ 'executablePath',
48
+ 'cacheDir',
49
+ 'headless',
50
+ ...Object.keys(INTEGER_LIMITS),
51
+ ]);
52
+
53
+ function normalizeOptionalPath(value) {
54
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
55
+ }
56
+
57
+ function clampInteger(value, fallback, [minimum, maximum]) {
58
+ const number = Number(value);
59
+ if (!Number.isFinite(number)) return fallback;
60
+ return Math.min(maximum, Math.max(minimum, Math.floor(number)));
61
+ }
62
+
63
+ /**
64
+ * Normalize the Agent-owned Browser Runtime configuration. Unknown keys are
65
+ * dropped and every resource knob is clamped to a hard ceiling. The feature is
66
+ * disabled unless the persisted value is the literal boolean `true`.
67
+ *
68
+ * @param {unknown} raw
69
+ * @returns {typeof BROWSER_RUNTIME_DEFAULTS}
70
+ */
71
+ export function normaliseBrowserRuntimeSection(raw) {
72
+ const source = raw && typeof raw === 'object' && !Array.isArray(raw)
73
+ ? /** @type {Record<string, unknown>} */ (raw)
74
+ : {};
75
+ const result = {
76
+ ...BROWSER_RUNTIME_DEFAULTS,
77
+ enabled: source.enabled === true,
78
+ executablePath: normalizeOptionalPath(source.executablePath),
79
+ cacheDir: normalizeOptionalPath(source.cacheDir),
80
+ headless: source.headless !== false,
81
+ };
82
+ for (const [key, limits] of Object.entries(INTEGER_LIMITS)) {
83
+ result[key] = clampInteger(source[key], BROWSER_RUNTIME_DEFAULTS[key], limits);
84
+ }
85
+ return result;
86
+ }
87
+
88
+ /**
89
+ * Validate a partial write without silently turning a typo into a different
90
+ * resource policy. Reads clamp hand-edited values; public writes reject them.
91
+ *
92
+ * @param {unknown} update
93
+ * @returns {string|null}
94
+ */
95
+ export function validateBrowserRuntimeUpdate(update) {
96
+ if (!update || typeof update !== 'object' || Array.isArray(update)) return 'update payload required';
97
+ const value = /** @type {Record<string, unknown>} */ (update);
98
+ const unknown = Object.keys(value).find(key => !BROWSER_RUNTIME_SETTING_KEYS.includes(key));
99
+ if (unknown) return `unknown browser runtime setting: ${unknown}`;
100
+ for (const key of ['enabled', 'headless']) {
101
+ if (key in value && typeof value[key] !== 'boolean') return `${key} must be a boolean`;
102
+ }
103
+ for (const key of ['executablePath', 'cacheDir']) {
104
+ if (key in value && value[key] !== null && typeof value[key] !== 'string') {
105
+ return `${key} must be a string or null`;
106
+ }
107
+ }
108
+ for (const [key, [minimum, maximum]] of Object.entries(INTEGER_LIMITS)) {
109
+ if (!(key in value)) continue;
110
+ const number = Number(value[key]);
111
+ if (!Number.isInteger(number) || number < minimum || number > maximum) {
112
+ return `${key} must be an integer between ${minimum} and ${maximum}`;
113
+ }
114
+ }
115
+ return null;
116
+ }