vigthoria-cli 1.13.34 → 1.13.42

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.
Files changed (51) hide show
  1. package/README.md +11 -56
  2. package/SECURITY_HARDENING.md +2 -4
  3. package/dist/commands/auth.js +16 -14
  4. package/dist/commands/cancel.d.ts +1 -0
  5. package/dist/commands/cancel.js +21 -4
  6. package/dist/commands/chat.d.ts +13 -0
  7. package/dist/commands/chat.js +170 -14
  8. package/dist/commands/fork.js +8 -3
  9. package/dist/commands/history.js +8 -3
  10. package/dist/commands/legion.js +1 -1
  11. package/dist/commands/replay.js +8 -3
  12. package/dist/commands/update-registration.js +63 -206
  13. package/dist/commands/v4-menu.d.ts +0 -1
  14. package/dist/commands/v4-menu.js +8 -6
  15. package/dist/commands/v4-registration.js +0 -2
  16. package/dist/commands/v4.js +9 -4
  17. package/dist/commands/wallet.d.ts +1 -1
  18. package/dist/commands/wallet.js +3 -3
  19. package/dist/index.js +2 -3
  20. package/dist/utils/agent-stream-state.d.ts +7 -0
  21. package/dist/utils/agent-stream-state.js +31 -0
  22. package/dist/utils/agentRunOutcome.js +5 -1
  23. package/dist/utils/api.d.ts +19 -4
  24. package/dist/utils/api.js +322 -550
  25. package/dist/utils/frontend-preview-service.d.ts +10 -1
  26. package/dist/utils/frontend-preview-service.js +162 -17
  27. package/dist/utils/javascript-syntax.d.ts +19 -0
  28. package/dist/utils/javascript-syntax.js +35 -0
  29. package/dist/utils/localTestMode.js +1 -1
  30. package/dist/utils/mutation-journal.d.ts +10 -0
  31. package/dist/utils/mutation-journal.js +104 -76
  32. package/dist/utils/network-policy.js +7 -10
  33. package/dist/utils/preview-screenshot-adapter.d.ts +81 -0
  34. package/dist/utils/preview-screenshot-adapter.js +615 -38
  35. package/dist/utils/release-install.d.ts +0 -1
  36. package/dist/utils/release-install.js +44 -7
  37. package/dist/utils/requestIntent.d.ts +1 -1
  38. package/dist/utils/requestIntent.js +17 -3
  39. package/dist/utils/tools.js +21 -15
  40. package/dist/utils/update-policy.d.ts +0 -4
  41. package/dist/utils/update-policy.js +5 -9
  42. package/dist/utils/v3-agent-client.js +25 -11
  43. package/install.ps1 +30 -50
  44. package/install.sh +13 -24
  45. package/package.json +4 -2
  46. package/release-policy.json +1 -5
  47. package/scripts/release/LOCAL_MACHINE_USER_VERIFICATION.md +6 -7
  48. package/scripts/release/install-release.mjs +5 -4
  49. package/scripts/release/publish-cli-release.mjs +21 -8
  50. package/scripts/release/test-balanced-model-live.sh +4 -1
  51. package/scripts/release/validate-live-service-gates.sh +27 -5
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import * as fs from 'node:fs';
3
+ import { createServer } from 'node:http';
3
4
  import * as path from 'node:path';
4
5
  import { pathToFileURL } from 'node:url';
5
6
  import WebSocket from 'ws';
@@ -12,7 +13,35 @@ const BROWSER_TIMEOUT_MS = 30_000;
12
13
  // Chromium creates short-lived files below TMPDIR while starting. Keep enough
13
14
  // room below the 108-byte POSIX sockaddr_un ceiling without rejecting normal
14
15
  // per-user Vigthoria roots (for example ~/.vigthoria/tmp/browser-*/scratch).
