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.
- package/LICENSE +21 -0
- package/README.md +172 -36
- package/annotation-capture.js +111 -0
- package/browser-safety.js +45 -1
- package/client.js +3721 -132
- package/context-menu.js +134 -0
- package/core/clock.js +88 -0
- package/core/config-store.js +566 -0
- package/core/config.js +892 -0
- package/core/contracts.js +240 -0
- package/core/errors.js +156 -0
- package/core/evidence.js +141 -0
- package/core/geometry.js +217 -0
- package/core/identity.js +180 -0
- package/core/index.js +24 -0
- package/core/p0.js +1101 -0
- package/core/readiness.js +175 -0
- package/core/registry.js +239 -0
- package/core/tab-access.js +1350 -0
- package/docs/adapters.md +160 -0
- package/docs/architecture.md +44 -9
- package/docs/capability-tiers.md +20 -4
- package/docs/capture-p0.md +39 -2
- package/docs/diagnose-reason-codes.md +157 -0
- package/docs/element-picker.md +24 -6
- package/docs/getting-started.md +47 -1
- package/docs/images/tutorial-01-overview.png +0 -0
- package/docs/images/tutorial-02-history-dropdown.png +0 -0
- package/docs/images/tutorial-03-pick-element.png +0 -0
- package/docs/images/tutorial-04-annotate.png +0 -0
- package/docs/index.md +11 -7
- package/docs/multi-tab.md +55 -20
- package/docs/network-p0.md +2 -2
- package/docs/panel-context-menu.md +41 -0
- package/docs/t2-structured-gui.md +2 -2
- package/docs/t3-vision.md +6 -1
- package/docs/t4-dynamic-behavior.md +1 -0
- package/docs/tools.md +65 -7
- package/docs/tutorial.md +125 -0
- package/docs/verification.md +26 -2
- package/docs/viewport-control.md +21 -8
- package/index.js +3263 -251
- package/integration/cross-environment.js +1535 -0
- package/integration/diagnose.js +945 -0
- package/integration/tab-principal.js +493 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/network-p0.js +24 -7
- package/package.json +4 -3
- package/select-control.js +298 -0
- package/skills/ui-testing-common/SKILL.md +54 -0
- package/skills/ui-testing-fake/SKILL.md +43 -0
- package/skills/{ui-testing → ui-testing-web}/SKILL.md +64 -24
- package/skills/{ui-testing → ui-testing-web}/manifests/declared-capabilities.json +9 -2
- package/skills/{ui-testing → ui-testing-web}/references/contracts/capability.md +3 -3
- package/skills/{ui-testing → ui-testing-web}/references/contracts/evidence.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/contracts/operation.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/core/fallback.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/core/page-identity.md +3 -2
- package/skills/{ui-testing → ui-testing-web}/references/core/selection.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/core/tool-routing-and-limits.md +6 -3
- package/skills/{ui-testing → ui-testing-web}/references/platforms/index.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/platforms/web.md +2 -2
- package/skills/ui-testing-web/references/scenarios/annotations.md +104 -0
- package/skills/{ui-testing → ui-testing-web}/references/scenarios/functional.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/scenarios/visual-motion.md +1 -1
- package/skills/{ui-testing → ui-testing-web}/references/tiers/t1-programmatic.md +2 -2
- package/skills/{ui-testing → ui-testing-web}/references/tiers/t3-static-visual.md +2 -2
- package/skills/{ui-testing → ui-testing-web}/references/tiers/t4-dynamic-visual.md +2 -1
- package/targets/fake/adapter.js +1163 -0
- package/targets/fake/fixtures.js +177 -0
- package/targets/web/adapter.js +1231 -0
- package/targets/web/provider.js +415 -0
- package/skills/ui-testing/references/scenarios/annotations.md +0 -87
- /package/skills/{ui-testing → ui-testing-web}/references/core/dynamic-visual.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/network-and-state.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/report-template.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/reporting.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/safety.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-examples.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-gate.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/verification.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/core/workflow.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/references/tiers/t2-structured.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/capability-note.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/platform.md +0 -0
- /package/skills/{ui-testing → ui-testing-web}/templates/regression-case.md +0 -0
- /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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} from './
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`.
|
|
998
|
-
*
|
|
999
|
-
*
|
|
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
|
-
*
|
|
1002
|
-
*
|
|
1003
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
|
|
6435
|
-
|
|
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
|
-
|
|
6733
|
-
|
|
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
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
6745
|
-
|
|
6746
|
-
|
|
6747
|
-
|
|
6748
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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')
|
|
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
|
-
|
|
12481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12549
|
-
|
|
12550
|
-
|
|
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
|
-
|
|
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 {
|
|
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, {
|
|
12562
|
-
|
|
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
|
-
|
|
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())
|
|
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
|
-
|
|
12602
|
-
|
|
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
|
-
|
|
14314
|
+
dimension: 'workspace-provider',
|
|
14315
|
+
maxPopupChildren: this.maxPopupChildren, popupCount: this.popupCount(primaryEntry),
|
|
12605
14316
|
});
|
|
12606
14317
|
}
|
|
12607
|
-
const
|
|
14318
|
+
const provider = primaryEntry.provider ?? DEFAULT_RUNTIME_PROVIDER;
|
|
14319
|
+
this.browserHost(provider);
|
|
12608
14320
|
this.reservations += 1;
|
|
12609
|
-
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
12779
|
-
|
|
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
|
-
|
|
12787
|
-
|
|
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 = {
|
|
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
|
|
14583
|
+
reason: 'selected-tab-closed', tabId, fallbackTabId: 'primary',
|
|
12794
14584
|
});
|
|
12795
14585
|
}
|
|
12796
14586
|
return { entry, selection: record };
|
|
12797
14587
|
}
|
|
12798
14588
|
|
|
12799
|
-
|
|
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
|
-
|
|
12806
|
-
|
|
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
|
-
|
|
12814
|
-
|
|
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.
|
|
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
|
-
//
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
12978
|
-
|
|
12979
|
-
|
|
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
|
-
|
|
12983
|
-
|
|
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
|
-
|
|
13010
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 (
|
|
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.
|
|
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
|
|
13143
|
-
|
|
13144
|
-
|
|
13145
|
-
|
|
13146
|
-
|
|
13147
|
-
|
|
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
|
-
//
|
|
13153
|
-
//
|
|
13154
|
-
|
|
13155
|
-
|
|
13156
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
14031
|
-
//
|
|
14032
|
-
// the
|
|
14033
|
-
//
|
|
14034
|
-
|
|
14035
|
-
|
|
14036
|
-
|
|
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).
|
|
14053
|
-
|
|
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
|
-
|
|
14057
|
-
|
|
14058
|
-
|
|
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)
|
|
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
|
|
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
|
-
//
|
|
14225
|
-
//
|
|
14226
|
-
//
|
|
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
|
-
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
-
...(
|
|
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
|
-
|
|
14692
|
-
//
|
|
14693
|
-
//
|
|
14694
|
-
|
|
14695
|
-
|
|
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
|
-
|
|
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 (
|
|
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, {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
}, {
|
|
14852
|
-
|
|
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
|
-
|
|
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
|
-
+ '
|
|
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
|
-
|
|
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
|
|
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: {
|
|
15345
|
-
|
|
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
|
-
|
|
15374
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
+ '
|
|
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: {
|
|
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
|
|
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
|
-
|
|
16046
|
-
|
|
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
|
|
18705
|
+
ok: true, action, tabs,
|
|
16051
18706
|
notices: registry.noticesView(primary), recentClosed: registry.recentClosedView(primary),
|
|
16052
|
-
|
|
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 {
|
|
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
|
-
|
|
16062
|
-
|
|
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
|
-
|
|
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
|
|
19308
|
+
const shot = await p0(exec, 'A', 'capture', {
|
|
16441
19309
|
scale: resolvedScale, region: normalized.region ?? undefined, scaleSource,
|
|
16442
|
-
}
|
|
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:
|
|
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:
|
|
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',
|
|
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
|
|
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
|
|
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
|
-
|
|
19404
|
-
|
|
19405
|
-
|
|
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.
|
|
19749
|
-
//
|
|
19750
|
-
//
|
|
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:
|
|
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
|
}
|