dsh-agent-team-ui-testing 0.1.1 → 0.1.3

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -36
  3. package/annotation-capture.js +111 -0
  4. package/browser-safety.js +45 -1
  5. package/client.js +3721 -132
  6. package/context-menu.js +134 -0
  7. package/core/clock.js +88 -0
  8. package/core/config-store.js +566 -0
  9. package/core/config.js +892 -0
  10. package/core/contracts.js +240 -0
  11. package/core/errors.js +156 -0
  12. package/core/evidence.js +141 -0
  13. package/core/geometry.js +217 -0
  14. package/core/identity.js +180 -0
  15. package/core/index.js +24 -0
  16. package/core/p0.js +1101 -0
  17. package/core/readiness.js +175 -0
  18. package/core/registry.js +239 -0
  19. package/core/tab-access.js +1350 -0
  20. package/docs/adapters.md +160 -0
  21. package/docs/architecture.md +44 -9
  22. package/docs/capability-tiers.md +20 -4
  23. package/docs/capture-p0.md +39 -2
  24. package/docs/diagnose-reason-codes.md +157 -0
  25. package/docs/element-picker.md +24 -6
  26. package/docs/getting-started.md +47 -1
  27. package/docs/images/tutorial-01-overview.png +0 -0
  28. package/docs/images/tutorial-02-history-dropdown.png +0 -0
  29. package/docs/images/tutorial-03-pick-element.png +0 -0
  30. package/docs/images/tutorial-04-annotate.png +0 -0
  31. package/docs/index.md +11 -7
  32. package/docs/multi-tab.md +55 -20
  33. package/docs/network-p0.md +2 -2
  34. package/docs/panel-context-menu.md +41 -0
  35. package/docs/t2-structured-gui.md +2 -2
  36. package/docs/t3-vision.md +6 -1
  37. package/docs/t4-dynamic-behavior.md +1 -0
  38. package/docs/tools.md +65 -7
  39. package/docs/tutorial.md +125 -0
  40. package/docs/verification.md +26 -2
  41. package/docs/viewport-control.md +21 -8
  42. package/index.js +3263 -251
  43. package/integration/cross-environment.js +1535 -0
  44. package/integration/diagnose.js +945 -0
  45. package/integration/tab-principal.js +493 -0
  46. package/locale/en.json +1 -1
  47. package/locale/zh.json +1 -1
  48. package/network-p0.js +24 -7
  49. package/package.json +4 -3
  50. package/select-control.js +298 -0
  51. package/skills/ui-testing-common/SKILL.md +54 -0
  52. package/skills/ui-testing-fake/SKILL.md +43 -0
  53. package/skills/{ui-testing → ui-testing-web}/SKILL.md +64 -24
  54. package/skills/{ui-testing → ui-testing-web}/manifests/declared-capabilities.json +9 -2
  55. package/skills/{ui-testing → ui-testing-web}/references/contracts/capability.md +3 -3
  56. package/skills/{ui-testing → ui-testing-web}/references/contracts/evidence.md +1 -1
  57. package/skills/{ui-testing → ui-testing-web}/references/contracts/operation.md +1 -1
  58. package/skills/{ui-testing → ui-testing-web}/references/core/fallback.md +1 -1
  59. package/skills/{ui-testing → ui-testing-web}/references/core/page-identity.md +3 -2
  60. package/skills/{ui-testing → ui-testing-web}/references/core/selection.md +1 -1
  61. package/skills/{ui-testing → ui-testing-web}/references/core/tool-routing-and-limits.md +6 -3
  62. package/skills/{ui-testing → ui-testing-web}/references/platforms/index.md +1 -1
  63. package/skills/{ui-testing → ui-testing-web}/references/platforms/web.md +2 -2
  64. package/skills/ui-testing-web/references/scenarios/annotations.md +104 -0
  65. package/skills/{ui-testing → ui-testing-web}/references/scenarios/functional.md +1 -1
  66. package/skills/{ui-testing → ui-testing-web}/references/scenarios/visual-motion.md +1 -1
  67. package/skills/{ui-testing → ui-testing-web}/references/tiers/t1-programmatic.md +2 -2
  68. package/skills/{ui-testing → ui-testing-web}/references/tiers/t3-static-visual.md +2 -2
  69. package/skills/{ui-testing → ui-testing-web}/references/tiers/t4-dynamic-visual.md +2 -1
  70. package/targets/fake/adapter.js +1163 -0
  71. package/targets/fake/fixtures.js +177 -0
  72. package/targets/web/adapter.js +1231 -0
  73. package/targets/web/provider.js +415 -0
  74. package/skills/ui-testing/references/scenarios/annotations.md +0 -87
  75. /package/skills/{ui-testing → ui-testing-web}/references/core/dynamic-visual.md +0 -0
  76. /package/skills/{ui-testing → ui-testing-web}/references/core/network-and-state.md +0 -0
  77. /package/skills/{ui-testing → ui-testing-web}/references/core/report-template.md +0 -0
  78. /package/skills/{ui-testing → ui-testing-web}/references/core/reporting.md +0 -0
  79. /package/skills/{ui-testing → ui-testing-web}/references/core/safety.md +0 -0
  80. /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-examples.md +0 -0
  81. /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-gate.md +0 -0
  82. /package/skills/{ui-testing → ui-testing-web}/references/core/verification.md +0 -0
  83. /package/skills/{ui-testing → ui-testing-web}/references/core/workflow.md +0 -0
  84. /package/skills/{ui-testing → ui-testing-web}/references/tiers/t2-structured.md +0 -0
  85. /package/skills/{ui-testing → ui-testing-web}/templates/capability-note.md +0 -0
  86. /package/skills/{ui-testing → ui-testing-web}/templates/platform.md +0 -0
  87. /package/skills/{ui-testing → ui-testing-web}/templates/regression-case.md +0 -0
  88. /package/skills/{ui-testing → ui-testing-web}/templates/scenario.md +0 -0
package/index.js CHANGED
@@ -8,30 +8,206 @@ import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
8
8
  import { homedir, tmpdir } from 'node:os';
9
9
  import { delimiter, extname, isAbsolute, join, sep } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
- import { FrameSource } from './frame-source.js';
12
11
  import { attachFramePush } from './frame-push.js';
12
+ import { verifyProviderProduct } from './targets/web/adapter.js';
13
+ import { WEB_PROVIDER_REASONS } from './core/contracts.js';
14
+ import { resolveProviderExecutable } from './targets/web/adapter.js';
13
15
  import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
14
16
  import http from 'node:http';
15
17
  import https from 'node:https';
16
18
  import net from 'node:net';
17
19
  import { performance } from 'node:perf_hooks';
18
20
  import { BrowserError, Cdp, asBrowserError, checkAbort, abortable, redactUrl, redactText, validateTarget } from './browser-safety.js';