15
- const POSIX_BROWSER_SCRATCH_MAX_BYTES = 76;
16
+ // Microsoft Edge adds a longer internal singleton suffix than Chromium; on
17
+ // the production Linux host a 61-byte scratch path starts reliably while a
18
+ // 66-byte path aborts before exposing DevTools. Fail closed before that abort.
19
+ const POSIX_BROWSER_SCRATCH_MAX_BYTES = 61;
20
+ function createIsolatedBrowserEnvironment(environment, platform, browserProfile, browserScratch) {
21
+ const additions = {
22
+ TMPDIR: browserScratch,
23
+ TMP: browserScratch,
24
+ TEMP: browserScratch,
25
+ };
26
+ if (platform !== 'win32') {
27
+ const xdgDirectories = {
28
+ XDG_CONFIG_HOME: path.join(browserProfile, 'xdg-config'),
29
+ XDG_CACHE_HOME: path.join(browserProfile, 'xdg-cache'),
30
+ XDG_DATA_HOME: path.join(browserProfile, 'xdg-data'),
31
+ XDG_RUNTIME_DIR: path.join(browserProfile, 'xdg-runtime'),
32
+ };
33
+ for (const directory of Object.values(xdgDirectories)) {
34
+ fs.mkdirSync(directory, { recursive: false, mode: 0o700 });
35
+ }
36
+ Object.assign(additions, xdgDirectories);
37
+ }
38
+ return safeChildProcessEnv(environment, additions);
39
+ }
40
+ export function assertPreviewBrowserSandboxUser(platform = process.platform, effectiveUserId = typeof process.getuid === 'function' ? process.getuid() : undefined) {
41
+ if (platform !== 'win32' && effectiveUserId === 0) {
42
+ throw new Error('browser proof requires an unprivileged OS account so the Chromium sandbox remains enabled; do not run the Vigthoria CLI as root');
43
+ }
44
+ }
16
45
  export function resolvePreviewBrowserExecutable(environment = process.env, platform = process.platform, exists = fs.existsSync) {
17
46
  const candidates = platform === 'win32'
18
47
  ? [
@@ -90,6 +119,13 @@ async function terminateBrowserProcess(child, platform) {
90
119
  catch { /* group exited */ }
91
120
  }
92
121
  export const runPreviewBrowser = (executablePath, args, platform, environment = process.env) => new Promise((resolve, reject) => {
122
+ try {
123
+ assertPreviewBrowserSandboxUser(platform);
124
+ }
125
+ catch (error) {
126
+ reject(error);
127
+ return;
128
+ }
93
129
  const child = spawn(executablePath, [...args], {
94
130
  stdio: 'ignore',
95
131
  windowsHide: true,
@@ -240,6 +276,7 @@ function runtimeKeyDefinition(rawKey = '') {
240
276
  space: { key: ' ', code: 'Space', virtualKeyCode: 32, text: ' ' },
241
277
  spacebar: { key: ' ', code: 'Space', virtualKeyCode: 32, text: ' ' },
242
278
  enter: { key: 'Enter', code: 'Enter', virtualKeyCode: 13, text: '\r' },
279
+ tab: { key: 'Tab', code: 'Tab', virtualKeyCode: 9 },
243
280
  escape: { key: 'Escape', code: 'Escape', virtualKeyCode: 27 },
244
281
  esc: { key: 'Escape', code: 'Escape', virtualKeyCode: 27 },
245
282
  arrowup: { key: 'ArrowUp', code: 'ArrowUp', virtualKeyCode: 38 },
@@ -255,19 +292,53 @@ function runtimeKeyDefinition(rawKey = '') {
255
292
  }
256
293
  return null;
257
294
  }
258
- async function waitForRuntimeReady(client, sessionId) {
295
+ export function runtimeKeyChordDefinition(rawKey = '') {
296
+ const parts = rawKey.trim().toLowerCase().replace(/\s+/g, '').split('+').filter(Boolean);
297
+ if (!parts.length)
298
+ return null;
299
+ const base = parts.at(-1) || '';
300
+ const aliases = {
301
+ shift: { key: 'Shift', code: 'ShiftLeft', virtualKeyCode: 16, bit: 8 },
302
+ control: { key: 'Control', code: 'ControlLeft', virtualKeyCode: 17, bit: 2 },
303
+ ctrl: { key: 'Control', code: 'ControlLeft', virtualKeyCode: 17, bit: 2 },
304
+ alt: { key: 'Alt', code: 'AltLeft', virtualKeyCode: 18, bit: 1 },
305
+ meta: { key: 'Meta', code: 'MetaLeft', virtualKeyCode: 91, bit: 4 },
306
+ cmd: { key: 'Meta', code: 'MetaLeft', virtualKeyCode: 91, bit: 4 },
307
+ command: { key: 'Meta', code: 'MetaLeft', virtualKeyCode: 91, bit: 4 },
308
+ };
309
+ const modifiers = [];
310
+ const seen = new Set();
311
+ for (const part of parts.slice(0, -1)) {
312
+ const modifier = aliases[part];
313
+ if (!modifier || seen.has(modifier.bit))
314
+ return null;
315
+ seen.add(modifier.bit);
316
+ modifiers.push(modifier);
317
+ }
318
+ const key = runtimeKeyDefinition(base);
319
+ if (!key || aliases[base])
320
+ return null;
321
+ return {
322
+ key,
323
+ modifiers,
324
+ modifierMask: modifiers.reduce((mask, modifier) => mask | modifier.bit, 0),
325
+ };
326
+ }
327
+ async function waitForRuntimeReady(client, sessionId, expectedUrl) {
259
328
  const deadline = Date.now() + 8_000;
260
329
  while (Date.now() < deadline) {
261
330
  const evaluated = await client.send('Runtime.evaluate', {
262
- expression: 'document.readyState',
331
+ expression: '({ readyState: document.readyState, href: location.href })',
263
332
  returnByValue: true,
264
333
  }, sessionId);
265
- const readyState = String(evaluated.result?.value || '');
266
- if (readyState === 'interactive' || readyState === 'complete')
334
+ const observation = evaluated.result?.value || {};
335
+ const readyState = String(observation.readyState || '');
336
+ const currentUrl = String(observation.href || '');
337
+ if (currentUrl === expectedUrl && (readyState === 'interactive' || readyState === 'complete'))
267
338
  return;
268
339
  await new Promise((resolve) => setTimeout(resolve, 100));
269
340
  }
270
- throw new Error('browser document did not become ready');
341
+ throw new Error('browser document did not become ready at the requested loopback URL');
271
342
  }
272
343
  async function readRuntimeBody(client, sessionId) {
273
344
  const evaluated = await client.send('Runtime.evaluate', {
@@ -276,6 +347,343 @@ async function readRuntimeBody(client, sessionId) {
276
347
  }, sessionId);
277
348
  return normalizeRuntimeBody(evaluated.result?.value);
278
349
  }
350
+ async function evaluateRuntimeValue(client, sessionId, expression) {
351
+ const evaluated = await client.send('Runtime.evaluate', {
352
+ expression,
353
+ awaitPromise: true,
354
+ returnByValue: true,
355
+ }, sessionId);
356
+ if (evaluated.exceptionDetails) {
357
+ const description = evaluated.exceptionDetails.exception?.description
358
+ || evaluated.exceptionDetails.text
359
+ || 'browser scenario evaluation failed';
360
+ throw new Error(normalizeRuntimeBody(description));
361
+ }
362
+ return evaluated.result?.value;
363
+ }
364
+ async function focusRuntimeSelector(client, sessionId, selector) {
365
+ await evaluateRuntimeValue(client, sessionId, `(() => {
366
+ const selector = ${JSON.stringify(selector)};
367
+ const element = document.querySelector(selector);
368
+ if (!element) throw new Error('selector not found: ' + selector);
369
+ if (typeof element.focus !== 'function') throw new Error('element is not focusable: ' + selector);
370
+ element.focus();
371
+ return true;
372
+ })()`);
373
+ }
374
+ async function dispatchRuntimeKey(client, sessionId, rawKey) {
375
+ const chord = runtimeKeyChordDefinition(rawKey);
376
+ if (!chord)
377
+ throw new Error(`unsupported browser interaction key: ${rawKey}`);
378
+ let activeModifiers = 0;
379
+ for (const modifier of chord.modifiers) {
380
+ activeModifiers |= modifier.bit;
381
+ await client.send('Input.dispatchKeyEvent', {
382
+ type: 'keyDown', key: modifier.key, code: modifier.code,
383
+ windowsVirtualKeyCode: modifier.virtualKeyCode,
384
+ nativeVirtualKeyCode: modifier.virtualKeyCode,
385
+ modifiers: activeModifiers,
386
+ }, sessionId);
387
+ }
388
+ await client.send('Input.dispatchKeyEvent', {
389
+ type: 'keyDown', key: chord.key.key, code: chord.key.code,
390
+ windowsVirtualKeyCode: chord.key.virtualKeyCode,
391
+ nativeVirtualKeyCode: chord.key.virtualKeyCode,
392
+ modifiers: chord.modifierMask,
393
+ ...(chord.key.text && chord.modifierMask === 0
394
+ ? { text: chord.key.text, unmodifiedText: chord.key.text }
395
+ : {}),
396
+ }, sessionId);
397
+ await client.send('Input.dispatchKeyEvent', {
398
+ type: 'keyUp', key: chord.key.key, code: chord.key.code,
399
+ windowsVirtualKeyCode: chord.key.virtualKeyCode,
400
+ nativeVirtualKeyCode: chord.key.virtualKeyCode,
401
+ modifiers: chord.modifierMask,
402
+ }, sessionId);
403
+ for (const modifier of [...chord.modifiers].reverse()) {
404
+ activeModifiers &= ~modifier.bit;
405
+ await client.send('Input.dispatchKeyEvent', {
406
+ type: 'keyUp', key: modifier.key, code: modifier.code,
407
+ windowsVirtualKeyCode: modifier.virtualKeyCode,
408
+ nativeVirtualKeyCode: modifier.virtualKeyCode,
409
+ modifiers: activeModifiers,
410
+ }, sessionId);
411
+ }
412
+ }
413
+ export async function applyRuntimeStepAction(client, sessionId, step) {
414
+ const selector = String(step.selector || '').trim();
415
+ const value = String(step.value || '');
416
+ if (step.action === 'wait') {
417
+ const milliseconds = Math.max(0, Math.min(Number(value || step.wait_ms) || 0, 2_000));
418
+ if (milliseconds)
419
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
420
+ return;
421
+ }
422
+ if (step.action === 'reload') {
423
+ const currentUrl = String(await evaluateRuntimeValue(client, sessionId, 'location.href'));
424
+ await client.send('Page.reload', { ignoreCache: false }, sessionId);
425
+ await waitForRuntimeReady(client, sessionId, currentUrl);
426
+ return;
427
+ }
428
+ if (step.action === 'press') {
429
+ if (selector)
430
+ await focusRuntimeSelector(client, sessionId, selector);
431
+ await dispatchRuntimeKey(client, sessionId, value);
432
+ return;
433
+ }
434
+ if (step.action === 'assert')
435
+ return;
436
+ if (step.action === 'click'
437
+ && (step.offset_x_ratio !== undefined || step.offset_y_ratio !== undefined)) {
438
+ const point = await evaluateRuntimeValue(client, sessionId, `(() => {
439
+ const element = document.querySelector(${JSON.stringify(selector)});
440
+ if (!element) throw new Error('selector not found: ' + ${JSON.stringify(selector)});
441
+ const rect = element.getBoundingClientRect();
442
+ if (!(rect.width > 0 && rect.height > 0)) throw new Error('click target has no layout box: ' + ${JSON.stringify(selector)});
443
+ const clamp = (value) => Math.max(0.01, Math.min(Number(value), 0.99));
444
+ return {
445
+ x: rect.left + rect.width * clamp(${JSON.stringify(step.offset_x_ratio ?? 0.5)}),
446
+ y: rect.top + rect.height * clamp(${JSON.stringify(step.offset_y_ratio ?? 0.5)}),
447
+ };
448
+ })()`);
449
+ await client.send('Input.dispatchMouseEvent', {
450
+ type: 'mouseMoved', x: Number(point.x), y: Number(point.y), button: 'none', buttons: 0,
451
+ }, sessionId);
452
+ await client.send('Input.dispatchMouseEvent', {
453
+ type: 'mousePressed', x: Number(point.x), y: Number(point.y),
454
+ button: 'left', buttons: 1, clickCount: 1,
455
+ }, sessionId);
456
+ await client.send('Input.dispatchMouseEvent', {
457
+ type: 'mouseReleased', x: Number(point.x), y: Number(point.y),
458
+ button: 'left', buttons: 0, clickCount: 1,
459
+ }, sessionId);
460
+ return;
461
+ }
462
+ await evaluateRuntimeValue(client, sessionId, `(() => {
463
+ const step = ${JSON.stringify(step)};
464
+ const selector = String(step.selector || '');
465
+ const element = document.querySelector(selector);
466
+ if (!element) throw new Error('selector not found: ' + selector);
467
+ if (step.action === 'click') {
468
+ if (typeof element.click !== 'function') throw new Error('element is not clickable: ' + selector);
469
+ // HTMLElement.click() dispatches click handlers but does not reproduce
470
+ // the focus transfer of a real pointer click. That left the previously
471
+ // filled search input active when a scenario clicked a native select or
472
+ // button, producing a false keyboard/focus failure. Move focus first,
473
+ // as a trusted user click does, then dispatch the activation.
474
+ if (typeof element.focus === 'function') element.focus();
475
+ element.click();
476
+ } else if (step.action === 'fill') {
477
+ if (!('value' in element)) throw new Error('element cannot be filled: ' + selector);
478
+ element.focus();
479
+ element.value = String(step.value ?? '');
480
+ element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: String(step.value ?? '') }));
481
+ element.dispatchEvent(new Event('change', { bubbles: true }));
482
+ } else if (step.action === 'select') {
483
+ if (!(element instanceof HTMLSelectElement)) throw new Error('element is not a select: ' + selector);
484
+ element.value = String(step.value ?? '');
485
+ element.dispatchEvent(new Event('input', { bubbles: true }));
486
+ element.dispatchEvent(new Event('change', { bubbles: true }));
487
+ } else if (step.action === 'focus') {
488
+ if (typeof element.focus !== 'function') throw new Error('element is not focusable: ' + selector);
489
+ element.focus();
490
+ }
491
+ return true;
492
+ })()`);
493
+ }
494
+ export async function waitForRuntimeActionTarget(client, sessionId, selector, scenarioDeadline) {
495
+ const targetDeadline = Math.min(scenarioDeadline, Date.now() + 3_000);
496
+ while (Date.now() < targetDeadline) {
497
+ const exists = Boolean(await evaluateRuntimeValue(client, sessionId, `Boolean(document.querySelector(${JSON.stringify(selector)}))`));
498
+ if (exists)
499
+ return;
500
+ await new Promise((resolve) => setTimeout(resolve, 100));
501
+ }
502
+ throw new Error(`selector not found: ${selector}`);
503
+ }
504
+ export function runtimeAttributeExpectationFailures(step, snapshot) {
505
+ const failures = [];
506
+ const attribute = String(step.expect_attribute || '').trim();
507
+ const absentAttribute = String(step.expect_attribute_absent || '').trim();
508
+ if (attribute && absentAttribute && attribute.toLowerCase() === absentAttribute.toLowerCase()) {
509
+ return [`runtime contract cannot require attribute ${JSON.stringify(attribute)} to be both present and absent`];
510
+ }
511
+ if (step.expect_attribute_value !== undefined) {
512
+ const expectedValue = String(step.expect_attribute_value);
513
+ const expectedToken = expectedValue.trim().toLowerCase();
514
+ if (attribute.toLowerCase() === 'hidden' && ['false', '0', 'off', 'no', 'absent'].includes(expectedToken)) {
515
+ if (snapshot.attributeValue !== null) {
516
+ failures.push('HTML boolean attribute "hidden" should be absent for the visible/false state');
517
+ }
518
+ }
519
+ else if (attribute.toLowerCase() === 'hidden' && ['true', '1', 'on', 'yes', 'present', ''].includes(expectedToken)) {
520
+ if (snapshot.attributeValue === null) {
521
+ failures.push('HTML boolean attribute "hidden" should be present for the hidden/true state');
522
+ }
523
+ }
524
+ else if (snapshot.attributeValue !== expectedValue) {
525
+ failures.push(`attribute ${JSON.stringify(attribute)} expected ${JSON.stringify(expectedValue)}, got ${JSON.stringify(snapshot.attributeValue)}`);
526
+ }
527
+ }
528
+ else if (step.expect_attribute_token !== undefined) {
529
+ const expectedToken = String(step.expect_attribute_token).trim();
530
+ const actualTokens = String(snapshot.attributeValue || '').split(/\s+/).filter(Boolean);
531
+ if (!actualTokens.includes(expectedToken)) {
532
+ failures.push(`attribute ${JSON.stringify(attribute)} should contain token ${JSON.stringify(expectedToken)}; got ${JSON.stringify(snapshot.attributeValue)}`);
533
+ }
534
+ }
535
+ else if (step.expect_attribute_token_absent !== undefined) {
536
+ const absentToken = String(step.expect_attribute_token_absent).trim();
537
+ const actualTokens = String(snapshot.attributeValue || '').split(/\s+/).filter(Boolean);
538
+ if (actualTokens.includes(absentToken)) {
539
+ failures.push(`attribute ${JSON.stringify(attribute)} should not contain token ${JSON.stringify(absentToken)}; got ${JSON.stringify(snapshot.attributeValue)}`);
540
+ }
541
+ }
542
+ else if (attribute && snapshot.attributeValue === null) {
543
+ failures.push(`attribute ${JSON.stringify(attribute)} should be present`);
544
+ }
545
+ if (absentAttribute && snapshot.absentAttributeValue !== null) {
546
+ failures.push(`attribute ${JSON.stringify(absentAttribute)} should be absent`);
547
+ }
548
+ return failures;
549
+ }
550
+ export function runtimeVisibilityExpectationFailures(step, snapshot) {
551
+ if (step.expect_visible === true && !snapshot.visible)
552
+ return ['expected visible'];
553
+ if ((step.expect_hidden === true || step.expect_visible === false) && snapshot.visible) {
554
+ if (snapshot.hiddenAttribute || snapshot.hiddenProperty) {
555
+ return [
556
+ `expected hidden, but authored CSS overrides the HTML hidden state (computed display: ${JSON.stringify(snapshot.computedDisplay || 'unknown')}); `
557
+ + 'add [hidden] { display: none !important; } or scope the visible display rule to :not([hidden])',
558
+ ];
559
+ }
560
+ return ['expected hidden'];
561
+ }
562
+ return [];
563
+ }
564
+ async function runtimeStepFailures(client, sessionId, step) {
565
+ const selector = String(step.expect_selector || step.selector || '').trim();
566
+ const focusWithinSelector = String(step.expect_focus_within || '').trim();
567
+ const snapshot = await evaluateRuntimeValue(client, sessionId, `(() => {
568
+ const selector = ${JSON.stringify(selector)};
569
+ const focusWithinSelector = ${JSON.stringify(focusWithinSelector)};
570
+ const nodes = selector ? Array.from(document.querySelectorAll(selector)) : [];
571
+ const element = nodes[0] || null;
572
+ const visible = element ? (() => {
573
+ const style = getComputedStyle(element);
574
+ const rect = element.getBoundingClientRect();
575
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'
576
+ && rect.width > 0 && rect.height > 0;
577
+ })() : false;
578
+ const attribute = ${JSON.stringify(String(step.expect_attribute || '').trim())};
579
+ const absentAttribute = ${JSON.stringify(String(step.expect_attribute_absent || '').trim())};
580
+ const focusRoot = focusWithinSelector ? document.querySelector(focusWithinSelector) : null;
581
+ const activeElement = document.activeElement;
582
+ const activeDescriptor = activeElement ? (() => {
583
+ const tag = String(activeElement.tagName || '').toLowerCase();
584
+ const id = activeElement.id ? '#' + activeElement.id : '';
585
+ const classes = activeElement.classList && activeElement.classList.length
586
+ ? '.' + Array.from(activeElement.classList).slice(0, 3).join('.')
587
+ : '';
588
+ const label = activeElement.getAttribute && activeElement.getAttribute('aria-label');
589
+ return tag + id + classes + (label ? '[aria-label=' + JSON.stringify(label) + ']' : '');
590
+ })() : '<none>';
591
+ return {
592
+ count: nodes.length,
593
+ visible,
594
+ text: nodes.map((node) => String(
595
+ node instanceof HTMLElement ? node.innerText : (node.textContent || '')
596
+ )).join('\\n'),
597
+ canvasNonblank: element instanceof HTMLCanvasElement ? (() => {
598
+ try {
599
+ const context = element.getContext('2d', { willReadFrequently: true });
600
+ if (!context || element.width < 1 || element.height < 1) return false;
601
+ const pixels = context.getImageData(0, 0, element.width, element.height).data;
602
+ for (let offset = 3; offset < pixels.length; offset += 4) {
603
+ if (pixels[offset] !== 0) return true;
604
+ }
605
+ return false;
606
+ } catch { return false; }
607
+ })() : false,
608
+ value: element && 'value' in element ? String(element.value ?? '') : '',
609
+ attributeValue: element && attribute ? element.getAttribute(attribute) : null,
610
+ absentAttributeValue: element && absentAttribute ? element.getAttribute(absentAttribute) : null,
611
+ hiddenAttribute: Boolean(element && element.hasAttribute('hidden')),
612
+ hiddenProperty: Boolean(element && 'hidden' in element && element.hidden),
613
+ computedDisplay: element ? getComputedStyle(element).display : '',
614
+ focused: Boolean(element && element === document.activeElement),
615
+ focusWithin: Boolean(focusRoot && focusRoot.contains(document.activeElement)),
616
+ activeDescriptor,
617
+ };
618
+ })()`);
619
+ const failures = [];
620
+ if (step.expect_count !== undefined && snapshot.count !== Number(step.expect_count)) {
621
+ failures.push(`count expected ${Number(step.expect_count)}, got ${snapshot.count}`);
622
+ }
623
+ failures.push(...runtimeVisibilityExpectationFailures(step, snapshot));
624
+ if (step.expect_text !== undefined && !String(snapshot.text || '').includes(String(step.expect_text))) {
625
+ const actualText = String(snapshot.text || '').replace(/\s+/g, ' ').trim();
626
+ const boundedActual = actualText.length > 240 ? actualText.slice(0, 237) + '...' : actualText;
627
+ failures.push(`text missing ${JSON.stringify(String(step.expect_text))}; actual rendered text was ${JSON.stringify(boundedActual)}`);
628
+ }
629
+ if (step.expect_text_not !== undefined && String(snapshot.text || '').includes(String(step.expect_text_not))) {
630
+ const actualText = String(snapshot.text || '').replace(/\s+/g, ' ').trim();
631
+ const boundedActual = actualText.length > 240 ? actualText.slice(0, 237) + '...' : actualText;
632
+ failures.push(`text still contains forbidden pre-action value ${JSON.stringify(String(step.expect_text_not))}; actual rendered text was ${JSON.stringify(boundedActual)}`);
633
+ }
634
+ if (step.expect_canvas_nonblank === true && !snapshot.canvasNonblank) {
635
+ failures.push('expected canvas to contain rendered pixels, but it remained transparent/blank');
636
+ }
637
+ if (step.expect_value !== undefined && String(snapshot.value || '') !== String(step.expect_value)) {
638
+ failures.push(`value expected ${JSON.stringify(String(step.expect_value))}, got ${JSON.stringify(String(snapshot.value || ''))}`);
639
+ }
640
+ failures.push(...runtimeAttributeExpectationFailures(step, snapshot));
641
+ if (step.expect_focused === true && !snapshot.focused) {
642
+ failures.push(`expected ${JSON.stringify(selector)} to be focused; actual active element is ${JSON.stringify(snapshot.activeDescriptor)}`);
643
+ }
644
+ if (focusWithinSelector && !snapshot.focusWithin) {
645
+ failures.push(`focus is not within ${JSON.stringify(focusWithinSelector)}; actual active element is ${JSON.stringify(snapshot.activeDescriptor)}`);
646
+ }
647
+ return failures;
648
+ }
649
+ async function executeRuntimeScenario(client, sessionId, steps, timeoutMs) {
650
+ const failures = [];
651
+ let passed = 0;
652
+ const scenarioDeadline = Date.now() + Math.max(1_000, Math.min(timeoutMs, 120_000));
653
+ for (const [offset, step] of steps.entries()) {
654
+ const index = offset + 1;
655
+ try {
656
+ const actionSelector = String(step.selector || '').trim();
657
+ if (actionSelector && ['click', 'fill', 'select', 'focus', 'press'].includes(step.action)) {
658
+ await waitForRuntimeActionTarget(client, sessionId, actionSelector, scenarioDeadline);
659
+ }
660
+ await applyRuntimeStepAction(client, sessionId, step);
661
+ const waitMs = Math.max(0, Math.min(Number(step.wait_ms) || 0, 2_000));
662
+ if (waitMs && step.action !== 'wait')
663
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
664
+ let assertions = await runtimeStepFailures(client, sessionId, step);
665
+ const assertionDeadline = Math.min(scenarioDeadline, Date.now() + 3_000);
666
+ while (assertions.length > 0 && Date.now() < assertionDeadline) {
667
+ await new Promise((resolve) => setTimeout(resolve, 100));
668
+ assertions = await runtimeStepFailures(client, sessionId, step);
669
+ }
670
+ if (assertions.length > 0) {
671
+ failures.push(`step ${index} (${step.action}): ${assertions.join('; ')}`);
672
+ break;
673
+ }
674
+ passed += 1;
675
+ }
676
+ catch (error) {
677
+ failures.push(`step ${index} (${step.action}): ${normalizeRuntimeBody(error instanceof Error ? error.message : String(error))}`);
678
+ break;
679
+ }
680
+ if (Date.now() >= scenarioDeadline && index < steps.length) {
681
+ failures.push(`scenario timed out after step ${index}`);
682
+ break;
683
+ }
684
+ }
685
+ return { passed, failures };
686
+ }
279
687
  async function captureRuntimePng(client, sessionId) {
280
688
  const captured = await client.send('Page.captureScreenshot', {
281
689
  format: 'png',
@@ -288,6 +696,7 @@ async function captureRuntimePng(client, sessionId) {
288
696
  return Buffer.from(captured.data, 'base64');
289
697
  }
290
698
  export const runPreviewRuntimeBrowser = async (executablePath, args, platform, environment, screenshotPath, options) => {
699
+ assertPreviewBrowserSandboxUser(platform);
291
700
  const child = spawn(executablePath, [...args], {
292
701
  stdio: ['ignore', 'ignore', 'pipe'],
293
702
  windowsHide: true,
@@ -319,7 +728,18 @@ export const runPreviewRuntimeBrowser = async (executablePath, args, platform, e
319
728
  runtimeErrors.push(normalizeRuntimeBody(description));
320
729
  }
321
730
  if (event.method === 'Log.entryAdded' && event.params?.entry?.level === 'error') {
322
- runtimeErrors.push(normalizeRuntimeBody(event.params.entry.text || 'Browser console error'));
731
+ const entry = event.params.entry;
732
+ const entryUrl = String(entry.url || '').trim();
733
+ runtimeErrors.push(normalizeRuntimeBody(`${entry.text || 'Browser console error'}${entryUrl ? ` (${entryUrl})` : ''}`));
734
+ }
735
+ if (event.method === 'Network.responseReceived') {
736
+ const resourceType = String(event.params?.type || '');
737
+ const status = Number(event.params?.response?.status || 0);
738
+ const responseUrl = String(event.params?.response?.url || '').trim();
739
+ if (status >= 400
740
+ && ['Document', 'Script', 'Fetch', 'XHR', 'Stylesheet'].includes(resourceType)) {
741
+ runtimeErrors.push(normalizeRuntimeBody(`HTTP ${status} ${resourceType} request: ${responseUrl || 'unknown URL'}`));
742
+ }
323
743
  }
324
744
  if (event.method === 'Network.loadingFailed') {
325
745
  const resourceType = String(event.params?.type || '');
@@ -340,27 +760,28 @@ export const runPreviewRuntimeBrowser = async (executablePath, args, platform, e
340
760
  }, sessionId);
341
761
  const entryUrl = String(args.at(-1) || '');
342
762
  await client.send('Page.navigate', { url: entryUrl }, sessionId);
343
- await waitForRuntimeReady(client, sessionId);
763
+ await waitForRuntimeReady(client, sessionId, entryUrl);
344
764
  await new Promise((resolve) => setTimeout(resolve, 250));
345
765
  const initialBodyText = await readRuntimeBody(client, sessionId);
346
766
  const initialPng = await captureRuntimePng(client, sessionId);
347
- const key = options.key ? runtimeKeyDefinition(options.key) : null;
348
- if (options.key && !key)
349
- throw new Error(`unsupported browser interaction key: ${options.key}`);
350
767
  let action;
351
- if (key) {
352
- action = `press:${key.code}`;
353
- await client.send('Input.dispatchKeyEvent', {
354
- type: 'keyDown', key: key.key, code: key.code,
355
- windowsVirtualKeyCode: key.virtualKeyCode, nativeVirtualKeyCode: key.virtualKeyCode,
356
- ...(key.text ? { text: key.text, unmodifiedText: key.text } : {}),
357
- }, sessionId);
358
- await client.send('Input.dispatchKeyEvent', {
359
- type: 'keyUp', key: key.key, code: key.code,
360
- windowsVirtualKeyCode: key.virtualKeyCode, nativeVirtualKeyCode: key.virtualKeyCode,
361
- }, sessionId);
362
- }
363
- const waitMs = Math.max(250, Math.min(Number(options.waitMs) || 1_750, 5_000));
768
+ const steps = Array.isArray(options.steps) ? options.steps.slice(0, 64) : [];
769
+ let interactionStepsPassed = 0;
770
+ let interactionFailures = [];
771
+ if (steps.length > 0) {
772
+ action = `scenario:${steps.length}`;
773
+ const scenario = await executeRuntimeScenario(client, sessionId, steps, Number(options.timeoutMs) || 30_000);
774
+ interactionStepsPassed = scenario.passed;
775
+ interactionFailures = scenario.failures;
776
+ }
777
+ else if (options.key) {
778
+ const chord = runtimeKeyChordDefinition(options.key);
779
+ if (!chord)
780
+ throw new Error(`unsupported browser interaction key: ${options.key}`);
781
+ action = `press:${chord.key.code}`;
782
+ await dispatchRuntimeKey(client, sessionId, options.key);
783
+ }
784
+ const waitMs = steps.length > 0 ? 250 : Math.max(250, Math.min(Number(options.waitMs) || 1_750, 5_000));
364
785
  const deadline = Date.now() + waitMs;
365
786
  let finalBodyText = initialBodyText;
366
787
  while (Date.now() < deadline) {
@@ -380,6 +801,9 @@ export const runPreviewRuntimeBrowser = async (executablePath, args, platform, e
380
801
  bodyChanged: finalBodyText !== initialBodyText,
381
802
  visualChanged: !finalPng.equals(initialPng),
382
803
  runtimeErrors: Array.from(new Set(runtimeErrors.filter(Boolean))).slice(0, 12),
804
+ interactionStepsRequested: steps.length,
805
+ interactionStepsPassed,
806
+ interactionFailures,
383
807
  };
384
808
  }
385
809
  finally {
@@ -460,6 +884,126 @@ function isTransientBrowserFailure(result) {
460
884
  return false;
461
885
  return /(?:process exited with (?:SIG[A-Z]+|[1-9][0-9]*)|timed out|\bE(?:ACCES|PERM|BUSY|AGAIN)\b|resource temporarily unavailable)/i.test(result.error);
462
886
  }
887
+ const PREVIEW_CONTENT_TYPES = {
888
+ '.css': 'text/css; charset=utf-8',
889
+ '.gif': 'image/gif',
890
+ '.html': 'text/html; charset=utf-8',
891
+ '.htm': 'text/html; charset=utf-8',
892
+ '.ico': 'image/x-icon',
893
+ '.jpeg': 'image/jpeg',
894
+ '.jpg': 'image/jpeg',
895
+ '.js': 'text/javascript; charset=utf-8',
896
+ '.json': 'application/json; charset=utf-8',
897
+ '.mjs': 'text/javascript; charset=utf-8',
898
+ '.png': 'image/png',
899
+ '.svg': 'image/svg+xml; charset=utf-8',
900
+ '.wasm': 'application/wasm',
901
+ '.webp': 'image/webp',
902
+ '.woff': 'font/woff',
903
+ '.woff2': 'font/woff2',
904
+ };
905
+ function isPathInside(rootPath, candidatePath) {
906
+ const relative = path.relative(rootPath, candidatePath);
907
+ return relative === '' || (relative !== '..'
908
+ && !relative.startsWith(`..${path.sep}`)
909
+ && !path.isAbsolute(relative));
910
+ }
911
+ async function startLoopbackPreviewServer(entryAbsolutePath, requestedWorkspaceRoot) {
912
+ const entryPath = fs.realpathSync(entryAbsolutePath);
913
+ const workspaceRoot = fs.realpathSync(requestedWorkspaceRoot || path.dirname(entryPath));
914
+ if (!isPathInside(workspaceRoot, entryPath) || !fs.statSync(entryPath).isFile()) {
915
+ throw new Error('runtime preview entry is outside the authoritative workspace');
916
+ }
917
+ let server;
918
+ return new Promise((resolve, reject) => {
919
+ server = createServer((request, response) => {
920
+ try {
921
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
922
+ response.writeHead(405, { Allow: 'GET, HEAD' });
923
+ response.end();
924
+ return;
925
+ }
926
+ const requestUrl = new URL(request.url || '/', 'http://127.0.0.1');
927
+ let decodedPath;
928
+ try {
929
+ decodedPath = decodeURIComponent(requestUrl.pathname);
930
+ }
931
+ catch {
932
+ response.writeHead(400);
933
+ response.end();
934
+ return;
935
+ }
936
+ if (decodedPath.includes('\0')) {
937
+ response.writeHead(400);
938
+ response.end();
939
+ return;
940
+ }
941
+ // Chromium requests /favicon.ico even when the document declares no
942
+ // icon. That optional browser decoration is not application runtime
943
+ // evidence. Answer the absent implicit request without hiding an
944
+ // explicitly provided favicon or any missing script/style/module.
945
+ if (decodedPath === '/favicon.ico') {
946
+ const explicitFavicon = path.resolve(workspaceRoot, 'favicon.ico');
947
+ if (!fs.existsSync(explicitFavicon)) {
948
+ response.writeHead(204, { 'Cache-Control': 'no-store' });
949
+ response.end();
950
+ return;
951
+ }
952
+ }
953
+ const relativeRequest = decodedPath.replace(/^\/+/, '').split('/').join(path.sep);
954
+ const unresolved = path.resolve(workspaceRoot, relativeRequest);
955
+ if (!isPathInside(workspaceRoot, unresolved) || !fs.existsSync(unresolved)) {
956
+ response.writeHead(404);
957
+ response.end();
958
+ return;
959
+ }
960
+ const resolved = fs.realpathSync(unresolved);
961
+ if (!isPathInside(workspaceRoot, resolved) || !fs.statSync(resolved).isFile()) {
962
+ response.writeHead(404);
963
+ response.end();
964
+ return;
965
+ }
966
+ response.writeHead(200, {
967
+ 'Cache-Control': 'no-store',
968
+ 'Content-Type': PREVIEW_CONTENT_TYPES[path.extname(resolved).toLowerCase()] || 'application/octet-stream',
969
+ 'X-Content-Type-Options': 'nosniff',
970
+ });
971
+ if (request.method === 'HEAD') {
972
+ response.end();
973
+ return;
974
+ }
975
+ const stream = fs.createReadStream(resolved);
976
+ stream.once('error', () => response.destroy());
977
+ stream.pipe(response);
978
+ }
979
+ catch {
980
+ if (!response.headersSent)
981
+ response.writeHead(404);
982
+ response.end();
983
+ }
984
+ });
985
+ server.on('clientError', (_error, socket) => socket.destroy());
986
+ server.once('error', reject);
987
+ server.listen(0, '127.0.0.1', () => {
988
+ const address = server.address();
989
+ if (!address || typeof address.port !== 'number') {
990
+ server.close();
991
+ reject(new Error('loopback runtime preview server did not bind an ephemeral port'));
992
+ return;
993
+ }
994
+ const relativeEntry = path.relative(workspaceRoot, entryPath)
995
+ .split(path.sep)
996
+ .map((segment) => encodeURIComponent(segment))
997
+ .join('/');
998
+ resolve({
999
+ entryUrl: `http://127.0.0.1:${address.port}/${relativeEntry}`,
1000
+ close: () => new Promise((closeResolve, closeReject) => {
1001
+ server.close((error) => error ? closeReject(error) : closeResolve());
1002
+ }),
1003
+ });
1004
+ });
1005
+ });
1006
+ }
463
1007
  /** Dependency-free system-browser adapter. No browser download or archive
464
1008
  * extractor is shipped, and only fixed operating-system installation paths
465
1009
  * are eligible.
@@ -529,9 +1073,13 @@ export class SystemBrowserScreenshotAdapter {
529
1073
  }
530
1074
  async captureRuntimeOnce(resolution, entryAbsolutePath, screenshotPath, options) {
531
1075
  let browserProfile = null;
1076
+ let previewServer = null;
532
1077
  let result;
533
1078
  try {
534
- browserProfile = this.allocateTemp('browser-runtime-', 128 * 1024 * 1024);
1079
+ // Chromium creates process-singleton sockets below this directory. Keep
1080
+ // the managed leaf deliberately short so ordinary per-user temp roots
1081
+ // retain enough POSIX socket-path budget for real interaction proof.
1082
+ browserProfile = this.allocateTemp('br-', 128 * 1024 * 1024);
535
1083
  const browserUserData = path.join(browserProfile, 'profile');
536
1084
  const browserScratch = path.join(browserProfile, 'scratch');
537
1085
  if (this.platform !== 'win32' && Buffer.byteLength(browserScratch) > POSIX_BROWSER_SCRATCH_MAX_BYTES) {
@@ -547,6 +1095,11 @@ export class SystemBrowserScreenshotAdapter {
547
1095
  if (error?.code !== 'ENOENT')
548
1096
  throw error;
549
1097
  }
1098
+ // Chromium blocks normal ES-module graphs loaded from file://. Use an
1099
+ // ephemeral HTTP origin bound exclusively to loopback so the proof sees
1100
+ // the same browser semantics as the user's local preview without
1101
+ // exposing the workspace on the network.
1102
+ previewServer = await startLoopbackPreviewServer(entryAbsolutePath, options.workspaceRoot);
550
1103
  const args = [
551
1104
  '--headless=new',
552
1105
  '--disable-gpu',
@@ -559,17 +1112,13 @@ export class SystemBrowserScreenshotAdapter {
559
1112
  '--disable-sync',
560
1113
  '--metrics-recording-only',
561
1114
  '--proxy-server=http://127.0.0.1:9',
562
- '--proxy-bypass-list=<-loopback>',
1115
+ '--proxy-bypass-list=127.0.0.1;localhost',
563
1116
  '--remote-debugging-port=0',
564
1117
  `--user-data-dir=${browserUserData}`,
565
1118
  `--window-size=${SCREENSHOT_WIDTH},${SCREENSHOT_HEIGHT}`,
566
- pathToFileURL(entryAbsolutePath).toString(),
1119
+ previewServer.entryUrl,
567
1120
  ];
568
- const browserEnvironment = safeChildProcessEnv(this.environment, {
569
- TMPDIR: browserScratch,
570
- TMP: browserScratch,
571
- TEMP: browserScratch,
572
- });
1121
+ const browserEnvironment = createIsolatedBrowserEnvironment(this.environment, this.platform, browserProfile, browserScratch);
573
1122
  const observation = await this.runtimeRunner(resolution.executablePath, args, this.platform, browserEnvironment, screenshotPath, options);
574
1123
  validatePng(screenshotPath);
575
1124
  const runtimeErrors = observation.runtimeErrors.map((entry) => redactSensitiveText(entry)).slice(0, 12);
@@ -579,6 +1128,19 @@ export class SystemBrowserScreenshotAdapter {
579
1128
  const reasons = [];
580
1129
  if (runtimeErrors.length > 0)
581
1130
  reasons.push(`browser errors: ${runtimeErrors.join(' | ')}`);
1131
+ const unresolvedRenderedValues = Array.from(new Set(observation.finalBodyText.match(/\b(?:undefined|null|NaN)\s*(?:%|°(?:C|F)?\b|(?:px|rem|em|vh|vw|ms)\b)/gi) || [])).slice(0, 8);
1132
+ if (unresolvedRenderedValues.length > 0) {
1133
+ reasons.push(`rendered output exposes unresolved data values: ${unresolvedRenderedValues.join(', ')}`);
1134
+ }
1135
+ const stringifiedDomNodes = Array.from(new Set(observation.finalBodyText.match(/\[object\s+(?:HTML|SVG)[A-Za-z0-9]*Element\]/g) || [])).slice(0, 8);
1136
+ if (stringifiedDomNodes.length > 0) {
1137
+ reasons.push(`rendered output exposes stringified DOM nodes: ${stringifiedDomNodes.join(', ')}`);
1138
+ }
1139
+ const interactionFailures = (observation.interactionFailures || [])
1140
+ .map((entry) => redactSensitiveText(entry)).slice(0, 20);
1141
+ if (interactionFailures.length > 0) {
1142
+ reasons.push(`interaction failures: ${interactionFailures.join(' | ')}`);
1143
+ }
582
1144
  if (!expectedTextMatched)
583
1145
  reasons.push(`expected rendered text was not found after the interaction: ${redactSensitiveText(expectedText)}`);
584
1146
  if (options.requireStateChange && !stateChanged) {
@@ -593,7 +1155,10 @@ export class SystemBrowserScreenshotAdapter {
593
1155
  bodyChanged: observation.bodyChanged,
594
1156
  visualChanged: observation.visualChanged,
595
1157
  runtimeErrors,
596
- error: reasons.length > 0 ? `Direct-file browser runtime proof failed: ${reasons.join('; ')}.` : undefined,
1158
+ interactionStepsRequested: Number(observation.interactionStepsRequested || 0),
1159
+ interactionStepsPassed: Number(observation.interactionStepsPassed || 0),
1160
+ interactionFailures,
1161
+ error: reasons.length > 0 ? `Loopback HTTP browser runtime proof failed: ${reasons.join('; ')}.` : undefined,
597
1162
  };
598
1163
  }
599
1164
  catch (error) {
@@ -608,6 +1173,22 @@ export class SystemBrowserScreenshotAdapter {
608
1173
  };
609
1174
  }
610
1175
  finally {
1176
+ if (previewServer) {
1177
+ try {
1178
+ await previewServer.close();
1179
+ }
1180
+ catch (cleanupError) {
1181
+ try {
1182
+ fs.unlinkSync(screenshotPath);
1183
+ }
1184
+ catch { /* proof is invalid when cleanup is incomplete */ }
1185
+ result = {
1186
+ captured: false,
1187
+ passed: false,
1188
+ error: redactSensitiveText(`runtime preview server cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`),
1189
+ };
1190
+ }
1191
+ }
611
1192
  if (browserProfile) {
612
1193
  try {
613
1194
  this.releaseTemp(browserProfile);
@@ -672,11 +1253,7 @@ export class SystemBrowserScreenshotAdapter {
672
1253
  '--allow-file-access-from-files',
673
1254
  pathToFileURL(entryAbsolutePath).toString(),
674
1255
  ];
675
- const browserEnvironment = safeChildProcessEnv(this.environment, {
676
- TMPDIR: browserScratch,
677
- TMP: browserScratch,
678
- TEMP: browserScratch,
679
- });
1256
+ const browserEnvironment = createIsolatedBrowserEnvironment(this.environment, this.platform, browserProfile, browserScratch);
680
1257
  await this.runner(resolution.executablePath, args, this.platform, browserEnvironment);
681
1258
  validatePng(screenshotPath);
682
1259
  result = { captured: true };