21
+ // Native <select> support for the debug panel: a browser-drawn popup never reaches the frame, so
22
+ // the Host exposes a read-only option projection (POST /element) and one guarded write
23
+ // (POST /select). Kept in its own pure module so the contract suite can pin it without a runtime.
19
24
  import {
20
- CAPTURE_LIMITS, checkPixelSize, composeCanvasImage, createCaptureMapping, decodePng, detectImageAnomaly,
21
- encodePngRgba, expectedPixelSize, hasPositiveOutputSize, inspectPng, intersectRects,
22
- projectCaptureMapping, publishCapture, resolveCapturePath,
23
- } from './capture-p0.js';
24
- // Web T3 static vision (docs/t3-vision.md). The Host layer never
25
- // re-implements the pixel algorithms: it validates arguments, resolves sources, owns the product
26
- // store per scope, delivers the single PNG and implements the act/check decision rules on top of
27
- // the pure module API.
28
- import * as vision from './vision-t3.js';
29
- import { createBaselineStore } from './vision-baseline.js';
25
+ classifySelectWrite, normalizeSelectProjection, normalizeSelectWriteRequest,
26
+ selectProbeDeclaration, selectWriteExpression,
27
+ } from './select-control.js';
28
+ import { openPageContextMenu, applyPageContextAction } from './context-menu.js';
29
+ import { createCrossEnvironment } from './integration/cross-environment.js';
30
+ import { createDiagnose } from './integration/diagnose.js';
31
+ import { nowMonoMs } from './core/clock.js';
32
+ import { webProviderSupport } from './core/config.js';
33
+ // M2a: the neutral page-access model (M1) is the single authority for "who may touch which page".
34
+ // index.js keeps no authorisation rules of its own beyond mapping a tool call onto that model: it
35
+ // resolves a Principal (integration/tab-principal.js), expands the call's real effects
36
+ // (core/tab-access.js `operationEffect`) and asks `authorize`/`authorizeAll`. Re-deriving "the
37
+ // 15s lease means permission" here is exactly the confusion this milestone removes.
38
+ import {
39
+ ACTOR_SELECTION_KEY_PREFIX, AUTHORIZE_REASON, CONTROL_EVENT, CONTROL_STATE, EFFECT,
40
+ PAGE_GROUP_KEY_PREFIX, PAGE_TARGET_KEY_PREFIX, PRINCIPAL_ROLE, RESOURCE,
41
+ PRINCIPAL_SOURCE, applyControlTransition, authorize, authorizeAll, makeActorSelectionKey,
42
+ makeControl, makeOwnership, makePageAccess, makePageGroupKey, makePageTargetKey, makePrincipal,
43
+ operationEffect, parsePageGroupKey, projectPermissions,
44
+ } from './core/index.js';
45
+ import { defaultTabPrincipalResolver, resolvePrincipal } from './integration/tab-principal.js';
46
+ /*
47
+ * Web-only host modules are loaded on demand (A14 按需加载).
48
+ *
49
+ * The frame source, capture pipeline, T3 vision, the baseline store and the P0 network collector
50
+ * exist only for the Web environment. Importing them at module load made every Host — including
51
+ * one whose configuration disables Web — pay for the whole Web dependency chain, which is exactly
52
+ * what "未启用环境不加载专用运行依赖" forbids. They are now behind `ensureWebHostModules()`:
53
+ * the tool/HTTP entry points await it before touching a page, and a Web-disabled Host never
54
+ * resolves these specifiers at all.
55
+ */
56
+ const WEB_HOST_MODULE_SPECS = Object.freeze({
57
+ 'frame-source': './frame-source.js',
58
+ 'capture-p0': './capture-p0.js',
59
+ 'annotation-capture': './annotation-capture.js',
60
+ 'vision-t3': './vision-t3.js',
61
+ 'vision-baseline': './vision-baseline.js',
62
+ 'network-p0': './network-p0.js',
63
+ });
64
+ const webHostModules = { loaded: false, loading: null, namespaces: {}, failed: null };
65
+
66
+ /** Resolved namespace, or a typed "not loaded" view when the Web chain was never demanded. */
67
+ function webHostNamespace(id) {
68
+ const namespace = webHostModules.namespaces[id];
69
+ if (namespace) return namespace;
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Load every Web-only module once. Safe to call from any concurrency: callers share one promise.
75
+ *
76
+ * This is the *only* place the Web dependency chain is resolved, so a host that never enables the
77
+ * Web environment (or never touches a page) never loads it.
78
+ */
79
+ async function ensureWebHostModules() {
80
+ if (webHostModules.loaded) return webHostModules.namespaces;
81
+ if (webHostModules.loading) return webHostModules.loading;
82
+ webHostModules.loading = (async () => {
83
+ const entries = Object.entries(WEB_HOST_MODULE_SPECS);
84
+ const loaded = await Promise.all(entries.map(([, specifier]) => import(specifier)));
85
+ entries.forEach(([id], index) => { webHostModules.namespaces[id] = loaded[index]; });
86
+ webHostModules.loaded = true;
87
+ return webHostModules.namespaces;
88
+ })();
89
+ try {
90
+ return await webHostModules.loading;
91
+ } catch (error) {
92
+ webHostModules.loading = null;
93
+ webHostModules.failed = String((error && error.message) || error);
94
+ throw new BrowserError('ADAPTER_DISABLED', 'Web 专用模块加载失败:' + webHostModules.failed, {
95
+ module: Object.keys(WEB_HOST_MODULE_SPECS),
96
+ });
97
+ }
98
+ }
99
+
100
+ /**
101
+ * A namespace view that forwards to the lazily loaded module.
102
+ *
103
+ * Properties stay `undefined` until the module is loaded: read-only diagnostics that happen to
104
+ * touch a vision/network constant must answer "not available" instead of throwing. Constructions
105
+ * (`new FrameSource()`) are gated explicitly, because a silently inert page source would be worse
106
+ * than a typed refusal.
107
+ */
108
+ function lazyWebNamespace(id) {
109
+ return new Proxy({}, {
110
+ get(_target, property) {
111
+ if (property === 'then' || typeof property === 'symbol') return undefined;
112
+ const namespace = webHostNamespace(id);
113
+ return namespace ? namespace[property] : undefined;
114
+ },
115
+ has(_target, property) {
116
+ const namespace = webHostNamespace(id);
117
+ return namespace ? property in namespace : false;
118
+ },
119
+ ownKeys() {
120
+ const namespace = webHostNamespace(id);
121
+ return namespace ? Reflect.ownKeys(namespace) : [];
122
+ },
123
+ getOwnPropertyDescriptor(_target, property) {
124
+ const namespace = webHostNamespace(id);
125
+ if (!namespace) return undefined;
126
+ const descriptor = Object.getOwnPropertyDescriptor(namespace, property);
127
+ return descriptor ? { ...descriptor, configurable: true } : undefined;
128
+ },
129
+ });
130
+ }
131
+
132
+ // Web T3 static vision (docs/t3-vision.md). The Host layer never re-implements the pixel
133
+ // algorithms: it validates arguments, resolves sources, owns the product store per scope,
134
+ // delivers the single PNG and implements the act/check decision rules on top of the pure API.
135
+ const vision = lazyWebNamespace('vision-t3');
30
136
  // Web P0 network (docs/network-p0.md). The Host layer owns CDP collection, the single Fetch
31
137
  // dispatcher and the 7 tool surfaces; every projection/aggregation/evidence rule lives in the
32
138
  // pure module so it can be unit tested without a browser.
33
- import * as network from './network-p0.js';
139
+ const network = lazyWebNamespace('network-p0');
140
+ /**
141
+ * A lazily forwarded *value* export (not a namespace): `CAPTURE_LIMITS.scaleMin` must read the
142
+ * module's `CAPTURE_LIMITS` object, not the module namespace itself.
143
+ */
144
+ function lazyWebValue(id, exportName) {
145
+ return new Proxy({}, {
146
+ get(_target, property) {
147
+ if (property === 'then' || typeof property === 'symbol') return undefined;
148
+ const namespace = webHostNamespace(id);
149
+ const value = namespace ? namespace[exportName] : undefined;
150
+ return value ? value[property] : undefined;
151
+ },
152
+ has(_target, property) {
153
+ const namespace = webHostNamespace(id);
154
+ const value = namespace ? namespace[exportName] : undefined;
155
+ return value ? property in value : false;
156
+ },
157
+ ownKeys() {
158
+ const namespace = webHostNamespace(id);
159
+ const value = namespace ? namespace[exportName] : undefined;
160
+ return value ? Reflect.ownKeys(value) : [];
161
+ },
162
+ getOwnPropertyDescriptor(_target, property) {
163
+ const namespace = webHostNamespace(id);
164
+ const value = namespace ? namespace[exportName] : undefined;
165
+ const descriptor = value ? Object.getOwnPropertyDescriptor(value, property) : undefined;
166
+ return descriptor ? { ...descriptor, configurable: true } : undefined;
167
+ },
168
+ });
169
+ }
170
+
171
+ /** Capture-p0 constants (a value export, hence `lazyWebValue`); functions forward on call. */
172
+ const CAPTURE_LIMITS = lazyWebValue('capture-p0', 'CAPTURE_LIMITS');
173
+ const checkPixelSize = (...args) => webHostNamespace('capture-p0').checkPixelSize(...args);
174
+ const composeCanvasImage = (...args) => webHostNamespace('capture-p0').composeCanvasImage(...args);
175
+ const createCaptureMapping = (...args) => webHostNamespace('capture-p0').createCaptureMapping(...args);
176
+ const decodePng = (...args) => webHostNamespace('capture-p0').decodePng(...args);
177
+ const detectImageAnomaly = (...args) => webHostNamespace('capture-p0').detectImageAnomaly(...args);
178
+ const encodePngRgba = (...args) => webHostNamespace('capture-p0').encodePngRgba(...args);
179
+ const expectedPixelSize = (...args) => webHostNamespace('capture-p0').expectedPixelSize(...args);
180
+ const hasPositiveOutputSize = (...args) => webHostNamespace('capture-p0').hasPositiveOutputSize(...args);
181
+ const inspectPng = (...args) => webHostNamespace('capture-p0').inspectPng(...args);
182
+ const intersectRects = (...args) => webHostNamespace('capture-p0').intersectRects(...args);
183
+ const projectCaptureMapping = (...args) => webHostNamespace('capture-p0').projectCaptureMapping(...args);
184
+ const publishCapture = (...args) => webHostNamespace('capture-p0').publishCapture(...args);
185
+ const resolveCapturePath = (...args) => webHostNamespace('capture-p0').resolveCapturePath(...args);
186
+ const createBaselineStore = (...args) => webHostNamespace('vision-baseline').createBaselineStore(...args);
187
+ /** `new FrameSource()` must refuse instead of returning an inert source. */
188
+ const FrameSource = new Proxy(function FrameSourceLazy() {}, {
189
+ construct(_target, args, newTarget) {
190
+ const namespace = webHostNamespace('frame-source');
191
+ if (!namespace) {
192
+ throw new BrowserError('ADAPTER_DISABLED', 'Web 帧源尚未加载:当前进程未启用 Web 环境', {
193
+ module: 'frame-source',
194
+ });
195
+ }
196
+ return Reflect.construct(namespace.FrameSource, args, newTarget === FrameSource ? namespace.FrameSource : newTarget);
197
+ },
198
+ get(_target, property) {
199
+ if (property === 'prototype') return webHostNamespace('frame-source')?.FrameSource?.prototype ?? {};
200
+ return undefined;
201
+ },
202
+ });
34
203
  export { BrowserError, Cdp } from './browser-safety.js';
204
+ /**
205
+ * Load the Web-only host modules on demand (A14).
206
+ *
207
+ * The plugin loads them itself before the first call that needs them; this export exists for
208
+ * tests/diagnostics that construct the Web `Runtime` directly and therefore sit outside that gate.
209
+ */
210
+ export const loadWebHostModules = ensureWebHostModules;
35
211
 
36
212
  export const inject = ['tools', 'webServer'];
37
213
 
@@ -314,8 +490,8 @@ const INPUT_EVENT_LIMIT = 200;
314
490
  export const IMAGE_INLINE_BASE64_LIMIT = 24 * 1024 * 1024;
315
491
  /** Throttle for the merged background title/url/history read (UI polls at 500ms / 80ms). */
316
492
  const METADATA_THROTTLE_MS = 250;
317
- const SKILL_NAME = 'ui-testing';
318
- const SKILL_DIR = fileURLToPath(new URL('./skills/ui-testing/', import.meta.url));
493
+ const SKILL_NAME = 'ui-testing-web';
494
+ const SKILL_DIR = fileURLToPath(new URL('./skills/ui-testing-web/', import.meta.url));
319
495
  const SKILL_FALLBACK_DESCRIPTION =
320
496
  'Web/Chromium UI testing through 36 bundled browser_* tools: T1 programmatic (navigation, eval, input, ' +
321
497
  'network, computed style, screenshots, independent HTTP, viewport, state, capabilities), T2 structured GUI, ' +
@@ -381,12 +557,56 @@ function normalizeKeyEventType(value) {
381
557
  return value;
382
558
  }
383
559
 
560
+ /**
561
+ * Held-key identity for the input state machine (`browser_input`).
562
+ *
563
+ * Keyed by physical `code` when present (so `ShiftLeft`/`ShiftRight` stay distinct until the
564
+ * modifier bit is derived) and falling back to `key` for callers that only pass a character.
565
+ */
566
+ function inputKeyIdentity(event) {
567
+ const code = typeof event.code === 'string' ? event.code.trim() : '';
568
+ const key = typeof event.key === 'string' ? event.key.trim() : '';
569
+ return (code || key).toLowerCase();
570
+ }
571
+
572
+ /** Which modifier bit a key stands for, if any (CDP: Alt=1, Ctrl=2, Meta=4, Shift=8). */
573
+ function inputKeyModifier(event) {
574
+ const code = typeof event.code === 'string' ? event.code.toLowerCase() : '';
575
+ const key = typeof event.key === 'string' ? event.key.toLowerCase() : '';
576
+ if (code.startsWith('shift') || key === 'shift') return 8;
577
+ if (code.startsWith('control') || key === 'control') return 2;
578
+ if (code.startsWith('alt') || key === 'alt') return 1;
579
+ if (code.startsWith('meta') || key === 'meta' || key === 'os') return 4;
580
+ return 0;
581
+ }
582
+
583
+ /** True for the event types that *press* a key (everything except keyUp). */
584
+ function inputPressesKey(type) {
585
+ const normalized = String(type || '').toLowerCase();
586
+ return normalized === 'keydown' || normalized === 'rawkeydown' || normalized === 'char';
587
+ }
588
+
384
589
  /**
385
590
  * Turn one caller-supplied input event into CDP parameters.
386
591
  * Validation happens here, before the browser is started, so bad arguments never launch Chromium.
387
592
  */
388
593
  function prepareInputEvent(event) {
389
594
  const modifiers = normalizeModifiers(event.modifiers);
595
+ // Relative motion (`kind:'mouse'` + deltaX/deltaY) and the modifier-synthesis escape hatch are
596
+ // Host-side fields, not CDP parameters: validate them here so a bad value never starts Chromium.
597
+ for (const field of ['deltaX', 'deltaY']) {
598
+ if (event[field] === undefined) continue;
599
+ if (typeof event[field] !== 'number' || !Number.isFinite(event[field])) {
600
+ throw new BrowserError('INVALID_ARGUMENT', `输入事件 ${field} 必须是有限数`, {
601
+ field, valueType: typeof event[field],
602
+ });
603
+ }
604
+ }
605
+ if (event.synthesizeModifiers !== undefined && typeof event.synthesizeModifiers !== 'boolean') {
606
+ throw new BrowserError('INVALID_ARGUMENT', '输入事件 synthesizeModifiers 必须是布尔值', {
607
+ field: 'synthesizeModifiers', valueType: typeof event.synthesizeModifiers,
608
+ });
609
+ }
390
610
  if (event.kind === 'mouse') {
391
611
  return {
392
612
  kind: 'mouse',
@@ -795,7 +1015,13 @@ function normalizeCaptureRequest(args) {
795
1015
  }
796
1016
  persist = input.persist;
797
1017
  }
798
- return { scale, region, out, persist };
1018
+ const includeAnnotations = input.includeAnnotations === undefined ? false : input.includeAnnotations;
1019
+ if (typeof includeAnnotations !== 'boolean') {
1020
+ throw new BrowserError('INVALID_ARGUMENT', 'includeAnnotations 必须是布尔值(true = 叠加面板标注)', {
1021
+ field: 'includeAnnotations', valueType: typeof includeAnnotations,
1022
+ });
1023
+ }
1024
+ return { scale, region, out, persist, includeAnnotations };
799
1025
  }
800
1026
 
801
1027
  /** Pixel budget, judged before any CDP screenshot: Chrome must never be asked for more. */
@@ -894,7 +1120,7 @@ function readBundledSkill() {
894
1120
  return undefined;
895
1121
  }
896
1122
  const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(raw);
897
- if (!frontmatter) throw new Error('skills/ui-testing/SKILL.md 缺少 YAML frontmatter');
1123
+ if (!frontmatter) throw new Error('skills/ui-testing-web/SKILL.md 缺少 YAML frontmatter');
898
1124
  const singleLine = /^description:\s*(?!>)(.+)$/m.exec(frontmatter[1]);
899
1125
  return {
900
1126
  name: SKILL_NAME,
@@ -926,6 +1152,68 @@ function readBundledSkill() {
926
1152
  * by the create that owns it. Everything else is a popup and is blocked before it resumes.
927
1153
  * ------------------------------------------------------------------------------------------- */
928
1154
  const MAX_PAGES_DEFAULT = 8;
1155
+
1156
+ /**
1157
+ * Runtime provider (browser) this host container is keyed by (contract §6). Mirrors core's
1158
+ * `DEFAULT_WEB_PROVIDER`; the *support* truth (which providers may execute at all) lives only in
1159
+ * core's `WEB_PROVIDER_SUPPORT`, never here.
1160
+ */
1161
+ const DEFAULT_RUNTIME_PROVIDER = 'chrome';
1162
+
1163
+ /**
1164
+ * Every page group in this module belongs to the Web environment (index.js *is* the Web
1165
+ * implementation); the neutral model still validates it like any other dimension.
1166
+ */
1167
+ const PAGE_GROUP_ENVIRONMENT = 'web';
1168
+
1169
+ /**
1170
+ * Root dimension for callers that cannot carry a Principal at all: the legacy panel protocol
1171
+ * (no scope headers) and any old client. It is a *separate* domain, never a wildcard — an unscoped
1172
+ * caller must not inherit an Agent's page just because it named the same workspace.
1173
+ */
1174
+ const UNSCOPED_ROOT = 'root:unscoped';
1175
+
1176
+ /**
1177
+ * The one function that composes a page-group key (M2a, ruling §7.4).
1178
+ *
1179
+ * It used to be `<provider>\u0000<baseKey>`, which made the workspace — not the Session — the unit
1180
+ * of page ownership: two independent sessions in one workspace resolved to the *same* entry and
1181
+ * therefore fought over one page. The root dimension is now part of the key, so "A's primary" and
1182
+ * "B's primary" are simply different keys and can never alias, be evicted for each other, or be
1183
+ * addressed across roots. No caller may hand-compose this string: everything goes through M1's
1184
+ * factory, which rejects empty/composed dimensions instead of silently building a wrong root.
1185
+ */
1186
+ function pageGroupKeyFor(provider, baseKey, rootSessionId) {
1187
+ return makePageGroupKey({
1188
+ environment: PAGE_GROUP_ENVIRONMENT,
1189
+ workspaceKey: String(baseKey),
1190
+ provider: String(provider ?? DEFAULT_RUNTIME_PROVIDER),
1191
+ rootSessionId: String(rootSessionId || UNSCOPED_ROOT),
1192
+ });
1193
+ }
1194
+
1195
+ /** Canonical identity of one physical target inside a page group (primary stays a group alias). */
1196
+ function pageTargetKeyFor(groupKey, tabId) {
1197
+ return makePageTargetKey({ groupKey: String(groupKey), tabId: String(tabId) });
1198
+ }
1199
+
1200
+ /** True when the value is already a composed page-group key (binding/recent addressing). */
1201
+ function isPageGroupKey(value) {
1202
+ return typeof value === 'string' && value.startsWith(PAGE_GROUP_KEY_PREFIX);
1203
+ }
1204
+
1205
+ /** Root recorded in a group key; a malformed key degrades to the unscoped domain, never to "any". */
1206
+ function rootOfPageGroupKey(key) {
1207
+ try { return parsePageGroupKey(key).rootSessionId; } catch { return UNSCOPED_ROOT; }
1208
+ }
1209
+
1210
+ /**
1211
+ * Provider index key: the provider fallback must also be per root, otherwise one root's last-used
1212
+ * browser could silently become another root's routing decision for the same workspace.
1213
+ */
1214
+ function providerIndexKey(baseKey, rootSessionId) {
1215
+ return String(baseKey) + '\u0000' + String(rootSessionId || UNSCOPED_ROOT);
1216
+ }
929
1217
  const IDLE_MS_DEFAULT = 600000;
930
1218
  /** A viewer is only "visible" while the panel keeps posting metrics (WS liveness alone can leak). */
931
1219
  const VIEWER_TTL_MS = 15000;
@@ -942,6 +1230,17 @@ const TAB_WAIT_TIMEOUT_MAX_MS = 30000;
942
1230
  const TAB_WAIT_POLL_DEFAULT_MS = 100;
943
1231
  const TAB_WAIT_POLL_MIN_MS = 50;
944
1232
  const TAB_WAIT_POLL_MAX_MS = 1000;
1233
+ /**
1234
+ * Bounded drain budget for a Lead takeover (ruling §5.1: default 5000ms, 0–30000).
1235
+ *
1236
+ * The budget covers *waiting for the current operation to reach a safe boundary*, never interrupting
1237
+ * it: `Runtime.run` closes the page on ABORTED/CDP_TIMEOUT, so a forced takeover would destroy a
1238
+ * member's logged-in page. Exceeding the budget is BUSY and leaves the record untouched.
1239
+ */
1240
+ const TAB_ACQUIRE_TIMEOUT_DEFAULT_MS = 5000;
1241
+ const TAB_ACQUIRE_TIMEOUT_MAX_MS = 30000;
1242
+ /** How often the drain loop re-samples `active`/`inflight` (host memory only, no CDP). */
1243
+ const TAB_DRAIN_POLL_MS = 25;
945
1244
  /** Per-workspace bounded lifecycle projections: blocked popup notices + close audit. */
946
1245
  const POPUP_NOTICES_LIMIT = 8;
947
1246
  const CLOSED_HISTORY_LIMIT = 8;
@@ -994,13 +1293,19 @@ function sha256Short(value, length = 16) {
994
1293
  * (docs/viewport-control.md).
995
1294
  *
996
1295
  * The Host stores exactly one "last accepted" document per Runtime and exposes it through
997
- * `Runtime.state()` (→ `browser_state.annotations`) and the authenticated `ui.annotations`. The
998
- * panel writes it over its *only* write surface, `POST /plugins/ui-testing/annotations`; there is
999
- * deliberately no 37th tool (the tool surface stays 36).
1296
+ * `Runtime.state()` (→ `browser_state.annotations`) and the authenticated `ui.annotations`. Two
1297
+ * writers share that one document: the human panel over its scoped route
1298
+ * `POST /plugins/ui-testing/annotations`, and the Agent over the 37th tool `browser_annotation`
1299
+ * (read/draw/erase/clear). It stays ONE document on purpose: the panel already hydrates a newer
1300
+ * Host revision, so an Agent note is visible to the human with no second store and no second
1301
+ * conflict model. Agent writes additionally require a *fresh human-foreground claim* for the
1302
+ * target tab (the panel reports "I am displaying my selected tab and I am visible"), otherwise a
1303
+ * drawn note could be invisible to the person it is meant for.
1000
1304
  *
1001
- * Everything below is pure data handling: annotations never touch the page DOM/CSS, never enter a
1002
- * screenshot or a T3 source, never start Chromium, never take the page lease and never write the
1003
- * page `lastError`. Human notes are untrusted hints (`source.trusted:false`).
1305
+ * The document store below is pure data handling: it never touches page DOM/CSS, starts Chromium,
1306
+ * takes the page lease or writes page lastError. browser_screenshot may explicitly rasterize a
1307
+ * frozen document with includeAnnotations:true; raw captures and T3/T4 sources remain unannotated.
1308
+ * Human notes are untrusted hints (`source.trusted:false`).
1004
1309
  *
1005
1310
  * The limits are soft and deterministic (§11.2), and the client applies the same rule so both
1006
1311
  * sides converge on one normalized set: keep the newest 128 shapes, downsample every brush to
@@ -1023,6 +1328,25 @@ const ANNOTATION_LIMITS = Object.freeze({
1023
1328
  strokeWidth: Object.freeze([1, 20]),
1024
1329
  textFontSize: 20,
1025
1330
  });
1331
+ /**
1332
+ * Agent write surface (`browser_annotation`, docs/viewport-control.md).
1333
+ *
1334
+ * `foregroundTtlMs` is the freshness window of the panel's "this tab is my selected tab and I am
1335
+ * visible" claim. The panel polls `/state` every 500ms for its selected tab, so a 1.5s window means
1336
+ * "the human is looking at this tab right now" while a tab switch, a closed panel or a backgrounded
1337
+ * DSH tab expires the claim within one or two polls instead of relying on the 15s viewer TTL.
1338
+ */
1339
+ const ANNOTATION_FOREGROUND_TTL_MS = 1500;
1340
+ const ANNOTATION_AGENT_ACTIONS = Object.freeze(['read', 'draw', 'erase', 'clear']);
1341
+ /** One call may add at most this many shapes; the document budget is still 128. */
1342
+ const ANNOTATION_AGENT_MAX_SHAPES_PER_CALL = 64;
1343
+ const ANNOTATION_AGENT_DEFAULT_COLOR = '#ff3b30';
1344
+ const ANNOTATION_AGENT_DEFAULT_STROKE = 3;
1345
+ /** Panel eraser: r = max(strokeWidth / 2, 6) canvas CSS px (docs/viewport-control.md §11.4). */
1346
+ const ANNOTATION_ERASE_MIN_RADIUS = 6;
1347
+ const ANNOTATION_ERASE_DEFAULT_RADIUS = 8;
1348
+ /** Text has no measured box on the Host side: hit-test uses an estimate, never a DOM read. */
1349
+ const ANNOTATION_TEXT_BOX_CHAR_RATIO = 0.6;
1026
1350
  /** Shape geometry may sit anywhere on the canvas: C's lower bound plus the largest viewport. */
1027
1351
  const ANNOTATION_COORD_MIN = -10000000;
1028
1352
  const ANNOTATION_COORD_MAX = 10004096;
@@ -1069,6 +1393,8 @@ function annotationLimits() {
1069
1393
  maxBodyBytes: ANNOTATION_LIMITS.maxBodyBytes,
1070
1394
  maxPanels: ANNOTATION_LIMITS.maxPanels,
1071
1395
  staleAfterMs: ANNOTATION_LIMITS.staleAfterMs,
1396
+ maxShapesPerCall: ANNOTATION_AGENT_MAX_SHAPES_PER_CALL,
1397
+ foregroundTtlMs: ANNOTATION_FOREGROUND_TTL_MS,
1072
1398
  };
1073
1399
  }
1074
1400
 
@@ -1349,6 +1675,237 @@ function applyAnnotationLimits(shapes, clientTruncation, shortenedText) {
1349
1675
  return { shapes: list, truncation };
1350
1676
  }
1351
1677
 
1678
+ /**
1679
+ * Agent annotation write surface — pure pre-flight, no browser, no CDP, no file
1680
+ * (docs/viewport-control.md).
1681
+ *
1682
+ * `browser_annotation` writes the SAME document the human panel publishes, so both writers have to
1683
+ * agree on one set of shapes and one limit application. Everything below is deterministic data
1684
+ * handling: an invalid call costs nothing, and a shape that would not survive the panel's own
1685
+ * `annotWireShape`/Host validation is refused here instead of being drawn differently later.
1686
+ */
1687
+ const ANNOTATION_AGENT_SHAPE_FIELDS = Object.freeze([
1688
+ 'type', 'id', 'color', 'strokeWidth', 'style', 'fill', 'stroke',
1689
+ 'x', 'y', 'width', 'height', 'cx', 'cy', 'rx', 'ry', 'x1', 'y1', 'x2', 'y2', 'points', 'text', 'fontSize',
1690
+ ]);
1691
+
1692
+ /** Next free `a<N>` id, starting after every id already in the document and in this call. */
1693
+ function annotationAgentIdFactory(shapes, specs) {
1694
+ let max = 0;
1695
+ const consider = (id) => {
1696
+ const match = typeof id === 'string' ? /^a([1-9][0-9]{0,15})$/u.exec(id) : null;
1697
+ if (!match) return;
1698
+ const numeric = Number(match[1]);
1699
+ if (Number.isSafeInteger(numeric) && numeric > max) max = numeric;
1700
+ };
1701
+ for (const shape of shapes) consider(shape && shape.id);
1702
+ for (const spec of specs) consider(spec && typeof spec === 'object' && !Array.isArray(spec) ? spec.id : null);
1703
+ return () => { max += 1; return 'a' + max; };
1704
+ }
1705
+
1706
+ /**
1707
+ * 填充/外框 for one Agent shape. `style` is the panel's single-source mode; explicit
1708
+ * `fill`/`stroke` booleans are accepted for callers that want the raw contract, but mixing both is
1709
+ * refused instead of guessed (a silently ignored style would draw a different shape).
1710
+ */
1711
+ function annotationAgentFillStroke(type, spec, field) {
1712
+ const hasStyle = spec.style !== undefined && spec.style !== null;
1713
+ const hasFlags = spec.fill !== undefined || spec.stroke !== undefined;
1714
+ if (hasStyle && hasFlags) throw annotationInvalid(`${field}.style`, 'style-conflicts-fill-stroke');
1715
+ if (hasStyle) {
1716
+ if (spec.style !== 'fill' && spec.style !== 'stroke') throw annotationInvalid(`${field}.style`, 'invalid-enum');
1717
+ // `外框` is the panel's default mode for closed tools (annotStyleFor in client.js).
1718
+ return spec.style === 'fill' ? { fill: true, stroke: false } : { fill: false, stroke: true };
1719
+ }
1720
+ if (hasFlags) {
1721
+ return {
1722
+ fill: spec.fill === undefined ? false : annotationBoolean(spec.fill, `${field}.fill`),
1723
+ stroke: spec.stroke === undefined ? false : annotationBoolean(spec.stroke, `${field}.stroke`),
1724
+ };
1725
+ }
1726
+ // Panel defaults: text is filled text; free strokes/arrows are always outer strokes; circle and
1727
+ // rect follow the panel's default 外框 mode.
1728
+ return type === 'text' ? { fill: true, stroke: false } : { fill: false, stroke: true };
1729
+ }
1730
+
1731
+ /** One Agent shape spec → the wire shape the panel and the screenshot overlay already understand. */
1732
+ function normalizeAgentAnnotationShape(raw, field, allocateId, seenIds) {
1733
+ const spec = annotationObject(raw, field);
1734
+ annotationRejectUnknown(spec, ANNOTATION_AGENT_SHAPE_FIELDS, field);
1735
+ const type = spec.type;
1736
+ if (typeof type !== 'string' || !ANNOTATION_TYPES.includes(type)) throw annotationInvalid(`${field}.type`, 'invalid-enum');
1737
+ const style = annotationAgentFillStroke(type, spec, field);
1738
+ const wire = {
1739
+ id: spec.id === undefined || spec.id === null ? allocateId() : spec.id,
1740
+ type,
1741
+ color: spec.color === undefined || spec.color === null ? ANNOTATION_AGENT_DEFAULT_COLOR : spec.color,
1742
+ strokeWidth: spec.strokeWidth === undefined || spec.strokeWidth === null
1743
+ ? ANNOTATION_AGENT_DEFAULT_STROKE : spec.strokeWidth,
1744
+ fill: style.fill,
1745
+ stroke: style.stroke,
1746
+ };
1747
+ for (const key of ANNOTATION_GEOMETRY_FIELDS[type]) {
1748
+ // The arrow head is derived from the endpoints + stroke width; a supplied head never wins.
1749
+ if (key === 'head') continue;
1750
+ if (key === 'fontSize') {
1751
+ wire.fontSize = spec.fontSize === undefined || spec.fontSize === null
1752
+ ? ANNOTATION_LIMITS.textFontSize : spec.fontSize;
1753
+ continue;
1754
+ }
1755
+ wire[key] = spec[key];
1756
+ }
1757
+ return normalizeAnnotationShape(wire, field, seenIds);
1758
+ }
1759
+
1760
+ function annotationSegmentDistance(px, py, ax, ay, bx, by) {
1761
+ const dx = bx - ax;
1762
+ const dy = by - ay;
1763
+ const lengthSq = dx * dx + dy * dy;
1764
+ if (!(lengthSq > 0)) return Math.hypot(px - ax, py - ay);
1765
+ const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSq));
1766
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
1767
+ }
1768
+
1769
+ function annotationPointInRect(point, rect) {
1770
+ return point.x >= rect.x && point.x <= rect.x + rect.width
1771
+ && point.y >= rect.y && point.y <= rect.y + rect.height;
1772
+ }
1773
+
1774
+ /** Distance from a point to a rect's outline (inside included): 0 on the border. */
1775
+ function annotationRectOutlineDistance(point, rect) {
1776
+ const left = rect.x;
1777
+ const right = rect.x + rect.width;
1778
+ const top = rect.y;
1779
+ const bottom = rect.y + rect.height;
1780
+ if (!annotationPointInRect(point, rect)) {
1781
+ const nx = Math.min(Math.max(point.x, left), right);
1782
+ const ny = Math.min(Math.max(point.y, top), bottom);
1783
+ return Math.hypot(point.x - nx, point.y - ny);
1784
+ }
1785
+ return Math.min(point.x - left, right - point.x, point.y - top, bottom - point.y);
1786
+ }
1787
+
1788
+ /**
1789
+ * Host-side eraser hit test (the panel uses Path2D/isPointInStroke plus the rendered text bbox; the
1790
+ * Host must not read the page, so the rules below are analytic and stated in the docs):
1791
+ * rect — inside when filled, else within `r` of the outline;
1792
+ * circle — normalized radial distance |k−1| · min(rx,ry) within `r`, inside when filled;
1793
+ * line — point/segment distance within `r`;
1794
+ * arrow — the shaft only (the head triangle is not modelled);
1795
+ * brush — nearest polyline segment within `r`;
1796
+ * text — an estimated box (see ANNOTATION_TEXT_BOX_CHAR_RATIO), expanded by `r`.
1797
+ * `r = max(requested radius, strokeWidth / 2, 6)` mirrors `annotEraseRadius` in client.js.
1798
+ */
1799
+ function annotationAgentHit(shape, point, radius) {
1800
+ const width = Number(shape.strokeWidth);
1801
+ const limit = Math.max(radius, Number.isFinite(width) ? width / 2 : 0, ANNOTATION_ERASE_MIN_RADIUS);
1802
+ if (shape.type === 'rect') {
1803
+ const rect = { x: shape.x, y: shape.y, width: shape.width, height: shape.height };
1804
+ if (shape.fill === true && annotationPointInRect(point, rect)) return true;
1805
+ if (shape.stroke !== true) return false;
1806
+ return annotationRectOutlineDistance(point, rect) <= limit;
1807
+ }
1808
+ if (shape.type === 'circle') {
1809
+ const rx = shape.rx;
1810
+ const ry = shape.ry;
1811
+ const k = Math.hypot((point.x - shape.cx) / rx, (point.y - shape.cy) / ry);
1812
+ if (shape.fill === true && k <= 1) return true;
1813
+ if (shape.stroke !== true) return false;
1814
+ return Math.abs(k - 1) * Math.min(rx, ry) <= limit;
1815
+ }
1816
+ if (shape.type === 'line' || shape.type === 'arrow') {
1817
+ return annotationSegmentDistance(point.x, point.y, shape.x1, shape.y1, shape.x2, shape.y2) <= limit;
1818
+ }
1819
+ if (shape.type === 'brush') {
1820
+ const points = Array.isArray(shape.points) ? shape.points : [];
1821
+ if (points.length === 1) return Math.hypot(point.x - points[0][0], point.y - points[0][1]) <= limit;
1822
+ for (let index = 1; index < points.length; index += 1) {
1823
+ if (annotationSegmentDistance(point.x, point.y, points[index - 1][0], points[index - 1][1],
1824
+ points[index][0], points[index][1]) <= limit) return true;
1825
+ }
1826
+ return false;
1827
+ }
1828
+ if (shape.type === 'text') {
1829
+ const size = Number(shape.fontSize);
1830
+ const fontSize = Number.isFinite(size) && size > 0 ? size : ANNOTATION_LIMITS.textFontSize;
1831
+ const chars = Math.max(1, String(shape.text || '').length);
1832
+ const rect = {
1833
+ x: shape.x,
1834
+ y: shape.y - fontSize,
1835
+ width: chars * fontSize * ANNOTATION_TEXT_BOX_CHAR_RATIO,
1836
+ height: fontSize * 1.2,
1837
+ };
1838
+ return annotationRectOutlineDistance(point, rect) <= limit || annotationPointInRect(point, rect);
1839
+ }
1840
+ return false;
1841
+ }
1842
+
1843
+ /**
1844
+ * `browser_annotation` arguments → one validated call. Pure: no runtime, no browser, no file, so an
1845
+ * invalid call is refused before any state is read (and before the foreground claim is consulted).
1846
+ */
1847
+ function normalizeAgentAnnotationCall(args) {
1848
+ const input = annotationObject(args, 'browser_annotation');
1849
+ annotationRejectUnknown(input,
1850
+ ['action', 'shapes', 'ids', 'point', 'expectRevision'], 'browser_annotation');
1851
+ const action = input.action;
1852
+ if (typeof action !== 'string' || !ANNOTATION_AGENT_ACTIONS.includes(action)) {
1853
+ throw annotationInvalid('action', 'invalid-enum');
1854
+ }
1855
+ const allowed = {
1856
+ read: [],
1857
+ draw: ['shapes', 'expectRevision'],
1858
+ erase: ['ids', 'point', 'expectRevision'],
1859
+ clear: ['expectRevision'],
1860
+ }[action];
1861
+ for (const key of Object.keys(input)) {
1862
+ if (key === 'action') continue;
1863
+ if (!allowed.includes(key)) throw annotationInvalid(key, 'unknown-field');
1864
+ }
1865
+ let expectRevision = null;
1866
+ if (input.expectRevision !== undefined && input.expectRevision !== null) {
1867
+ expectRevision = annotationNumber(input.expectRevision, 'expectRevision', {
1868
+ min: 0, max: Number.MAX_SAFE_INTEGER, integer: true,
1869
+ });
1870
+ }
1871
+ if (action === 'read') return { action, specs: [], ids: [], point: null, expectRevision };
1872
+ if (action === 'clear') return { action, specs: [], ids: [], point: null, expectRevision };
1873
+ if (action === 'draw') {
1874
+ if (!Array.isArray(input.shapes) || input.shapes.length === 0) throw annotationInvalid('shapes', 'not-an-array');
1875
+ if (input.shapes.length > ANNOTATION_AGENT_MAX_SHAPES_PER_CALL) throw annotationInvalid('shapes', 'too-many');
1876
+ return { action, specs: input.shapes, ids: [], point: null, expectRevision };
1877
+ }
1878
+ // erase: ids and/or a point hit. Both empty would be a no-op reported as success — refuse it.
1879
+ const ids = [];
1880
+ if (input.ids !== undefined && input.ids !== null) {
1881
+ if (!Array.isArray(input.ids)) throw annotationInvalid('ids', 'not-an-array');
1882
+ if (input.ids.length > ANNOTATION_AGENT_MAX_SHAPES_PER_CALL) throw annotationInvalid('ids', 'too-many');
1883
+ for (let index = 0; index < input.ids.length; index += 1) {
1884
+ const id = input.ids[index];
1885
+ if (typeof id !== 'string' || !ANNOTATION_ID_PATTERN.test(id)) throw annotationInvalid(`ids[${index}]`, 'invalid-id');
1886
+ if (ids.includes(id)) throw annotationInvalid(`ids[${index}]`, 'duplicate-id');
1887
+ ids.push(id);
1888
+ }
1889
+ }
1890
+ let point = null;
1891
+ if (input.point !== undefined && input.point !== null) {
1892
+ const raw = annotationObject(input.point, 'point');
1893
+ annotationRejectUnknown(raw, ['x', 'y', 'radius'], 'point');
1894
+ const coord = (value, name) => annotationNumber(value, `point.${name}`, {
1895
+ min: ANNOTATION_COORD_MIN, max: ANNOTATION_COORD_MAX,
1896
+ });
1897
+ point = {
1898
+ x: coord(raw.x, 'x'),
1899
+ y: coord(raw.y, 'y'),
1900
+ radius: raw.radius === undefined || raw.radius === null
1901
+ ? ANNOTATION_ERASE_DEFAULT_RADIUS
1902
+ : annotationNumber(raw.radius, 'point.radius', { min: 1, max: 4096 }),
1903
+ };
1904
+ }
1905
+ if (ids.length === 0 && point === null) throw annotationInvalid('ids', 'erase-target-required');
1906
+ return { action, specs: [], ids, point, expectRevision };
1907
+ }
1908
+
1352
1909
  /** Content digest of one panel revision: the same revision must carry the same content. */
1353
1910
  function annotationDigest(request) {
1354
1911
  return createHash('sha256').update(JSON.stringify({
@@ -1503,9 +2060,13 @@ function isBlankPageUrl(value) {
1503
2060
  }
1504
2061
 
1505
2062
  class BrowserHost {
1506
- constructor(log) {
2063
+ constructor(log, options = {}) {
1507
2064
  this.log = log;
1508
2065
  this.proc = undefined;
2066
+ /** Runtime provider this host launches (chrome|edge); set by the registry, never by tools. */
2067
+ this.provider = options?.provider ?? 'chrome';
2068
+ this.product = null;
2069
+ this.executablePath = null;
1509
2070
  this.profileDir = undefined;
1510
2071
  this.port = 0;
1511
2072
  this.browserCdp = undefined;
@@ -1594,7 +2155,10 @@ class BrowserHost {
1594
2155
  const override = process.env.DSH_UI_TESTING_PROFILE_DIR;
1595
2156
  if (override) return override;
1596
2157
  const home = process.env.DSH_HOME || join(homedir(), '.dsh');
1597
- return join(home, 'ui-testing', 'headed-profile');
2158
+ // Per-provider persistent profile: Chrome keeps its historical directory, Edge gets its own.
2159
+ // Sharing one headed profile across vendors would mix state and hide identity problems.
2160
+ const suffix = this.provider === 'chrome' ? '' : `-${this.provider}`;
2161
+ return join(home, 'ui-testing', `headed-profile${suffix}`);
1598
2162
  }
1599
2163
 
1600
2164
  /**
@@ -1737,8 +2301,18 @@ class BrowserHost {
1737
2301
  }
1738
2302
 
1739
2303
  async #spawn(signal) {
1740
- // Resolve first: a missing browser must fail before a profile directory is created.
1741
- const chromeBin = resolveChromeExecutable();
2304
+ // Resolve first: a missing browser must fail before a profile directory is created. The
2305
+ // resolution is per provider — an Edge host must never launch a Chrome binary (§8).
2306
+ const resolution = resolveProviderExecutable({ provider: this.provider });
2307
+ this.executablePath = resolution.path;
2308
+ if (!resolution.path) {
2309
+ throw new BrowserError(
2310
+ 'DEPENDENCY_MISSING',
2311
+ `${this.provider} 可执行文件不可用(${resolution.reason}):已尝试 ${resolution.candidates.slice(0, 4).join(', ')}`,
2312
+ { provider: this.provider, reason: resolution.reason, candidates: resolution.candidates.slice(0, 8) },
2313
+ );
2314
+ }
2315
+ const chromeBin = resolution.path;
1742
2316
  const generation = this.generation + 1;
1743
2317
  this.generation = generation;
1744
2318
  const overallDeadline = Date.now() + DEVTOOLS_STARTUP.totalBudgetMs;
@@ -1802,6 +2376,36 @@ class BrowserHost {
1802
2376
  // Record the pre-existing about:blank target BEFORE enabling auto-attach: setAutoAttach
1803
2377
  // also attaches to existing targets, and an attach event for the initial page must never
1804
2378
  // be classified as a popup (that would block/close the page we are about to reuse).
2379
+ // §8: "CDP connected" is not proof of the product. Verify the vendor after the transport
2380
+ // is up and before any page work; a mismatch is reported, never silently accepted.
2381
+ {
2382
+ // Every provider is checked; whether *missing* evidence rejects is per vendor
2383
+ // (`WEB_PROVIDER_VENDOR.failClosedOnMissing`): edge fails closed, chrome stays lenient.
2384
+ let version = null;
2385
+ try {
2386
+ version = await browserCdp.send('Browser.getVersion', {}, { signal });
2387
+ } catch {
2388
+ version = null; // no evidence; the vendor rule decides whether that is fatal
2389
+ }
2390
+ const identity = verifyProviderProduct({ provider: this.provider, version });
2391
+ this.product = identity.product;
2392
+ this.productVerified = identity.verified === true;
2393
+ if (!identity.ok) {
2394
+ throw new BrowserError(
2395
+ 'DEPENDENCY_MISSING',
2396
+ identity.reason === WEB_PROVIDER_REASONS.IDENTITY_UNVERIFIED
2397
+ ? `启动的产品身份无法确认(provider=${this.provider}):拿不到 Browser.getVersion`
2398
+ : `启动的产品身份与 provider 不符:期望 ${String(identity.expected)},实际 ${String(identity.product)}`,
2399
+ {
2400
+ provider: this.provider,
2401
+ product: identity.product,
2402
+ expected: identity.expected,
2403
+ reason: identity.reason,
2404
+ path: resolution.path,
2405
+ },
2406
+ );
2407
+ }
2408
+ }
1805
2409
  this.initialTargetId = await this.#findInitialPage(signal);
1806
2410
  await browserCdp.send('Target.setAutoAttach', {
1807
2411
  autoAttach: true, waitForDebuggerOnStart: true, flatten: true,
@@ -4091,6 +4695,11 @@ export function inlineImageDelivery(base64, limit = IMAGE_INLINE_BASE64_LIMIT) {
4091
4695
  }
4092
4696
 
4093
4697
  export class Runtime {
4698
+ #frames = null;
4699
+ #visionStore = null;
4700
+ #net = null;
4701
+ #netCtl = null;
4702
+
4094
4703
  /**
4095
4704
  * @param log logger
4096
4705
  * @param {{host?: BrowserHost, origins?: Set<string>, scope?: object, registry?: RuntimeRegistry}} [options]
@@ -4117,7 +4726,11 @@ export class Runtime {
4117
4726
  this.cdp = undefined;
4118
4727
  /** { targetId, generation } of this runtime's own page target (undefined when cold). */
4119
4728
  this.page = undefined;
4120
- this.frames = new FrameSource();
4729
+ // A14: the frame source is the Web-only capture chain. Constructing it eagerly made
4730
+ // `new Runtime()` require the whole Web module graph, so a host that never captures frames
4731
+ // still paid for it (and a direct construction outside the plugin's load gate threw). It is
4732
+ // created on first use instead; a runtime that never captures never loads it.
4733
+ this.#frames = null;
4121
4734
  this.frameTransport = undefined;
4122
4735
  this.clientMetrics = undefined;
4123
4736
  this.ackFailures = 0;
@@ -4179,6 +4792,11 @@ export class Runtime {
4179
4792
  updatedAt: null,
4180
4793
  lastRotateReason: null,
4181
4794
  salt: randomUUID().replace(/-/gu, ''),
4795
+ // Human-foreground claim (`browser_annotation`, docs/viewport-control.md): the panel reports
4796
+ // "this runtime is my selected tab and I am visible" on its authenticated requests. It is a
4797
+ // separate clock from `lastSeenAt` (document freshness) and from the 15s viewer TTL.
4798
+ guiForegroundAt: 0,
4799
+ guiForegroundView: null,
4182
4800
  };
4183
4801
  this.activity = '空闲';
4184
4802
  this.agentUntil = 0;
@@ -4189,8 +4807,16 @@ export class Runtime {
4189
4807
  this.lastErrorTarget = null;
4190
4808
  this.starting = undefined;
4191
4809
  this.queue = Promise.resolve();
4810
+ // Cross-environment lifecycle (A12): a config transition that removes the Web selection for
4811
+ // this page's scope must cancel queued/running operations instead of letting them mutate a
4812
+ // page the environment no longer owns. The composed signal is what `run()` observes.
4813
+ this.envController = new AbortController();
4814
+ this.envAbortReason = null;
4192
4815
  this.lease = undefined;
4193
4816
  this.active = 0;
4817
+ // `browser_input` state (docs/tools.md): the keys this page currently holds down and the
4818
+ // virtual cursor position used by relative mouse moves. Both die with the document.
4819
+ this.inputState = { keys: new Map(), cursor: null };
4194
4820
  // Every queued/running operation (agent OR panel): idle sweep/LRU must never close a page
4195
4821
  // that has work in flight.
4196
4822
  this.inflight = 0;
@@ -4221,7 +4847,7 @@ export class Runtime {
4221
4847
  // instance and independent of the page lifecycle — a page navigation/close keeps the stored
4222
4848
  // images analysable offline (their actionable资格 is invalidated separately). Only dispose
4223
4849
  // (plugin unload / runtime retire) clears it.
4224
- this.visionProducts = new vision.VisionProductStore({ now: () => Date.now() });
4850
+ this.#visionStore = null;
4225
4851
  // Provenance per product (runtimeId/pageGeneration/documentEpoch) so `act` can prove it is
4226
4852
  // operating on the same page generation, and evidence can cite the capture lineage.
4227
4853
  this.visionProvenance = new Map();
@@ -4260,15 +4886,10 @@ export class Runtime {
4260
4886
  // TOOL-P1-04: session attribution is a construction-time fact -- scopeId from this Runtime's
4261
4887
  // workspace, runtimeId its own id, tabId 'primary' for the primary runtime and the popup viewId
4262
4888
  // for a child tab. It is never inferred from cdpSessionId/targetId and stays stable per tab.
4263
- this.net = new network.NetworkStore({
4264
- scopeId: this.scope.id,
4265
- attribution: {
4266
- scopeId: this.scope.id,
4267
- runtimeId: this.runtimeId,
4268
- tabId: this.kind === 'popup' ? (this.viewId || 'primary') : 'primary',
4269
- },
4270
- });
4271
- this.netCtl = new network.ControlState();
4889
+ // A14: the P0 network store/control state belong to the Web-only module chain; they are
4890
+ // created on first use so a runtime that never touches the network never loads them.
4891
+ this.#net = null;
4892
+ this.#netCtl = null;
4272
4893
  this.netTargets = new Map();
4273
4894
  this.netReq = new Map();
4274
4895
  this.netHops = new Map();
@@ -4279,7 +4900,8 @@ export class Runtime {
4279
4900
  this.netHoldTimer = null;
4280
4901
  this.netBodyTimeoutMs = 1500;
4281
4902
  // Tool-surface evidence: last real network tool call (projected into capabilities.network).
4282
- this.net.observed = null;
4903
+ // The store itself is created on first network use, so the field is seeded here as data.
4904
+ this.netObserved = null;
4283
4905
  this.lastNetSessionId = null;
4284
4906
  // Web T4 dynamic behaviour (docs/t4-dynamic-behavior.md, docs/t4-dynamic-behavior.md):
4285
4907
  // one active session per scope, up to four retained summaries, one screencast sink, and the
@@ -4347,6 +4969,52 @@ export class Runtime {
4347
4969
  cdp.send('Page.bringToFront', {}, { timeoutMs: 1000 }).catch(() => {});
4348
4970
  }
4349
4971
 
4972
+ /** Lazily created P0 network store (scope-local attribution is fixed at first use). */
4973
+ get net() {
4974
+ if (!this.#net) {
4975
+ this.#net = new network.NetworkStore({
4976
+ scopeId: this.scope.id,
4977
+ attribution: {
4978
+ scopeId: this.scope.id,
4979
+ runtimeId: this.runtimeId,
4980
+ tabId: this.kind === 'popup' ? (this.viewId || 'primary') : 'primary',
4981
+ },
4982
+ });
4983
+ }
4984
+ return this.#net;
4985
+ }
4986
+
4987
+ set net(value) { this.#net = value; }
4988
+
4989
+ /** Last real network tool call (`capabilities.network.observed`); null until one happens. */
4990
+ get netObserved() { return this.#net ? this.#net.observed : null; }
4991
+
4992
+ set netObserved(value) { if (this.#net) this.#net.observed = value; }
4993
+
4994
+ /** Lazily created interception/throttle/hold state machine. */
4995
+ get netCtl() {
4996
+ if (!this.#netCtl) this.#netCtl = new network.ControlState();
4997
+ return this.#netCtl;
4998
+ }
4999
+
5000
+ set netCtl(value) { this.#netCtl = value; }
5001
+
5002
+ /** Lazily created T3 product store (created on first vision use, like the frame source). */
5003
+ get visionProducts() {
5004
+ if (!this.#visionStore) this.#visionStore = new vision.VisionProductStore({ now: () => Date.now() });
5005
+ return this.#visionStore;
5006
+ }
5007
+
5008
+ set visionProducts(value) { this.#visionStore = value; }
5009
+
5010
+ /** Lazily created frame source (see the constructor note). Throws only when actually used. */
5011
+ get frames() {
5012
+ if (!this.#frames) this.#frames = new FrameSource();
5013
+ return this.#frames;
5014
+ }
5015
+
5016
+ set frames(value) { this.#frames = value; }
5017
+
4350
5018
  hasLease() { return Boolean(this.lease && (this.active || this.lease.expiresAt > Date.now())); }
4351
5019
 
4352
5020
  /** Idle sweep / LRU eviction is only allowed when nothing is using this page. */
@@ -4371,7 +5039,39 @@ export class Runtime {
4371
5039
  * and clear the error projection (contract §9.1). Diagnostics, HTTP requests, BUSY and
4372
5040
  * pre-validation failures pass no action, so they can never overwrite another owner's error.
4373
5041
  */
4374
- async run(operation, { signal, sessionId = 'anonymous', agent = false, action } = {}) {
5042
+ /** Observed signal for one operation: caller cancellation plus the environment-transition gate. */
5043
+ #effectiveSignal(signal) {
5044
+ const environmentSignal = this.envController?.signal;
5045
+ if (!environmentSignal) return signal;
5046
+ if (!signal) return environmentSignal;
5047
+ return typeof AbortSignal.any === 'function' ? AbortSignal.any([signal, environmentSignal]) : signal;
5048
+ }
5049
+
5050
+ /**
5051
+ * Cancel queued/running work because the configuration no longer selects this environment.
5052
+ * The controller is replaced so later operations (after re-enabling Web) are not still aborted;
5053
+ * `run()`'s ABORTED classification closes the page, releasing connection/frames/network/lease.
5054
+ */
5055
+ abortForEnvironmentChange(reason = 'config-transition') {
5056
+ if (!this.envController || this.envController.signal.aborted) return { aborted: false, reason: this.envAbortReason };
5057
+ this.envAbortReason = reason;
5058
+ try { this.envController.abort(new Error('environment changed: ' + reason)); } catch { /* best effort */ }
5059
+ this.envController = new AbortController();
5060
+ return { aborted: true, reason };
5061
+ }
5062
+
5063
+ /**
5064
+ * Run one operation in the page queue.
5065
+ *
5066
+ * `activate` (default true) fronts the page so Chromium keeps compositing it — required for real
5067
+ * page operations, and the reason a panel preview follows the operated tab. A **read-only
5068
+ * observation** must pass `activate:false`: bringing a teammate's page to the front changes its
5069
+ * `visibilityState` and can stall its background frames, which is exactly the interference the
5070
+ * non-destructive-observation rule forbids. It still enters this queue (serialised with writes) —
5071
+ * only the foreground switch is skipped.
5072
+ */
5073
+ async run(operation, { signal, sessionId = 'anonymous', agent = false, action, activate = true } = {}) {
5074
+ signal = this.#effectiveSignal(signal);
4375
5075
  checkAbort(signal);
4376
5076
  this.inflight += 1;
4377
5077
  // Everything that can throw after the counter moved must sit inside the try/finally below:
@@ -4392,7 +5092,9 @@ export class Runtime {
4392
5092
  this.active += 1;
4393
5093
  }
4394
5094
  // The page being operated must be the compositing one, otherwise its preview freezes.
4395
- this.activate();
5095
+ // A pure observation opts out (`activate:false`) so looking at somebody's page cannot move
5096
+ // their foreground page or toggle their `visibilityState`.
5097
+ if (activate) this.activate();
4396
5098
  await abortable(previous, signal);
4397
5099
  checkAbort(signal);
4398
5100
  if (agent) this.claim(sessionId);
@@ -6340,6 +7042,45 @@ export class Runtime {
6340
7042
  };
6341
7043
  }
6342
7044
 
7045
+ /**
7046
+ * Refresh the human-foreground claim for this runtime (`browser_annotation`).
7047
+ *
7048
+ * Only the authenticated panel sends the report (it does so on `/state`, `/frame` and `/metrics`
7049
+ * for the tab it is actually displaying, and stops as soon as it is hidden or another tab is
7050
+ * selected). The Agent cannot raise its own claim through any tool: this method is reachable only
7051
+ * from the scoped GUI routes.
7052
+ */
7053
+ noteGuiForeground(viewId = null) {
7054
+ this.annot.guiForegroundAt = Date.now();
7055
+ this.annot.guiForegroundView = typeof viewId === 'string' && viewId.length > 0 ? viewId : 'primary';
7056
+ }
7057
+
7058
+ /**
7059
+ * Is this runtime the tab a human is looking at right now?
7060
+ *
7061
+ * `active` is a *freshness* answer, never a stored permission: it goes false on its own within
7062
+ * `ANNOTATION_FOREGROUND_TTL_MS` when the panel stops reporting (tab switched away, panel closed,
7063
+ * DSH tab backgrounded). A dead page is never in the foreground.
7064
+ */
7065
+ guiForeground() {
7066
+ const at = Number(this.annot.guiForegroundAt) || 0;
7067
+ const ageMs = at > 0 ? Math.max(0, Date.now() - at) : null;
7068
+ return {
7069
+ active: this.running && at > 0 && ageMs !== null && ageMs <= ANNOTATION_FOREGROUND_TTL_MS,
7070
+ viewId: this.annot.guiForegroundView || null,
7071
+ ageMs,
7072
+ maxAgeMs: ANNOTATION_FOREGROUND_TTL_MS,
7073
+ };
7074
+ }
7075
+
7076
+ /** The document's shapes for a read-modify-write, or [] when the document is not this one. */
7077
+ annotationCurrentShapes() {
7078
+ this.#annotationPrune();
7079
+ const document = this.annot.document;
7080
+ if (!document || !document.context || document.context.documentKey !== this.annot.documentKey) return [];
7081
+ return document.shapes.map((shape) => JSON.parse(JSON.stringify(shape)));
7082
+ }
7083
+
6343
7084
  /** Rotate the navigation token and drop everything stored under the old one (§11.2). */
6344
7085
  #annotationRotate(reason) {
6345
7086
  const hadDocument = this.annot.document !== null;
@@ -6394,6 +7135,9 @@ export class Runtime {
6394
7135
  : { droppedShapes: 0, droppedPoints: 0, shortenedTextCodeUnits: 0 },
6395
7136
  stale,
6396
7137
  staleReason,
7138
+ // Human-foreground claim (docs/viewport-control.md): `browser_annotation` writes are refused
7139
+ // unless `active` is true. Computed at read time, never stored in the document.
7140
+ foreground: this.guiForeground(),
6397
7141
  };
6398
7142
  }
6399
7143
 
@@ -6427,13 +7171,17 @@ export class Runtime {
6427
7171
  space: ANNOTATION_SPACE,
6428
7172
  revision: this.annot.revision + 1,
6429
7173
  updatedAt,
6430
- source: {
6431
- kind: 'human-panel',
6432
- panelId: request.panelId,
6433
- surface: request.surface,
6434
- clientRevision: request.clientRevision,
6435
- trusted: false,
6436
- },
7174
+ // A panel publish cites the panel; `browser_annotation` passes its own source so a document
7175
+ // written by the Agent never claims a human drew it (`trusted` stays false either way).
7176
+ source: request.source
7177
+ ? { ...request.source }
7178
+ : {
7179
+ kind: 'human-panel',
7180
+ panelId: request.panelId,
7181
+ surface: request.surface,
7182
+ clientRevision: request.clientRevision,
7183
+ trusted: false,
7184
+ },
6437
7185
  context: this.annotationContext(),
6438
7186
  view: { ...request.view },
6439
7187
  shapes: shapes.map((shape) => JSON.parse(JSON.stringify(shape))),
@@ -6511,6 +7259,132 @@ export class Runtime {
6511
7259
  return ack(true, false, request.clientRevision);
6512
7260
  }
6513
7261
 
7262
+ /** The annotation document's `view` snapshot for an Agent write (canvas CSS px space). */
7263
+ #annotationAgentView() {
7264
+ const view = this.view;
7265
+ const content = this.contentProjection();
7266
+ return {
7267
+ canvasX: view.canvasX,
7268
+ canvasY: view.canvasY,
7269
+ x: view.x,
7270
+ y: view.y,
7271
+ width: view.width,
7272
+ height: view.height,
7273
+ scale: view.scale,
7274
+ contentWidth: content.width,
7275
+ contentHeight: content.height,
7276
+ viewRevision: view.revision,
7277
+ frameEpoch: this.frames && Number.isFinite(this.frames.epoch) ? this.frames.epoch : null,
7278
+ };
7279
+ }
7280
+
7281
+ /**
7282
+ * `browser_annotation` — the Agent's half of the same document (docs/viewport-control.md).
7283
+ *
7284
+ * Read-modify-write on ONE shape list: a human note is never overwritten by a draw, and an Agent
7285
+ * note survives the human's next edit because the panel hydrates this document before it
7286
+ * publishes its own. Every refusal happens before any state is touched, and nothing here starts a
7287
+ * page, takes the page lease, dispatches CDP or writes page `lastError`.
7288
+ *
7289
+ * @param {{action:string, specs:ReadonlyArray<object>, ids:ReadonlyArray<string>,
7290
+ * point:{x:number,y:number,radius:number}|null, expectRevision:number|null}} call
7291
+ * Output of `normalizeAgentAnnotationCall` (already structurally validated).
7292
+ * @param {{sessionId?:string|null}} [options] Caller identity for the document's `source`.
7293
+ */
7294
+ agentAnnotation(call, options = {}) {
7295
+ // `read` answers on a cold runtime too (the document is simply empty / stale=not-running);
7296
+ // only a *write* needs a page, because only a running page has a panel showing it.
7297
+ if (call.action !== 'read' && !this.running) {
7298
+ throw annotationInvalid('runtime', 'annotation-runtime-not-running');
7299
+ }
7300
+ const foreground = this.guiForeground();
7301
+ if (call.action !== 'read' && !foreground.active) {
7302
+ // The whole point of an Agent note is that a human sees it: drawing on a tab nobody displays
7303
+ // would be an invisible write. Zero writes, and the caller is told what has to change.
7304
+ throw new BrowserError(
7305
+ 'ANNOTATION_NOT_FOREGROUND',
7306
+ '标注只能画在人类当前选中的标签页上:请在调试视图里选中该标签页并保持可见后重试(本次零写入)',
7307
+ {
7308
+ reason: 'annotation-not-foreground',
7309
+ viewId: foreground.viewId,
7310
+ ageMs: foreground.ageMs,
7311
+ maxAgeMs: foreground.maxAgeMs,
7312
+ },
7313
+ );
7314
+ }
7315
+ const current = this.annotationCurrentShapes();
7316
+ if (call.expectRevision !== null && call.expectRevision !== this.annot.revision) {
7317
+ throw new BrowserError('ANNOTATION_REVISION_CONFLICT', '标注文档 revision 已变化:本次零写入,请先读取最新文档', {
7318
+ reason: 'annotation-revision-conflict',
7319
+ expectedRevision: call.expectRevision,
7320
+ currentRevision: this.annot.revision,
7321
+ });
7322
+ }
7323
+ if (call.action === 'read') {
7324
+ return { ok: true, action: 'read', removed: [], ...this.annotationsDocument() };
7325
+ }
7326
+ let shapes = current;
7327
+ const removed = [];
7328
+ let shortenedText = 0;
7329
+ if (call.action === 'draw') {
7330
+ const allocateId = annotationAgentIdFactory(current, call.specs);
7331
+ const seenIds = new Set(current.map((shape) => shape.id));
7332
+ const added = [];
7333
+ for (let index = 0; index < call.specs.length; index += 1) {
7334
+ const result = normalizeAgentAnnotationShape(call.specs[index], `shapes[${index}]`, allocateId, seenIds);
7335
+ added.push(result.shape);
7336
+ shortenedText += result.shortenedText;
7337
+ }
7338
+ shapes = current.concat(added);
7339
+ } else if (call.action === 'erase') {
7340
+ const ids = new Set(call.ids);
7341
+ shapes = current.filter((shape) => {
7342
+ if (ids.has(shape.id)) { removed.push(shape.id); return false; }
7343
+ if (call.point && annotationAgentHit(shape, call.point, call.point.radius)) { removed.push(shape.id); return false; }
7344
+ return true;
7345
+ });
7346
+ } else {
7347
+ for (const shape of current) removed.push(shape.id);
7348
+ shapes = [];
7349
+ }
7350
+ const limited = applyAnnotationLimits(
7351
+ shapes,
7352
+ { droppedShapes: 0, droppedPoints: 0, shortenedTextCodeUnits: 0 },
7353
+ shortenedText,
7354
+ );
7355
+ const updatedAt = Date.now();
7356
+ const request = {
7357
+ // No panel watermark is consumed: an Agent write must never satisfy a stale panel revision.
7358
+ panelId: null,
7359
+ surface: 'main',
7360
+ clientRevision: 0,
7361
+ view: this.#annotationAgentView(),
7362
+ source: {
7363
+ kind: 'agent-tool',
7364
+ tool: 'browser_annotation',
7365
+ agentSessionId: options.sessionId ? String(options.sessionId) : null,
7366
+ surface: 'main',
7367
+ trusted: false,
7368
+ },
7369
+ };
7370
+ let finalShapes = limited.shapes;
7371
+ let truncation = limited.truncation;
7372
+ let document = this.#annotationBuild(request, finalShapes, truncation, updatedAt);
7373
+ // Same byte budget as a panel publish: drop the oldest whole shapes, never a half shape.
7374
+ while (Buffer.byteLength(JSON.stringify(document), 'utf8') > ANNOTATION_LIMITS.maxDocumentBytes
7375
+ && finalShapes.length > 0) {
7376
+ finalShapes = finalShapes.slice(1);
7377
+ truncation = { ...truncation, droppedShapes: truncation.droppedShapes + 1 };
7378
+ document = this.#annotationBuild(request, finalShapes, truncation, updatedAt);
7379
+ }
7380
+ this.annot.revision += 1;
7381
+ document.revision = this.annot.revision;
7382
+ this.annot.document = document;
7383
+ this.annot.updatedAt = updatedAt;
7384
+ this.annot.lastSeenAt = updatedAt;
7385
+ return { ok: true, action: call.action, removed, ...this.annotationsDocument() };
7386
+ }
7387
+
6514
7388
  /** One HTTP-poll response in the historical shape plus frame metadata and epoch. */
6515
7389
  framePayload(since, state = this.state()) {
6516
7390
  const latest = this.frames.latest();
@@ -6729,30 +7603,155 @@ export class Runtime {
6729
7603
  return { moved: true, direction };
6730
7604
  }
6731
7605
 
6732
- async input(events, signal) {
6733
- if (!Array.isArray(events) || events.some((event) => !event || !['mouse', 'wheel', 'key', 'text'].includes(event.kind))) {
7606
+ /** Drop the held-key table and the virtual cursor (document replaced or page closed). */
7607
+ resetInputState() {
7608
+ this.inputState.keys.clear();
7609
+ this.inputState.cursor = null;
7610
+ }
7611
+
7612
+ /** Modifier bits (CDP Alt=1/Ctrl=2/Meta=4/Shift=8) of every key this page holds down. */
7613
+ #heldModifierBits() {
7614
+ let bits = 0;
7615
+ for (const modifier of this.inputState.keys.values()) bits |= modifier;
7616
+ return bits;
7617
+ }
7618
+
7619
+ /**
7620
+ * Dispatch input events through the held-key state machine (docs/tools.md `browser_input`).
7621
+ *
7622
+ * Three things a real keyboard/mouse gives a page for free, and a stream of independent CDP
7623
+ * dispatches does not:
7624
+ * 1. **modifier synthesis** — while Shift is down, every later key/mouse/wheel event carries
7625
+ * `shiftKey:true`. The page's own `keydown` bookkeeping is not enough: `e.shiftKey` comes
7626
+ * from the browser, not from the page's listener.
7627
+ * 2. **relative motion** — `deltaX/deltaY` advance a virtual cursor, so pointer-lock style
7628
+ * "move by delta" callers never have to know the absolute position.
7629
+ * 3. **a release path** — `keyUp` clears the entry, and a replaced document clears everything
7630
+ * (a stale held key would otherwise keep marking events as Shift-pressed forever).
7631
+ *
7632
+ * @returns {{applied:number, modifiers:number, held:string[], cursor:{x:number,y:number}|null}}
7633
+ */
7634
+ async input(events, signal) {
7635
+ if (!Array.isArray(events) || events.some((event) => !event || !['mouse', 'wheel', 'key', 'text'].includes(event.kind))) {
6734
7636
  throw new BrowserError('INVALID_ARGUMENT', '输入事件 kind 无效');
6735
7637
  }
6736
7638
  if (events.length > INPUT_EVENT_LIMIT) {
6737
7639
  throw new BrowserError('INVALID_ARGUMENT', `单批输入事件过多:${events.length} > ${INPUT_EVENT_LIMIT}`, { maxItems: INPUT_EVENT_LIMIT });
6738
7640
  }
6739
7641
  // Normalize/validate the whole batch first: a bad modifiers value must not start Chromium.
6740
- const prepared = events.map((event) => prepareInputEvent(event));
7642
+ const prepared = events.map((event) => ({ event, prepared: prepareInputEvent(event) }));
6741
7643
  await this.ensure(signal);
6742
- for (const event of prepared) {
6743
- if (event.kind === 'mouse' || event.kind === 'wheel') {
6744
- await this.send('Input.dispatchMouseEvent', event.params, signal);
6745
- } else if (event.kind === 'key') {
6746
- await this.send('Input.dispatchKeyEvent', event.params, signal);
6747
- } else if (event.kind === 'text') {
6748
- await this.send('Input.insertText', event.params, signal);
7644
+ const viewport = this.viewport;
7645
+ for (const item of prepared) {
7646
+ const { event } = item;
7647
+ const params = item.prepared.params;
7648
+ if (item.prepared.kind === 'key') {
7649
+ const type = String(params.type || '').toLowerCase();
7650
+ const identity = inputKeyIdentity(event);
7651
+ const modifier = inputKeyModifier(event);
7652
+ const synthesize = event.synthesizeModifiers !== false;
7653
+ // A keyUp still reports the modifier it is releasing; a press reports the state *before*
7654
+ // it lands (same order a real OS produces).
7655
+ params.modifiers = (Number(params.modifiers) || 0) | (synthesize ? this.#heldModifierBits() : 0);
7656
+ if (identity) {
7657
+ if (inputPressesKey(type)) this.inputState.keys.set(identity, modifier);
7658
+ else if (type === 'keyup') this.inputState.keys.delete(identity);
7659
+ }
7660
+ } else if (item.prepared.kind === 'mouse' || item.prepared.kind === 'wheel') {
7661
+ if (event.synthesizeModifiers !== false) {
7662
+ params.modifiers = (Number(params.modifiers) || 0) | this.#heldModifierBits();
7663
+ }
7664
+ if (item.prepared.kind === 'mouse') {
7665
+ const hasDelta = Number.isFinite(event.deltaX) || Number.isFinite(event.deltaY);
7666
+ if (hasDelta) {
7667
+ if (!params.type) params.type = 'mouseMoved';
7668
+ const base = this.inputState.cursor
7669
+ || { x: Math.round(viewport.width / 2), y: Math.round(viewport.height / 2) };
7670
+ const next = {
7671
+ x: Math.min(Math.max(0, base.x + (Number.isFinite(event.deltaX) ? event.deltaX : 0)), Math.max(0, viewport.width - 1)),
7672
+ y: Math.min(Math.max(0, base.y + (Number.isFinite(event.deltaY) ? event.deltaY : 0)), Math.max(0, viewport.height - 1)),
7673
+ };
7674
+ params.x = next.x;
7675
+ params.y = next.y;
7676
+ this.inputState.cursor = { ...next };
7677
+ if (params.type === 'mouseMoved' && !Number.isFinite(event.buttons)) params.buttons = 0;
7678
+ } else if (Number.isFinite(params.x) && Number.isFinite(params.y)) {
7679
+ this.inputState.cursor = { x: params.x, y: params.y };
7680
+ }
7681
+ }
7682
+ }
7683
+ if (item.prepared.kind === 'mouse' || item.prepared.kind === 'wheel') {
7684
+ await this.send('Input.dispatchMouseEvent', params, signal);
7685
+ } else if (item.prepared.kind === 'key') {
7686
+ await this.send('Input.dispatchKeyEvent', params, signal);
7687
+ } else if (item.prepared.kind === 'text') {
7688
+ await this.send('Input.insertText', params, signal);
6749
7689
  }
6750
7690
  }
6751
7691
  // A wheel/click/drag can move the page (momentum/smooth scroll/snap): two coalesced settling
6752
7692
  // samples, 50ms and 250ms after the dispatch (docs/viewport-control.md).
6753
7693
  this.refreshViewObservationSoon({ delayMs: 50 });
6754
7694
  this.refreshViewObservationSoon({ delayMs: 250 });
6755
- return events.length;
7695
+ return {
7696
+ applied: events.length,
7697
+ modifiers: this.#heldModifierBits(),
7698
+ held: [...this.inputState.keys.keys()],
7699
+ cursor: this.inputState.cursor ? { ...this.inputState.cursor } : null,
7700
+ };
7701
+ }
7702
+
7703
+ /**
7704
+ * Panel-side `<select>` write (`POST /select`).
7705
+ *
7706
+ * One DOM write through the page: the native `selectedIndex` setter plus `input`/`change`.
7707
+ * Bound requests validate the document and resolve the original node before writing; compatibility
7708
+ * requests may use a selector or focus. The write stays in the serial queue; the guards in the HTTP route
7709
+ * (same order as `/input`) and complete before this method is reached.
7710
+ */
7711
+ async selectValue(request, signal) {
7712
+ await this.ensure(signal);
7713
+ let answer;
7714
+ if (request.target) {
7715
+ const stale = () => new BrowserError('INVALID_ARGUMENT', '下拉目标已变化,请重新打开', { reason: 'stale-target' });
7716
+ const tree = await this.send('Page.getFrameTree', {}, signal);
7717
+ // A bound `<select>` may live in a same-process child frame: validate the target document
7718
+ // against the whole frame tree, not just the root frame.
7719
+ if (!frameTreeHasLoaderId(tree && tree.frameTree, request.target.loaderId)) throw stale();
7720
+ let objectId;
7721
+ try {
7722
+ const resolved = await this.send('DOM.resolveNode', { backendNodeId: request.target.backendNodeId }, signal);
7723
+ objectId = resolved?.object?.objectId;
7724
+ } catch (error) {
7725
+ if (['ABORTED', 'CDP_TIMEOUT', 'CDP_DISCONNECTED'].includes(error?.code)) throw error;
7726
+ throw stale();
7727
+ }
7728
+ if (!objectId) throw stale();
7729
+ try {
7730
+ answer = await this.send('Runtime.callFunctionOn', {
7731
+ objectId,
7732
+ functionDeclaration: `function () { return ${selectWriteExpression(request, { objectBound: true })}; }`,
7733
+ returnByValue: true,
7734
+ userGesture: true,
7735
+ }, signal);
7736
+ } finally {
7737
+ try { await this.send('Runtime.releaseObject', { objectId }, signal); } catch { /* best effort */ }
7738
+ }
7739
+ } else {
7740
+ answer = await this.send('Runtime.evaluate', {
7741
+ expression: selectWriteExpression(request),
7742
+ returnByValue: true,
7743
+ awaitPromise: false,
7744
+ userGesture: true,
7745
+ }, signal);
7746
+ }
7747
+ if (answer && answer.exceptionDetails) {
7748
+ const details = answer.exceptionDetails;
7749
+ const description = details.exception ? details.exception.description : details.text;
7750
+ throw new BrowserError('EVAL_ERROR', String(description || '<select> 写入失败'));
7751
+ }
7752
+ const classified = classifySelectWrite(answer && answer.result ? answer.result.value : null);
7753
+ if (!classified.ok) throw new BrowserError(classified.code, classified.message, classified.details);
7754
+ return classified;
6756
7755
  }
6757
7756
 
6758
7757
  async evaluate(expression, awaitPromise = true, signal) {
@@ -6846,13 +7845,19 @@ export class Runtime {
6846
7845
  * unchanged; only a protocol error, empty data or a capture failure without a more fitting code
6847
7846
  * becomes CAPTURE_FAILED.
6848
7847
  */
6849
- async capture({ scale = 1, region, scaleSource = 'explicit' } = {}, signal) {
7848
+ async capture({ scale = 1, region, scaleSource = 'explicit', includeAnnotations = false } = {}, signal) {
6850
7849
  const capturedAt = Date.now();
6851
7850
  // The resolved density source travels with the capture so evidence can say whether the caller
6852
7851
  // asked for this scale or the shared view.scale supplied it (docs/viewport-control.md).
6853
7852
  const resolvedScaleSource = scaleSource === 'view' ? 'view' : 'explicit';
6854
7853
  const viewRevisionAtCapture = this.view.revision;
6855
7854
  await this.ensure(signal);
7855
+ // Freeze the accepted panel document once, before sampling page pixels. Mid-capture edits
7856
+ // cannot mix revisions, and a navigation during either stage fails closed.
7857
+ const annotationDocument = includeAnnotations ? this.annotationsDocument() : null;
7858
+ const annotationContext = includeAnnotations ? this.annotationContext() : null;
7859
+ const finish = shot => includeAnnotations
7860
+ ? this.compositeCaptureAnnotations(shot, annotationDocument, annotationContext, signal) : shot;
6856
7861
  const deviceScaleFactor = 1;
6857
7862
  const effectiveScale = scale * deviceScaleFactor;
6858
7863
 
@@ -6996,11 +8001,11 @@ export class Runtime {
6996
8001
  phase: 'captureScreenshot', reason: 'empty-bytes',
6997
8002
  });
6998
8003
  }
6999
- return {
8004
+ return finish({
7000
8005
  base64: shot.data,
7001
8006
  pngBytes,
7002
8007
  capture: { ...baseCapture, pageCapture: null },
7003
- };
8008
+ });
7004
8009
  }
7005
8010
 
7006
8011
  // O ≠ 0 must be composited (docs/viewport-control.md + the 2026-11-02 negative-clip spike):
@@ -7079,11 +8084,82 @@ export class Runtime {
7079
8084
  phase: 'encodeComposite', reason: redactText(String(error && error.message ? error.message : error)).slice(0, 200),
7080
8085
  });
7081
8086
  }
7082
- return {
8087
+ return finish({
7083
8088
  base64: pngBytes.toString('base64'),
7084
8089
  pngBytes,
7085
8090
  capture: { ...baseCapture, pageCapture, composite: true },
8091
+ });
8092
+ }
8093
+
8094
+ /** Opt-in panel overlay. Raw capture/T3/T4 callers keep the original PNG path. */
8095
+ async compositeCaptureAnnotations(shot, document, context, signal) {
8096
+ const assertContext = () => {
8097
+ const current = this.annotationContext();
8098
+ if (current.documentKey !== context.documentKey
8099
+ || current.pageGeneration !== context.pageGeneration
8100
+ || current.documentEpoch !== context.documentEpoch) {
8101
+ throw new BrowserError('CAPTURE_FAILED', '截图期间页面已切换,请重新读取标注并截图', {
8102
+ phase: 'annotationOverlay', reason: 'annotation-context-changed',
8103
+ });
8104
+ }
7086
8105
  };
8106
+ assertContext();
8107
+ if (document.shapes.length && (!document.context
8108
+ || document.context.documentKey !== context.documentKey
8109
+ || document.context.pageGeneration !== context.pageGeneration
8110
+ || document.context.documentEpoch !== context.documentEpoch)) {
8111
+ throw new BrowserError('CAPTURE_FAILED', '标注与截图不属于同一页面文档', {
8112
+ phase: 'annotationOverlay', reason: 'annotation-context-changed',
8113
+ });
8114
+ }
8115
+ const annotations = {
8116
+ enabled: true, composited: false, space: document.space,
8117
+ revision: document.revision, updatedAt: document.updatedAt,
8118
+ source: document.source, context: document.context,
8119
+ shapeCount: document.shapes.length,
8120
+ stale: document.stale, staleReason: document.staleReason,
8121
+ truncated: document.truncated, truncation: document.truncation,
8122
+ };
8123
+ shot.capture.annotations = annotations;
8124
+ if (!document.shapes.length) return shot;
8125
+ const png = inspectPng(shot.pngBytes, { checkCrc: true });
8126
+ if (!png.ok) throw new BrowserError('INVALID_PNG', '叠加前的页面 PNG 校验失败', { phase: 'annotationOverlay' });
8127
+ const expected = expectedPixelSize({
8128
+ width: shot.capture.region.width, height: shot.capture.region.height,
8129
+ scale: shot.capture.effectiveScale, deviceScaleFactor: 1,
8130
+ });
8131
+ const size = checkPixelSize({ width: png.width, height: png.height }, expected);
8132
+ if (!size.ok) throw new BrowserError(size.code, '叠加前的页面 PNG 尺寸不符', size.details);
8133
+ const renderer = webHostNamespace('annotation-capture');
8134
+ try {
8135
+ const tree = await this.send('Page.getFrameTree', {}, signal);
8136
+ const world = await this.send('Page.createIsolatedWorld', {
8137
+ frameId: tree.frameTree.frame.id, worldName: 'dsh-annotation-capture',
8138
+ }, signal);
8139
+ const input = { shapes: document.shapes, region: shot.capture.region, width: png.width, height: png.height };
8140
+ // A detached OffscreenCanvas in an isolated world avoids page prototype overrides and CSP
8141
+ // image restrictions. No DOM insertion, image load, page state or extra browser tab.
8142
+ const rendered = await this.send('Runtime.evaluate', {
8143
+ contextId: world.executionContextId,
8144
+ expression: `(${renderer.renderAnnotationOverlay.toString()})(${JSON.stringify(input)})`,
8145
+ awaitPromise: true, returnByValue: true,
8146
+ }, signal);
8147
+ if (rendered.exceptionDetails || !rendered.result?.value?.base64) {
8148
+ throw new BrowserError('CAPTURE_FAILED', '标注离屏绘制失败', { phase: 'annotationOverlay', reason: 'raster-failed' });
8149
+ }
8150
+ assertContext();
8151
+ const page = decodePng(shot.pngBytes);
8152
+ const overlay = decodePng(Buffer.from(rendered.result.value.base64, 'base64'));
8153
+ if (!page.ok || !overlay.ok) {
8154
+ throw new BrowserError('CAPTURE_FAILED', '标注叠加 PNG 无法解码', { phase: 'annotationOverlay' });
8155
+ }
8156
+ const pixels = renderer.compositeAnnotationPixels(page, overlay);
8157
+ const pngBytes = encodePngRgba(pixels);
8158
+ annotations.composited = true;
8159
+ return { ...shot, pngBytes, base64: pngBytes.toString('base64') };
8160
+ } catch (error) {
8161
+ throw this.#captureError(error, 'annotationOverlay');
8162
+ }
7087
8163
  }
7088
8164
 
7089
8165
  /**
@@ -10646,6 +11722,9 @@ export class Runtime {
10646
11722
  // §11.2): rotate the navigation token and drop the stored document with it, so a late publish
10647
11723
  // can only be refused as `annotation-context-stale`.
10648
11724
  this.#annotationRotate('document');
11725
+ // A new document has no keys held and no known cursor: keeping them would synthesize modifiers
11726
+ // from a page that no longer exists (docs/tools.md, browser_input).
11727
+ this.resetInputState();
10649
11728
  }
10650
11729
 
10651
11730
  #t2ReleaseGroup(objectGroup) {
@@ -12185,7 +13264,8 @@ export class Runtime {
12185
13264
  this.dynamic.session = null;
12186
13265
  this.dynamic.sink = null;
12187
13266
  } catch { /* evidence only */ }
12188
- this.frames.stop();
13267
+ // Teardown must never construct the lazily loaded Web chain (A14): stop only what exists.
13268
+ if (this.#frames) this.#frames.stop();
12189
13269
  // Network collector teardown: release held requests (never leave a paused request behind),
12190
13270
  // stop collecting, drop child-session bookkeeping. Exported evidence files stay on disk.
12191
13271
  try { await this.#netReleaseAllHeld(); } catch { /* ignore */ }
@@ -12194,7 +13274,7 @@ export class Runtime {
12194
13274
  this.netReq.clear();
12195
13275
  this.netHops.clear();
12196
13276
  this.netCacheHits.clear();
12197
- if (this.net) this.net.stop();
13277
+ if (this.#net) this.#net.stop();
12198
13278
  this.network.length = 0;
12199
13279
  this.requests.clear();
12200
13280
  this.url = '';
@@ -12257,6 +13337,14 @@ const SCOPE_HEADER_BINDING = 'x-ui-testing-scope-binding';
12257
13337
  */
12258
13338
  const SCOPE_HEADER_TAB = 'x-ui-testing-scope-tab';
12259
13339
  const SCOPE_TAB_PRIMARY = 'primary';
13340
+ /**
13341
+ * "This runtime is my selected tab and I am visible" (P0-D `browser_annotation`).
13342
+ *
13343
+ * Only the authenticated panel sends it, and only while it is displaying that tab — a selected
13344
+ * internal tab, its DSH seat visible, and the GUI page itself not hidden. It is a claim, not a
13345
+ * permission: the Host only turns it into a short freshness window, and `read` never needs it.
13346
+ */
13347
+ const SCOPE_HEADER_FOREGROUND = 'x-ui-testing-gui-foreground';
12260
13348
  const SCOPE_SPECIAL_VALUES = new Set(['recent', 'unassigned']);
12261
13349
 
12262
13350
  function headerValue(headers, name) {
@@ -12350,10 +13438,36 @@ function scopeHttpStatus(code) {
12350
13438
  case 'SCOPE_NOT_FOUND': return 404;
12351
13439
  case 'SCOPE_UNAVAILABLE': return 503;
12352
13440
  case 'RUNTIME_LIMIT': return 503;
13441
+ // Team/page access model (M2a, ruling §7.3). The HTTP surface must answer with the same code
13442
+ // the tool surface uses, otherwise a refused page write looks like a Host crash over HTTP.
13443
+ case 'TAB_FORBIDDEN': return 403;
13444
+ case 'TAB_NOT_FOUND': return 404;
13445
+ case 'TARGET_GONE': return 410;
13446
+ case 'TAB_CONTROL_CHANGED': return 409;
13447
+ case 'TAB_OWNERSHIP_STALE': return 409;
13448
+ case 'TAB_OBSERVATION_STALE': return 409;
13449
+ case 'TAB_LIMIT': return 409;
13450
+ case 'TAB_IDENTITY_UNAVAILABLE': return 503;
13451
+ case 'OBSERVATION_UNAVAILABLE': return 503;
13452
+ case 'TARGET_NOT_BOUND': return 400;
13453
+ case 'INVALID_ARGUMENT': return 400;
12353
13454
  default: return 500;
12354
13455
  }
12355
13456
  }
12356
13457
 
13458
+ /**
13459
+ * Codes that are *pre-dispatch refusals* and must never become a page's `lastError` (ruling §7.3).
13460
+ *
13461
+ * A page's lastError answers "what went wrong with this page". "You may not write it", "your
13462
+ * revision is stale" and "the budget is full" are answers about the *caller*, and writing them onto
13463
+ * the target would blame the page — and its owner — for somebody else's refusal.
13464
+ */
13465
+ const NON_PAGE_ERROR_CODES = new Set([
13466
+ 'TAB_FORBIDDEN', 'TAB_NOT_FOUND', 'TAB_CONTROL_CHANGED', 'TAB_OWNERSHIP_STALE',
13467
+ 'TAB_OBSERVATION_STALE', 'TAB_IDENTITY_UNAVAILABLE', 'TAB_LIMIT', 'OBSERVATION_UNAVAILABLE',
13468
+ 'RUNTIME_LIMIT', 'TARGET_NOT_BOUND', 'INVALID_ARGUMENT', 'SCOPE_FORBIDDEN', 'SCOPE_NOT_FOUND',
13469
+ ]);
13470
+
12357
13471
  function hostService(ctx, name) {
12358
13472
  try {
12359
13473
  if (ctx && typeof ctx.get === 'function') {
@@ -12456,14 +13570,138 @@ async function resolveSessionScope(ctx, sessionId, { cwd, signal } = {}) {
12456
13570
  return sessionScope(sessionId);
12457
13571
  }
12458
13572
 
13573
+ /**
13574
+ * The fake registry a host *without* `ctx.agents` gets (M2a).
13575
+ *
13576
+ * It can only ever confirm the exact object the host already attached to this execution, and it is
13577
+ * one stable instance so the resolver's `membershipRevision` does not churn merely because a fresh
13578
+ * adapter object was passed on every call. It is not a second identity source: nothing else may
13579
+ * write to it.
13580
+ */
13581
+ const DEGRADED_AGENT_OBJECTS = new Map();
13582
+ const DEGRADED_AGENT_REGISTRY = {
13583
+ get(id) { return DEGRADED_AGENT_OBJECTS.get(String(id)); },
13584
+ };
13585
+
13586
+ /**
13587
+ * One stable stand-in object per Session id for the no-registry host.
13588
+ *
13589
+ * With no Agent registry there is nothing to compare against, so the *domain identity* is the
13590
+ * Session id itself. Minting a fresh object per call would look — correctly, to the resolver — like
13591
+ * a replaced instance, revoke the previous epoch and refuse every later call; callers legitimately
13592
+ * pass copies of `exec.agent` (the acceptance suites spread it to attach a signal), so the copy must
13593
+ * not be able to invalidate the domain.
13594
+ */
13595
+ function degradedAgentIdentity(actorId) {
13596
+ const id = String(actorId);
13597
+ let canonical = DEGRADED_AGENT_OBJECTS.get(id);
13598
+ if (canonical === undefined) {
13599
+ canonical = Object.freeze({ id, degraded: 'host-has-no-agent-registry' });
13600
+ DEGRADED_AGENT_OBJECTS.set(id, canonical);
13601
+ }
13602
+ return canonical;
13603
+ }
13604
+
13605
+ /**
13606
+ * Resolve the calling Agent's trusted Principal.
13607
+ *
13608
+ * Three host states, three different answers — conflating them is what makes a permission model
13609
+ * either useless or unusable:
13610
+ * 1. `ctx.agents` present: identity must be *proved*. The exact object must come back from
13611
+ * `agents.get(id)`, and the Team service (when installed) must return a native membership
13612
+ * whose root is still the exact registry object. `tryMembership === undefined` is
13613
+ * TAB_IDENTITY_UNAVAILABLE — never "this must be a standalone session".
13614
+ * 2. `ctx.agents` present, `ctx.agentTeams` absent (Team plugin not installed): exact-object
13615
+ * proof plus the host's explicit standalone proof from ruling §2.2. The root is the actor, so
13616
+ * it reaches exactly its own pages.
13617
+ * 3. `ctx.agents` absent (the acceptance harness, a non-Team host): there is nothing to compare
13618
+ * against, so the caller degrades to a standalone domain keyed by its own Session id. This is
13619
+ * the *only* allowed degradation and it is deliberately not a wildcard: a missing service
13620
+ * grants "my own domain", never "any page". Note it still cannot be forged — `exec.agent` is
13621
+ * put on the execution by the Host, and no tool argument, header or query can set it.
13622
+ */
13623
+ function resolveToolPrincipal(ctx, exec) {
13624
+ const agent = exec && exec.agent;
13625
+ if (!agent || typeof agent !== 'object' || typeof agent.id !== 'string' || agent.id.trim() === '') {
13626
+ // No subject at all: the documented legacy tool protocol (an old client, or a harness that
13627
+ // drives tools without an exec identity). This is *not* "a session we could not verify" — there
13628
+ // is no session — so it keeps its own isolated domain (`root:unscoped`, where `legacy:default`
13629
+ // already lived) instead of being refused. It can therefore never reach an Agent's page, which
13630
+ // is strictly stronger than the old behaviour where the legacy entry shared the workspace.
13631
+ return resolvePrincipal(degradedAgentIdentity(UNSCOPED_ROOT), {
13632
+ agentRegistry: DEGRADED_AGENT_REGISTRY,
13633
+ teamService: undefined,
13634
+ standaloneProof: () => true,
13635
+ });
13636
+ }
13637
+ const agents = hostService(ctx, 'agents');
13638
+ if (!agents || typeof agents.get !== 'function') {
13639
+ return resolvePrincipal(degradedAgentIdentity(agent.id), {
13640
+ agentRegistry: DEGRADED_AGENT_REGISTRY,
13641
+ teamService: undefined,
13642
+ standaloneProof: () => true,
13643
+ });
13644
+ }
13645
+ const agentTeams = hostService(ctx, 'agentTeams');
13646
+ const hasTeamService = Boolean(agentTeams)
13647
+ && (typeof agentTeams.tryMembership === 'function' || typeof agentTeams.membership === 'function');
13648
+ return resolvePrincipal(agent, {
13649
+ agentRegistry: agents,
13650
+ teamService: hasTeamService ? agentTeams : undefined,
13651
+ // Without a Team service the host itself is the proof of "no team": the actor is its own root.
13652
+ standaloneProof: hasTeamService ? undefined : () => true,
13653
+ });
13654
+ }
13655
+
13656
+ /**
13657
+ * Principal for one **diagnostic dispatcher** probe (task-12), or `null` for every other call.
13658
+ *
13659
+ * `integration/diagnose.js` runs a fixed set of read-only probes against a page it already froze.
13660
+ * That page belongs to a *root* (page groups are keyed by workspace × provider × root), so the probe
13661
+ * has to act inside that root; the frozen entry records its owner, which is exactly the identity the
13662
+ * probe is entitled to use.
13663
+ *
13664
+ * Why this is not an identity backdoor:
13665
+ * - it exists only when `exec.diagnosticScope.entry` is present, which only the diagnose
13666
+ * dispatcher sets — no tool call, header, argument or RPC can attach it;
13667
+ * - the entry was resolved by the same authenticated GUI scope resolution every other panel route
13668
+ * uses (session header, or a binding that must hit an existing runtime), so the panel could
13669
+ * already read that page through `/state|/frame`;
13670
+ * - the probe set and its arguments come from `DISPATCHER_ALLOWED` + `validateProbeArgs`
13671
+ * (expression `1`, snapshot, screenshot, computedStyle, network list): no navigation, no input,
13672
+ * no arbitrary script, and the response is a structural summary, never page content;
13673
+ * - the principal is `source:'panel'`, is never persisted into any long-lived record and cannot be
13674
+ * handed to another call, so it buys exactly one probe round.
13675
+ *
13676
+ * It deliberately does NOT consult `ctx.agents`: a panel may diagnose a session whose agent is not
13677
+ * currently loaded, and the subject here is the panel acting on its own resolved page — not a turn.
13678
+ */
13679
+ function diagnosticPrincipalFor(exec) {
13680
+ const entry = exec?.diagnosticScope?.entry;
13681
+ const owner = entry?.ownership?.ownerSessionId || entry?.rootSessionId || null;
13682
+ if (!owner) return null;
13683
+ return makePrincipal({
13684
+ actorSessionId: String(owner),
13685
+ rootSessionId: String(entry.rootSessionId || owner),
13686
+ role: PRINCIPAL_ROLE.STANDALONE,
13687
+ membershipRevision: 1,
13688
+ executionEpoch: 1,
13689
+ source: PRINCIPAL_SOURCE.PANEL,
13690
+ hostEpoch: defaultTabPrincipalResolver().hostEpoch,
13691
+ });
13692
+ }
13693
+
12459
13694
  /** Tools carry the Agent's session header; no exec identity at all is the old protocol. */
12460
- function toolScopeRequest(exec) {
13695
+ function toolScopeRequest(exec, principal = null) {
12461
13696
  const agent = exec && exec.agent;
12462
13697
  if (!agent || !agent.id) return { mode: 'legacy' };
12463
13698
  const headerCwd = agent.session && agent.session.header && agent.session.header.cwd;
12464
13699
  return {
12465
13700
  mode: 'session',
12466
13701
  sessionId: String(agent.id),
13702
+ // The verified identity decides the page domain. A tool call never gets to name its own root:
13703
+ // it is whatever `integration/tab-principal.js` proved from the exact agent object.
13704
+ ...(principal ? { rootSessionId: principal.rootSessionId, creatorSessionId: principal.actorSessionId } : {}),
12467
13705
  ...(typeof headerCwd === 'string' && headerCwd ? { cwd: headerCwd } : {}),
12468
13706
  ...(exec && exec.signal ? { signal: exec.signal } : {}),
12469
13707
  };
@@ -12473,22 +13711,53 @@ function toolScopeRequest(exec) {
12473
13711
  * Resolve a parsed scope request to { key, scope }. A `binding` must hit an existing runtime
12474
13712
  * (404 otherwise) and is owner-only at the HTTP/WS layer; `recent` never falls back to legacy.
12475
13713
  */
13714
+ /**
13715
+ * Resolve a parsed scope request to `{ key, scope, provider, rootSessionId, creatorSessionId }`.
13716
+ *
13717
+ * The **root** dimension is resolved here and nowhere else (M2a, ruling §7.4):
13718
+ * - a tool call supplies the verified Principal's root (`request.rootSessionId`);
13719
+ * - a bound/recent GUI request inherits the root of the entry it already names (the runtime id is
13720
+ * the only thing it knows, and it was minted inside one group);
13721
+ * - a session-addressed GUI request resolves to *that session's* domain, so a panel bound to a
13722
+ * session still shows exactly the page that session drives — the pre-M5 panel has no verified
13723
+ * actor of its own, and inventing one would be worse than reusing the addressed session id;
13724
+ * - the legacy protocol (no scope headers at all) gets `UNSCOPED_ROOT`, its own domain: an old
13725
+ * client must not silently inherit an Agent's page just because it named the workspace.
13726
+ */
12476
13727
  async function resolveScopeRequest(ctx, registry, request) {
12477
- if (!request || request.mode === 'legacy') return LEGACY_SCOPE;
12478
- if (request.mode === 'unassigned') return UNASSIGNED_SCOPE;
13728
+ if (!request || request.mode === 'legacy') return { ...LEGACY_SCOPE, rootSessionId: UNSCOPED_ROOT };
13729
+ if (request.mode === 'unassigned') {
13730
+ return { ...UNSCOPED_SCOPE_FACTORY(), rootSessionId: String(request.rootSessionId || UNSCOPED_ROOT) };
13731
+ }
12479
13732
  if (request.mode === 'recent') {
12480
- const entry = registry.recentEntry();
12481
- return entry ? { key: entry.key, scope: entry.scope } : UNASSIGNED_SCOPE;
13733
+ // A tool/GUI request that carries a root only follows that root; a rootless (legacy panel)
13734
+ // request keeps the historical global pointer. See RuntimeRegistry#recentEntry.
13735
+ const entry = registry.recentEntry(request.rootSessionId);
13736
+ const rootSessionId = String(request.rootSessionId || entry?.rootSessionId || UNSCOPED_ROOT);
13737
+ return entry
13738
+ ? { key: entry.key, scope: entry.scope, provider: entry.provider, rootSessionId: entry.rootSessionId }
13739
+ : { ...UNSCOPED_SCOPE_FACTORY(), rootSessionId };
12482
13740
  }
12483
13741
  if (request.mode === 'binding') {
12484
13742
  const entry = registry.findByRuntimeId(request.runtimeId);
12485
13743
  if (!entry) throw new BrowserError('SCOPE_NOT_FOUND', 'scope binding 指向的运行时不存在', { scope: 'binding' });
12486
- return { key: entry.key, scope: entry.scope };
13744
+ // `key` is the entry's own page-group key (root + provider + workspace, or a PageTargetKey);
13745
+ // the provider and root travel with it so a bound lookup never has to guess them back.
13746
+ return { key: entry.key, scope: entry.scope, provider: entry.provider, rootSessionId: entry.rootSessionId };
12487
13747
  }
12488
13748
  // Bounded: tools pass exec.signal; HTTP/WS requests get a 5s metadata-read budget.
12489
- return resolveSessionScope(ctx, request.sessionId, { cwd: request.cwd, signal: request.signal || AbortSignal.timeout(5000) });
13749
+ const resolved = await resolveSessionScope(ctx, request.sessionId, { cwd: request.cwd, signal: request.signal || AbortSignal.timeout(5000) });
13750
+ const rootSessionId = String(request.rootSessionId || request.sessionId || UNSCOPED_ROOT);
13751
+ return {
13752
+ ...resolved,
13753
+ rootSessionId,
13754
+ creatorSessionId: String(request.creatorSessionId || request.sessionId || rootSessionId),
13755
+ };
12490
13756
  }
12491
13757
 
13758
+ /** A fresh (mutable) copy of the unassigned scope so a root dimension can be attached. */
13759
+ function UNSCOPED_SCOPE_FACTORY() { return { key: UNASSIGNED_SCOPE.key, scope: UNASSIGNED_SCOPE.scope }; }
13760
+
12492
13761
  function emptyMetrics() {
12493
13762
  return {
12494
13763
  ok: true,
@@ -12534,38 +13803,459 @@ export class RuntimeRegistry {
12534
13803
  this.selectedBySession = new Map();
12535
13804
  /** P0-E/T2: one monotonic ref allocator across all tabs of this registry. */
12536
13805
  this.nextGlobalRef = 1;
12537
- this.host = undefined;
13806
+ /** provider -> BrowserHost. A provider only gets a host once its driver exists (§6). */
13807
+ this.hosts = new Map();
13808
+ /** base scope key -> provider that most recently used it (GUI/session fallback, §16 open item 2). */
13809
+ this.providerByBaseKey = new Map();
12538
13810
  this.reservations = 0;
12539
- this.recentKey = undefined;
13811
+ /** rootSessionId -> entry key of the last real interaction in that root (never cross-root). */
13812
+ this.recentByRoot = new Map();
12540
13813
  this.disposed = false;
12541
13814
  this.sweeping = false;
12542
13815
  this.sweepTimer = setInterval(() => { this.sweep().catch(() => {}); }, Math.max(1000, Math.min(60000, Math.floor(idleMs / 4))));
12543
13816
  this.sweepTimer.unref?.();
12544
13817
  }
12545
13818
 
12546
- get pageCount() { return this.host ? this.host.pageCount : 0; }
13819
+ /**
13820
+ * Pages across **all** provider hosts. The page budget stays one global `maxPages`: splitting
13821
+ * hosts must never multiply the ceiling, so every budget check compares this sum.
13822
+ */
13823
+ get pageCount() {
13824
+ let total = 0;
13825
+ for (const host of this.hosts.values()) total += host.pageCount;
13826
+ return total;
13827
+ }
12547
13828
 
12548
- browserHost() {
12549
- if (!this.host) this.host = new BrowserHost(this.log);
12550
- return this.host;
13829
+ /**
13830
+ * Compatibility accessor for host-idle checks only. It can never hand out another provider's host:
13831
+ * a question about a non-chrome provider must go through `hostFor(provider)`.
13832
+ */
13833
+ get host() { return this.hosts.get(DEFAULT_RUNTIME_PROVIDER); }
13834
+
13835
+ /**
13836
+ * Test/stepper seam for the default provider slot only — retained **solely** for the existing
13837
+ * A12 stub injection in the acceptance suite; never use it on an execution path. It can never install a stub for another
13838
+ * provider: a non-default provider is addressed through `hostFor`/`browserHost` and must have a
13839
+ * real driver behind it.
13840
+ */
13841
+ set host(value) {
13842
+ if (value === undefined || value === null) {
13843
+ this.hosts.delete(DEFAULT_RUNTIME_PROVIDER);
13844
+ return;
13845
+ }
13846
+ this.hosts.set(DEFAULT_RUNTIME_PROVIDER, value);
12551
13847
  }
12552
13848
 
12553
- peek(key) { return this.entries.get(key); }
13849
+ /** Existing host for one provider, or undefined. Never creates, never substitutes chrome. */
13850
+ hostFor(provider = DEFAULT_RUNTIME_PROVIDER) {
13851
+ const id = typeof provider === 'string' && provider.length > 0 ? provider : DEFAULT_RUNTIME_PROVIDER;
13852
+ return this.hosts.get(id);
13853
+ }
12554
13854
 
13855
+ /**
13856
+ * Create (lazily) the host for one provider.
13857
+ *
13858
+ * A provider without a driver must never reach this point: `resolveWebDriver()` refuses
13859
+ * `pending`/`unsupported` providers before any execution, exactly so an "edge" label cannot end up
13860
+ * spawning Chrome (§8).
13861
+ */
13862
+ browserHost(provider = DEFAULT_RUNTIME_PROVIDER) {
13863
+ const id = typeof provider === 'string' && provider.length > 0 ? provider : DEFAULT_RUNTIME_PROVIDER;
13864
+ let host = this.hosts.get(id);
13865
+ if (!host) {
13866
+ host = new BrowserHost(this.log, { provider: id });
13867
+ this.hosts.set(id, host);
13868
+ }
13869
+ return host;
13870
+ }
13871
+
13872
+ /**
13873
+ * Provider identity for one resolved scope request.
13874
+ *
13875
+ * `binding` is reserved for an *explicit* provider (the caller already knows which browser it
13876
+ * means). Everything else resolves to "the provider that last touched this workspace" and is
13877
+ * reported as `recent`, even when only one provider exists — callers never have to guess.
13878
+ */
13879
+ providerFor(resolved) {
13880
+ const explicit = typeof resolved?.provider === 'string' && resolved.provider.length > 0
13881
+ ? resolved.provider
13882
+ : null;
13883
+ if (explicit !== null) return { provider: explicit, providerResolved: 'binding' };
13884
+ // The provider fallback is per (workspace, root): another root's last-used browser must never
13885
+ // become this root's routing decision for the same workspace.
13886
+ const recent = this.providerByBaseKey.get(providerIndexKey(resolved?.key, resolved?.rootSessionId));
13887
+ return {
13888
+ provider: typeof recent === 'string' && recent.length > 0 ? recent : DEFAULT_RUNTIME_PROVIDER,
13889
+ providerResolved: 'recent',
13890
+ };
13891
+ }
13892
+
13893
+ /**
13894
+ * Provider facts for a response, so a fallback never looks like an exact match.
13895
+ * `resolved` (the lookup that produced the entry) wins: `binding` only when the caller named the
13896
+ * provider; every fallback reports `recent`, even with a single provider installed.
13897
+ */
13898
+ providerFacts(entry, resolved = null) {
13899
+ const providerResolved = resolved
13900
+ ? this.providerFor(resolved).providerResolved
13901
+ : (entry?.providerResolved === 'binding' ? 'binding' : 'recent');
13902
+ return {
13903
+ provider: entry?.provider ?? DEFAULT_RUNTIME_PROVIDER,
13904
+ providerResolved,
13905
+ };
13906
+ }
13907
+
13908
+ peek(input) {
13909
+ // Both addressing styles must produce the identical composed key (asserted in the contract suite).
13910
+ if (typeof input === 'string') return this.entries.get(input);
13911
+ if (!input || typeof input !== 'object') return undefined;
13912
+ // A `binding` resolves to an entry key that ALREADY carries the provider prefix
13913
+ // (`<provider>\0<base>`, or `<provider>\0<base>:view:<id>` for a child tab), so composing the
13914
+ // provider again would double it and miss every bound runtime — that silently broke every
13915
+ // panel request addressed by runtime id (child tab /state + /frame, the tab `+` and the `×`).
13916
+ // Exact entry keys win; a bare workspace/scope key still goes through the provider composition.
13917
+ const exact = this.entries.get(String(input.key));
13918
+ if (exact) return exact;
13919
+ // A binding/recent request already carries a composed page-group key: composing it again would
13920
+ // manufacture a second root and silently miss the bound runtime.
13921
+ if (isPageGroupKey(input.key)) return undefined;
13922
+ const { provider } = this.providerFor(input);
13923
+ return this.entries.get(pageGroupKeyFor(provider, input.key, input.rootSessionId));
13924
+ }
13925
+
13926
+ /**
13927
+ * Get (or lazily create) the page group of one (workspace, provider, **root**).
13928
+ *
13929
+ * The root dimension is what makes two independent sessions in one workspace stop sharing a
13930
+ * page: their keys can never collide, so neither can address, evict or write the other's target.
13931
+ * Creation is still cold (no page, no Chromium); it only binds an ownership record so the first
13932
+ * real operation has an owner to authorise against.
13933
+ */
12555
13934
  getOrCreate(resolved) {
12556
13935
  if (this.disposed) throw new BrowserError('SCOPE_UNAVAILABLE', '插件正在卸载');
12557
- const { key, scope } = resolved;
13936
+ const { scope } = resolved;
13937
+ const baseKey = resolved.key;
13938
+ // A `recent` resolution hands back an ALREADY composed entry key (`<provider>\0<base>`), so
13939
+ // composing the provider again would manufacture a second runtime — with its own page — for a
13940
+ // workspace that already has one, on every `scope=recent` request (and it burns the global
13941
+ // page budget until `+` answers TAB_LIMIT for a workspace with two tabs). An exact key wins.
13942
+ const exact = this.entries.get(String(baseKey));
13943
+ if (exact) return exact;
13944
+ const { provider, providerResolved } = this.providerFor(resolved);
13945
+ const rootSessionId = String(resolved.rootSessionId || UNSCOPED_ROOT);
13946
+ // §6/§7.4: an entry is owned by (workspace, provider, root). `baseKey` stays the raw
13947
+ // workspace/scope identity (aggregate counting and config transitions address it that way);
13948
+ // the composed key carries all four dimensions and is never built by hand.
13949
+ const key = pageGroupKeyFor(provider, baseKey, rootSessionId);
12558
13950
  const existing = this.entries.get(key);
12559
13951
  if (existing) return existing;
12560
13952
  if (this.entries.size >= this.maxEntries) this.#evictColdEntry();
12561
- const runtime = new Runtime(this.log, { host: this.browserHost(), origins: this.origins, scope, registry: this, viewerTtlMs: this.viewerTtlMs });
12562
- const entry = { key, scope, runtime, kind: 'primary', viewId: 'primary', primaryKey: key,
13953
+ const runtime = new Runtime(this.log, {
13954
+ host: this.browserHost(provider), origins: this.origins, scope, registry: this, viewerTtlMs: this.viewerTtlMs,
13955
+ });
13956
+ // Ownership is bound at first sight of the group, not at first page: an entry that exists but
13957
+ // has no owner could never be authorised, and "no owner yet" would have to mean "anyone".
13958
+ const creatorSessionId = String(resolved.creatorSessionId || resolved.sessionId || rootSessionId);
13959
+ const access = makePageAccess({ rootSessionId, creatorSessionId });
13960
+ const entry = { key, baseKey, provider, providerResolved, scope, runtime, kind: 'primary',
13961
+ viewId: 'primary', primaryKey: key, rootSessionId,
13962
+ ownership: access.ownership, control: access.control,
12563
13963
  parentRuntimeId: null, parentTabId: null, openEventId: null, createdBy: 'host',
12564
13964
  tabId: 'primary', createdAt: Date.now(), lastUsedAt: Date.now() };
12565
13965
  this.entries.set(key, entry);
13966
+ this.providerByBaseKey.set(providerIndexKey(baseKey, rootSessionId), provider);
12566
13967
  return entry;
12567
13968
  }
12568
13969
 
13970
+ /**
13971
+ * Frozen M1 records of one physical page.
13972
+ *
13973
+ * Every entry (primary slot *and* child) carries its own ownership/control pair: a child page is
13974
+ * a separate page with a separate controller, and sharing the primary's records would let a
13975
+ * takeover of one silently authorise the other.
13976
+ */
13977
+ accessOf(entry) {
13978
+ const target = entry || {};
13979
+ return { ownership: target.ownership ?? null, control: target.control ?? null };
13980
+ }
13981
+
13982
+ /** Replace an entry's records after a transition (M1 returns fresh frozen objects). */
13983
+ setAccess(entry, next) {
13984
+ if (!entry || !next) return;
13985
+ entry.ownership = next.ownership;
13986
+ entry.control = next.control;
13987
+ }
13988
+
13989
+ /**
13990
+ * Authorise one effect against one page, throwing the mapped structured error on refusal.
13991
+ *
13992
+ * This is the only place index.js turns a permission verdict into an error, so every surface
13993
+ * reports the same code for the same reason (and the cross-root answer carries no details).
13994
+ */
13995
+ authorizePage(entry, principal, effect, operation = null, extra = {}) {
13996
+ const { ownership, control } = this.accessOf(entry);
13997
+ const verdict = authorize({ principal, ownership, control, effect, operation, ...extra });
13998
+ if (verdict.allowed) return verdict;
13999
+ throw this.accessError(verdict, operation);
14000
+ }
14001
+
14002
+ accessError(verdict, operation) {
14003
+ if (verdict.notFound || verdict.reason === AUTHORIZE_REASON.CROSS_ROOT) {
14004
+ // Uniform, detail-free: a foreign root learns nothing about whether the page exists.
14005
+ return new BrowserError('TAB_NOT_FOUND', '标签不存在或不属于当前调用者', {
14006
+ hidden: true, operation: operation || null,
14007
+ });
14008
+ }
14009
+ const reason = verdict.reason;
14010
+ const message = reason === AUTHORIZE_REASON.ROLE_FORBIDDEN ? '当前会话无权操作该标签'
14011
+ : reason === AUTHORIZE_REASON.NOT_CONTROLLER ? '该标签当前由其他会话控制(读取最新状态后重新决策,不要自动重试)'
14012
+ : reason === AUTHORIZE_REASON.CONTROL_DRAINING ? '该标签正在被接管,旧版本写入已关闭'
14013
+ : reason === AUTHORIZE_REASON.TARGET_RETIRED ? '该标签已关闭或退休'
14014
+ : reason === AUTHORIZE_REASON.CONTROL_REVISION_STALE ? '标签控制版本已变化'
14015
+ : reason === AUTHORIZE_REASON.OWNERSHIP_REVISION_STALE ? '标签归属版本已变化'
14016
+ : reason === AUTHORIZE_REASON.IDENTITY_UNAVAILABLE ? '当前调用者身份无法证明'
14017
+ : reason === AUTHORIZE_REASON.HOST_MANAGE_FORBIDDEN ? '该操作只能由独立宿主管理通道执行'
14018
+ : '该操作不被允许';
14019
+ return new BrowserError(verdict.code || 'TAB_FORBIDDEN', message, {
14020
+ reason, effect: verdict.effect, resource: verdict.resource, operation: operation || null,
14021
+ });
14022
+ }
14023
+
14024
+ /**
14025
+ * Read projection of one page's long-lived ownership/control (ruling §5: management replies carry
14026
+ * `changed` plus the final controller/versions, so callers never re-derive them from `busy`).
14027
+ */
14028
+ controlProjection(entry) {
14029
+ const { ownership, control } = this.accessOf(entry);
14030
+ return {
14031
+ accessVersion: 1,
14032
+ tabId: entry ? this.#tabId(entry) : null,
14033
+ rootSessionId: entry?.rootSessionId ?? null,
14034
+ creatorSessionId: ownership?.creatorSessionId ?? null,
14035
+ ownerSessionId: ownership?.ownerSessionId ?? null,
14036
+ ownershipRevision: ownership?.ownershipRevision ?? null,
14037
+ controllerSessionId: control?.controllerSessionId ?? null,
14038
+ controlRevision: control?.controlRevision ?? null,
14039
+ state: control?.state ?? null,
14040
+ pendingControllerSessionId: control?.pendingControllerSessionId ?? null,
14041
+ };
14042
+ }
14043
+
14044
+ /**
14045
+ * Undo our own drain marker, leaving the record byte-identical (ruling §5.2 step 6).
14046
+ *
14047
+ * Only the actor that started the drain may cancel it, and only while the record is still the one
14048
+ * we drained (same state + same revision). Anything else is somebody else's move: the controller
14049
+ * must never "quietly" change because our request finished late, so we simply do not touch it.
14050
+ */
14051
+ cancelDrain(entry, principal, expectedControlRevision) {
14052
+ const { ownership, control } = this.accessOf(entry);
14053
+ if (!control || control.state !== CONTROL_STATE.DRAINING) return false;
14054
+ if (control.pendingControllerSessionId !== principal?.actorSessionId) return false;
14055
+ if (control.controlRevision !== expectedControlRevision) return false;
14056
+ try {
14057
+ const cancelled = applyControlTransition({ ownership, control }, {
14058
+ type: CONTROL_EVENT.ACQUIRE_CANCEL, principal, expectedControlRevision,
14059
+ });
14060
+ this.setAccess(entry, cancelled);
14061
+ return true;
14062
+ } catch {
14063
+ // The record moved on (closed/retired/transferred): never invent a state we cannot prove.
14064
+ return false;
14065
+ }
14066
+ }
14067
+
14068
+ /**
14069
+ * Lead takeover: drain, wait for a safe boundary, then commit. Never abort in-flight work.
14070
+ *
14071
+ * Sequence (plan §5.2 / ruling §5.1):
14072
+ * 1. root → role → expected control version, all before anything moves (zero dispatch on refuse)
14073
+ * 2. mark draining: older writes now see a closed gate, while the controller is still the member
14074
+ * 3. wait (bounded, cancellable) until nothing is queued or running — `active`/`inflight` reach 0,
14075
+ * which is host memory only: no CDP, no lease games, no interruption
14076
+ * 4. re-read the version, then commit controller=Lead with controlRevision+1 — or, on
14077
+ * timeout/cancel/race, revoke the drain marker and report BUSY/ABORTED/CHANGED with the
14078
+ * controller exactly where it was
14079
+ *
14080
+ * @returns {{changed:boolean, ownership:object, control:object, state:string, waitedMs:number}}
14081
+ */
14082
+ async acquireControl(entry, principal, {
14083
+ expectedControlRevision, timeoutMs = TAB_ACQUIRE_TIMEOUT_DEFAULT_MS, signal = undefined,
14084
+ } = {}) {
14085
+ this.authorizePage(entry, principal,
14086
+ { effect: EFFECT.MANAGE, resource: RESOURCE.PAGE, targetRequired: true },
14087
+ 'browser_tabs.acquire', { expectedControlRevision });
14088
+ const runtime = entry.runtime;
14089
+ const startedAt = Date.now();
14090
+ const drained = applyControlTransition(this.accessOf(entry), {
14091
+ type: CONTROL_EVENT.ACQUIRE, principal, expectedControlRevision,
14092
+ });
14093
+ this.setAccess(entry, drained);
14094
+ if (!drained.changed) {
14095
+ return { changed: false, ownership: entry.ownership, control: entry.control, state: entry.control.state, waitedMs: 0 };
14096
+ }
14097
+
14098
+ let outcome = 'settled';
14099
+ try {
14100
+ const deadline = startedAt + timeoutMs;
14101
+ for (;;) {
14102
+ if (runtime.active === 0 && runtime.inflight === 0) break;
14103
+ const remaining = deadline - Date.now();
14104
+ if (remaining <= 0) { outcome = 'timeout'; break; }
14105
+ await this.#waitNextPoll(Math.min(TAB_DRAIN_POLL_MS, remaining), signal);
14106
+ }
14107
+ } catch (error) {
14108
+ outcome = asBrowserError(error).code === 'ABORTED' ? 'aborted' : 'failed';
14109
+ }
14110
+
14111
+ if (outcome === 'settled') {
14112
+ const current = this.accessOf(entry);
14113
+ const stillOurs = current.control
14114
+ && current.control.state === CONTROL_STATE.DRAINING
14115
+ && current.control.controlRevision === expectedControlRevision
14116
+ && current.control.pendingControllerSessionId === principal.actorSessionId;
14117
+ if (stillOurs) {
14118
+ const committed = applyControlTransition(current, {
14119
+ type: CONTROL_EVENT.ACQUIRE_COMMIT, principal, expectedControlRevision,
14120
+ });
14121
+ this.setAccess(entry, committed);
14122
+ // The drain proved there is no in-flight work, so the member's stale "recently wrote" lease
14123
+ // must not block the new controller's first write.
14124
+ this.clearForeignLease(entry, principal.actorSessionId);
14125
+ return {
14126
+ changed: committed.changed, ownership: entry.ownership, control: entry.control,
14127
+ state: entry.control.state, waitedMs: Date.now() - startedAt,
14128
+ };
14129
+ }
14130
+ outcome = 'raced';
14131
+ }
14132
+
14133
+ this.cancelDrain(entry, principal, expectedControlRevision);
14134
+ const waitedMs = Date.now() - startedAt;
14135
+ if (outcome === 'aborted') {
14136
+ throw new BrowserError('ABORTED', '接管已取消:controller 与 controlRevision 均未改变', {
14137
+ reason: 'acquire-cancelled', controlRevision: entry.control?.controlRevision ?? null, waitedMs,
14138
+ });
14139
+ }
14140
+ if (outcome === 'raced') {
14141
+ throw new BrowserError('TAB_CONTROL_CHANGED', '接管期间控制版本已变化(draining 已撤销)', {
14142
+ reason: 'control-revision-stale', controlRevision: entry.control?.controlRevision ?? null, waitedMs,
14143
+ });
14144
+ }
14145
+ if (outcome === 'failed') {
14146
+ throw new BrowserError('TAB_CONTROL_CHANGED', '接管等待失败(draining 已撤销)', {
14147
+ reason: 'drain-failed', controlRevision: entry.control?.controlRevision ?? null, waitedMs,
14148
+ });
14149
+ }
14150
+ // BUSY, never "we finished it in the background": the drain marker is already gone.
14151
+ throw new BrowserError('BUSY', `接管在 ${timeoutMs}ms 内未等到安全边界,已撤销 draining,controller 未改变`, {
14152
+ reason: 'drain-timeout', timeoutMs, waitedMs, controlRevision: entry.control?.controlRevision ?? null,
14153
+ });
14154
+ }
14155
+
14156
+ /**
14157
+ * A completed control transition voids the previous writer's short lease.
14158
+ *
14159
+ * The 15s lease only means "this session wrote recently"; it is not an ACL. Once control has moved
14160
+ * — and the drain certified there is no in-flight work — keeping the old lease would refuse the new
14161
+ * controller's very first write with BUSY, making the takeover useless for the 15s that follow.
14162
+ */
14163
+ clearForeignLease(entry, keepSessionId) {
14164
+ const runtime = entry?.runtime;
14165
+ if (!runtime || !runtime.lease) return false;
14166
+ if (runtime.lease.sessionId === keepSessionId) return false;
14167
+ runtime.release(runtime.lease.sessionId);
14168
+ return true;
14169
+ }
14170
+
14171
+ /** Lead gives control back to the owner (controlRevision+1, ownership untouched). */
14172
+ releaseControl(entry, principal, { expectedControlRevision } = {}) {
14173
+ this.authorizePage(entry, principal,
14174
+ { effect: EFFECT.MANAGE, resource: RESOURCE.PAGE, targetRequired: true },
14175
+ 'browser_tabs.release', { expectedControlRevision });
14176
+ const next = applyControlTransition(this.accessOf(entry), {
14177
+ type: CONTROL_EVENT.RELEASE, principal, expectedControlRevision,
14178
+ });
14179
+ this.setAccess(entry, next);
14180
+ this.clearForeignLease(entry, entry.ownership.ownerSessionId);
14181
+ return { changed: next.changed, ownership: entry.ownership, control: entry.control, state: entry.control.state };
14182
+ }
14183
+
14184
+ /**
14185
+ * Lead transfers ownership+control to another valid member of this root.
14186
+ *
14187
+ * The roster is re-read from the native service **before** the metadata commit (ruling §2.2):
14188
+ * a cached membership list could already be stale, and handing a page to somebody who left the
14189
+ * team is exactly the kind of grant that must not survive on stale data.
14190
+ */
14191
+ transferControl(entry, principal, { toSessionId, expectedControlRevision, expectedOwnershipRevision } = {}) {
14192
+ this.authorizePage(entry, principal,
14193
+ { effect: EFFECT.MANAGE, resource: RESOURCE.PAGE, targetRequired: true },
14194
+ 'browser_tabs.transfer', { expectedControlRevision, expectedOwnershipRevision });
14195
+ const row = this.assertTransferTarget(principal, toSessionId);
14196
+ const next = applyControlTransition(this.accessOf(entry), {
14197
+ type: CONTROL_EVENT.TRANSFER, principal, toSessionId: String(toSessionId).trim(),
14198
+ expectedControlRevision, expectedOwnershipRevision,
14199
+ });
14200
+ this.setAccess(entry, next);
14201
+ this.clearForeignLease(entry, String(toSessionId).trim());
14202
+ return {
14203
+ changed: next.changed, ownership: entry.ownership, control: entry.control,
14204
+ state: entry.control.state, to: row,
14205
+ };
14206
+ }
14207
+
14208
+ /**
14209
+ * Validate a transfer target against the **fresh** native roster.
14210
+ *
14211
+ * A transfer is a grant, so "cannot prove membership" must never read as "allowed": without a
14212
+ * roster service the answer is TAB_IDENTITY_UNAVAILABLE, an unknown row is TAB_FORBIDDEN, and a
14213
+ * failed/provisioning row is TAB_FORBIDDEN too. Self-transfer is an argument error.
14214
+ */
14215
+ assertTransferTarget(principal, toSessionId) {
14216
+ const to = typeof toSessionId === 'string' ? toSessionId.trim() : '';
14217
+ if (!to || to.length > 128) {
14218
+ throw new BrowserError('INVALID_ARGUMENT', 'transfer.toSessionId 必须是 1–128 字符', { field: 'toSessionId' });
14219
+ }
14220
+ if (to === principal?.actorSessionId) {
14221
+ throw new BrowserError('INVALID_ARGUMENT', 'transfer 不能转交给自己', { field: 'toSessionId' });
14222
+ }
14223
+ const teams = hostService(this.ctx, 'agentTeams');
14224
+ const agents = hostService(this.ctx, 'agents');
14225
+ if (!teams || typeof teams.listMembers !== 'function' || !agents || typeof agents.get !== 'function') {
14226
+ throw new BrowserError('TAB_IDENTITY_UNAVAILABLE', 'transfer 需要原生 roster 才能证明目标成员身份', {
14227
+ reason: 'transfer-needs-roster',
14228
+ });
14229
+ }
14230
+ const rootAgent = agents.get(principal.rootSessionId);
14231
+ if (!rootAgent) {
14232
+ throw new BrowserError('TAB_IDENTITY_UNAVAILABLE', 'root 未加载,无法核对 roster', {
14233
+ reason: 'root-not-loaded', rootSessionId: principal.rootSessionId,
14234
+ });
14235
+ }
14236
+ let rows;
14237
+ try {
14238
+ rows = teams.listMembers(rootAgent);
14239
+ } catch {
14240
+ throw new BrowserError('TAB_IDENTITY_UNAVAILABLE', 'roster 读取失败', { reason: 'roster-failed' });
14241
+ }
14242
+ if (!Array.isArray(rows)) {
14243
+ throw new BrowserError('TAB_IDENTITY_UNAVAILABLE', 'roster 形状异常', { reason: 'roster-malformed' });
14244
+ }
14245
+ const row = rows.find((candidate) => candidate && candidate.id === to);
14246
+ if (!row) {
14247
+ throw new BrowserError('TAB_FORBIDDEN', 'transfer 目标不是本 root 的成员', {
14248
+ reason: 'not-a-member', toSessionId: to,
14249
+ });
14250
+ }
14251
+ if (row.status === 'failed' || row.status === 'provisioning') {
14252
+ throw new BrowserError('TAB_FORBIDDEN', 'transfer 目标成员当前无有效身份', {
14253
+ reason: 'member-not-active', toSessionId: to, status: row.status,
14254
+ });
14255
+ }
14256
+ return { id: row.id, role: row.role ?? null, status: row.status ?? null };
14257
+ }
14258
+
12569
14259
  findByRuntimeId(runtimeId) {
12570
14260
  if (typeof runtimeId !== 'string' || !runtimeId) return undefined;
12571
14261
  for (const entry of this.entries.values()) if (entry.runtime.runtimeId === runtimeId) return entry;
@@ -12584,9 +14274,27 @@ export class RuntimeRegistry {
12584
14274
  return this.entries.get(key) || (entry.kind === 'primary' ? entry : undefined);
12585
14275
  }
12586
14276
 
12587
- popupCount(primaryKey) {
14277
+ /**
14278
+ * Child pages of one **(workspace, provider)** — deliberately *not* of one root.
14279
+ *
14280
+ * The product budget is "4 pages per workspace/provider" (1 primary + 3 children). Counting per
14281
+ * page-group key would turn that into "3 children per root", so two roots in one workspace could
14282
+ * hold 8 pages there and the per-workspace ceiling would be multiplied by the root count. The
14283
+ * global `maxPages` ceiling stays one number across every provider and root (a later quota slice
14284
+ * rewrites the full reservation algorithm; this is the minimal correction that keeps today's
14285
+ * numbers true).
14286
+ */
14287
+ popupCount(primaryEntry) {
14288
+ if (!primaryEntry) return 0;
14289
+ const workspaceKey = primaryEntry.baseKey;
14290
+ const provider = primaryEntry.provider ?? DEFAULT_RUNTIME_PROVIDER;
12588
14291
  let count = 0;
12589
- for (const entry of this.entries.values()) if (entry.kind === 'popup' && entry.primaryKey === primaryKey) count += 1;
14292
+ for (const entry of this.entries.values()) {
14293
+ if (entry.kind !== 'popup') continue;
14294
+ if (entry.baseKey !== workspaceKey) continue;
14295
+ if ((entry.provider ?? DEFAULT_RUNTIME_PROVIDER) !== provider) continue;
14296
+ count += 1;
14297
+ }
12590
14298
  return count;
12591
14299
  }
12592
14300
 
@@ -12598,18 +14306,23 @@ export class RuntimeRegistry {
12598
14306
  /** Reserve one popup slot without ever evicting a parent or another workspace. */
12599
14307
  reservePopupSlot(primaryEntry) {
12600
14308
  if (this.disposed) throw new BrowserError('SCOPE_UNAVAILABLE', '插件正在卸载');
12601
- if (!primaryEntry) throw new BrowserError('TAB_LIMIT', '找不到 popup 所属的 primary 工作区');
12602
- if (this.popupCount(primaryEntry.key) >= this.maxPopupChildren) {
14309
+ // A missing page group is a scope-resolution failure, never a tab-count limit: answering
14310
+ // TAB_LIMIT here made the panel tell a user with two tabs "最多 4 个标签".
14311
+ if (!primaryEntry) throw new BrowserError('SCOPE_NOT_FOUND', '找不到 popup 所属的 primary 工作区', { scope: 'binding' });
14312
+ if (this.popupCount(primaryEntry) >= this.maxPopupChildren) {
12603
14313
  throw new BrowserError('TAB_LIMIT', `每个工作区最多 ${this.maxPopupChildren} 个 popup 子页`, {
12604
- maxPopupChildren: this.maxPopupChildren, popupCount: this.popupCount(primaryEntry.key),
14314
+ dimension: 'workspace-provider',
14315
+ maxPopupChildren: this.maxPopupChildren, popupCount: this.popupCount(primaryEntry),
12605
14316
  });
12606
14317
  }
12607
- const host = this.browserHost();
14318
+ const provider = primaryEntry.provider ?? DEFAULT_RUNTIME_PROVIDER;
14319
+ this.browserHost(provider);
12608
14320
  this.reservations += 1;
12609
- if (host.pageCount + this.reservations > this.maxPages) {
14321
+ // Cross-provider sum against the *same* ceiling: splitting hosts must not widen the budget.
14322
+ if (this.pageCount + this.reservations > this.maxPages) {
12610
14323
  this.reservations = Math.max(0, this.reservations - 1);
12611
14324
  throw new BrowserError('TAB_LIMIT', `popup 超出全局 ${this.maxPages} 页上限`, {
12612
- maxPages: this.maxPages, pageCount: host.pageCount, reservations: this.reservations,
14325
+ maxPages: this.maxPages, pageCount: this.pageCount, reservations: this.reservations,
12613
14326
  });
12614
14327
  }
12615
14328
  let released = false;
@@ -12620,11 +14333,21 @@ export class RuntimeRegistry {
12620
14333
  };
12621
14334
  }
12622
14335
 
12623
- addPopupEntry({ parentEntry, runtime, viewId, openEventId, sourcePanelId }) {
14336
+ addPopupEntry({ parentEntry, runtime, viewId, openEventId, sourcePanelId, creatorSessionId = null }) {
12624
14337
  const primaryKey = parentEntry.primaryKey || parentEntry.key;
12625
- const key = `${parentEntry.key}:view:${viewId}`;
14338
+ // A child is its own target inside the same page group (ruling §7.4: PageTargetKey wraps the
14339
+ // group key, never a hand-built colon suffix on a composed key).
14340
+ const key = pageTargetKeyFor(primaryKey, viewId);
14341
+ const access = makePageAccess({
14342
+ rootSessionId: parentEntry.rootSessionId,
14343
+ creatorSessionId: creatorSessionId || parentEntry.rootSessionId,
14344
+ });
12626
14345
  const entry = {
12627
- key, scope: parentEntry.scope, runtime, kind: 'popup', viewId, tabId: viewId,
14346
+ key, baseKey: parentEntry.baseKey ?? key, provider: parentEntry.provider ?? DEFAULT_RUNTIME_PROVIDER,
14347
+ providerResolved: parentEntry.providerResolved ?? 'recent',
14348
+ scope: parentEntry.scope, runtime, kind: 'popup', viewId, tabId: viewId,
14349
+ rootSessionId: parentEntry.rootSessionId,
14350
+ ownership: access.ownership, control: access.control,
12628
14351
  primaryKey, parentKey: parentEntry.key, parentRuntimeId: parentEntry.runtime.runtimeId,
12629
14352
  parentTabId: parentEntry.tabId || (parentEntry.kind === 'primary' ? 'primary' : parentEntry.viewId),
12630
14353
  openEventId: openEventId || null, sourcePanelId: sourcePanelId || null,
@@ -12751,13 +14474,59 @@ export class RuntimeRegistry {
12751
14474
  && entry.primaryKey === primaryKey && (entry.tabId === tabId || entry.viewId === tabId));
12752
14475
  }
12753
14476
 
14477
+ /**
14478
+ * Selection identity: one row per (page group, actor). Both dimensions matter — a selection is
14479
+ * the *actor's* view of *their* group, so it can never be inherited across roots or actors.
14480
+ */
12754
14481
  #selectionKey(primaryEntry, sessionId) {
12755
- return String(primaryEntry.key) + '\u0000' + String(sessionId || 'anonymous');
14482
+ return makeActorSelectionKey({
14483
+ groupKey: String(primaryEntry.key),
14484
+ actorSessionId: String(sessionId || 'anonymous'),
14485
+ });
12756
14486
  }
12757
14487
 
14488
+ /**
14489
+ * One actor's two selections inside one page group.
14490
+ *
14491
+ * `tabId` is the **work** selection and stays the compatibility projection (`selection` in every
14492
+ * response). `observationTabId` is the read-only selection: it is set only by an explicit
14493
+ * `select(mode:'observe')` and never by a write, so watching a teammate's page cannot redirect
14494
+ * either side's writes.
14495
+ */
12758
14496
  selectionFor(primaryEntry, sessionId) {
12759
14497
  const key = this.#selectionKey(primaryEntry, sessionId);
12760
- return this.selectedBySession.get(key) || { tabId: 'primary', revision: 0, resetReason: null, pendingReset: false };
14498
+ const stored = this.selectedBySession.get(key);
14499
+ if (stored) return stored;
14500
+ const actor = String(sessionId || 'anonymous');
14501
+ return {
14502
+ // No record yet: default to the page this actor *already owns* in this group — its primary if
14503
+ // it owns the primary, otherwise a child it created — and to "no work page" when it owns
14504
+ // nothing. Defaulting a teammate to the Lead's primary turned "you have no page yet"
14505
+ // (TARGET_NOT_BOUND, "open one") into "you may not touch this page" (TAB_FORBIDDEN), which is
14506
+ // both the wrong code and a misleading story.
14507
+ tabId: this.#ownWorkTabId(primaryEntry, actor),
14508
+ observationTabId: null, revision: 0, resetReason: null, pendingReset: false,
14509
+ groupKey: String(primaryEntry.key), actorSessionId: actor,
14510
+ rootSessionId: primaryEntry.rootSessionId ?? rootOfPageGroupKey(primaryEntry.key),
14511
+ workspaceKey: primaryEntry.baseKey ?? null, provider: primaryEntry.provider ?? null,
14512
+ };
14513
+ }
14514
+
14515
+ /**
14516
+ * The tab id this actor already owns in the group, or `null` when it owns none.
14517
+ *
14518
+ * Ownership — not the short lease and not "the group has a primary" — decides it: a teammate that
14519
+ * never opened a page must have no work selection at all rather than borrow somebody else's.
14520
+ */
14521
+ #ownWorkTabId(primaryEntry, actor) {
14522
+ if (!primaryEntry) return null;
14523
+ const primary = this.entries.get(primaryEntry.primaryKey || primaryEntry.key) || primaryEntry;
14524
+ if (primary?.ownership?.ownerSessionId === actor) return this.#tabId(primary);
14525
+ for (const entry of this.tabEntries(primaryEntry)) {
14526
+ if (entry.kind !== 'popup') continue;
14527
+ if (entry.ownership?.ownerSessionId === actor) return this.#tabId(entry);
14528
+ }
14529
+ return null;
12761
14530
  }
12762
14531
 
12763
14532
  /** Confirm a pending tab retirement (browser_tabs.current / explicit select). */
@@ -12769,49 +14538,97 @@ export class RuntimeRegistry {
12769
14538
  if (!record.pendingReset) return { ...record, confirmedResetReason: null };
12770
14539
  record.pendingReset = false;
12771
14540
  record.resetReason = null;
12772
- record.tabId = 'primary';
14541
+ // Back to the page this actor actually owns (their own primary, a child they created, or
14542
+ // nothing) — never blindly to "primary", which may belong to the Lead.
14543
+ record.tabId = this.#ownWorkTabId(primaryEntry, String(sessionId || 'anonymous'));
14544
+ record.observationTabId = null;
14545
+ delete record.resetTabId;
12773
14546
  record.revision += 1;
12774
14547
  this.selectedBySession.set(key, record);
12775
14548
  return { ...record, confirmedResetReason: previous };
12776
14549
  }
12777
14550
 
12778
- /** Resolve the page tool target for one Agent session; pending retirement is a zero-dispatch error. */
12779
- resolveSessionTab(primaryEntry, sessionId) {
14551
+ /**
14552
+ * Resolve the page tool target for one Agent session.
14553
+ *
14554
+ * Reads use the observation selection when the caller set one, writes always use the work
14555
+ * selection (plan §5: "只读默认 observationSelection,未设再取 workSelection;写仅取 workSelection").
14556
+ * A pending retirement is a zero-dispatch error: the caller must confirm instead of being silently
14557
+ * re-pointed at another tab.
14558
+ */
14559
+ resolveSessionTab(primaryEntry, sessionId, { mode = 'work' } = {}) {
12780
14560
  const record = this.selectionFor(primaryEntry, sessionId);
12781
14561
  if (record.pendingReset) {
12782
14562
  throw new BrowserError('TARGET_NOT_FOUND', '选中的标签已关闭或退休,需用 browser_tabs.current 或 select 确认', {
12783
14563
  reason: record.resetReason || 'selected-tab-closed', tabId: record.tabId, fallbackTabId: 'primary',
12784
14564
  });
12785
14565
  }
12786
- if (record.tabId === 'primary') return { entry: this.entries.get(primaryEntry.key) || primaryEntry, selection: record };
12787
- const entry = this.tabEntry(primaryEntry, record.tabId);
14566
+ const tabId = mode === 'read' ? (record.observationTabId || record.tabId) : record.tabId;
14567
+ if (tabId === null || tabId === undefined) {
14568
+ // "You have no page of your own yet" is a binding answer, not a permission one (plan §6.1).
14569
+ throw new BrowserError('TARGET_NOT_BOUND', '当前会话还没有自己的工作页:先用 browser_tabs open 新建,或 select 自己拥有的标签', {
14570
+ reason: 'no-work-page', fallbackTabId: null, hint: 'browser_tabs.open', mode,
14571
+ });
14572
+ }
14573
+ if (tabId === 'primary') return { entry: this.entries.get(primaryEntry.key) || primaryEntry, selection: record };
14574
+ const entry = this.tabEntry(primaryEntry, tabId);
12788
14575
  if (!entry) {
12789
14576
  const key = this.#selectionKey(primaryEntry, sessionId);
12790
- const next = { ...record, tabId: 'primary', resetReason: 'tab-closed', pendingReset: true, revision: record.revision + 1 };
14577
+ const next = {
14578
+ ...record, tabId: null, observationTabId: null, resetTabId: tabId,
14579
+ resetReason: 'tab-closed', pendingReset: true, revision: record.revision + 1,
14580
+ };
12791
14581
  this.selectedBySession.set(key, next);
12792
14582
  throw new BrowserError('TARGET_NOT_FOUND', '选中的标签已被关闭', {
12793
- reason: 'selected-tab-closed', tabId: record.tabId, fallbackTabId: 'primary',
14583
+ reason: 'selected-tab-closed', tabId, fallbackTabId: 'primary',
12794
14584
  });
12795
14585
  }
12796
14586
  return { entry, selection: record };
12797
14587
  }
12798
14588
 
12799
- selectTab(primaryEntry, sessionId, tabId) {
14589
+ /**
14590
+ * Select a tab for one actor, in two independent modes (plan §5, ruling §7.5).
14591
+ *
14592
+ * `mode:'work'` needs the right to *write* that page (owner/controller, or this root's Lead);
14593
+ * `mode:'observe'` needs only the right to *read* it. Selecting never claims the short lease and
14594
+ * never activates the page: a selection is a routing preference, and the old "select claims, so
14595
+ * everyone else is BUSY" behaviour was the very thing that made two sessions fight over one tab.
14596
+ * A refused selection leaves the caller's existing selection untouched.
14597
+ */
14598
+ selectTab(primaryEntry, sessionId, tabId, { mode = 'work', principal = null } = {}) {
12800
14599
  if (!sessionId || sessionId === 'anonymous') {
12801
14600
  throw new BrowserError('UNSUPPORTED', 'browser_tabs 需要真实 Agent 会话身份', { reason: 'agent-session-required' });
12802
14601
  }
12803
14602
  const target = this.tabEntry(primaryEntry, tabId);
12804
14603
  if (!target) throw new BrowserError('TAB_NOT_FOUND', '标签不存在或不属于当前 workspace', { tabId, fallbackTabId: 'primary' });
12805
- if (target !== primaryEntry && target.scope !== primaryEntry.scope) {
12806
- throw new BrowserError('TAB_FORBIDDEN', '标签属于其他 workspace', { tabId });
14604
+ // `tabEntry` already filters by page group, so a foreign root's tabId cannot resolve here at
14605
+ // all; the ACL below is about role/control *inside* the group.
14606
+ if (principal) {
14607
+ if (mode === 'observe') {
14608
+ this.authorizePage(target, principal, { effect: EFFECT.OBSERVE, resource: RESOURCE.PAGE, targetRequired: true }, 'browser_tabs.select');
14609
+ } else {
14610
+ const verdict = authorize({
14611
+ principal, ...this.accessOf(target),
14612
+ effect: { effect: EFFECT.WRITE, resource: RESOURCE.PAGE, targetRequired: true },
14613
+ operation: 'browser_tabs.select',
14614
+ });
14615
+ // A Lead who may *manage* the page may also aim work at it (the takeover happens on the
14616
+ // first write, M4); a teammate who may not write it may not work-select it either.
14617
+ const manageable = verdict.allowed || authorize({
14618
+ principal, ...this.accessOf(target),
14619
+ effect: { effect: EFFECT.MANAGE, resource: RESOURCE.PAGE, targetRequired: true },
14620
+ operation: 'browser_tabs.acquire',
14621
+ }).allowed;
14622
+ if (!manageable) throw this.accessError(verdict, 'browser_tabs.select');
14623
+ }
12807
14624
  }
12808
14625
  const record = this.selectionFor(primaryEntry, sessionId);
12809
- const previous = this.tabEntry(primaryEntry, record.tabId) || primaryEntry;
12810
- target.runtime.claim(sessionId);
12811
- if (previous !== target) previous.runtime.release(sessionId);
12812
14626
  const next = {
12813
- tabId: target.tabId || target.viewId || 'primary', revision: (record.revision || 0) + 1,
12814
- resetReason: null, pendingReset: false,
14627
+ ...record,
14628
+ ...(mode === 'observe'
14629
+ ? { observationTabId: target.tabId || target.viewId || 'primary' }
14630
+ : { tabId: target.tabId || target.viewId || 'primary', pendingReset: false, resetReason: null }),
14631
+ revision: (record.revision || 0) + 1,
12815
14632
  };
12816
14633
  this.selectedBySession.set(this.#selectionKey(primaryEntry, sessionId), next);
12817
14634
  return { entry: target, selection: next };
@@ -12834,7 +14651,7 @@ export class RuntimeRegistry {
12834
14651
  }
12835
14652
 
12836
14653
  /** One list row. Read-only: no ensure, no queue, no lease, no CDP. */
12837
- #tabView(entry, selection, sessionId, now = Date.now()) {
14654
+ #tabView(entry, selection, sessionId, now = Date.now(), principal = null) {
12838
14655
  const runtime = entry.runtime;
12839
14656
  const owner = runtime.lease && runtime.hasLease() ? runtime.lease.sessionId : null;
12840
14657
  const tabId = this.#tabId(entry);
@@ -12848,7 +14665,8 @@ export class RuntimeRegistry {
12848
14665
  status: runtime.page && runtime.cdp && !runtime.cdp.closed ? 'ready' : 'closed',
12849
14666
  selected: selection.tabId === tabId,
12850
14667
  busy: Boolean(owner), owner: owner === sessionId ? 'self' : (owner ? 'other' : null),
12851
- reset: selection.pendingReset && selection.tabId === tabId ? selection.resetReason || null : null,
14668
+ reset: selection.pendingReset && (selection.resetTabId || selection.tabId) === tabId
14669
+ ? selection.resetReason || null : null,
12852
14670
  // P0-E lifecycle projection: where the tab came from, who opened it and how long it lives.
12853
14671
  source: this.#tabSource(entry),
12854
14672
  parentTabId: entry.kind === 'popup' ? (entry.parentTabId || null) : null,
@@ -12858,15 +14676,28 @@ export class RuntimeRegistry {
12858
14676
  idleRemainingMs: Number.isFinite(this.idleMs) && Number.isFinite(lastUsedAt)
12859
14677
  ? Math.max(0, this.idleMs - Math.max(0, now - lastUsedAt)) : null,
12860
14678
  // `manageable` = this Agent session may act on the tab now (not another session's lease).
12861
- // Closing additionally needs the caller's own lease and no in-flight operation.
14679
+ // Kept ONLY as the legacy short-lease projection (ruling §5): it never expressed an ACL, and
14680
+ // new callers must read `permissions` instead.
12862
14681
  manageable: owner === null || owner === sessionId,
14682
+ // Long-lived ownership/control projection (ruling §5 "每页返回", all of it root-scoped: the
14683
+ // rows only ever describe pages inside the caller's own page group).
14684
+ accessVersion: 1,
14685
+ rootSessionId: entry.rootSessionId ?? null,
14686
+ creatorSessionId: entry.ownership?.creatorSessionId ?? null,
14687
+ ownerSessionId: entry.ownership?.ownerSessionId ?? null,
14688
+ controllerSessionId: entry.control?.controllerSessionId ?? null,
14689
+ ownershipRevision: entry.ownership?.ownershipRevision ?? null,
14690
+ controlRevision: entry.control?.controlRevision ?? null,
14691
+ accessState: entry.control?.state ?? null,
14692
+ permissions: principal ? projectPermissions({ principal, ...this.accessOf(entry) }) : null,
12863
14693
  };
12864
14694
  }
12865
14695
 
12866
- listTabs(primaryEntry, sessionId) {
14696
+ listTabs(primaryEntry, sessionId, principal = null) {
12867
14697
  const selection = this.selectionFor(primaryEntry, sessionId);
12868
14698
  const now = Date.now();
12869
- return this.tabEntries(primaryEntry).map((entry) => this.#tabView(entry, selection, sessionId, now));
14699
+ return this.tabEntries(primaryEntry)
14700
+ .map((entry) => this.#tabView(entry, selection, sessionId, now, principal));
12870
14701
  }
12871
14702
 
12872
14703
  /**
@@ -12944,10 +14775,16 @@ export class RuntimeRegistry {
12944
14775
  }
12945
14776
 
12946
14777
  /** P0-E explicit open: create about:blank child and select it for this session. */
12947
- async openTab(primaryEntry, sessionId, source = 'agent', { select = true } = {}) {
14778
+ async openTab(primaryEntry, sessionId, source = 'agent', { select = true, principal = null } = {}) {
12948
14779
  if (!sessionId || sessionId === 'anonymous') {
12949
14780
  throw new BrowserError('UNSUPPORTED', 'browser_tabs 需要真实 Agent 会话身份', { reason: 'agent-session-required' });
12950
14781
  }
14782
+ // Creation authorisation runs *before* the reservation and before any CDP: a caller that may
14783
+ // not create pages in this group must not even reach `createTarget`.
14784
+ if (principal) {
14785
+ this.authorizePage(primaryEntry, principal,
14786
+ { effect: EFFECT.WRITE, resource: RESOURCE.PAGE, targetRequired: false }, 'browser_tabs.open');
14787
+ }
12951
14788
  const release = this.reservePopupSlot(primaryEntry);
12952
14789
  const viewId = randomUUID().replace(/-/gu, '').slice(0, 20);
12953
14790
  const primary = this.entries.get(primaryEntry.primaryKey || primaryEntry.key) || primaryEntry;
@@ -12956,14 +14793,29 @@ export class RuntimeRegistry {
12956
14793
  kind: 'popup', parentRuntimeId: primary.runtime.runtimeId, viewId, parentKey: primary.key,
12957
14794
  openEventId: null, sourcePanelId: null, createdBy: source, inheritView: primary.runtime.view,
12958
14795
  });
12959
- const entry = this.addPopupEntry({ parentEntry: primary, runtime: child, viewId, openEventId: null, sourcePanelId: null });
14796
+ // Who owns a page the *panel* opened? The session the panel is showing, not the panel's own
14797
+ // synthetic actor: the panel is a view of that session's domain, and a tab opened from it must
14798
+ // stay drivable by that session. A tool-driven open is owned by the calling actor.
14799
+ const creatorSessionId = principal ? sessionId : (primary.ownership?.ownerSessionId || sessionId);
14800
+ const entry = this.addPopupEntry({
14801
+ parentEntry: primary, runtime: child, viewId, openEventId: null, sourcePanelId: null,
14802
+ creatorSessionId,
14803
+ });
12960
14804
  entry.status = 'initializing';
12961
14805
  try {
12962
14806
  const page = await this.browserHost().createPage(child, AbortSignal.timeout(10000));
12963
14807
  await child.adoptPage(page, { signal: AbortSignal.timeout(10000) });
12964
14808
  entry.status = 'ready';
12965
14809
  this.bumpViews(entry.primaryKey);
12966
- const selected = select ? this.selectTab(primary, sessionId, viewId) : { selection: this.selectionFor(primary, sessionId) };
14810
+ // `open` sets both selections for its own actor (plan §5): the caller asked for this page
14811
+ // as its work target *and* as the thing it is looking at.
14812
+ const selected = select
14813
+ ? this.selectTab(primary, sessionId, viewId, { mode: 'work', principal })
14814
+ : { selection: this.selectionFor(primary, sessionId) };
14815
+ if (select) {
14816
+ const record = this.selectionFor(primary, sessionId);
14817
+ this.selectedBySession.set(this.#selectionKey(primary, sessionId), { ...record, observationTabId: viewId });
14818
+ }
12967
14819
  return { entry, selection: selected.selection };
12968
14820
  } catch (error) {
12969
14821
  try { await child.close(); } catch { /* ignore */ }
@@ -12974,13 +14826,35 @@ export class RuntimeRegistry {
12974
14826
  }
12975
14827
  }
12976
14828
 
12977
- /** Close one tab. Agent close requires the caller's own live lease; primary only goes cold. */
12978
- async closeTab(primaryEntry, sessionId, tabId, { gui = false } = {}) {
12979
- const target = this.tabEntry(primaryEntry, tabId || this.selectionFor(primaryEntry, sessionId).tabId);
14829
+ /**
14830
+ * Close one tab. Primary only goes cold; children are destroyed.
14831
+ *
14832
+ * Agent close is now authorised by *ownership/control* (M1 `authorize`), not by holding the 15s
14833
+ * lease: the lease says "someone is busy right now", which is a scheduling fact, not a permission.
14834
+ * A teammate closing a teammate's page is refused here; a foreign root never resolves the tab at
14835
+ * all. The GUI path (`gui:true`) keeps its historical shape — it has no Principal until M5, which
14836
+ * is reported as a known gap rather than papered over with a fake one.
14837
+ */
14838
+ async closeTab(primaryEntry, sessionId, tabId, { gui = false, principal = null } = {}) {
14839
+ const wanted = tabId || this.selectionFor(primaryEntry, sessionId).tabId;
14840
+ if (!wanted) {
14841
+ // Same rule as a page tool: nothing selected means "no page yet", never "the primary".
14842
+ throw new BrowserError('TARGET_NOT_BOUND', '当前会话没有可关闭的页:省略 tabId 时需要先 open/select', {
14843
+ reason: 'no-work-page', hint: 'browser_tabs.open',
14844
+ });
14845
+ }
14846
+ const target = this.tabEntry(primaryEntry, wanted);
12980
14847
  if (!target) throw new BrowserError('TAB_NOT_FOUND', '标签不存在或不属于当前 workspace', { tabId });
12981
14848
  if (!gui) {
12982
- const owner = target.runtime.lease && target.runtime.hasLease() ? target.runtime.lease.sessionId : null;
12983
- if (owner !== sessionId) throw new BrowserError('TAB_FORBIDDEN', '关闭标签需要当前会话持有该标签的 lease', { tabId, owner });
14849
+ if (principal) {
14850
+ this.authorizePage(target, principal,
14851
+ { effect: EFFECT.MANAGE, resource: RESOURCE.PAGE, targetRequired: true }, 'browser_tabs.close');
14852
+ } else {
14853
+ // Legacy/GUI callers without a Principal keep the old short-lease rule (M5 will replace it
14854
+ // with a verified panel seat; until then this must not silently become "anyone may close").
14855
+ const owner = target.runtime.lease && target.runtime.hasLease() ? target.runtime.lease.sessionId : null;
14856
+ if (owner !== sessionId) throw new BrowserError('TAB_FORBIDDEN', '关闭标签需要当前会话持有该标签的 lease', { tabId, owner });
14857
+ }
12984
14858
  }
12985
14859
  if (gui && target.runtime.hasLease()) throw new BrowserError('BUSY', '标签正由 Agent 操作,GUI 暂不能关闭', { tabId });
12986
14860
  if (target.runtime.active > 0 || target.runtime.inflight > 0) {
@@ -12988,6 +14862,17 @@ export class RuntimeRegistry {
12988
14862
  }
12989
14863
  // Primary close only makes primary cold; children stay live (P0-E §1).
12990
14864
  target.closeReason = 'closed';
14865
+ // Lifecycle versioning (ruling §7.1): a close always advances controlRevision, so a management
14866
+ // CAS that was in flight against the old page can never land on the reopened one. A child page
14867
+ // is retired outright; a primary slot keeps its history and stays `owned` because it will be
14868
+ // reopened in place (M4 adds the explicit acquire/release/transfer transitions).
14869
+ if (target.ownership && target.control) {
14870
+ target.control = makeControl({
14871
+ state: target.kind === 'popup' ? CONTROL_STATE.RETIRED : CONTROL_STATE.OWNED,
14872
+ controllerSessionId: target.kind === 'popup' ? null : target.ownership.ownerSessionId,
14873
+ controlRevision: target.control.controlRevision + 1,
14874
+ });
14875
+ }
12991
14876
  await target.runtime.run(() => target.runtime.close(), { sessionId, agent: false });
12992
14877
  return { closed: target.tabId || (target.kind === 'primary' ? 'primary' : target.viewId) };
12993
14878
  }
@@ -13006,9 +14891,22 @@ export class RuntimeRegistry {
13006
14891
  if (entry.kind === 'popup') {
13007
14892
  const tabId = entry.tabId || entry.viewId;
13008
14893
  for (const [key, record] of this.selectedBySession) {
13009
- if (record && record.tabId === tabId) {
13010
- this.selectedBySession.set(key, { ...record, tabId: 'primary', resetReason: 'tab-closed', pendingReset: true, revision: record.revision + 1 });
13011
- }
14894
+ // Only selections *inside this page group* may be reset: a same-named tabId in another
14895
+ // root's group is a different page and must not be disturbed.
14896
+ if (!record || record.groupKey !== entry.primaryKey) continue;
14897
+ const touchesWork = record.tabId === tabId;
14898
+ const touchesObserve = record.observationTabId === tabId;
14899
+ if (!touchesWork && !touchesObserve) continue;
14900
+ this.selectedBySession.set(key, {
14901
+ ...record,
14902
+ // The retired tab must not be replaced by "primary": for a teammate the primary belongs to
14903
+ // the Lead, and pointing there would turn the confirm flow into "you may not touch this
14904
+ // page". The work selection becomes *unbound* and `resetTabId` keeps the audit of what
14905
+ // retired (the panel still shows which tab was reset).
14906
+ ...(touchesWork ? { tabId: null, resetTabId: tabId, resetReason: 'tab-closed', pendingReset: true } : {}),
14907
+ ...(touchesObserve ? { observationTabId: null } : {}),
14908
+ revision: record.revision + 1,
14909
+ });
13012
14910
  }
13013
14911
  this.entries.delete(entry.key);
13014
14912
  this.bumpViews(entry.primaryKey);
@@ -13017,13 +14915,39 @@ export class RuntimeRegistry {
13017
14915
  }
13018
14916
  }
13019
14917
 
13020
- recentEntry() { return this.recentKey ? this.entries.get(this.recentKey) : undefined; }
14918
+ /**
14919
+ * Last real interaction, per root when a root is named.
14920
+ *
14921
+ * A root-scoped caller only ever sees its own root's entry. A **rootless** caller (the legacy
14922
+ * panel protocol, which carries no session identity at all) keeps the historical single
14923
+ * "most recently used page" pointer: that is a navigation convenience, not an authorisation —
14924
+ * every page operation re-authorises against the caller's own identity, and M5 replaces the
14925
+ * unbound panel with a verified seat. Answering `undefined` there would break the panel without
14926
+ * making anything safer.
14927
+ */
14928
+ recentEntry(rootSessionId) {
14929
+ if (rootSessionId !== undefined && rootSessionId !== null && rootSessionId !== '') {
14930
+ const root = String(rootSessionId);
14931
+ const key = this.recentByRoot.get(root);
14932
+ if (!key) return undefined;
14933
+ const entry = this.entries.get(key);
14934
+ if (!entry) this.recentByRoot.delete(root);
14935
+ return entry;
14936
+ }
14937
+ let best;
14938
+ for (const entry of this.entries.values()) {
14939
+ if (entry.kind === 'popup') continue;
14940
+ if (entry.baseKey === LEGACY_SCOPE.key) continue;
14941
+ if (!best || (entry.lastUsedAt || 0) > (best.lastUsedAt || 0)) best = entry;
14942
+ }
14943
+ return best;
14944
+ }
13021
14945
 
13022
14946
  markRecent(entry) {
13023
- if (!entry || entry.key === LEGACY_SCOPE.key) return;
14947
+ if (!entry || entry.baseKey === LEGACY_SCOPE.key) return;
13024
14948
  // A popup child never becomes the Agent's recent/primary target.
13025
14949
  if (entry.kind === 'popup') return;
13026
- this.recentKey = entry.key;
14950
+ this.recentByRoot.set(String(entry.rootSessionId || UNSCOPED_ROOT), entry.key);
13027
14951
  }
13028
14952
 
13029
14953
  touch(entry) { if (entry) entry.lastUsedAt = Date.now(); }
@@ -13095,7 +15019,8 @@ export class RuntimeRegistry {
13095
15019
  } catch { /* audit only */ }
13096
15020
  this.entries.delete(entry.key);
13097
15021
  if (entry.kind === 'popup' && entry.primaryKey) this.bumpViews(entry.primaryKey);
13098
- if (this.recentKey === entry.key) this.recentKey = undefined;
15022
+ else if (entry.kind === 'primary') this.pruneSelections();
15023
+ for (const [root, key] of [...this.recentByRoot]) if (key === entry.key) this.recentByRoot.delete(root);
13099
15024
  try { this.onRetire?.(entry.runtime); } catch { /* ignore */ }
13100
15025
  try { entry.runtime.frameTransport?.dispose?.(); } catch { /* ignore */ }
13101
15026
  // A retired scope runtime is gone: its stored images must not outlive it (a later productId
@@ -13110,7 +15035,7 @@ export class RuntimeRegistry {
13110
15035
  try {
13111
15036
  const now = Date.now();
13112
15037
  for (const entry of [...this.entries.values()]) {
13113
- if (entry.key === LEGACY_SCOPE.key) continue; // old client compatibility: keep the entry
15038
+ if (entry.baseKey === LEGACY_SCOPE.key) continue; // old client compatibility: keep the entry
13114
15039
  const runtime = entry.runtime;
13115
15040
  const idleFor = now - entry.lastUsedAt;
13116
15041
  if (idleFor < this.idleMs) continue;
@@ -13136,25 +15061,51 @@ export class RuntimeRegistry {
13136
15061
  }
13137
15062
 
13138
15063
  /** The shared host exits only when the last page is gone and no create is in flight. */
13139
- async maybeCloseHost() {
15064
+ async maybeCloseHost(provider = null) {
13140
15065
  if (this.disposed) return;
13141
15066
  if (this.reservations > 0) return;
13142
- const host = this.host;
13143
- if (!host) return;
13144
- if (host.pageCount > 0 || host.pendingCreates > 0 || host.starting) return;
13145
- if (host.closing) return host.closing;
13146
- if (!host.proc && !host.profileDir && !host.browserCdp) return undefined;
13147
- return host.close();
15067
+ const candidates = provider === null
15068
+ ? [...this.hosts.values()]
15069
+ : [this.hosts.get(provider)].filter(Boolean);
15070
+ let closing;
15071
+ for (const host of candidates) {
15072
+ if (host.pageCount > 0 || host.pendingCreates > 0 || host.starting) continue;
15073
+ if (host.closing) { closing = closing ?? host.closing; continue; }
15074
+ if (!host.proc && !host.profileDir && !host.browserCdp) continue;
15075
+ closing = closing ?? host.close();
15076
+ }
15077
+ return closing;
13148
15078
  }
13149
15079
 
13150
15080
  releaseSession(sessionId) {
13151
15081
  for (const entry of this.entries.values()) entry.runtime.release(sessionId);
13152
- // Selection keys are `<primaryKey>\u0000<sessionId>`: drop the records of a session that ended,
13153
- // otherwise every closed agent session pins one small record per workspace it touched.
13154
- const suffix = '\u0000' + String(sessionId || 'anonymous');
13155
- for (const key of [...this.selectedBySession.keys()]) {
13156
- if (key.endsWith(suffix)) this.selectedBySession.delete(key);
15082
+ // Selections are deliberately NOT dropped here any more.
15083
+ //
15084
+ // A real host emits `agent/disposed` at almost every turn boundary, so deleting the record there
15085
+ // reset the selection revision to 0 and silently discarded `pendingReset`: the caller was never
15086
+ // told "your tab was retired, confirm with current", it just got a fresh default that pointed at
15087
+ // the Lead's primary. A selection is (page group, actor) state — it outlives an execution
15088
+ // instance and is pruned when its page group disappears (see `pruneSelections`).
15089
+ this.pruneSelections();
15090
+ }
15091
+
15092
+ /** Drop selection rows whose page group is gone (bounded by the live entry table). */
15093
+ pruneSelections() {
15094
+ if (this.selectedBySession.size === 0) return 0;
15095
+ const liveGroups = new Set();
15096
+ for (const entry of this.entries.values()) {
15097
+ if (entry.kind === 'primary') liveGroups.add(entry.key);
15098
+ if (entry.primaryKey) liveGroups.add(entry.primaryKey);
15099
+ }
15100
+ let dropped = 0;
15101
+ for (const [key, record] of [...this.selectedBySession]) {
15102
+ const groupKey = record && record.groupKey;
15103
+ if (!groupKey || !liveGroups.has(groupKey)) {
15104
+ this.selectedBySession.delete(key);
15105
+ dropped += 1;
15106
+ }
13157
15107
  }
15108
+ return dropped;
13158
15109
  }
13159
15110
 
13160
15111
  capabilities() {
@@ -13176,7 +15127,7 @@ export class RuntimeRegistry {
13176
15127
  clearInterval(this.sweepTimer);
13177
15128
  const runtimes = [...this.entries.values()].map((entry) => entry.runtime);
13178
15129
  this.entries.clear();
13179
- this.recentKey = undefined;
15130
+ this.recentByRoot.clear();
13180
15131
  for (const runtime of runtimes) {
13181
15132
  try { await runtime.close(); } catch { /* ignore */ }
13182
15133
  try { this.onRetire?.(runtime); } catch { /* ignore */ }
@@ -13184,7 +15135,33 @@ export class RuntimeRegistry {
13184
15135
  try { runtime.disposeVision?.(); } catch { /* ignore */ }
13185
15136
  try { await runtime.disposeDynamic?.(); } catch { /* ignore */ }
13186
15137
  }
13187
- if (this.host) { try { await this.host.close(); } catch { /* ignore */ } }
15138
+ for (const host of this.hosts.values()) {
15139
+ try { await host.close(); } catch { /* one provider's failure must not mask the others */ }
15140
+ }
15141
+ this.hosts.clear();
15142
+ this.providerByBaseKey.clear();
15143
+ }
15144
+
15145
+ /**
15146
+ * Close one provider only (§6): its entries and its host. Another provider's pages, leases and
15147
+ * refs are untouched, so closing Chrome never kills an Agent's Edge session.
15148
+ */
15149
+ async closeProvider(provider) {
15150
+ const id = typeof provider === 'string' && provider.length > 0 ? provider : DEFAULT_RUNTIME_PROVIDER;
15151
+ const doomed = [...this.entries.values()].filter((entry) => (entry.provider ?? DEFAULT_RUNTIME_PROVIDER) === id);
15152
+ for (const entry of doomed) {
15153
+ try { await entry.runtime.close(); } catch { /* ignore */ }
15154
+ try { this.onRetire?.(entry.runtime); } catch { /* ignore */ }
15155
+ this.entries.delete(entry.key);
15156
+ for (const [root, key] of [...this.recentByRoot]) if (key === entry.key) this.recentByRoot.delete(root);
15157
+ }
15158
+ for (const [baseKey, owner] of [...this.providerByBaseKey]) {
15159
+ if (owner === id) this.providerByBaseKey.delete(baseKey);
15160
+ }
15161
+ const host = this.hosts.get(id);
15162
+ this.hosts.delete(id);
15163
+ if (host) { try { await host.close(); } catch { /* ignore */ } }
15164
+ return { provider: id, closedEntries: doomed.length };
13188
15165
  }
13189
15166
  }
13190
15167
 
@@ -13885,7 +15862,11 @@ function elementPickRequest(body) {
13885
15862
  if (body.detail !== undefined && body.detail !== null && typeof body.detail !== 'boolean') {
13886
15863
  throw new BrowserError('INVALID_ARGUMENT', 'element detail 必须是布尔值', { field: 'detail', valueType: typeof body.detail });
13887
15864
  }
13888
- return { x, y, detail: body.detail === true };
15865
+ const cursorOnly = body.cursorOnly === true;
15866
+ // A cursor-only probe answers just the computed `cursor` of the hit: it exists so the panel can
15867
+ // mirror the page's hand/text/grab cursor onto the preview while the pointer moves, without
15868
+ // paying for the box model, frame tree and selector work a full pick needs.
15869
+ return { x, y, detail: body.detail === true, cursorOnly };
13889
15870
  }
13890
15871
 
13891
15872
  /**
@@ -13914,6 +15895,49 @@ function elementAttributes(node) {
13914
15895
  return out;
13915
15896
  }
13916
15897
 
15898
+ /**
15899
+ * The frame a pick belongs to, read from the `Page.getFrameTree` reply.
15900
+ *
15901
+ * `main` marks the root frame; a nested frame carries its own `loaderId` (document generation) and
15902
+ * URL so the reply can say that the selector/rect are scoped to that document. An id absent from
15903
+ * the tree answers `null`: the caller fails closed instead of trusting an unverifiable frame.
15904
+ */
15905
+ function elementFrameOf(frameTree, frameId) {
15906
+ const wanted = String(frameId || '');
15907
+ if (!wanted || !frameTree || !frameTree.frame) return null;
15908
+ const walk = (node, depth) => {
15909
+ if (!node || !node.frame || depth > 32) return null;
15910
+ if (String(node.frame.id) === wanted) {
15911
+ return {
15912
+ id: String(node.frame.id),
15913
+ loaderId: typeof node.frame.loaderId === 'string' ? node.frame.loaderId : null,
15914
+ url: typeof node.frame.url === 'string' ? node.frame.url : '',
15915
+ main: depth === 0,
15916
+ };
15917
+ }
15918
+ const children = Array.isArray(node.childFrames) ? node.childFrames : [];
15919
+ for (const child of children) {
15920
+ const found = walk(child, depth + 1);
15921
+ if (found) return found;
15922
+ }
15923
+ return null;
15924
+ };
15925
+ return walk(frameTree, 0);
15926
+ }
15927
+
15928
+ /** Does this frame tree still carry a document whose loader id is `loaderId`? */
15929
+ function frameTreeHasLoaderId(frameTree, loaderId) {
15930
+ const wanted = String(loaderId || '');
15931
+ if (!wanted) return false;
15932
+ const walk = (node, depth) => {
15933
+ if (!node || !node.frame || depth > 32) return false;
15934
+ if (String(node.frame.loaderId || '') === wanted) return true;
15935
+ const children = Array.isArray(node.childFrames) ? node.childFrames : [];
15936
+ return children.some((child) => walk(child, depth + 1));
15937
+ };
15938
+ return walk(frameTree, 0);
15939
+ }
15940
+
13917
15941
  /** Border-box quad (viewport CSS px) -> document rect; a degenerate/absent quad is `null`. */
13918
15942
  function elementQuadRect(model, scrollX, scrollY) {
13919
15943
  const quad = model && (model.border || model.padding || model.content);
@@ -13993,7 +16017,7 @@ async function elementDetailFacts(cdp, backendNodeId) {
13993
16017
  * Read the element at one document point. Never throws and never mutates the runtime: the caller
13994
16018
  * turns the result into `{ok:true, element, reason}`.
13995
16019
  */
13996
- async function pickElementAt(runtime, { x, y, detail }) {
16020
+ async function pickElementAt(runtime, { x, y, detail, cursorOnly = false }) {
13997
16021
  if (!runtime || !runtime.running || !runtime.cdp) return { element: null, reason: 'no-page' };
13998
16022
  const cdp = runtime.cdp;
13999
16023
  const bounded = (method, params) => cdp.send(method, params, { timeoutMs: ELEMENT_CDP_TIMEOUT_MS });
@@ -14018,7 +16042,10 @@ async function pickElementAt(runtime, { x, y, detail }) {
14018
16042
  // quantised (nothing is dispatched and nothing is mutated).
14019
16043
  hit = await bounded('DOM.getNodeForLocation', {
14020
16044
  x: Math.floor(x), y: Math.floor(y),
14021
- includeUserAgentShadowDOM: true, ignorePointerEventsNone: false,
16045
+ // Native controls contain browser-owned shadow nodes (e.g. select's inner DIV).
16046
+ // Pick the authored control so its options and identity can be read. This flag only
16047
+ // excludes UA shadow; authored open/closed shadow roots keep their existing behavior.
16048
+ includeUserAgentShadowDOM: false, ignorePointerEventsNone: false,
14022
16049
  });
14023
16050
  } catch (error) {
14024
16051
  // "No node found at given location" is a miss; a timeout / detached document / closed CDP
@@ -14027,14 +16054,13 @@ async function pickElementAt(runtime, { x, y, detail }) {
14027
16054
  return { element: null, reason: noNode ? 'no-hit' : 'stale' };
14028
16055
  }
14029
16056
  if (!hit || !Number.isInteger(hit.backendNodeId)) return { element: null, reason: 'no-hit' };
14030
- // v1 picks inside the main frame only (the same "frame: main" tree T2 snapshots use). A hit in
14031
- // a child frame fails closed instead of returning a child-frame-relative selector/rect; when
14032
- // the main frame id is not known yet, a hit that claims a frame is fail-closed as `stale`
14033
- // (never trust an unverifiable frame, e.g. mid-navigation).
14034
- if (hit.frameId) {
14035
- if (!runtime.mainFrameId) return { element: null, reason: 'stale' };
14036
- if (hit.frameId !== runtime.mainFrameId) return { element: null, reason: 'frame' };
14037
- }
16057
+ // A hit may land in a same-process child frame: a native `<select>` inside an `<iframe>` still
16058
+ // needs the picker's popover, and `DOM.getBoxModel` already reports ROOT-frame viewport quads,
16059
+ // so the document rect stays comparable with the panel's coordinates. Only what cannot be
16060
+ // verified is refused: an unknown main frame id (mid-navigation) is `stale`, and a hit whose
16061
+ // frame is missing from the frame tree (read below) is `stale` too — never unchecked cross-frame
16062
+ // data. OOPIF/closed targets simply never hit on this session.
16063
+ if (hit.frameId && !runtime.mainFrameId) return { element: null, reason: 'stale' };
14038
16064
  const described = await bounded('DOM.describeNode', { backendNodeId: hit.backendNodeId, depth: 0 });
14039
16065
  // A hit may land on a text (or shadow-root) node: walk up to its element, bounded.
14040
16066
  let node = described && described.node;
@@ -14044,18 +16070,54 @@ async function pickElementAt(runtime, { x, y, detail }) {
14044
16070
  node = parent && parent.node;
14045
16071
  }
14046
16072
  if (!node || node.nodeType !== 1) return { element: null, reason: 'no-hit' };
16073
+ // List-style selects hit their OPTION/OPTGROUP rather than the control's border. Project the
16074
+ // owning control too, so unsupported shapes can show the keyboard hint instead of disappearing.
16075
+ if (['option', 'optgroup'].includes(String(node.localName || '').toLowerCase())) {
16076
+ const handles = [];
16077
+ try {
16078
+ const resolved = await bounded('DOM.resolveNode', { backendNodeId: node.backendNodeId });
16079
+ const objectId = resolved?.object?.objectId;
16080
+ if (objectId) {
16081
+ handles.push(objectId);
16082
+ const owner = await bounded('Runtime.callFunctionOn', {
16083
+ objectId, functionDeclaration: 'function () { return this.closest("select"); }',
16084
+ });
16085
+ if (owner?.result?.objectId) {
16086
+ handles.push(owner.result.objectId);
16087
+ const describedOwner = await bounded('DOM.describeNode', { objectId: owner.result.objectId, depth: 0 });
16088
+ if (describedOwner?.node?.localName === 'select') node = describedOwner.node;
16089
+ }
16090
+ }
16091
+ } finally {
16092
+ for (const objectId of handles) {
16093
+ try { await bounded('Runtime.releaseObject', { objectId }); } catch { /* best effort */ }
16094
+ }
16095
+ }
16096
+ }
14047
16097
  const tag = String(node.localName || '').toLowerCase();
16098
+ if (cursorOnly) {
16099
+ // Empty page background is a legitimate cursor answer (`default`), not a miss: the panel is
16100
+ // asking "what would the real page show here", and a straight arrow is a real answer.
16101
+ const cursor = ['html', 'body'].includes(tag)
16102
+ ? { cursor: 'default', tag: tag || 'html' }
16103
+ : await cursorProjectionAt(bounded, node.backendNodeId);
16104
+ return { element: cursor, cursorOnly: true };
16105
+ }
14048
16106
  // A point on empty page background resolves to <html>/<body>; for a picker that is "nothing".
14049
16107
  if (!tag || tag === 'html' || tag === 'body') return { element: null, reason: 'no-hit' };
14050
16108
  const box = await bounded('DOM.getBoxModel', { backendNodeId: node.backendNodeId });
14051
16109
  // Loader id = the document generation the hit belongs to (the panel uses it to discard a
14052
- // response that raced a navigation). Best-effort: an unreadable frame tree omits the field.
14053
- let loaderId = null;
16110
+ // response that raced a navigation). The frame tree read also proves that a child-frame hit
16111
+ // still belongs to a live frame; `main` separates the root frame from a nested one.
16112
+ let frame = null;
14054
16113
  try {
14055
16114
  const tree = await bounded('Page.getFrameTree', {});
14056
- const mainFrame = tree && tree.frameTree && tree.frameTree.frame;
14057
- loaderId = mainFrame && typeof mainFrame.loaderId === 'string' && mainFrame.loaderId ? mainFrame.loaderId : null;
14058
- } catch { loaderId = null; }
16115
+ frame = elementFrameOf(tree && tree.frameTree, hit.frameId || runtime.mainFrameId);
16116
+ } catch { frame = null; }
16117
+ // An unreadable frame tree keeps the historical best-effort shape for root-frame hits (no
16118
+ // loaderId), but a nested hit without a verifiable frame is refused.
16119
+ if (!frame && hit.frameId && hit.frameId !== runtime.mainFrameId) return { element: null, reason: 'stale' };
16120
+ const loaderId = frame && typeof frame.loaderId === 'string' && frame.loaderId ? frame.loaderId : null;
14059
16121
  const attributes = elementAttributes(node);
14060
16122
  const element = {
14061
16123
  tag,
@@ -14063,6 +16125,9 @@ async function pickElementAt(runtime, { x, y, detail }) {
14063
16125
  classes: attributes.class ? attributes.class.split(/\s+/u).filter(Boolean) : [],
14064
16126
  rect: elementQuadRect(box && box.model, scrollX, scrollY),
14065
16127
  frameId: String(hit.frameId || runtime.mainFrameId || ''),
16128
+ // Nested frames only: the selector/outerHTML above are valid inside that frame's document,
16129
+ // not in the main document, so the caller gets the frame identity instead of guessing.
16130
+ ...(frame && frame.main === false ? { frame: { id: frame.id, url: redactUrl(frame.url), main: false } } : {}),
14066
16131
  // The SAME Page.getLayoutMetrics read that produced the document rect: the panel compares it
14067
16132
  // with its own view to detect a response computed while the page was still scrolling.
14068
16133
  scroll: { x: scrollX, y: scrollY },
@@ -14072,7 +16137,22 @@ async function pickElementAt(runtime, { x, y, detail }) {
14072
16137
  title: runtime.title ? String(runtime.title) : '',
14073
16138
  },
14074
16139
  };
14075
- if (detail) Object.assign(element, await elementDetailFacts(cdp, node.backendNodeId));
16140
+ if (detail) {
16141
+ Object.assign(element, await elementDetailFacts(cdp, node.backendNodeId));
16142
+ // The picker's own preview mirrors the page cursor from the hit it already has.
16143
+ const pointer = await cursorProjectionAt(bounded, node.backendNodeId);
16144
+ if (pointer) element.cursor = pointer.cursor;
16145
+ }
16146
+ // A native <select> popup is painted by the browser UI layer, never by the page, so the panel
16147
+ // cannot see it in the frame. Only for a select hit (and only when the projection is readable)
16148
+ // does the reply gain a `select` field; every other hit keeps the exact historical shape.
16149
+ if (tag === 'select') {
16150
+ const select = await selectProjectionAt(bounded, node.backendNodeId);
16151
+ if (select) element.select = {
16152
+ ...select,
16153
+ ...(loaderId ? { target: { backendNodeId: node.backendNodeId, loaderId } } : {}),
16154
+ };
16155
+ }
14076
16156
  return { element };
14077
16157
  } catch {
14078
16158
  // Any other read failure (CDP timeout/disconnect, detached target, missing box) is stale.
@@ -14080,6 +16160,69 @@ async function pickElementAt(runtime, { x, y, detail }) {
14080
16160
  }
14081
16161
  }
14082
16162
 
16163
+ /**
16164
+ * Computed `cursor` of one hit node (POST /element `cursorOnly`, and the detail projection).
16165
+ *
16166
+ * Read-only and failure-tolerant, exactly like `selectProjectionAt`: one `DOM.resolveNode` plus one
16167
+ * `Runtime.callFunctionOn` that only reads, `null` on any failure, so an unreadable node never turns
16168
+ * a successful hover into an error. The value passes through verbatim — the CLIENT decides which
16169
+ * keywords it is willing to apply, because `cursor:url(...)` must never reach a style attribute.
16170
+ */
16171
+ async function cursorProjectionAt(bounded, backendNodeId) {
16172
+ let objectId = null;
16173
+ try {
16174
+ const resolved = await bounded('DOM.resolveNode', { backendNodeId });
16175
+ objectId = resolved && resolved.object ? resolved.object.objectId : null;
16176
+ if (!objectId) return null;
16177
+ const answer = await bounded('Runtime.callFunctionOn', {
16178
+ objectId,
16179
+ functionDeclaration: 'function () { return String(getComputedStyle(this).cursor || "") }',
16180
+ returnByValue: true,
16181
+ });
16182
+ const value = answer && answer.result ? answer.result.value : null;
16183
+ if (typeof value !== 'string' || !value) return null;
16184
+ return { cursor: value.slice(0, 64) };
16185
+ } catch {
16186
+ return null;
16187
+ } finally {
16188
+ if (objectId) {
16189
+ try { await bounded('Runtime.releaseObject', { objectId }); } catch { /* best effort */ }
16190
+ }
16191
+ }
16192
+ }
16193
+
16194
+ /**
16195
+ * Read-only option projection for a hit `<select>` (POST /element).
16196
+ *
16197
+ * Same read-only contract as `pickElementAt` (no ensure, no queue, no lease, no `lastError`): one
16198
+ * `DOM.resolveNode` + one `Runtime.callFunctionOn` that merely reads. Every failure answers `null`
16199
+ * so the element reply keeps its historical shape — a page that cannot be read must not turn a
16200
+ * successful pick into an error, and a non-select hit must never grow a new field.
16201
+ */
16202
+ async function selectProjectionAt(bounded, backendNodeId) {
16203
+ let objectId = null;
16204
+ try {
16205
+ const resolved = await bounded('DOM.resolveNode', { backendNodeId });
16206
+ objectId = resolved && resolved.object ? resolved.object.objectId : null;
16207
+ if (!objectId) return null;
16208
+ const answer = await bounded('Runtime.callFunctionOn', {
16209
+ objectId,
16210
+ functionDeclaration: selectProbeDeclaration(),
16211
+ returnByValue: true,
16212
+ });
16213
+ const value = answer && answer.result ? answer.result.value : null;
16214
+ return normalizeSelectProjection(value);
16215
+ } catch {
16216
+ return null;
16217
+ } finally {
16218
+ // CallFunctionOn leaves a remote handle behind; releasing it is best-effort and must never
16219
+ // mask the read result (a dropped handle only costs the inspector's object group).
16220
+ if (objectId) {
16221
+ try { await bounded('Runtime.releaseObject', { objectId }); } catch { /* best effort */ }
16222
+ }
16223
+ }
16224
+ }
16225
+
14083
16226
  /** Register the runtime, its HTTP surface, and the agent tools. */
14084
16227
  export function apply(ctx) {
14085
16228
  const log = (text) => {
@@ -14096,6 +16239,11 @@ export function apply(ctx) {
14096
16239
  idleMs: envPositiveInt('DSH_UI_TESTING_IDLE_MS', IDLE_MS_DEFAULT),
14097
16240
  viewerTtlMs: envPositiveInt('DSH_UI_TESTING_VIEWER_TTL_MS', VIEWER_TTL_MS),
14098
16241
  });
16242
+ // Cross-environment composition root (M2/M3): authoritative config, adapter registry, tool
16243
+ // admission, per-execution adapter dispatch, readiness and Skill discovery. The Web runtime
16244
+ // stays here; `integration/` only consumes the public core/targets contracts.
16245
+ const crossEnvironment = createCrossEnvironment({ ctx, registry, log, BrowserError, asBrowserError });
16246
+ ctx.effect(() => () => crossEnvironment.dispose(), 'ui-testing: cross-environment');
14099
16247
  // Independent HTTP client: shares only the collected host origins, never a page runtime.
14100
16248
  const httpClient = new HttpClient({ origins: () => [...origins] });
14101
16249
  ctx.effect(() => () => { httpClient.dispose(); }, 'ui-testing: http client');
@@ -14197,7 +16345,7 @@ export function apply(ctx) {
14197
16345
  resolve: async (scopeRequest) => {
14198
16346
  const resolved = await resolveScopeRequest(ctx, registry, scopeRequest);
14199
16347
  let entry = scopeRequest && scopeRequest.mode === 'binding'
14200
- ? registry.peek(resolved.key) : registry.getOrCreate(resolved);
16348
+ ? registry.peek(resolved) : registry.getOrCreate(resolved);
14201
16349
  if (!entry) throw new BrowserError('SCOPE_NOT_FOUND', 'scope binding 指向的运行时不存在', { scope: 'binding' });
14202
16350
  if (scopeRequest && scopeRequest.mode === 'session' && scopeRequest.tab !== SCOPE_TAB_PRIMARY) {
14203
16351
  entry = registry.resolveSessionTab(entry, scopeRequest.sessionId).entry;
@@ -14221,22 +16369,15 @@ export function apply(ctx) {
14221
16369
  };
14222
16370
  }, 'ui-testing: frame push');
14223
16371
 
14224
- // The bundled Skill is the documented, structured tool surface plus capability discovery.
14225
- // It is optional: a deployment without the skill asset (or without the skills service) keeps
14226
- // the browser tools working, and the failure is logged instead of aborting the plugin.
16372
+ // Environment-aware Skill discovery (M3). The provider exposes the common rules plus only the
16373
+ // current environment's extension; `get()` re-checks visibility because an explicit skill load
16374
+ // does not pass through `list()`. The legacy single-skill registration is the fallback.
14227
16375
  ctx.inject(['skills'], (sub) => {
14228
- let skill;
14229
16376
  try {
14230
- skill = readBundledSkill();
16377
+ crossEnvironment.registerSkills(sub);
14231
16378
  } catch (error) {
14232
16379
  log('ui-testing skill 未注册:' + String((error && error.message) || error));
14233
- return;
14234
- }
14235
- if (!skill) {
14236
- log('ui-testing skill 未注册:skills/ui-testing/SKILL.md 不存在');
14237
- return;
14238
16380
  }
14239
- sub.effect(() => sub.skills.register(skill), 'ui-testing: bundled skill');
14240
16381
  });
14241
16382
 
14242
16383
  /**
@@ -14266,7 +16407,38 @@ export function apply(ctx) {
14266
16407
  return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
14267
16408
  };
14268
16409
 
16410
+ /**
16411
+ * Provider diagnostics (Host slice): `POST /plugins/ui-testing/diagnose` streams NDJSON and runs
16412
+ * five real checks. It reuses the registered tool handlers (never `/test/invoke`) through a
16413
+ * restricted dispatcher; `toolDefinitions` is read lazily because it is declared later.
16414
+ */
16415
+ const diagnose = createDiagnose({
16416
+ log,
16417
+ crossEnvironment,
16418
+ registry,
16419
+ readJson,
16420
+ sendJson,
16421
+ nowMonoMs,
16422
+ resolveProviderExecutable,
16423
+ randomUUID,
16424
+ webProviderSupport: (providerId) => {
16425
+ try { return webProviderSupport(providerId); } catch { return undefined; }
16426
+ },
16427
+ toolDefinitions: {
16428
+ get(name) { return toolDefinitions.get(name); },
16429
+ keys() { return [...toolDefinitions.keys()]; },
16430
+ has(name) { return toolDefinitions.has(name); },
16431
+ },
16432
+ });
16433
+
14269
16434
  const handle = async (req, res) => {
16435
+ // A14: the HTTP surface (panel/WS/legacy routes) may create or drive a page; resolve the Web
16436
+ // module chain before the first such request instead of at module load.
16437
+ try {
16438
+ await ensureWebHostModules();
16439
+ } catch (error) {
16440
+ log('ui-testing: web host modules unavailable: ' + String((error && error.message) || error));
16441
+ }
14270
16442
  let action = '/';
14271
16443
  let selected;
14272
16444
  try {
@@ -14319,6 +16491,22 @@ export function apply(ctx) {
14319
16491
  const status = scopeAuthorization(req);
14320
16492
  if (status) { scopeAuthError(res, status); return; }
14321
16493
  }
16494
+ // Host-side provider diagnostics. Handled before the page-route dispatch so it never creates
16495
+ // a Runtime or starts Chromium just by being routed (observe must stay cheap).
16496
+ if (await diagnose.handleHttp(req, res, {
16497
+ action,
16498
+ url,
16499
+ scopeRequest: parseHttpScope(req),
16500
+ scoped,
16501
+ requestRejection,
16502
+ rejectUi,
16503
+ readJson,
16504
+ sendJson,
16505
+ // Bound resolver: this seam calls `resolveScopeRequest(request)`. Passing the 3-argument
16506
+ // module function unbound left `request` undefined, so every diagnose run silently froze
16507
+ // the LEGACY scope instead of the panel's own session/binding.
16508
+ resolveScopeRequest: (request) => resolveScopeRequest(ctx, registry, request),
16509
+ })) return;
14322
16510
  const scopeRequest = parseHttpScope(req);
14323
16511
  if (req.headers?.host) {
14324
16512
  try { origins.add(new URL('http://' + req.headers.host).origin); } catch { /* invalid host */ }
@@ -14326,21 +16514,43 @@ export function apply(ctx) {
14326
16514
  const select = async ({ create = true } = {}) => {
14327
16515
  const resolved = await resolveScopeRequest(ctx, registry, scopeRequest);
14328
16516
  // A binding already names an existing primary/child; never create a new runtime for it.
14329
- let entry = create && scopeRequest.mode !== 'binding' ? registry.getOrCreate(resolved) : registry.peek(resolved.key);
16517
+ let entry = create && scopeRequest.mode !== 'binding' ? registry.getOrCreate(resolved) : registry.peek(resolved);
14330
16518
  if (entry && scopeRequest.mode === 'session' && scopeRequest.tab !== SCOPE_TAB_PRIMARY) {
14331
16519
  // P0-E: a session-scoped HTTP read/write follows that Agent session's selectedTab.
14332
16520
  // `scope-tab: primary` opts out: a GUI panel renders its own selected tab, so the Agent's
14333
16521
  // selection must never redirect it (docs/multi-tab.md).
14334
16522
  entry = registry.resolveSessionTab(entry, scopeRequest.sessionId).entry;
14335
16523
  }
16524
+ if (entry && scoped && headerValue(req.headers, SCOPE_HEADER_FOREGROUND) === '1') {
16525
+ // Human-foreground claim (docs/viewport-control.md): the panel reports it on the requests
16526
+ // it makes for the tab it is displaying, so switching tabs/closing the panel/backgrounding
16527
+ // the GUI lets the claim expire instead of leaving a stale "somebody is watching" bit.
16528
+ entry.runtime.noteGuiForeground(entry.viewId);
16529
+ }
14336
16530
  return { resolved, entry };
14337
16531
  };
14338
16532
  const assertGuiPageWrite = (runtime, selfSessionId = null) => {
16533
+ // Configuration gate: a disabled/non-Web environment must not be bypassed through the
16534
+ // panel HTTP routes (the same snapshot as tool admission).
16535
+ crossEnvironment.assertGuiAllowed(scopeRequest);
14339
16536
  const lease = runtime && runtime.lease;
14340
16537
  if (lease && lease.sessionId !== selfSessionId && (runtime.active > 0 || lease.expiresAt > Date.now())) {
14341
16538
  throw new BrowserError('BUSY', '该标签正由 Agent 操作,GUI 输入暂不可用', { owner: lease.sessionId });
14342
16539
  }
14343
16540
  };
16541
+ // Cross-environment control plane: /config, /config/select, /readiness. Consumed before the
16542
+ // page-route dispatch so these endpoints never create a Runtime or start Chromium.
16543
+ if (await crossEnvironment.handleHttp(req, res, {
16544
+ action,
16545
+ url,
16546
+ scopeRequest,
16547
+ scoped,
16548
+ requestRejection,
16549
+ rejectUi,
16550
+ readJson,
16551
+ sendJson,
16552
+ resolveScopeRequest: (request) => resolveScopeRequest(ctx, registry, request),
16553
+ })) return;
14344
16554
  // Scoped callers are owner-only by construction; legacy callers keep the old fail-closed
14345
16555
  // requestRejection projection. `ui.devtools` is scoped-only: the legacy uiState() shape must
14346
16556
  // not expose the loopback port/target id even to an accepted old client.
@@ -14363,7 +16573,7 @@ export function apply(ctx) {
14363
16573
  return;
14364
16574
  }
14365
16575
  if (req.method === 'GET' && action === '/state') {
14366
- const { entry } = await select();
16576
+ const { entry, resolved } = await select();
14367
16577
  selected = entry;
14368
16578
  const runtime = entry.runtime;
14369
16579
  if (runtime.running) {
@@ -14371,7 +16581,10 @@ export function apply(ctx) {
14371
16581
  // Same for the 500ms GUI state poll: coalesced + ≥200ms throttled, no standing timer.
14372
16582
  runtime.refreshViewObservationSoon();
14373
16583
  }
14374
- sendJson(res, 200, projected(runtime));
16584
+ sendJson(res, 200, {
16585
+ ...projected(runtime),
16586
+ ...registry.providerFacts(entry, resolved),
16587
+ });
14375
16588
  return;
14376
16589
  }
14377
16590
  if (req.method === 'GET' && action === '/metrics') {
@@ -14404,6 +16617,7 @@ export function apply(ctx) {
14404
16617
  sendJson(res, 403, { ok: false, ...error.payload(), error: error.message });
14405
16618
  return;
14406
16619
  }
16620
+ crossEnvironment.assertGuiAllowed(scopeRequest);
14407
16621
  let ack;
14408
16622
  try {
14409
16623
  const request = normalizeAnnotationPublish(await readAnnotationBody(req, ANNOTATION_LIMITS.maxBodyBytes));
@@ -14472,11 +16686,16 @@ export function apply(ctx) {
14472
16686
  return;
14473
16687
  }
14474
16688
  if (action === '/ensure') {
16689
+ // Environment gate (A10): a legacy/unscoped caller must not warm up (or navigate) a page
16690
+ // for an environment the configuration disabled. This runs for scoped *and* legacy
16691
+ // callers, before `select()` can create a page, so the bypass cannot resurrect Chromium.
16692
+ crossEnvironment.assertGuiAllowed(scopeRequest);
14475
16693
  const { entry } = await select();
14476
16694
  selected = entry;
14477
16695
  registry.touch(entry);
14478
16696
  // A URL here is a page write, not a warm-up: gate it exactly like /navigate so a GUI
14479
- // caller cannot navigate a tab an Agent owns.
16697
+ // caller cannot navigate a tab an Agent owns. The lease gate stays scoped-only; the
16698
+ // environment gate above applies to every caller.
14480
16699
  if (body.url && scoped) assertGuiPageWrite(entry.runtime, scopeRequest.mode === 'session' ? scopeRequest.sessionId : null);
14481
16700
  await entry.runtime.run(async () => {
14482
16701
  await entry.runtime.ensure();
@@ -14487,6 +16706,9 @@ export function apply(ctx) {
14487
16706
  return;
14488
16707
  }
14489
16708
  if (action === '/navigate') {
16709
+ // Environment gate (A10): the legacy compatibility route used to skip every config check
16710
+ // unless the caller was scoped, so `enabledEnvironments=[]` still let it navigate.
16711
+ crossEnvironment.assertGuiAllowed(scopeRequest);
14490
16712
  const { entry } = await select();
14491
16713
  selected = entry;
14492
16714
  registry.touch(entry);
@@ -14512,6 +16734,8 @@ export function apply(ctx) {
14512
16734
  return;
14513
16735
  }
14514
16736
  if (action === '/reload') {
16737
+ // A10: reload/history are page writes too; the environment switch gates every caller.
16738
+ crossEnvironment.assertGuiAllowed(scopeRequest);
14515
16739
  // These two routes exist only for the authenticated GUI. Scoped callers already passed
14516
16740
  // the strict owner-only gate above; legacy callers keep the historical check.
14517
16741
  if (!scoped) {
@@ -14528,6 +16752,7 @@ export function apply(ctx) {
14528
16752
  return;
14529
16753
  }
14530
16754
  if (action === '/history') {
16755
+ crossEnvironment.assertGuiAllowed(scopeRequest); // A10: page write, same gate as /reload.
14531
16756
  if (!scoped) {
14532
16757
  const rejection = requestRejection(req);
14533
16758
  if (rejection) { rejectUi(res, rejection); return; }
@@ -14542,6 +16767,9 @@ export function apply(ctx) {
14542
16767
  return;
14543
16768
  }
14544
16769
  if (action === '/viewport') {
16770
+ // A10: changing the shared viewport is a page write; a disabled Web environment must not
16771
+ // drive it through the legacy GUI route.
16772
+ crossEnvironment.assertGuiAllowed(scopeRequest);
14545
16773
  // Two shapes (docs/viewport-control.md):
14546
16774
  // legacy {width,height} -> historical Number/round/clamp, same reply
14547
16775
  // v1 {version:1, ...patch, expectedRevision?, originId?} -> strict, same validation as
@@ -14609,6 +16837,7 @@ export function apply(ctx) {
14609
16837
  return;
14610
16838
  }
14611
16839
  if (action === '/input') {
16840
+ crossEnvironment.assertGuiAllowed(scopeRequest); // A10: same environment gate as /navigate.
14612
16841
  const { entry } = await select();
14613
16842
  selected = entry;
14614
16843
  registry.touch(entry);
@@ -14624,7 +16853,57 @@ export function apply(ctx) {
14624
16853
  sendJson(res, 200, { ok: true, applied });
14625
16854
  return;
14626
16855
  }
16856
+ if (action === '/context-menu' || action === '/context-action') {
16857
+ crossEnvironment.assertGuiAllowed(scopeRequest);
16858
+ const { entry } = await select();
16859
+ selected = entry;
16860
+ registry.touch(entry);
16861
+ if (scoped) assertGuiPageWrite(entry.runtime, scopeRequest.mode === 'session' ? scopeRequest.sessionId : null);
16862
+ const result = await entry.runtime.run(async () => {
16863
+ entry.runtime.touchHuman();
16864
+ return action === '/context-menu'
16865
+ ? openPageContextMenu(entry.runtime, body)
16866
+ : applyPageContextAction(entry.runtime, body);
16867
+ });
16868
+ registry.markRecent(entry);
16869
+ sendJson(res, 200, { ok: true, ...result, state: projected(entry.runtime) });
16870
+ return;
16871
+ }
16872
+ if (action === '/select') {
16873
+ // Panel-side native <select> write (POST /select, body {value, selector?}).
16874
+ //
16875
+ // Why this exists: the popup of a native <select> is drawn by the browser UI layer, so the
16876
+ // panel's frame shows nothing and a mouse user cannot pick an option. The panel renders its
16877
+ // own list (POST /element gives it the options) and writes the chosen value back here.
16878
+ //
16879
+ // Gate order is byte-for-byte the same as `/input` — environment/config gate, scoped
16880
+ // ownership + lease (BUSY), body validation, and only then the serial queue + dispatch:
16881
+ // * `crossEnvironment.assertGuiAllowed(scopeRequest)` first (A10: a disabled Web
16882
+ // environment must not be bypassed through a panel route);
16883
+ // * `assertGuiPageWrite` before anything reaches the page, so a refused write dispatches
16884
+ // nothing at all (the tests assert the interception point, not a message);
16885
+ // * the body is validated outside `runtime.run`, so a bad request costs zero CDP;
16886
+ // * `runtime.run` gives the same single-owner serial queue and lease semantics as input.
16887
+ // A refusal is a panel-facing input answer: INVALID_ARGUMENT is in NON_PAGE_ERROR_CODES, so
16888
+ // a rejected value never overwrites the page's `lastError` projection.
16889
+ crossEnvironment.assertGuiAllowed(scopeRequest); // A10: same environment gate as /input.
16890
+ const { entry } = await select();
16891
+ selected = entry;
16892
+ registry.touch(entry);
16893
+ if (scoped) assertGuiPageWrite(entry.runtime, scopeRequest.mode === 'session' ? scopeRequest.sessionId : null);
16894
+ const request = normalizeSelectWriteRequest(body);
16895
+ if (!request.ok) {
16896
+ const refused = new BrowserError(request.code, request.message, request.details);
16897
+ sendJson(res, scopeHttpStatus(request.code), { ok: false, ...refused.payload(), error: refused.message });
16898
+ return;
16899
+ }
16900
+ const applied = await entry.runtime.run(() => entry.runtime.selectValue(request));
16901
+ registry.markRecent(entry);
16902
+ sendJson(res, 200, { ok: true, value: applied.value, selectedIndex: applied.selectedIndex, selector: request.selector });
16903
+ return;
16904
+ }
14627
16905
  if (action === '/eval') {
16906
+ crossEnvironment.assertGuiAllowed(scopeRequest); // A10: same environment gate as /navigate.
14628
16907
  const { entry } = await select();
14629
16908
  selected = entry;
14630
16909
  registry.touch(entry);
@@ -14635,12 +16914,18 @@ export function apply(ctx) {
14635
16914
  return;
14636
16915
  }
14637
16916
  if (action === '/screenshot') {
16917
+ crossEnvironment.assertGuiAllowed(scopeRequest);
14638
16918
  const { entry } = await select();
14639
16919
  selected = entry;
14640
16920
  registry.touch(entry);
14641
16921
  // `canvas:true` enables the canvas-space seed provenance (O = 0 keeps the raw single-CDP
14642
16922
  // PNG path). Empty/legacy bodies keep the historical raw reply shape byte-for-byte.
14643
16923
  const canvas = body && typeof body === 'object' && body.canvas === true;
16924
+ // `includeAnnotations:true` (annot bar → 截图): the panel asks for the SAME composited
16925
+ // capture the agent's `browser_screenshot{includeAnnotations:true}` produces, so the human
16926
+ // can hand its own page+notes to the chat as one image. It goes through `capture()`, i.e.
16927
+ // the documented canvas-space path, and therefore also carries `capture`.
16928
+ const includeAnnotations = body && typeof body === 'object' && body.includeAnnotations === true;
14644
16929
  // The panel's seed capture is a display request, not a lifecycle action: a closed primary
14645
16930
  // must stay closed (the empty tab state), otherwise every panel mount would resurrect a
14646
16931
  // blank tab. Agent screenshot calls go through the tool runtime, not this GUI route.
@@ -14648,18 +16933,28 @@ export function apply(ctx) {
14648
16933
  sendJson(res, 200, { ok: true, closed: true, image: null, capturedAt: Date.now() });
14649
16934
  return;
14650
16935
  }
14651
- const shot = await entry.runtime.run(() => entry.runtime.screenshot(canvas ? { canvas: true } : {}));
16936
+ const shot = includeAnnotations
16937
+ ? await entry.runtime.run(() => entry.runtime.capture({ scale: 1, includeAnnotations: true }))
16938
+ : await entry.runtime.run(() => entry.runtime.screenshot(canvas ? { canvas: true } : {}));
14652
16939
  registry.markRecent(entry);
16940
+ const capture = shot.capture || null;
16941
+ const viewport = capture && capture.viewport ? capture.viewport : null;
14653
16942
  sendJson(res, 200, {
14654
16943
  ok: true,
14655
16944
  image: shot.base64,
14656
- viewport: shot.viewport,
14657
- capturedAt: shot.capturedAt,
14658
- ...(canvas ? { view: shot.view, capture: shot.capture } : {}),
16945
+ viewport: viewport || shot.viewport || null,
16946
+ capturedAt: (capture && capture.capturedAt) || shot.capturedAt || Date.now(),
16947
+ ...(includeAnnotations ? {
16948
+ capture,
16949
+ // Same metadata the tool reports: revision/context/source/truncation of the frozen
16950
+ // document, so the panel can say which document the picture shows.
16951
+ annotations: (capture && capture.annotations) || null,
16952
+ } : canvas ? { view: shot.view, capture } : {}),
14659
16953
  });
14660
16954
  return;
14661
16955
  }
14662
16956
  if (action === '/tabs') {
16957
+ crossEnvironment.assertGuiAllowed(scopeRequest);
14663
16958
  const { entry } = await select();
14664
16959
  selected = entry;
14665
16960
  const primary = registry.primaryEntry(entry) || entry;
@@ -14687,13 +16982,23 @@ export function apply(ctx) {
14687
16982
  // scope binding remain stable; the shared host exits only when no page is left.
14688
16983
  const { entry } = await select({ create: false });
14689
16984
  selected = entry;
14690
- if (entry) {
14691
- registry.touch(entry);
14692
- // P0-E: primary close only makes it cold; child tabs stay live. Closing a page is a
14693
- // lifecycle action, not page input, so it is not gated by the per-tab Agent lease;
14694
- // browser_tabs.close keeps its own session/lease rule.
14695
- await entry.runtime.run(() => entry.runtime.close());
16985
+ if (!entry) {
16986
+ // A scoped close that names no live runtime must not answer ok:true: the panel would
16987
+ // drop its tab locally while the Host kept the page (an unresolved scope binding used to
16988
+ // look like a successful close). Legacy callers keep the historical idempotent reply.
16989
+ if (!scoped) {
16990
+ sendJson(res, 200, { ok: true });
16991
+ return;
16992
+ }
16993
+ const error = new BrowserError('SCOPE_NOT_FOUND', '关闭请求指向的运行时不存在', { scope: 'binding' });
16994
+ sendJson(res, 404, { ok: false, ...error.payload(), error: error.message });
16995
+ return;
14696
16996
  }
16997
+ registry.touch(entry);
16998
+ // P0-E: primary close only makes it cold; child tabs stay live. Closing a page is a
16999
+ // lifecycle action, not page input, so it is not gated by the per-tab Agent lease;
17000
+ // browser_tabs.close keeps its own session/lease rule.
17001
+ await entry.runtime.run(() => entry.runtime.close());
14697
17002
  sendJson(res, 200, { ok: true });
14698
17003
  return;
14699
17004
  }
@@ -14701,7 +17006,7 @@ export function apply(ctx) {
14701
17006
  sendJson(res, 404, { ok: false, ...error.payload(), error: error.message });
14702
17007
  } catch (error) {
14703
17008
  const classified = asBrowserError(error);
14704
- if (selected) {
17009
+ if (selected && !NON_PAGE_ERROR_CODES.has(classified.code)) {
14705
17010
  selected.runtime.setError(classified.message, {
14706
17011
  actionId: null,
14707
17012
  target: selected.runtime.url ? redactUrl(selected.runtime.url) : null,
@@ -14713,13 +17018,96 @@ export function apply(ctx) {
14713
17018
 
14714
17019
  ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler: handle }), 'ui-testing: http routes');
14715
17020
 
17021
+ /**
17022
+ * Describe the *live* target this tool call is running against, when it has one.
17023
+ *
17024
+ * Readiness must answer "is the target this session is actually driving bound?", not a static
17025
+ * projection: a running page with a runtimeId is a bound target with a connected transport
17026
+ * (plan §6.2 six axes, A13). Diagnostics never start a target, so an idle runtime reports null
17027
+ * and the axes stay `not-probed`/`unbound` truthfully.
17028
+ */
17029
+ function liveReadinessTarget(exec, runtime) {
17030
+ const rt = runtime || exec?.runtime || null;
17031
+ if (!rt || typeof rt !== 'object') return null;
17032
+ // A created-but-idle runtime entry is not a bound target: only a running page (or a live CDP
17033
+ // transport) proves the connection/target axes. Otherwise the probe's not-probed/unbound stay.
17034
+ const live = rt.running === true || Boolean(rt.cdp && rt.cdp.closed !== true);
17035
+ if (!live) return null;
17036
+ const runtimeId = rt.runtimeId || rt.id || null;
17037
+ if (!runtimeId) return null;
17038
+ return {
17039
+ sessionId: exec?.agent?.id ? String(exec.agent.id) : null,
17040
+ runtimeId: String(runtimeId),
17041
+ url: typeof rt.url === 'string' ? redactUrl(rt.url) : null,
17042
+ transport: rt.transport || (rt.send ? 'ws' : null),
17043
+ // The Runtime keeps the page/document generation as `generation`; both names are accepted so
17044
+ // the target axis can carry a real generation instead of a null placeholder.
17045
+ pageGeneration: Number.isInteger(rt.pageGeneration) ? rt.pageGeneration
17046
+ : (Number.isInteger(rt.generation) ? rt.generation : null),
17047
+ documentEpoch: Number.isInteger(rt.documentEpoch) ? rt.documentEpoch : null,
17048
+ };
17049
+ }
17050
+
14716
17051
  const text = (value) => [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value) }];
17052
+ /**
17053
+ * P0 data access for tools (A01): one entry through the environment's P0 provider.
17054
+ *
17055
+ * The provider owns target binding, generation and the evidence envelope; the tool keeps its
17056
+ * public result shape. `raw: true` (default) keeps platform-shaped payloads untouched.
17057
+ */
17058
+ const p0 = async (exec, family, method, args = {}, extra = {}) => {
17059
+ const result = await crossEnvironment.p0Call({
17060
+ family,
17061
+ method,
17062
+ args,
17063
+ exec,
17064
+ runtime: exec?.runtime ?? null,
17065
+ entry: extra.entry ?? null,
17066
+ meta: exec?.meta ?? null,
17067
+ selection: extra.selection ?? null,
17068
+ resolvedScope: extra.resolvedScope ?? null,
17069
+ raw: extra.raw !== false,
17070
+ });
17071
+ return extra.withEvidence === true ? result : result.value;
17072
+ };
14717
17073
  const register = (definition) => {
14718
17074
  const execute = definition.execute;
14719
17075
  if (!definition.timeoutMs) definition.timeoutMs = 30000;
14720
17076
  definition.execute = async (args, exec = {}) => {
17077
+ // Execution guard: conditional registration alone is not enough (old calls, /test/invoke,
17078
+ // programmatic dispatch). The same snapshot gates the actual dispatch.
17079
+ crossEnvironment.assertToolAllowed(exec.agent, definition.name);
17080
+ // A14: the Web-only module chain is resolved here, once, before any page/state work — and
17081
+ // only when this call actually needs it. Neutral diagnostics must still answer on a host
17082
+ // whose Web environment is disabled, so for them the load is optional and failure-tolerant:
17083
+ // the capability matrix then reports "limits not available" instead of the tool failing.
17084
+ // The two neutral diagnostics describe the Web capability matrix, so they need the module
17085
+ // chain too — but they must keep answering if it cannot be loaded (a disabled/absent
17086
+ // environment then reports "limits unavailable" instead of failing the whole tool).
17087
+ if (definition.name !== 'browser_fake_probe') {
17088
+ if (['browser_state', 'browser_capabilities'].includes(definition.name)) {
17089
+ try { await ensureWebHostModules(); } catch { /* diagnostics keep answering */ }
17090
+ } else {
17091
+ await ensureWebHostModules();
17092
+ }
17093
+ }
17094
+ const admissionSnapshot = crossEnvironment.snapshotForAgent(exec.agent);
14721
17095
  const sessionId = String(exec.agent?.id || 'anonymous');
17096
+ // M2a: prove *who* is calling before any scope resolution, entry creation or CDP. An
17097
+ // unprovable identity must fail while there is still nothing to dispatch to.
17098
+ // The diagnostic dispatcher is the one narrow exception: its probes act as the panel inside the
17099
+ // root of the page the run froze (see `diagnosticPrincipalFor`).
17100
+ const principal = diagnosticPrincipalFor(exec) || resolveToolPrincipal(ctx, exec);
17101
+ // The call's real effects are known before any scope/entry work: they decide which selection
17102
+ // this call uses (observation vs work) and later authorise its page part. One expansion, one
17103
+ // truth — re-deriving "is this a read?" from the tool name is how a read tool ends up writing.
17104
+ const callEffects = operationEffect(definition.name, args);
17105
+ const observeOnlyCall = callEffects.length > 0
17106
+ && callEffects.every((row) => row.effect === EFFECT.OBSERVE || row.effect === EFFECT.CALLER_LOCAL);
14722
17107
  const meta = { actionId: randomUUID(), sessionId, startedAt: Date.now(), tool: definition.name,
17108
+ configRevision: admissionSnapshot.revision,
17109
+ environment: admissionSnapshot.environment,
17110
+ adapterId: admissionSnapshot.adapterId,
14723
17111
  // Expressions and input text may contain secrets: record a bounded structural summary.
14724
17112
  args: Object.fromEntries(Object.entries(args || {}).map(([key, value]) => [key,
14725
17113
  key === 'url' ? redactUrl(String(value))
@@ -14738,6 +17126,15 @@ export function apply(ctx) {
14738
17126
  // no recent/error bookkeeping. It may still read another owner's cached view.
14739
17127
  const readOnly = typeof definition.readOnly === 'function' && definition.readOnly(args) === true;
14740
17128
  const isHttpRequest = definition.name === 'browser_http_request';
17129
+ // test-only fake environment tool: no Web runtime/page is created or touched.
17130
+ const isFakeTool = definition.name === 'browser_fake_probe';
17131
+ // Environment-neutral diagnostics (browser_state / browser_capabilities) answer "what is
17132
+ // here / why is it refused". Outside the Web environment there is no page to report, and
17133
+ // creating one would start a target the user did not enable — so they run without a runtime.
17134
+ // Only `browser_state` needs the runtime-free path: `browser_capabilities` is documented as
17135
+ // never starting a target, so it keeps its (inert) page entry and reports the adapter matrix.
17136
+ const neutralWithoutWeb = definition.name === 'browser_state'
17137
+ && admissionSnapshot.environment !== 'web';
14741
17138
  // Web P0 network read-only tools (session.status/reset, events, detail, trace query, evidence,
14742
17139
  // subscribe) are scope-local reads: they resolve the workspace runtime but never take the page
14743
17140
  // queue, the lease, or lazily start Chromium (docs/network-p0.md, R1-5).
@@ -14748,25 +17145,47 @@ export function apply(ctx) {
14748
17145
  let primaryEntry;
14749
17146
  let selection;
14750
17147
  let runtime;
17148
+ let resolvedScope = null;
14751
17149
  try {
14752
- const resolved = await resolveScopeRequest(ctx, registry, toolScopeRequest(exec));
17150
+ // Diagnostic dispatcher (integration/diagnose.js): the caller already froze the scope and
17151
+ // the runtime it is looking at, and re-resolving here would manufacture a second runtime
17152
+ // for the same workspace (or fail with SCOPE_NOT_FOUND for an unassigned scope). The call
17153
+ // still goes through admission, the adapter and Runtime.run below.
17154
+ const diagnostic = exec.diagnosticScope;
17155
+ if (diagnostic && diagnostic.entry && diagnostic.resolved) {
17156
+ resolvedScope = diagnostic.resolved;
17157
+ meta.scope = resolvedScope.scope;
17158
+ if (definition.name !== 'browser_http_request' && definition.name !== 'browser_fake_probe' && !neutralWithoutWeb) {
17159
+ entry = diagnostic.entry;
17160
+ primaryEntry = entry;
17161
+ runtime = entry.runtime;
17162
+ selection = null;
17163
+ }
17164
+ throw { __diagnosticScope: true };
17165
+ }
17166
+ const resolved = await resolveScopeRequest(ctx, registry, toolScopeRequest(exec, principal));
17167
+ resolvedScope = resolved;
14753
17168
  meta.scope = resolved.scope;
14754
17169
  // The independent HTTP tool is evidence-only: resolve identity, but never create/touch a
14755
- // page entry (no queue, no lease, no page).
14756
- if (!isHttpRequest) {
17170
+ // page entry (no queue, no lease, no page). The fake tool has no Web runtime either.
17171
+ if (!isHttpRequest && !isFakeTool && !neutralWithoutWeb) {
14757
17172
  entry = registry.getOrCreate(resolved);
14758
17173
  primaryEntry = entry;
14759
17174
  if (isTabsTool) {
14760
17175
  runtime = entry.runtime;
14761
17176
  } else {
14762
- const selected = registry.resolveSessionTab(entry, sessionId);
17177
+ const selected = registry.resolveSessionTab(entry, sessionId, {
17178
+ mode: observeOnlyCall ? 'read' : 'work',
17179
+ });
14763
17180
  entry = selected.entry;
14764
17181
  selection = selected.selection;
14765
17182
  runtime = entry.runtime;
14766
17183
  }
14767
17184
  }
14768
17185
  } catch (error) {
14769
- if (!isHttpRequest) {
17186
+ if (error && error.__diagnosticScope) {
17187
+ // Scope already frozen by the diagnostic dispatcher: nothing left to resolve.
17188
+ } else if (!isHttpRequest && !isFakeTool && !neutralWithoutWeb) {
14770
17189
  const classified = asBrowserError(error);
14771
17190
  const evidence = { ...meta, finishedAt: Date.now(), target: '' };
14772
17191
  classified.details = losslessJson({ ...classified.details, ...evidence }, definition.name + '.details', []);
@@ -14782,10 +17201,45 @@ export function apply(ctx) {
14782
17201
  const hostOnly = typeof definition.hostOnly === 'function' && definition.hostOnly(args, runtime) === true;
14783
17202
  // The independent HTTP tool must not enter the page queue, the lease, activity or lastError;
14784
17203
  // only real page/network operations participate in the error projection.
14785
- const pageOperation = !readOnly && !hostOnly && !diagnostic && !isHttpRequest && !isNetworkRead;
17204
+ const pageOperation = !readOnly && !hostOnly && !diagnostic && !isHttpRequest && !isNetworkRead && !isFakeTool;
14786
17205
  const actionTarget = definition.name === 'browser_navigate'
14787
17206
  ? redactUrl(String((args && args.url) || ''))
14788
17207
  : (runtime && runtime.url ? redactUrl(runtime.url) : null);
17208
+ // M2a: authorise the *page* part of this call before it can touch a target.
17209
+ //
17210
+ // This runs after the target has been resolved (so the verdict is about the page this call
17211
+ // will really drive) and before `runtime.run` — i.e. before ensure, bringToFront, the queue
17212
+ // and any CDP command. `browser_tabs` is excluded here because it authorises per action and
17213
+ // per tab inside its own implementation, where the target is known.
17214
+ let pageEffects = null;
17215
+ let pageControlRevision = null;
17216
+ let autoAcquired = false;
17217
+ if (pageOperation && entry && !isTabsTool) {
17218
+ const effects = callEffects.filter((row) => row.resource === RESOURCE.PAGE);
17219
+ if (effects.length > 0) {
17220
+ pageEffects = effects;
17221
+ const verdict = authorizeAll({
17222
+ principal,
17223
+ ...registry.accessOf(entry),
17224
+ effects,
17225
+ operation: definition.name,
17226
+ });
17227
+ if (!verdict.allowed) throw registry.accessError(verdict.verdict, definition.name);
17228
+ if (verdict.verdict.autoAcquire === true) {
17229
+ // Atomic "take over, then dispatch" (plan §5.1): a Lead writing an idle member page
17230
+ // acquires control *inside this call* and keeps it until an explicit release, so a
17231
+ // member cannot slip a write into the middle of a multi-step Lead flow. The drain/commit
17232
+ // pair is the same bounded, non-aborting one the explicit `acquire` action uses.
17233
+ const acquired = await registry.acquireControl(entry, principal, {
17234
+ expectedControlRevision: registry.accessOf(entry).control.controlRevision,
17235
+ timeoutMs: TAB_ACQUIRE_TIMEOUT_DEFAULT_MS,
17236
+ signal: exec.signal,
17237
+ });
17238
+ autoAcquired = acquired.changed === true;
17239
+ }
17240
+ pageControlRevision = registry.accessOf(entry).control?.controlRevision ?? null;
17241
+ }
17242
+ }
14789
17243
  // Every tool value leaves through this single lossless-JSON boundary.
14790
17244
  const finish = (value, target) => {
14791
17245
  // Envelope ordering matters per tool family (docs/network-p0.md):
@@ -14795,24 +17249,49 @@ export function apply(ctx) {
14795
17249
  const tabMeta = selection
14796
17250
  ? { id: selection.tabId, runtimeId: runtime ? runtime.runtimeId : null, selectionRevision: selection.revision }
14797
17251
  : (runtime ? { id: 'primary', runtimeId: runtime.runtimeId, selectionRevision: 0 } : null);
17252
+ const controlMeta = autoAcquired
17253
+ ? { autoAcquired: true, control: registry.controlProjection(entry) }
17254
+ : {};
14798
17255
  const result = isNetworkRead
14799
- ? { ...meta, ...value, agentSessionId: meta.sessionId, ...(tabMeta ? { tab: tabMeta } : {}), finishedAt: Date.now(), target }
14800
- : { ...value, ...meta, ...(tabMeta ? { tab: tabMeta } : {}), finishedAt: Date.now(), target };
17256
+ ? { ...meta, ...value, agentSessionId: meta.sessionId, ...(tabMeta ? { tab: tabMeta } : {}), ...controlMeta, finishedAt: Date.now(), target }
17257
+ : { ...value, ...meta, ...(tabMeta ? { tab: tabMeta } : {}), ...controlMeta, finishedAt: Date.now(), target };
14801
17258
  const notes = [];
14802
17259
  const clean = losslessJson(result, definition.name, notes);
14803
17260
  if (!clean || typeof clean !== 'object' || Array.isArray(clean)) throw new BrowserError('INTERNAL', '工具返回值不是对象');
14804
17261
  if (notes.length > 0) clean.sanitized = notes;
14805
17262
  return clean;
14806
17263
  };
17264
+ // Every tool implementation goes through the environment's capability adapter. The caller
17265
+ // keeps the existing execution-class branch (queue/ensure/lease); the adapter validates
17266
+ // admission/identity and wraps the value with an evidence envelope.
17267
+ const runThroughAdapter = (thunk, executionClass) => crossEnvironment.invokeTool({
17268
+ name: definition.name,
17269
+ args,
17270
+ exec,
17271
+ thunk,
17272
+ // The wrapper branch decides queue/ensure; the adapter class is the operation's declared
17273
+ // class refined by its action (e.g. network_session status=readOnly, start=hostOnly).
17274
+ executionClass: crossEnvironment.executionClassFor(definition.name, args) || executionClass,
17275
+ resolvedScope,
17276
+ runtime,
17277
+ entry,
17278
+ selection,
17279
+ meta,
17280
+ });
14807
17281
  try {
14808
17282
  if (isTabsTool) {
14809
- const value = await execute(args, { ...exec, meta, sessionId, runtime, primaryEntry, registry, httpClient });
17283
+ const value = await runThroughAdapter(() => execute(args, {
17284
+ ...exec, meta, sessionId, principal, runtime, primaryEntry, registry, httpClient,
17285
+ }), 'tabs');
14810
17286
  return finish(value, runtime && runtime.url ? redactUrl(runtime.url) : '');
14811
17287
  }
14812
- if (isHttpRequest || isNetworkRead) {
17288
+ if (isHttpRequest || isNetworkRead || isFakeTool) {
14813
17289
  // Independent dispatch branch: no ensure/run/claim/touchAgent, no page queue, no
14814
17290
  // profile, no page lastError. The implementation does its own argument validation.
14815
- const value = await execute(args, { ...exec, meta, sessionId, runtime, httpClient });
17291
+ const value = await runThroughAdapter(
17292
+ () => execute(args, { ...exec, meta, sessionId, principal, runtime, httpClient }),
17293
+ isFakeTool ? 'page' : (isHttpRequest ? 'independentHttp' : 'networkRead'),
17294
+ );
14816
17295
  return finish(value, meta.target || '');
14817
17296
  }
14818
17297
  // Optional pure pre-flight, outside the queue: an invalid argument must cost zero CDP
@@ -14823,10 +17302,27 @@ export function apply(ctx) {
14823
17302
  // Cache-only diagnostic / host-memory-only action: deliberately outside run() so it cannot activate the page,
14824
17303
  // wait behind the queue, claim the lease or mark the entry recent.
14825
17304
  meta.target = runtime.url ? redactUrl(runtime.url) : '';
14826
- const value = await execute(args, { ...exec, meta, sessionId, runtime, httpClient });
17305
+ const value = await runThroughAdapter(
17306
+ () => execute(args, { ...exec, meta, sessionId, principal, runtime, httpClient }),
17307
+ hostOnly ? 'hostOnly' : 'readOnly',
17308
+ );
14827
17309
  return finish(value, meta.target);
14828
17310
  }
14829
17311
  const value = await runtime.run(async () => {
17312
+ // Dispatch-time re-check (plan §5.3 last bullet: "真正派发前重验 … ownership/controlRevision").
17313
+ // Between admission and this point another actor may have drained the page; the answer is
17314
+ // TAB_CONTROL_CHANGED with ZERO dispatch — never a write against a page whose control moved
17315
+ // while the call was queued.
17316
+ if (pageEffects) {
17317
+ const recheck = authorizeAll({
17318
+ principal,
17319
+ ...registry.accessOf(entry),
17320
+ effects: pageEffects,
17321
+ operation: definition.name,
17322
+ expectedControlRevision: pageControlRevision,
17323
+ });
17324
+ if (!recheck.allowed) throw registry.accessError(recheck.verdict, definition.name);
17325
+ }
14830
17326
  meta.target = runtime.url ? redactUrl(runtime.url) : '';
14831
17327
  if (pageOperation) runtime.touchAgent(definition.name === 'browser_navigate' ? '正在打开 ' + redactUrl(String(args?.url || '')) : ({
14832
17328
  browser_eval: '执行脚本', browser_input: '派发输入事件', browser_network: '读取网络记录',
@@ -14844,12 +17340,27 @@ export function apply(ctx) {
14844
17340
  // ever claiming causality.
14845
17341
  runtime.netMark({ tool: definition.name, actionId: meta.actionId, kind: 'tool', text: 'begin' });
14846
17342
  try {
14847
- return await execute(args, { ...exec, meta, runtime, httpClient });
17343
+ return await runThroughAdapter(
17344
+ () => execute(args, { ...exec, meta, runtime, httpClient, sessionId, principal }),
17345
+ diagnostic ? 'diagnostic' : 'page',
17346
+ );
14848
17347
  } finally {
14849
17348
  runtime.netMark({ tool: definition.name, actionId: meta.actionId, kind: 'tool', text: 'end' });
14850
17349
  }
14851
- }, { signal: exec.signal, sessionId, agent: pageOperation,
14852
- action: pageOperation ? { actionId: meta.actionId, target: actionTarget } : undefined });
17350
+ }, {
17351
+ signal: exec.signal,
17352
+ sessionId,
17353
+ // A read-only page operation must not take the single-owner write lease: claiming it made
17354
+ // "look at a teammate's page" block that teammate's next write for 15s. `activate()` still
17355
+ // runs inside run() — non-interference of the *foreground* switch is NOT implemented here
17356
+ // (see the M4 report: measured, reported, not papered over).
17357
+ agent: pageOperation && !observeOnlyCall,
17358
+ // An all-observe call must not bring the page to the front: that is what made a read of a
17359
+ // teammate's page flip its visibilityState / freeze its background frames. Reads still run
17360
+ // in this queue and still need read authorisation — only `activate()` is skipped.
17361
+ activate: !observeOnlyCall,
17362
+ action: pageOperation ? { actionId: meta.actionId, target: actionTarget } : undefined,
17363
+ });
14853
17364
  if (pageOperation) {
14854
17365
  registry.touch(entry);
14855
17366
  registry.markRecent(entry);
@@ -14886,9 +17397,68 @@ export function apply(ctx) {
14886
17397
  }
14887
17398
  };
14888
17399
  testToolDefs.set(definition.name, definition);
14889
- return ctx.effect(() => ctx.tools.register(definition), 'ui-testing: tool ' + definition.name);
17400
+ // Per-step admission: the same snapshot decides model-visible schema and dispatch. `when`
17401
+ // must never throw (a rejected condition fails the whole step preparation).
17402
+ const when = (context = {}) => {
17403
+ try {
17404
+ return crossEnvironment.toolAdmitted(context.agent, definition.name);
17405
+ } catch {
17406
+ return false;
17407
+ }
17408
+ };
17409
+ // Registration is deferred to `syncToolVisibility()`, which registers the *union* of tool
17410
+ // definitions once and lets the host resolve `when(agent)` per step. The host's declaration
17411
+ // path (`ToolsService.resolve` -> the `systemPrompt.tools` provider reading
17412
+ // `definitions(scope, toolSet)` in dsh-tools/lib/index.js) filters the model-facing list with
17413
+ // that same admitted set, so a per-agent snapshot decides declaration and dispatch together
17414
+ // (A08). The earlier global register/unregister sync leaked one agent's selection to every
17415
+ // other agent in the process.
17416
+ toolDefinitions.set(definition.name, { definition, when });
17417
+ return null;
14890
17418
  };
14891
17419
 
17420
+ /**
17421
+ * Register the union of tool definitions, each with its per-agent `when` predicate.
17422
+ *
17423
+ * Registration no longer depends on the *global* selection: the host admits per step, so every
17424
+ * environment's tools can stay registered without leaking into another agent's request (A08).
17425
+ * Environment-neutral diagnostics stay registered in every environment (including a disabled
17426
+ * one) so "why is it unavailable" remains answerable.
17427
+ */
17428
+ function syncToolVisibility() {
17429
+ let wanted;
17430
+ try {
17431
+ wanted = crossEnvironment.declarableToolNames();
17432
+ } catch (error) {
17433
+ log('ui-testing: tool visibility sync skipped: ' + String((error && error.message) || error));
17434
+ return;
17435
+ }
17436
+ for (const [name, dispose] of toolDisposers) {
17437
+ if (wanted.has(name)) continue;
17438
+ try { dispose(); } catch { /* best effort */ }
17439
+ toolDisposers.delete(name);
17440
+ }
17441
+ for (const name of wanted) {
17442
+ if (toolDisposers.has(name)) continue;
17443
+ const entry = toolDefinitions.get(name);
17444
+ if (!entry) continue;
17445
+ try {
17446
+ toolDisposers.set(name, ctx.tools.register(entry.definition, { when: entry.when }));
17447
+ } catch (error) {
17448
+ log('ui-testing: tool registration failed for ' + name + ': ' + String((error && error.message) || error));
17449
+ }
17450
+ }
17451
+ }
17452
+
17453
+ const toolDefinitions = new Map();
17454
+ const toolDisposers = new Map();
17455
+ ctx.effect(() => () => {
17456
+ for (const dispose of toolDisposers.values()) {
17457
+ try { dispose(); } catch { /* best effort */ }
17458
+ }
17459
+ toolDisposers.clear();
17460
+ }, 'ui-testing: tool visibility');
17461
+
14892
17462
  register({
14893
17463
  name: 'browser_navigate',
14894
17464
  description: '在调试视图的内嵌浏览器里打开一个 HTTP(S) 地址。',
@@ -14922,7 +17492,8 @@ export function apply(ctx) {
14922
17492
  name: 'browser_screenshot',
14923
17493
  description: '截取内嵌浏览器的画面(PNG):默认 canvas 视口 [C,C+W)×[C,C+H)、默认比例 = 当前共享显示比例 view.scale(冷启动 100%);可选 scale(连续 0.1–4,可用小数;重新光栅化,不是位图放大)、'
14924
17494
  + 'region(canvas CSS 像素矩形;O=0 时与文档坐标相同,可为负 → blank 纯白)、persist(默认 false:写插件暂存目录,不落工作区)、out(工作区相对 .png,提供即视为持久留存)。'
14925
- + 'O≠0 时以真实 S 截页面再白底合成(重编码),证据 capture.canvas.composite=true;O=0 保持单次 CDP PNG 不重编码。'
17495
+ + '用户提到标注、画、圈、框、箭头或笔迹等面板标注时,传 includeAnnotations:true,把已提交标注按当前画布坐标叠加到截图(含文字和画笔),返回 capture.annotations 版本与来源;默认 false。'
17496
+ + 'O≠0 时以真实 S 截页面再白底合成(重编码),证据 capture.canvas.composite=true;O=0 且未叠加标注时保持单次 CDP PNG 不重编码。'
14926
17497
  + '只有 out / persist:true / DSH_UI_TESTING_CAPTURE_DIR 三者之一才持久落盘(0600、原子写入、绝不覆盖);'
14927
17498
  + '暂存文件返回绝对路径并在插件卸载时删除。返回 PNG 结构/尺寸校验/空白识别/落盘/附件对比证据与 warnings;尺寸不符或 PNG 损坏时不交付图片。',
14928
17499
  parameters: {
@@ -14943,6 +17514,10 @@ export function apply(ctx) {
14943
17514
  },
14944
17515
  required: ['x', 'y', 'width', 'height'],
14945
17516
  },
17517
+ includeAnnotations: {
17518
+ type: 'boolean',
17519
+ description: '默认 false。用户提到标注、画、圈、框、箭头、笔迹等面板标注时设为 true:将当前已提交的圆、矩形、线、箭头、画笔与文字叠加到浏览器截图,按 region/scale 一起裁剪缩放。只含 Host 已接受的标注,不含未提交草稿和面板工具栏;capture.annotations 记录版本、来源、过期与截断状态。标注是人类线索,不是页面事实。',
17520
+ },
14946
17521
  persist: {
14947
17522
  type: 'boolean',
14948
17523
  description: '是否持久留存(默认 false)。false/省略 = 写插件暂存目录(返回绝对路径,插件卸载时删除,不落工作区);true = 持久写入可用根(DSH_UI_TESTING_CAPTURE_DIR,未配置时为工作区)下的 output/capture/<actionId>.png;根不可用则 OUTPUT_PATH_INVALID,不回落。',
@@ -15001,10 +17576,12 @@ export function apply(ctx) {
15001
17576
  }
15002
17577
 
15003
17578
  // 3) The single capture (no retry, no second encoding).
15004
- const shot = await runtime.capture({ scale, region: request.region, scaleSource }, exec.signal);
17579
+ // A01: the capture data plane goes through the P0 provider (target binding + evidence),
17580
+ // while the screenshot tool keeps owning its public result shape.
17581
+ const shot = await p0(exec, 'A', 'capture', { scale, region: request.region, scaleSource, includeAnnotations: request.includeAnnotations });
15005
17582
  const data = shot.pngBytes;
15006
17583
  const captured = shot.capture;
15007
- const product = await runtime.browserProduct(exec.signal);
17584
+ const product = await p0(exec, 'A', 'product');
15008
17585
 
15009
17586
  const warnings = [];
15010
17587
  const sha256 = createHash('sha256').update(data).digest('hex');
@@ -15021,6 +17598,7 @@ export function apply(ctx) {
15021
17598
  coordinateSpace: captured.composite ? 'canvas-css-px' : 'document-css-px',
15022
17599
  out: request.out,
15023
17600
  persist: request.persist,
17601
+ includeAnnotations: request.includeAnnotations,
15024
17602
  };
15025
17603
  const environment = {
15026
17604
  browser: 'chromium',
@@ -15068,6 +17646,7 @@ export function apply(ctx) {
15068
17646
  composite: captured.composite,
15069
17647
  contentRect: captured.contentRect,
15070
17648
  pageCapture: captured.pageCapture,
17649
+ annotations: captured.annotations || null,
15071
17650
  };
15072
17651
  if (!sizeCheck.ok) {
15073
17652
  // Use the module's own verdict verbatim: CAPTURE_UPSCALED only says "larger than expected"
@@ -15322,7 +17901,10 @@ export function apply(ctx) {
15322
17901
  register({
15323
17902
  name: 'browser_input',
15324
17903
  description: '向内嵌浏览器派发鼠标 / 滚轮 / 键盘事件(T1 原生事件能力)。坐标使用页面 CSS 像素。'
15325
- + '修饰键用 modifiers(字符串数组 alt/ctrl/meta/shift,或 CDP 位掩码整数 Alt=1/Ctrl=2/Meta=4/Shift=8)。',
17904
+ + '修饰键用 modifiers(字符串数组 alt/ctrl/meta/shift,或 CDP 位掩码整数 Alt=1/Ctrl=2/Meta=4/Shift=8)。'
17905
+ + '带按键状态机:keyDown 记位、keyUp 清位,按住 Shift 后其它事件自动带上 shiftKey(synthesizeModifiers:false 可关);'
17906
+ + 'mouse 事件给 deltaX/deltaY 时按相对位移移动虚拟光标(无需知道绝对位置);导航/关闭会清空状态。'
17907
+ + '返回 {ok,applied,modifiers,held,cursor},held 是当前仍按住的键。',
15326
17908
  parameters: {
15327
17909
  type: 'object',
15328
17910
  properties: {
@@ -15341,8 +17923,18 @@ export function apply(ctx) {
15341
17923
  buttons: { type: 'number', description: '按下的鼠标键位掩码(CDP buttons),移动/拖拽时需带上' },
15342
17924
  pointerType: { type: 'string', enum: ['mouse', 'pen'] },
15343
17925
  clickCount: { type: 'number' },
15344
- deltaX: { type: 'number' },
15345
- deltaY: { type: 'number' },
17926
+ deltaX: {
17927
+ type: 'number',
17928
+ description: 'mouse:相对位移 X(虚拟光标 += deltaX;用于 pointer-lock 类"按增量移动");wheel:滚动增量',
17929
+ },
17930
+ deltaY: {
17931
+ type: 'number',
17932
+ description: 'mouse:相对位移 Y(同上);wheel:滚动增量',
17933
+ },
17934
+ synthesizeModifiers: {
17935
+ type: 'boolean',
17936
+ description: '默认 true:把当前按住的修饰键自动合进本事件的 modifiers(页面 e.shiftKey 等)。false 用于刻意复现"按住但没带修饰位"的行为',
17937
+ },
15346
17938
  modifiers: {
15347
17939
  description: '修饰键:字符串数组 alt/ctrl/meta/shift,或 CDP 位掩码整数(Alt=1,Ctrl=2,Meta=4,Shift=8)。key/mouse/wheel 都支持。',
15348
17940
  // Host-supported JSON-schema subset only (no minimum/maximum): the 0–15 range
@@ -15370,12 +17962,19 @@ export function apply(ctx) {
15370
17962
  },
15371
17963
  output: { schema: { type: 'object' }, render: (args, value) => text(value) },
15372
17964
  async execute(args, exec) {
15373
- const applied = await exec.runtime.input(args?.events, exec.signal);
15374
- return { ok: true, applied };
17965
+ // `held`/`modifiers` are the state machine's echo: a model that lost track of a keyDown can
17966
+ // see the leftover keys instead of guessing, and `cursor` is where relative moves now point.
17967
+ const result = await exec.runtime.input(args?.events, exec.signal);
17968
+ return { ok: true, ...result };
15375
17969
  },
15376
17970
  });
15377
17971
 
15378
17972
  register({
17973
+ // Read-only metadata read: it resolves the workspace scope but never takes the page queue or the
17974
+ // single-owner lease. The lease exists to serialise page *writes*; a traffic snapshot neither
17975
+ // writes the page nor needs ownership, and holding it for the lease TTL would block the next
17976
+ // page operation of another session in the same workspace.
17977
+ networkRead: true,
15379
17978
  name: 'browser_network',
15380
17979
  description: '读取内嵌浏览器最近捕获的网络请求(T1 网络抓包,默认不含请求体)。',
15381
17980
  parameters: {
@@ -15410,7 +18009,7 @@ export function apply(ctx) {
15410
18009
  },
15411
18010
  output: { schema: { type: 'object' }, render: (args, value) => text(value) },
15412
18011
  async execute(args, exec) {
15413
- return exec.runtime.netSession(args || {}, exec, exec.signal);
18012
+ return p0(exec, 'B', String(args?.action || 'start'), args || {});
15414
18013
  },
15415
18014
  });
15416
18015
 
@@ -15438,7 +18037,7 @@ export function apply(ctx) {
15438
18037
  },
15439
18038
  output: { schema: { type: 'object' }, render: (args, value) => text(value) },
15440
18039
  async execute(args, exec) {
15441
- return exec.runtime.netEvents(args || {});
18040
+ return p0(exec, 'B', 'events', args || {});
15442
18041
  },
15443
18042
  });
15444
18043
 
@@ -15463,7 +18062,7 @@ export function apply(ctx) {
15463
18062
  },
15464
18063
  output: { schema: { type: 'object' }, render: (args, value) => text(value) },
15465
18064
  async execute(args, exec) {
15466
- return exec.runtime.netDetail(args || {});
18065
+ return p0(exec, 'B', 'detail', args || {});
15467
18066
  },
15468
18067
  });
15469
18068
 
@@ -15494,7 +18093,7 @@ export function apply(ctx) {
15494
18093
  },
15495
18094
  output: { schema: { type: 'object' }, render: (args, value) => text(value) },
15496
18095
  async execute(args, exec) {
15497
- return exec.runtime.netTrace(args || {});
18096
+ return p0(exec, 'B', 'trace', args || {});
15498
18097
  },
15499
18098
  });
15500
18099
 
@@ -15527,7 +18126,7 @@ export function apply(ctx) {
15527
18126
  },
15528
18127
  output: { schema: { type: 'object' }, render: (args, value) => text(value) },
15529
18128
  async execute(args, exec) {
15530
- return exec.runtime.netControl(args || {}, exec, exec.signal);
18129
+ return p0(exec, 'B', 'control', args || {});
15531
18130
  },
15532
18131
  });
15533
18132
 
@@ -15556,7 +18155,7 @@ export function apply(ctx) {
15556
18155
  },
15557
18156
  output: { schema: { type: 'object' }, render: (args, value) => text(value) },
15558
18157
  async execute(args, exec) {
15559
- return exec.runtime.netEvidence(args || {}, exec);
18158
+ return p0(exec, 'B', 'evidence', args || {});
15560
18159
  },
15561
18160
  });
15562
18161
 
@@ -15582,7 +18181,7 @@ export function apply(ctx) {
15582
18181
  },
15583
18182
  output: { schema: { type: 'object' }, render: (args, value) => text(value) },
15584
18183
  async execute(args, exec) {
15585
- return exec.runtime.netSubscribe(args || {}, exec.meta.sessionId);
18184
+ return p0(exec, 'B', 'subscribe', args || {});
15586
18185
  },
15587
18186
  });
15588
18187
 
@@ -16015,15 +18614,25 @@ export function apply(ctx) {
16015
18614
  description: 'P0-E 多标签页:list/current 只读诊断;wait 纯内存等待调用开始后新出现的标签(0–30000ms,不启动 Chromium、不取 lease),命中返回 matched/matchedTab/elapsedMs/polls,超时抛 WAIT_TIMEOUT;'
16016
18615
  + 'select 选择当前 Agent 会话的 tab(原子 claim,不启动页面);open 新建 about:blank 并选中;close 关闭当前会话持有 lease 的 tab(primary 只变 cold)。'
16017
18616
  + 'list 每项含 source/parentTabId/openEventId/createdAt/lastUsedAt/idleRemainingMs/manageable,并返回本 workspace 最近 8 条 blocked popup notices 与最近 8 条 recentClosed(reason closed|idle|evicted)。'
16018
- + '每个 Agent session 独立 selectedTab,默认 primary。',
18617
+ + 'select 的 mode=work(默认)设置写目标、observe 设置只读观察目标;select 不再 claim 15 秒写租约,被拒绝的 select 不改变调用者已有选择。'
18618
+ + '页面按 (workspace, provider, root) 分组:跨会话/跨 root 的标签根本不在同一组,因此 tabId 只会解析到本 root 的页。'
18619
+ + '每行新增 accessVersion/rootSessionId/ownerSessionId/controllerSessionId/ownershipRevision/controlRevision/accessState/permissions;busy/owner/manageable 仍表示原短租约含义(不作为 ACL)。',
16019
18620
  parameters: {
16020
18621
  type: 'object',
16021
18622
  properties: {
16022
- action: { type: 'string', enum: ['list', 'current', 'select', 'open', 'close', 'wait'] },
18623
+ action: {
18624
+ type: 'string',
18625
+ enum: ['list', 'current', 'select', 'open', 'close', 'wait', 'acquire', 'release', 'transfer'],
18626
+ description: 'acquire/release/transfer 为队长控制权动作(仅本 root Lead):acquire 进入 draining 等安全边界后接管,release 归还 owner,transfer 转交本 root 有效成员。',
18627
+ },
18628
+ toSessionId: { type: 'string', minLength: 1, maxLength: 128, description: '仅 transfer:本 root 内有效 roster 成员 SessionId(提交前重取原生 roster 校验)。' },
18629
+ expectedControlRevision: { type: 'integer', minimum: 1, maximum: 9007199254740991, description: '仅 acquire/release/transfer:CAS 期望的 controlRevision,不匹配返回 TAB_CONTROL_CHANGED。' },
18630
+ expectedOwnershipRevision: { type: 'integer', minimum: 1, maximum: 9007199254740991, description: '仅 transfer:CAS 期望的 ownershipRevision,不匹配返回 TAB_OWNERSHIP_STALE。' },
18631
+ mode: { type: 'string', enum: ['work', 'observe'], description: '仅 select:work(默认)=该页成为本会话写目标,需本会话可写或本 root Lead 可管理;observe=只读观察目标,只改 observationSelection,不 claim、不 activate。' },
16023
18632
  tabId: { type: 'string', description: '目标 tabId(1–128 字符);select/close 使用;wait 用它是等待该 tabId 出现(已存在则立即命中),其余 action 忽略。' },
16024
18633
  kind: { type: 'string', enum: ['primary', 'tab'], description: '仅 wait:只匹配该 kind(list 中 primary/tab)。' },
16025
18634
  source: { type: 'string', enum: ['primary', 'gui-popup', 'agent-open'], description: '仅 wait:只匹配该来源(primary 槽 / GUI 真人 popup 或 GUI + / Agent open)。' },
16026
- timeoutMs: { type: 'integer', minimum: 0, maximum: 30000, description: '仅 wait:等待预算,默认 5000;0 表示只采样一次。超时抛 WAIT_TIMEOUT(details 含 elapsedMs/polls/condition)。' },
18635
+ timeoutMs: { type: 'integer', minimum: 0, maximum: 30000, description: '仅 wait/acquire:wait 是等待预算(默认 5000,0 只采样一次,超时 WAIT_TIMEOUT);acquire 是等待安全边界的预算(默认 5000,超时返回 BUSY 并撤销 draining)。' },
16027
18636
  pollMs: { type: 'integer', minimum: 50, maximum: 1000, description: '仅 wait:轮询间隔,默认 100。' },
16028
18637
  },
16029
18638
  required: ['action'],
@@ -16038,37 +18647,159 @@ export function apply(ctx) {
16038
18647
  const registry = exec.registry;
16039
18648
  const sessionId = exec.sessionId;
16040
18649
  if (!primary || !registry) throw new BrowserError('UNSUPPORTED', 'browser_tabs 需要 workspace 页面组');
16041
- const allowed = ['action', 'tabId', 'kind', 'source', 'timeoutMs', 'pollMs'];
18650
+ const allowed = ['action', 'tabId', 'mode', 'kind', 'source', 'timeoutMs', 'pollMs',
18651
+ 'toSessionId', 'expectedControlRevision', 'expectedOwnershipRevision'];
16042
18652
  if (args && Object.keys(args).some((key) => !allowed.includes(key))) {
16043
18653
  throw new BrowserError('INVALID_ARGUMENT', 'browser_tabs 不接受未知字段', { field: 'additionalProperties', allowed });
16044
18654
  }
16045
- if (action !== 'wait' && args && ['kind', 'source', 'timeoutMs', 'pollMs'].some((key) => args[key] !== undefined)) {
16046
- throw new BrowserError('INVALID_ARGUMENT', 'kind/source/timeoutMs/pollMs 只用于 wait', { field: 'action' });
18655
+ // Every action declares exactly the fields it owns (ruling §7.5: "非所属 action 字段拒绝").
18656
+ // A field that belongs to another action is never ignored: silently dropping
18657
+ // `expectedControlRevision` would turn a CAS-guarded management call into an unguarded one.
18658
+ const actionFields = {
18659
+ list: ['action'],
18660
+ current: ['action'],
18661
+ open: ['action'],
18662
+ select: ['action', 'tabId', 'mode'],
18663
+ close: ['action', 'tabId'],
18664
+ wait: ['action', 'tabId', 'kind', 'source', 'timeoutMs', 'pollMs'],
18665
+ acquire: ['action', 'tabId', 'expectedControlRevision', 'timeoutMs'],
18666
+ release: ['action', 'tabId', 'expectedControlRevision'],
18667
+ transfer: ['action', 'tabId', 'toSessionId', 'expectedControlRevision', 'expectedOwnershipRevision'],
18668
+ };
18669
+ const foreign = Object.keys(args || {}).filter((key) => !(actionFields[action] || ['action']).includes(key));
18670
+ if (foreign.length > 0) {
18671
+ throw new BrowserError('INVALID_ARGUMENT', `${action} 不接受字段 ${foreign.join(',')}`, {
18672
+ field: 'action', action, allowed: actionFields[action] || ['action'], rejected: foreign,
18673
+ });
16047
18674
  }
18675
+ const principal = exec.principal || null;
16048
18676
  if (action === 'list') {
18677
+ // A01: the target list is a P0-E read; it goes through the provider so the projection and
18678
+ // its evidence come from one place (the provider binds the calling target internally).
18679
+ const listed = await p0(exec, 'E', 'list', {}, { entry: primary });
18680
+ const providerRows = Array.isArray(listed) ? listed : (Array.isArray(listed?.tabs) ? listed.tabs : []);
18681
+ // The provider projection answers "what target is this", not "may this caller act on it".
18682
+ // The long-lived ownership/control/permission fields are added here, from the registry this
18683
+ // call is authorised against, so the tool result carries one consistent ACL story (ruling
18684
+ // §5 "每页返回") without teaching the P0 adapter about principals (that is M3's wiring).
18685
+ const ownRows = new Map(registry.listTabs(primary, sessionId, principal)
18686
+ .map((row) => [String(row.tabId), row]));
18687
+ const tabs = providerRows.map((row) => {
18688
+ const own = ownRows.get(String(row.tabId));
18689
+ if (!own) return row;
18690
+ return {
18691
+ ...row,
18692
+ accessVersion: own.accessVersion,
18693
+ rootSessionId: own.rootSessionId,
18694
+ creatorSessionId: own.creatorSessionId,
18695
+ ownerSessionId: own.ownerSessionId,
18696
+ controllerSessionId: own.controllerSessionId,
18697
+ ownershipRevision: own.ownershipRevision,
18698
+ controlRevision: own.controlRevision,
18699
+ accessState: own.accessState,
18700
+ permissions: own.permissions,
18701
+ };
18702
+ });
18703
+ const selection = registry.selectionFor(primary, sessionId);
16049
18704
  return {
16050
- ok: true, action, tabs: registry.listTabs(primary, sessionId),
18705
+ ok: true, action, tabs,
16051
18706
  notices: registry.noticesView(primary), recentClosed: registry.recentClosedView(primary),
16052
- selection: registry.selectionFor(primary, sessionId),
18707
+ accessVersion: 1,
18708
+ // `selection` stays the workSelection compatibility projection (ruling §5).
18709
+ selection,
18710
+ workSelection: selection,
18711
+ observationSelection: selection.observationTabId
18712
+ ? { ...selection, tabId: selection.observationTabId }
18713
+ : null,
16053
18714
  };
16054
18715
  }
16055
18716
  if (action === 'current') {
18717
+ // current never claims: it only confirms the caller's own (possibly retired) selection.
16056
18718
  const selection = registry.confirmSelection(primary, sessionId);
16057
- return { ok: true, action, tabId: selection.tabId, selection, confirmedResetReason: selection.confirmedResetReason || null };
18719
+ return {
18720
+ ok: true, action, tabId: selection.tabId, selection, workSelection: selection,
18721
+ observationSelection: selection.observationTabId ? { ...selection, tabId: selection.observationTabId } : null,
18722
+ confirmedResetReason: selection.confirmedResetReason || null,
18723
+ };
16058
18724
  }
16059
18725
  if (action === 'select') {
16060
18726
  if (typeof args.tabId !== 'string' || !args.tabId.trim()) throw new BrowserError('INVALID_ARGUMENT', 'select 需要 tabId', { field: 'tabId' });
16061
- const selected = registry.selectTab(primary, sessionId, args.tabId.trim());
16062
- return { ok: true, action, tabId: selected.entry.tabId || selected.entry.viewId || 'primary', selection: selected.selection };
18727
+ if (args.tabId.trim().length > 128) throw new BrowserError('INVALID_ARGUMENT', 'select.tabId 必须是 1–128 字符', { field: 'tabId' });
18728
+ const mode = args.mode === undefined ? 'work' : String(args.mode);
18729
+ if (!['work', 'observe'].includes(mode)) {
18730
+ throw new BrowserError('INVALID_ARGUMENT', 'select.mode 只接受 work/observe', { field: 'mode', allowed: ['work', 'observe'] });
18731
+ }
18732
+ // A refusal throws before anything is written, so a denied select leaves the caller's
18733
+ // existing work/observation selection exactly as it was (plan §5).
18734
+ const selected = registry.selectTab(primary, sessionId, args.tabId.trim(), { mode, principal });
18735
+ return {
18736
+ ok: true, action, mode,
18737
+ tabId: selected.entry.tabId || selected.entry.viewId || 'primary',
18738
+ selection: selected.selection,
18739
+ workSelection: selected.selection.tabId ? selected.selection : null,
18740
+ observationSelection: selected.selection.observationTabId
18741
+ ? { ...selected.selection, tabId: selected.selection.observationTabId } : null,
18742
+ // select never claims a lease any more: say so instead of leaving callers to infer it.
18743
+ claimed: false,
18744
+ };
16063
18745
  }
16064
18746
  if (action === 'open') {
16065
- const opened = await registry.openTab(primary, sessionId, 'agent');
18747
+ const opened = await registry.openTab(primary, sessionId, 'agent', { principal });
16066
18748
  return { ok: true, action, tabId: opened.entry.tabId || opened.entry.viewId, selection: opened.selection };
16067
18749
  }
16068
18750
  if (action === 'close') {
16069
18751
  const tabId = typeof args.tabId === 'string' && args.tabId.trim() ? args.tabId.trim() : registry.selectionFor(primary, sessionId).tabId;
16070
- const closed = await registry.closeTab(primary, sessionId, tabId);
16071
- return { ok: true, action, tabId: closed.closed };
18752
+ const closed = await registry.closeTab(primary, sessionId, tabId, { principal });
18753
+ const closedEntry = registry.tabEntry(primary, tabId) || registry.primaryEntry(primary);
18754
+ return { ok: true, action, tabId: closed.closed, changed: true, control: registry.controlProjection(closedEntry) };
18755
+ }
18756
+ if (action === 'acquire' || action === 'release' || action === 'transfer') {
18757
+ // Management path. Order is the contract (plan §5.2): principal → same root → Lead role →
18758
+ // target → expected version. `authorizePage` inside the registry enforces the first four,
18759
+ // the transition enforces the versions, and the drain wait is the only thing that runs
18760
+ // before the commit — never an abort of somebody's in-flight page operation.
18761
+ if (typeof args.tabId !== 'string' || !args.tabId.trim()) {
18762
+ throw new BrowserError('INVALID_ARGUMENT', `${action} 需要 tabId`, { field: 'tabId' });
18763
+ }
18764
+ const target = registry.tabEntry(primary, args.tabId.trim());
18765
+ if (!target) throw new BrowserError('TAB_NOT_FOUND', '标签不存在或不属于当前 workspace', { tabId: args.tabId, fallbackTabId: 'primary' });
18766
+ const revisionOf = (value, field) => {
18767
+ if (value === undefined || value === null) {
18768
+ throw new BrowserError('INVALID_ARGUMENT', `${action} 需要 ${field}`, { field });
18769
+ }
18770
+ if (!Number.isInteger(value) || value < 1 || value > Number.MAX_SAFE_INTEGER) {
18771
+ throw new BrowserError('INVALID_ARGUMENT', `${field} 必须是 1–9007199254740991 的整数`, { field });
18772
+ }
18773
+ return value;
18774
+ };
18775
+ const expectedControlRevision = revisionOf(args.expectedControlRevision, 'expectedControlRevision');
18776
+ if (action === 'acquire') {
18777
+ const timeoutMs = args.timeoutMs === undefined ? TAB_ACQUIRE_TIMEOUT_DEFAULT_MS : args.timeoutMs;
18778
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > TAB_ACQUIRE_TIMEOUT_MAX_MS) {
18779
+ throw new BrowserError('INVALID_ARGUMENT', `acquire.timeoutMs 必须是 0–${TAB_ACQUIRE_TIMEOUT_MAX_MS} 的整数`, {
18780
+ field: 'timeoutMs', min: 0, max: TAB_ACQUIRE_TIMEOUT_MAX_MS,
18781
+ });
18782
+ }
18783
+ const result = await registry.acquireControl(target, principal, {
18784
+ expectedControlRevision, timeoutMs, signal: exec.signal,
18785
+ });
18786
+ return {
18787
+ ok: true, action, changed: result.changed, waitedMs: result.waitedMs,
18788
+ control: registry.controlProjection(target),
18789
+ };
18790
+ }
18791
+ if (action === 'release') {
18792
+ const result = registry.releaseControl(target, principal, { expectedControlRevision });
18793
+ return { ok: true, action, changed: result.changed, control: registry.controlProjection(target) };
18794
+ }
18795
+ const expectedOwnershipRevision = revisionOf(args.expectedOwnershipRevision, 'expectedOwnershipRevision');
18796
+ const result = registry.transferControl(target, principal, {
18797
+ toSessionId: args.toSessionId, expectedControlRevision, expectedOwnershipRevision,
18798
+ });
18799
+ return {
18800
+ ok: true, action, changed: result.changed, to: result.to,
18801
+ control: registry.controlProjection(target),
18802
+ };
16072
18803
  }
16073
18804
  if (action === 'wait') {
16074
18805
  const timeoutMs = args.timeoutMs === undefined ? TAB_WAIT_TIMEOUT_DEFAULT_MS : args.timeoutMs;
@@ -16159,6 +18890,125 @@ export function apply(ctx) {
16159
18890
  },
16160
18891
  });
16161
18892
 
18893
+ register({
18894
+ name: 'browser_annotation',
18895
+ // Reading is a cached host read; every mutating action only rewrites the Host-side document.
18896
+ // Neither branch may ensure a page, take the page queue/lease, activate the tab or touch
18897
+ // `lastError`: a drawn note must cost the page nothing (docs/viewport-control.md).
18898
+ readOnly: (args) => Boolean(args) && typeof args === 'object' && args.action === 'read',
18899
+ hostOnly: (args) => Boolean(args) && typeof args === 'object' && args.action !== 'read',
18900
+ // Pure pre-flight outside the queue: a refused call costs zero state and zero CDP.
18901
+ preflight: (args) => { normalizeAgentAnnotationCall(args); },
18902
+ description: 'P0 标注(人类可见):读写调试视图里同一份标注文档,人类在面板里能立刻看到。'
18903
+ + 'action=read 返回图形列表与 revision(不写、不需要前台);'
18904
+ + 'draw 追加图形(circle/rect/line/arrow/brush/text,对应面板的圆形/矩形/线段/箭头/画笔/文字);'
18905
+ + 'erase 按 id 或按点命中删除(对应橡皮擦);clear 清空画布。'
18906
+ + '每个图形可带 color(#rrggbb,默认 #ff3b30)、strokeWidth(1–20,默认 3)与 style=stroke|fill(对应外框/填充,仅圆形/矩形;'
18907
+ + '也可显式 fill/stroke 布尔值,二者不可同时给)。'
18908
+ + '坐标是画布 CSS px:q=d-C、O=C-S、页面视口 p=q+O=d-S;与 browser_screenshot{includeAnnotations:true} 同一空间。'
18909
+ + 'draw/erase/clear 只在目标标签页正被人类在调试视图选中并可见时才允许,否则 ANNOTATION_NOT_FOREGROUND 零写入;'
18910
+ + 'expectRevision 不一致返回 ANNOTATION_REVISION_CONFLICT。'
18911
+ + '只写 Host 文档:不启动浏览器、不进页面队列、不占租约、不改页面 DOM 与 lastError;软限额与面板写面相同(128 图形/8192 点/65536 字节,Host 确定性裁剪并如实报 truncated)。',
18912
+ parameters: {
18913
+ type: 'object',
18914
+ additionalProperties: false,
18915
+ required: ['action'],
18916
+ properties: {
18917
+ action: {
18918
+ type: 'string',
18919
+ enum: [...ANNOTATION_AGENT_ACTIONS],
18920
+ description: 'read=读当前文档;draw=追加图形;erase=删除图形;clear=清空画布(后三者需要人类前台标签页)',
18921
+ },
18922
+ shapes: {
18923
+ type: 'array',
18924
+ minItems: 1,
18925
+ maxItems: ANNOTATION_AGENT_MAX_SHAPES_PER_CALL,
18926
+ description: 'draw 专用:要追加的图形(1–64 个)。省略 id 时 Host 从现有最大 id 续分配 a<N>;id 一旦重复或格式不符即整批拒绝。',
18927
+ items: {
18928
+ type: 'object',
18929
+ additionalProperties: false,
18930
+ required: ['type'],
18931
+ properties: {
18932
+ type: {
18933
+ type: 'string',
18934
+ enum: [...ANNOTATION_TYPES],
18935
+ description: 'circle=圆形 / rect=矩形 / line=线段 / arrow=箭头 / brush=画笔(铅笔)/ text=文字(写问题、问号、说明)',
18936
+ },
18937
+ id: { type: 'string', description: '可选,形如 a12(^a[1-9][0-9]{0,15}$);省略时由 Host 分配' },
18938
+ color: { type: 'string', description: '可选,#rrggbb,默认 #ff3b30(面板默认色)' },
18939
+ strokeWidth: {
18940
+ type: 'integer',
18941
+ description: `可选,粗细 ${ANNOTATION_LIMITS.strokeWidth[0]}–${ANNOTATION_LIMITS.strokeWidth[1]},默认 ${ANNOTATION_AGENT_DEFAULT_STROKE};箭头头部按端点+线宽派生`,
18942
+ },
18943
+ style: {
18944
+ type: 'string',
18945
+ enum: ['stroke', 'fill'],
18946
+ description: '可选:stroke=外框(默认)/ fill=填充;只对 circle/rect 有意义(text 固定填充,line/arrow/brush 固定外框)。与 fill/stroke 互斥。',
18947
+ },
18948
+ fill: { type: 'boolean', description: '可选:显式填充位;与 style 互斥' },
18949
+ stroke: { type: 'boolean', description: '可选:显式外框位;与 style 互斥' },
18950
+ x: { type: 'number', description: 'rect/text 的 x(画布 CSS px)' },
18951
+ y: { type: 'number', description: 'rect 的左上 y;text 的基线 y(画布 CSS px)' },
18952
+ width: { type: 'number', description: 'rect 宽(>0)' },
18953
+ height: { type: 'number', description: 'rect 高(>0)' },
18954
+ cx: { type: 'number', description: 'circle 中心 x' },
18955
+ cy: { type: 'number', description: 'circle 中心 y' },
18956
+ rx: { type: 'number', description: 'circle 半径 x(>0)' },
18957
+ ry: { type: 'number', description: 'circle 半径 y(>0)' },
18958
+ x1: { type: 'number', description: 'line/arrow 起点 x' },
18959
+ y1: { type: 'number', description: 'line/arrow 起点 y' },
18960
+ x2: { type: 'number', description: 'line/arrow 终点 x(不得与起点重合)' },
18961
+ y2: { type: 'number', description: 'line/arrow 终点 y' },
18962
+ points: {
18963
+ type: 'array',
18964
+ minItems: 2,
18965
+ description: 'brush 笔迹:按顺序的 [[x,y],...] 画布坐标点(Host 会按单笔 512 点上限下采样并保留首末点)',
18966
+ items: {
18967
+ type: 'array',
18968
+ minItems: 2,
18969
+ maxItems: 2,
18970
+ items: { type: 'number' },
18971
+ },
18972
+ },
18973
+ text: { type: 'string', description: `text 内容(超过 ${ANNOTATION_LIMITS.maxTextCodeUnits} 字符会截断并在 truncation 里计数)` },
18974
+ fontSize: {
18975
+ type: 'integer',
18976
+ description: `可选,只能等于 ${ANNOTATION_LIMITS.textFontSize}(面板文字固定字号,其它值报 font-size-fixed)`,
18977
+ },
18978
+ },
18979
+ },
18980
+ },
18981
+ ids: {
18982
+ type: 'array',
18983
+ minItems: 1,
18984
+ maxItems: ANNOTATION_AGENT_MAX_SHAPES_PER_CALL,
18985
+ description: 'erase 专用:要删除的图形 id(形如 a12)。与 point 至少给一个;不存在的 id 只是不命中,不报错。',
18986
+ items: { type: 'string' },
18987
+ },
18988
+ point: {
18989
+ type: 'object',
18990
+ additionalProperties: false,
18991
+ required: ['x', 'y'],
18992
+ description: 'erase 专用:橡皮擦命中点(画布 CSS px)。命中规则见文档:形状几何到该点距离 ≤ max(radius, strokeWidth/2, 6)。',
18993
+ properties: {
18994
+ x: { type: 'number', description: '命中点 x(画布 CSS px)' },
18995
+ y: { type: 'number', description: '命中点 y(画布 CSS px)' },
18996
+ radius: { type: 'number', description: `可选半径,默认 ${ANNOTATION_ERASE_DEFAULT_RADIUS}(1–4096)` },
18997
+ },
18998
+ },
18999
+ expectRevision: {
19000
+ type: 'integer',
19001
+ description: '可选 CAS:与当前文档 revision 不同则 ANNOTATION_REVISION_CONFLICT(零写入,返回 currentRevision)',
19002
+ },
19003
+ },
19004
+ },
19005
+ output: { schema: { type: 'object' }, render: (args, value) => text(value) },
19006
+ async execute(args, exec) {
19007
+ const call = normalizeAgentAnnotationCall(args);
19008
+ return exec.runtime.agentAnnotation(call, { sessionId: exec.sessionId });
19009
+ },
19010
+ });
19011
+
16162
19012
  register({
16163
19013
  name: 'browser_state',
16164
19014
  description: '读取内嵌浏览器的当前状态:地址、标题、视口(旧 {width,height}、安全 view 投影与 canvas 画布投影)、控制权归属。'
@@ -16166,6 +19016,24 @@ export function apply(ctx) {
16166
19016
  parameters: { type: 'object', properties: {}, required: [] },
16167
19017
  output: { schema: { type: 'object' }, render: (args, value) => text(value) },
16168
19018
  async execute(args, exec) {
19019
+ if (!exec.runtime) {
19020
+ // Neutral diagnostic outside the Web environment (fake contract environment, or a Web
19021
+ // environment the user disabled): report the environment-scoped truth instead of failing
19022
+ // or starting a page. Nothing here claims a bound Web target.
19023
+ const snapshot = crossEnvironment.snapshotForAgent(exec.agent);
19024
+ return {
19025
+ mode: 'idle',
19026
+ activity: '空闲',
19027
+ url: '',
19028
+ environment: snapshot.environment,
19029
+ adapterId: snapshot.adapterId,
19030
+ configRevision: snapshot.revision,
19031
+ enabledEnvironments: snapshot.enabledEnvironments,
19032
+ running: false,
19033
+ target: null,
19034
+ note: 'no Web runtime: this environment has no page target (environment-neutral diagnostic)',
19035
+ };
19036
+ }
16169
19037
  await exec.runtime.refreshTitle(exec.signal);
16170
19038
  // Page-state ↔ network correlation (§3.3 / G2.6): one state mark per state read so the
16171
19039
  // timeline can replay url/title/revision changes next to the traffic they belong to.
@@ -16437,9 +19305,9 @@ export function apply(ctx) {
16437
19305
  ? normalized.scale
16438
19306
  : (Number.isFinite(liveScale) ? liveScale : runtime.view.scale);
16439
19307
  const scaleSource = normalized.scale !== undefined ? 'explicit' : 'view';
16440
- const shot = await runtime.capture({
19308
+ const shot = await p0(exec, 'A', 'capture', {
16441
19309
  scale: resolvedScale, region: normalized.region ?? undefined, scaleSource,
16442
- }, exec.signal);
19310
+ });
16443
19311
  const png = inspectPng(shot.pngBytes, { checkCrc: true });
16444
19312
  if (!png.ok) {
16445
19313
  throw new BrowserError('INVALID_PNG', '实时采集返回的字节不是结构完整的 PNG(不交付坏图)', {
@@ -17546,7 +20414,9 @@ export function apply(ctx) {
17546
20414
  description: 'T3 静态视觉:对两张显式源图做 YIQ 像素差异比较(固定算法 t3-v1),输出完整尺寸的标注差异 PNG(红=差异、黄=AA、灰=mask、蓝=区域 bbox)与 PASS/WARN/FAIL。'
17547
20415
  + 'before/after 必须显式给出;两图必须同像素尺寸,否则 VISION_SIZE_MISMATCH;region 是两图共同的图像像素 Rect。'
17548
20416
  + 'masks 缺省继承 before 基线元数据(before 非基线则空),显式 [] 清空;options/thresholds 逐字段覆盖 before 基线默认。'
17549
- + '全屏蔽无分母抛 VISION_EMPTY_COMPARISON,不返回 similarity=1;similarity 是阈值化差异覆盖率,不是语义相似度。',
20417
+ + '全屏蔽无分母抛 VISION_EMPTY_COMPARISON,不返回 similarity=1;similarity 是阈值化差异覆盖率,不是语义相似度。'
20418
+ + '映射门禁:两图几何映射不兼容(不同目标/缩放/坐标空间)时 verdict=BLOCKED/mapping-incompatible,不再给 PASS;'
20419
+ + '只有显式 bitmapOnly=true 才降级为纯位图比较(标注 bitmap-only,不可当作几何验收)。',
17550
20420
  parameters: {
17551
20421
  type: 'object',
17552
20422
  additionalProperties: false,
@@ -17573,6 +20443,11 @@ export function apply(ctx) {
17573
20443
  properties: { warnRatio: { type: 'number' }, failRatio: { type: 'number' } },
17574
20444
  required: [],
17575
20445
  },
20446
+ bitmapOnly: {
20447
+ type: 'boolean',
20448
+ description: '显式选择"只比较位图、不参与几何验收"(默认 false)。false 时两图 mapping 不兼容会返回 BLOCKED/mapping-incompatible,'
20449
+ + '不再给 PASS;true 时只返回位图差异比率并标 bitmap-only,其结论不得当作几何验收证据。',
20450
+ },
17576
20451
  output: {
17577
20452
  type: 'object',
17578
20453
  description: '差异 PNG 的落盘选择(同 browser_vision_image.output):默认暂存,out/persist:true/env 触发持久。',
@@ -17651,6 +20526,13 @@ export function apply(ctx) {
17651
20526
  });
17652
20527
  if (comparisonRegions.length > T3_EVIDENCE_REGIONS) t3Warning(warnings, 'regions-truncated');
17653
20528
  if (!mappingCompatible) t3Warning(warnings, 'mapping-incompatible');
20529
+ // Mapping gate (plan §4.5 / A03): a bitmap ratio over incompatible geometry is not an
20530
+ // acceptance signal. Without an explicit `bitmapOnly` opt-in the comparison is BLOCKED, so
20531
+ // "same pixels, different target region" can never come back as PASS.
20532
+ const bitmapOnly = request.bitmapOnly === true;
20533
+ const gatedVerdict = mappingCompatible ? verdict : (bitmapOnly ? verdict : 'BLOCKED');
20534
+ const gatedReason = mappingCompatible ? 'ratio' : (bitmapOnly ? 'bitmap-only' : 'mapping-incompatible');
20535
+ if (!mappingCompatible && bitmapOnly) t3Warning(warnings, 'advisory-only');
17654
20536
  const highlight = Buffer.isBuffer(diff.highlight) ? diff.highlight : Buffer.from(diff.highlight ?? []);
17655
20537
  const png = inspectPng(highlight, { checkCrc: true });
17656
20538
  if (!png.ok) throw new BrowserError('INVALID_PNG', '差异图未通过 PNG 结构/CRC 校验', { phase: 'inspectPng', reason: png.message });
@@ -17677,8 +20559,10 @@ export function apply(ctx) {
17677
20559
  regions: comparisonRegions,
17678
20560
  regionCount: Number.isInteger(diff.regionCount) ? diff.regionCount : comparisonRegions.length,
17679
20561
  regionsTruncated: diff.regionsTruncated === true || comparisonRegions.length > T3_EVIDENCE_REGIONS,
17680
- verdict,
17681
- reason: 'ratio',
20562
+ verdict: gatedVerdict,
20563
+ reason: gatedReason,
20564
+ ratioVerdict: verdict,
20565
+ bitmapOnly,
17682
20566
  mappingCompatible,
17683
20567
  };
17684
20568
  const metrics = {
@@ -17694,11 +20578,11 @@ export function apply(ctx) {
17694
20578
  beforeDocumentCss: item.beforeDocumentCss, afterDocumentCss: item.afterDocumentCss, area: item.area,
17695
20579
  })),
17696
20580
  metrics,
17697
- verdict: { status: verdict, reason: 'ratio' },
20581
+ verdict: { status: gatedVerdict, reason: gatedReason },
17698
20582
  output: { productId: diffProduct.productId, sha256: delivered.sha256, file: delivered.file, delivery: delivered.delivery },
17699
20583
  incomplete: comparison.regionsTruncated ? ['regions-truncated'] : [],
17700
20584
  }));
17701
- runtime.noteVisionOperation('compare', verdict);
20585
+ runtime.noteVisionOperation('compare', gatedVerdict);
17702
20586
  return {
17703
20587
  ok: true,
17704
20588
  before: resolvedBefore.product,
@@ -18045,7 +20929,7 @@ export function apply(ctx) {
18045
20929
  }
18046
20930
  if (mapping.visualViewportScale === null) incomplete.push('visual-viewport-scale-unknown');
18047
20931
  // §6 step 2: re-capture the ancestor capture rect/scale and compare decoded RGBA hashes.
18048
- const recapture = await runtime.capture({ scale: captureFacts.scale, region: captureFacts.region }, exec.signal);
20932
+ const recapture = await p0(exec, 'A', 'capture', { scale: captureFacts.scale, region: captureFacts.region });
18049
20933
  const recapturePng = inspectPng(recapture.pngBytes, { checkCrc: true });
18050
20934
  if (!recapturePng.ok) {
18051
20935
  throw new BrowserError('INVALID_PNG', '复验截图不是结构完整的 PNG', { phase: 'inspectPng', reason: recapturePng.message });
@@ -18161,7 +21045,7 @@ export function apply(ctx) {
18161
21045
  // §6 step 6: one immediate after-capture of the same rect/scale (no file/base64).
18162
21046
  let afterProduct = null;
18163
21047
  try {
18164
- const after = await runtime.capture({ scale: captureFacts.scale, region: captureFacts.region }, exec.signal);
21048
+ const after = await p0(exec, 'A', 'capture', { scale: captureFacts.scale, region: captureFacts.region });
18165
21049
  const afterPng = inspectPng(after.pngBytes, { checkCrc: true });
18166
21050
  if (!afterPng.ok) throw new Error('after PNG invalid');
18167
21051
  const afterDecoded = await t3Decode(after.pngBytes, exec.signal, t3NewBudget());
@@ -19389,7 +22273,13 @@ export function apply(ctx) {
19389
22273
  : null,
19390
22274
  }
19391
22275
  : null;
22276
+ // The capability matrix below describes the *Web* surface. In another environment it would be
22277
+ // a lie: report the environment-scoped truth (adapter.capabilities) and say so, rather than
22278
+ // listing web.* entries that this environment does not offer (A13/A08).
22279
+ const activeEnvironment = crossEnvironment.snapshotForAgent(exec.agent).environment;
22280
+ const webSurface = activeEnvironment === 'web';
19392
22281
  return { ok: true,
22282
+ environment: activeEnvironment,
19393
22283
  runtime: {
19394
22284
  running, browser: 'chromium', transport: running ? 'ws' : 'unavailable',
19395
22285
  isolation: 'single-owner-lease', attachments: typeof attachments?.saveImage === 'function',
@@ -19400,9 +22290,11 @@ export function apply(ctx) {
19400
22290
  },
19401
22291
  capabilities: [
19402
22292
  ...pageTools.map((name) => ({ id: 'web.t1.' + name, tool: 'browser_' + name,
19403
- status: busy ? 'unavailable' : 'available',
19404
- ...(busy ? { reason: 'BUSY: browser owned by another session' } : {}),
19405
- constraints: pageConstraints })),
22293
+ // The legacy traffic snapshot is a read-only metadata read: it neither takes nor
22294
+ // validates the single-owner lease, so another owner's BUSY never applies to it.
22295
+ status: name === 'network' ? 'available' : (busy ? 'unavailable' : 'available'),
22296
+ ...(name !== 'network' && busy ? { reason: 'BUSY: browser owned by another session' } : {}),
22297
+ ...(name === 'network' ? { reason: null, constraints: [...pageConstraints, 'read-only: no page queue and no single-owner lease'] } : { constraints: pageConstraints }) })),
19406
22298
  ...t2Tools.map((name) => ({ id: 'web.t2.' + name, tool: 'browser_' + name,
19407
22299
  status: busy ? 'unavailable' : 'available',
19408
22300
  ...(busy ? { reason: 'BUSY: browser owned by another session' } : {}),
@@ -19418,6 +22310,19 @@ export function apply(ctx) {
19418
22310
  status: busy ? 'unavailable' : 'available',
19419
22311
  ...(busy ? { reason: 'BUSY: browser owned by another session (get 仍可只读诊断)' } : {}),
19420
22312
  constraints: viewportConstraints },
22313
+ // P0-D annotations (docs/viewport-control.md): the document lives on the Host side, so
22314
+ // neither reading nor drawing waits for another session's page lease. `draw/erase/clear`
22315
+ // refuse with ANNOTATION_NOT_FOREGROUND unless a human is displaying that tab.
22316
+ { id: 'web.p0d.annotations', tool: 'browser_annotation', status: 'available',
22317
+ constraints: [
22318
+ 'One document per Runtime, shared with the human panel (read-modify-write; the panel hydrates newer Host revisions)',
22319
+ 'read never needs the foreground; draw/erase/clear need a fresh human-foreground claim ('
22320
+ + ANNOTATION_FOREGROUND_TTL_MS + 'ms) for the target tab, else ANNOTATION_NOT_FOREGROUND with zero writes',
22321
+ 'Host-side only: no page queue, no single-owner lease, no ensure, no page DOM/CSS/lastError change',
22322
+ 'Same soft limits as the panel write surface ('
22323
+ + ANNOTATION_LIMITS.maxShapes + ' shapes / ' + ANNOTATION_LIMITS.maxPointsPerDocument
22324
+ + ' points / ' + ANNOTATION_LIMITS.maxDocumentBytes + ' bytes); truncation is deterministic and reported',
22325
+ ] },
19421
22326
  { id: 'web.p0e.tabs', tool: 'browser_tabs', status: 'available',
19422
22327
  constraints: [
19423
22328
  'P0-E page group: primary + up to 3 child tabs per workspace (4 total; global maxPages still 8)',
@@ -19512,7 +22417,10 @@ export function apply(ctx) {
19512
22417
  { id: 'web.t4.dynamic-vision', status: 'available', aliasOf: 'web.t4.dynamic-behavior',
19513
22418
  constraints: ['legacy alias of web.t4.dynamic-behavior; not an independent tool', 'tool count unchanged'] },
19514
22419
  { id: 'web.t1.browser-extras', status: 'unsupported', reason: 'Not implemented' },
19515
- ],
22420
+ ].filter(() => webSurface),
22421
+ capabilitiesNote: webSurface
22422
+ ? null
22423
+ : 'capabilities 描述的是 Web 专属操作面;当前环境不是 web,请以 adapter.capabilities / adapter.p0 为准',
19516
22424
  // TOOL-P1-03 caps-level disclosure: measured beats requested, and low sampling can never
19517
22425
  // prove a negative. Same rule the T4 analyzers enforce in metrics.sampling/verdict.
19518
22426
  limitations: [
@@ -19745,19 +22653,28 @@ export function apply(ctx) {
19745
22653
  : null,
19746
22654
  },
19747
22655
  // P0-D annotations (docs/viewport-control.md). Declared, never probed: reading this
19748
- // starts nothing. This is NOT a tool — the Agent reads the document through
19749
- // `browser_state.annotations`, and only the authenticated scoped HTTP surface writes it, so
19750
- // the registered tool surface stays at 30.
22656
+ // starts nothing. Two writers share ONE document: the human panel over the authenticated
22657
+ // scoped route, and the Agent over `browser_annotation` (which additionally needs a fresh
22658
+ // human-foreground claim for the target tab).
19751
22659
  annotations: {
19752
22660
  version: ANNOTATION_VERSION,
19753
22661
  space: ANNOTATION_SPACE,
19754
22662
  readTool: 'browser_state',
19755
22663
  readPath: 'annotations',
22664
+ readTools: ['browser_state', 'browser_annotation'],
19756
22665
  writeSurface: 'authenticated-scoped-http',
19757
22666
  writeRoute: 'POST /plugins/ui-testing/annotations',
22667
+ writeTool: 'browser_annotation',
22668
+ writeActions: ['draw', 'erase', 'clear'],
22669
+ writeModel: 'read-modify-write of one document; the panel hydrates a newer Host revision',
22670
+ foregroundRequiredForWrite: true,
22671
+ foregroundTtlMs: ANNOTATION_FOREGROUND_TTL_MS,
19758
22672
  source: 'human-panel',
19759
22673
  trusted: false,
19760
- screenshotOverlay: false,
22674
+ screenshotOverlay: true,
22675
+ screenshotTool: 'browser_screenshot',
22676
+ screenshotParameter: 'includeAnnotations',
22677
+ screenshotDefault: false,
19761
22678
  coordinateSpace: 'canvas-css-px',
19762
22679
  // d = a shape's canvas point; q = d − C, O = C − S, page-viewport p = q + O = d − S. The
19763
22680
  // published `view` is the panel's accepted snapshot, not a live truth: read the current S
@@ -19775,6 +22692,9 @@ export function apply(ctx) {
19775
22692
  shapeCount: document.shapes.length,
19776
22693
  stale: document.stale,
19777
22694
  staleReason: document.staleReason,
22695
+ // Whether `browser_annotation` could write right now (a human displays this tab).
22696
+ foreground: document.foreground.active,
22697
+ foregroundAgeMs: document.foreground.ageMs,
19778
22698
  };
19779
22699
  })(),
19780
22700
  },
@@ -20007,10 +22927,102 @@ export function apply(ctx) {
20007
22927
  security: 'Not a complete network sandbox; DNS rebinding remains possible; unknown host origins and response content may contain secrets',
20008
22928
  },
20009
22929
  },
22930
+ // Cross-environment control plane (M2/M3): the same config revision that admitted this
22931
+ // call, the adapter capability matrix and the six-axis readiness. Reading it starts nothing.
22932
+ adapter: await crossEnvironment.capabilitiesBlock(
22933
+ null,
22934
+ liveReadinessTarget(exec, runtime),
22935
+ crossEnvironment.snapshotForAgent(exec.agent),
22936
+ ),
20010
22937
  };
20011
22938
  },
20012
22939
  });
22940
+ // Test-only fake environment tool. It is registered only when the explicit test hook is on,
22941
+ // and `when` keeps it invisible unless the resolved environment is the fake adapter.
22942
+ if (crossEnvironment.allowTestEnvironments) {
22943
+ register({
22944
+ name: 'browser_fake_probe',
22945
+ description: 'test-only:假环境契约探针(不启动浏览器,不代表任何真实平台)。',
22946
+ parameters: { type: 'object', properties: { payload: { type: 'object' } }, required: [] },
22947
+ output: { schema: { type: 'object' }, render: (args, value) => text(value) },
22948
+ async execute(args) {
22949
+ return { ok: true, environment: 'fake', payload: (args && args.payload) || null };
22950
+ },
22951
+ });
22952
+ }
22953
+ /**
22954
+ * Apply one authoritative config transition to the *running* resources (A12).
22955
+ *
22956
+ * Re-admitting the tool surface is not enough: a selection that no longer resolves to Web must
22957
+ * also cancel queued/running work, release the connection/frame source/network collector/lease
22958
+ * and rotate the tab selection, otherwise an old reference keeps operating a page its
22959
+ * environment no longer owns. Only pages whose scope lost Web are touched, so an unrelated
22960
+ * write (e.g. enabling the fake environment) never tears down a live Web page.
22961
+ *
22962
+ * @returns {{transitioned: Array<{scope:string, environment:string, reason:string}>}}
22963
+ */
22964
+ async function applyConfigTransition(notification = {}) {
22965
+ const transitioned = [];
22966
+ for (const entry of [...registry.entries.values()]) {
22967
+ const scope = entry.scope || {};
22968
+ let snapshot;
22969
+ try {
22970
+ snapshot = crossEnvironment.snapshotForScope({ id: scope.id, workspaceId: scope.workspaceId, cwd: scope.cwd });
22971
+ } catch {
22972
+ continue;
22973
+ }
22974
+ const webSelected = snapshot.environment === 'web'
22975
+ && Array.isArray(snapshot.enabledEnvironments)
22976
+ && snapshot.enabledEnvironments.includes('web');
22977
+ if (webSelected) continue;
22978
+ const runtime = entry.runtime;
22979
+ const reason = snapshot.enabledEnvironments.includes(snapshot.environment)
22980
+ ? 'environment-switched'
22981
+ : 'environment-disabled';
22982
+ const cancelled = typeof runtime?.abortForEnvironmentChange === 'function'
22983
+ ? runtime.abortForEnvironmentChange(reason)
22984
+ : { aborted: false, reason };
22985
+ // Rotate the per-session tab selection: an old tabId/ref must not resolve through the new
22986
+ // environment's page (identity rotation, not just cancellation).
22987
+ for (const [key, value] of [...registry.selectedBySession.entries()]) {
22988
+ // Match the provider-qualified key *and* the bare workspace key: an entry key is now
22989
+ // `<provider>\u0000<baseKey>`, and a stale selection record may still reference either form.
22990
+ // A provider switch must invalidate the old selection, so both are purged.
22991
+ // Exact match on the complete dimensions this entry owns (its page-group key, or the
22992
+ // group key a child belongs to). The old `key.includes(baseKey)` form matched every root
22993
+ // sharing one workspace and deleted *other* roots' selections on an unrelated config
22994
+ // change — the cross-root cleanup this milestone removes.
22995
+ if (!value) continue;
22996
+ const groupKey = entry.primaryKey || entry.key;
22997
+ if (value.groupKey === groupKey || value.groupKey === entry.key) {
22998
+ registry.selectedBySession.delete(key);
22999
+ }
23000
+ }
23001
+ registry.bumpViews(entry.key);
23002
+ if (typeof registry.touch === 'function') registry.touch(entry);
23003
+ try { await runtime?.close?.(); } catch { /* teardown is best effort */ }
23004
+ transitioned.push({ scope: entry.key, environment: snapshot.environment, reason, cancelled: cancelled.aborted });
23005
+ }
23006
+ if (transitioned.length > 0) {
23007
+ log('ui-testing: config transition released ' + transitioned.length + ' web scope(s): '
23008
+ + transitioned.map((item) => `${item.scope}:${item.reason}`).join(', '));
23009
+ }
23010
+ return { transitioned, revision: notification.revision ?? null };
23011
+ }
23012
+
23013
+ // Initial admission + re-admission on every successful config write: schema declaration follows
23014
+ // the same authoritative snapshot that execution uses (plan §5.1/§5.3, A08/A10), and the same
23015
+ // notification drives resource release for scopes that no longer own the Web environment (A12).
23016
+ syncToolVisibility();
23017
+ crossEnvironment.onConfigChange((notification) => {
23018
+ try { syncToolVisibility(); } catch (error) {
23019
+ log('ui-testing: tool visibility sync failed: ' + String((error && error.message) || error));
23020
+ }
23021
+ applyConfigTransition(notification).catch((error) => {
23022
+ log('ui-testing: config transition cleanup failed: ' + String((error && error.message) || error));
23023
+ });
23024
+ });
20013
23025
  // Returned for the fake-ctx acceptance harness only (Cordis ignores apply()'s return value);
20014
23026
  // production callers must keep using the HTTP/tool surface, not this handle.
20015
- return { registry };
23027
+ return { registry, syncToolVisibility, toolDefinitions, applyConfigTransition };
20016
23028
  }