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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -35
  3. package/annotation-capture.js +111 -0
  4. package/browser-safety.js +45 -1
  5. package/client.js +3709 -134
  6. package/context-menu.js +134 -0
  7. package/core/clock.js +88 -0
  8. package/core/config-store.js +566 -0
  9. package/core/config.js +892 -0
  10. package/core/contracts.js +240 -0
  11. package/core/errors.js +156 -0
  12. package/core/evidence.js +141 -0
  13. package/core/geometry.js +217 -0
  14. package/core/identity.js +180 -0
  15. package/core/index.js +24 -0
  16. package/core/p0.js +1101 -0
  17. package/core/readiness.js +175 -0
  18. package/core/registry.js +239 -0
  19. package/core/tab-access.js +1350 -0
  20. package/docs/adapters.md +160 -0
  21. package/docs/architecture.md +44 -9
  22. package/docs/capability-tiers.md +20 -4
  23. package/docs/capture-p0.md +39 -2
  24. package/docs/diagnose-reason-codes.md +157 -0
  25. package/docs/element-picker.md +24 -6
  26. package/docs/getting-started.md +47 -1
  27. package/docs/index.md +10 -7
  28. package/docs/multi-tab.md +55 -20
  29. package/docs/network-p0.md +2 -2
  30. package/docs/panel-context-menu.md +41 -0
  31. package/docs/t2-structured-gui.md +2 -2
  32. package/docs/t3-vision.md +6 -1
  33. package/docs/t4-dynamic-behavior.md +1 -0
  34. package/docs/tools.md +65 -7
  35. package/docs/verification.md +26 -2
  36. package/docs/viewport-control.md +21 -8
  37. package/index.js +3263 -251
  38. package/integration/cross-environment.js +1535 -0
  39. package/integration/diagnose.js +945 -0
  40. package/integration/tab-principal.js +493 -0
  41. package/locale/en.json +1 -1
  42. package/locale/zh.json +1 -1
  43. package/network-p0.js +24 -7
  44. package/package.json +4 -3
  45. package/select-control.js +298 -0
  46. package/skills/ui-testing-common/SKILL.md +54 -0
  47. package/skills/ui-testing-fake/SKILL.md +43 -0
  48. package/skills/{ui-testing → ui-testing-web}/SKILL.md +64 -24
  49. package/skills/{ui-testing → ui-testing-web}/manifests/declared-capabilities.json +9 -2
  50. package/skills/{ui-testing → ui-testing-web}/references/contracts/capability.md +3 -3
  51. package/skills/{ui-testing → ui-testing-web}/references/contracts/evidence.md +1 -1
  52. package/skills/{ui-testing → ui-testing-web}/references/contracts/operation.md +1 -1
  53. package/skills/{ui-testing → ui-testing-web}/references/core/fallback.md +1 -1
  54. package/skills/{ui-testing → ui-testing-web}/references/core/page-identity.md +3 -2
  55. package/skills/{ui-testing → ui-testing-web}/references/core/selection.md +1 -1
  56. package/skills/{ui-testing → ui-testing-web}/references/core/tool-routing-and-limits.md +6 -3
  57. package/skills/{ui-testing → ui-testing-web}/references/platforms/index.md +1 -1
  58. package/skills/{ui-testing → ui-testing-web}/references/platforms/web.md +2 -2
  59. package/skills/ui-testing-web/references/scenarios/annotations.md +104 -0
  60. package/skills/{ui-testing → ui-testing-web}/references/scenarios/functional.md +1 -1
  61. package/skills/{ui-testing → ui-testing-web}/references/scenarios/visual-motion.md +1 -1
  62. package/skills/{ui-testing → ui-testing-web}/references/tiers/t1-programmatic.md +2 -2
  63. package/skills/{ui-testing → ui-testing-web}/references/tiers/t3-static-visual.md +2 -2
  64. package/skills/{ui-testing → ui-testing-web}/references/tiers/t4-dynamic-visual.md +2 -1
  65. package/targets/fake/adapter.js +1163 -0
  66. package/targets/fake/fixtures.js +177 -0
  67. package/targets/web/adapter.js +1231 -0
  68. package/targets/web/provider.js +415 -0
  69. package/skills/ui-testing/references/scenarios/annotations.md +0 -87
  70. /package/skills/{ui-testing → ui-testing-web}/references/core/dynamic-visual.md +0 -0
  71. /package/skills/{ui-testing → ui-testing-web}/references/core/network-and-state.md +0 -0
  72. /package/skills/{ui-testing → ui-testing-web}/references/core/report-template.md +0 -0
  73. /package/skills/{ui-testing → ui-testing-web}/references/core/reporting.md +0 -0
  74. /package/skills/{ui-testing → ui-testing-web}/references/core/safety.md +0 -0
  75. /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-examples.md +0 -0
  76. /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-gate.md +0 -0
  77. /package/skills/{ui-testing → ui-testing-web}/references/core/verification.md +0 -0
  78. /package/skills/{ui-testing → ui-testing-web}/references/core/workflow.md +0 -0
  79. /package/skills/{ui-testing → ui-testing-web}/references/tiers/t2-structured.md +0 -0
  80. /package/skills/{ui-testing → ui-testing-web}/templates/capability-note.md +0 -0
  81. /package/skills/{ui-testing → ui-testing-web}/templates/platform.md +0 -0
  82. /package/skills/{ui-testing → ui-testing-web}/templates/regression-case.md +0 -0
  83. /package/skills/{ui-testing → ui-testing-web}/templates/scenario.md +0 -0
@@ -0,0 +1,1535 @@
1
+ /**
2
+ * Composition root for the cross-environment foundation (M2/M3).
3
+ *
4
+ * `index.js` remains the Web implementation and composition entry; this module owns the
5
+ * environment-neutral control plane: authoritative config, adapter registry, tool admission,
6
+ * per-execution adapter invocation, readiness, Skill discovery and the `/config` HTTP surface.
7
+ *
8
+ * Dependency direction: this module imports `core/` and `targets/` only. It never imports
9
+ * `index.js`, and the platform implementations never import this module. `index.js` injects the
10
+ * Web runtime/registry through explicit callbacks, so `core/` stays free of CDP/page assumptions.
11
+ */
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { dirname, join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ import {
18
+ AdapterError,
19
+ AdapterRegistry,
20
+ AVAILABILITY,
21
+ CONTRACT_VERSION,
22
+ ConfigStore,
23
+ DEFAULT_CONFIG,
24
+ DEFAULT_WEB_PROVIDER,
25
+ ENVIRONMENTS,
26
+ ERROR_CODES,
27
+ IMPLEMENTED_WEB_PROVIDERS,
28
+ KNOWN_ENVIRONMENTS,
29
+ READINESS_STATUS,
30
+ SUPPORT,
31
+ WEB_PROVIDER_IDS,
32
+ axis,
33
+ createReadinessReport,
34
+ isImplementedWebProvider,
35
+ isPlannedEnvironment,
36
+ resolveSnapshot,
37
+ webProviderSupport,
38
+ } from '../core/index.js';
39
+ import { createFakeEnvironment } from '../targets/fake/adapter.js';
40
+ import {
41
+ WEB_TOOL_OPERATIONS,
42
+ createWebCapabilityAdapter,
43
+ createWebTargetAdapter,
44
+ resolveExecutionClass,
45
+ resolveProviderExecutable,
46
+ resolveWebDriver,
47
+ } from '../targets/web/adapter.js';
48
+ import { createWebP0Provider } from '../targets/web/provider.js';
49
+
50
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
51
+ const DEFAULT_ENVIRONMENT = ENVIRONMENTS.WEB;
52
+ const FAKE_TOOL = 'browser_fake_probe';
53
+ const FAKE_OPERATION = 'fake.t1.echo';
54
+
55
+ /** Bounded freshness for readiness facts derived from a live target (plan §6.2 过期语义). */
56
+ const READINESS_TTL_MS = 30_000;
57
+
58
+ const SKILL_DEFINITIONS = Object.freeze([
59
+ Object.freeze({ dir: 'ui-testing-common', name: 'ui-testing-common', environment: null, testOnly: false }),
60
+ Object.freeze({ dir: 'ui-testing-web', name: 'ui-testing-web', environment: ENVIRONMENTS.WEB, testOnly: false }),
61
+ Object.freeze({ dir: 'ui-testing-fake', name: 'ui-testing-fake', environment: ENVIRONMENTS.FAKE, testOnly: true }),
62
+ ]);
63
+
64
+ function isPlainObject(value) {
65
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
66
+ }
67
+
68
+ function clone(value) {
69
+ return JSON.parse(JSON.stringify(value));
70
+ }
71
+
72
+ function readSkillDefinition(entry) {
73
+ const file = join(REPO_ROOT, 'skills', entry.dir, 'SKILL.md');
74
+ let text;
75
+ try {
76
+ text = readFileSync(file, 'utf8');
77
+ } catch {
78
+ return null;
79
+ }
80
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
81
+ const fields = {};
82
+ if (match) {
83
+ for (const line of match[1].split(/\r?\n/)) {
84
+ const index = line.indexOf(':');
85
+ if (index <= 0) continue;
86
+ const key = line.slice(0, index).trim();
87
+ if (key) fields[key] = line.slice(index + 1).trim();
88
+ }
89
+ }
90
+ return {
91
+ name: fields.name || entry.name,
92
+ description: fields.description || '',
93
+ content: match ? match[2] : text,
94
+ dir: entry.dir,
95
+ };
96
+ }
97
+
98
+ function skillDirPath(entry) {
99
+ return join(REPO_ROOT, 'skills', entry.dir);
100
+ }
101
+
102
+ function configDirectory(env = process.env) {
103
+ const explicit = typeof env.DSH_UI_TESTING_CONFIG_DIR === 'string' ? env.DSH_UI_TESTING_CONFIG_DIR.trim() : '';
104
+ if (explicit) return explicit;
105
+ const home = typeof env.DSH_HOME === 'string' && env.DSH_HOME.trim() ? env.DSH_HOME.trim() : join(homedir(), '.dsh');
106
+ return join(home, 'ui-testing');
107
+ }
108
+
109
+ /**
110
+ * Environment-neutral diagnostics (plan §5.2.4 / §6.2): "what exists here" and "why is it refused"
111
+ * must be answerable in *every* environment — including one the user disabled. They never touch a
112
+ * page, so they are not bound to a single capability id.
113
+ */
114
+ const ENV_NEUTRAL_TOOLS = Object.freeze(['browser_state', 'browser_capabilities']);
115
+
116
+ function isNeutralTool(name) {
117
+ return ENV_NEUTRAL_TOOLS.includes(name);
118
+ }
119
+
120
+ function capabilityForTool(name) {
121
+ if (isNeutralTool(name)) return null;
122
+ if (name === FAKE_TOOL) return 'fake.t1';
123
+ return WEB_TOOL_OPERATIONS[name] ?? null;
124
+ }
125
+
126
+ function operationForTool(name) {
127
+ if (name === FAKE_TOOL) return FAKE_OPERATION;
128
+ return name;
129
+ }
130
+
131
+ /**
132
+ * Build the composition root.
133
+ *
134
+ * @param {{ctx:object, registry:object, log:Function,
135
+ * BrowserError:Function, asBrowserError:Function, env?:object}} input
136
+ */
137
+ export function createCrossEnvironment({
138
+ ctx,
139
+ registry,
140
+ log = () => {},
141
+ BrowserError,
142
+ asBrowserError,
143
+ env = process.env,
144
+ } = {}) {
145
+ if (typeof BrowserError !== 'function') throw new TypeError('createCrossEnvironment requires BrowserError');
146
+ const classify = typeof asBrowserError === 'function' ? asBrowserError : (error) => error;
147
+
148
+ const allowTestEnvironments = env.DSH_UI_TESTING_TEST_HOOKS === '1'
149
+ || env.DSH_UI_TESTING_ALLOW_TEST_ENV === '1';
150
+ const configStore = new ConfigStore({
151
+ dir: configDirectory(env),
152
+ log: ctx?.logger ?? null,
153
+ allowTestEnvironments,
154
+ }).load();
155
+ const adapterRegistry = new AdapterRegistry();
156
+ let disposed = false;
157
+ let lastKnownGood = null;
158
+ let invalidateSkills = () => {};
159
+
160
+ /**
161
+ * Per-capability readiness probe (A13).
162
+ *
163
+ * The old probe answered "there is a bound target, so capabilities are fine". That is not a
164
+ * measurement: structure reading, input dispatch, the network collector and the frame source are
165
+ * independent, and a page can have some of them and lack the others. Each check below reads a
166
+ * real signal off the live runtime, records `checkedAt`, and the union decides the status:
167
+ * every check ok -> `available`, some ok -> `partial`, none -> `unavailable`, no target ->
168
+ * `not-probed`. Nothing is inferred from a target merely existing.
169
+ */
170
+ function probeWebCapabilities({ entry, checkedAt }) {
171
+ const runtime = entry?.runtime ?? null;
172
+ if (!runtime) {
173
+ return {
174
+ status: READINESS_STATUS.NOT_PROBED,
175
+ reason: 'no-runtime',
176
+ checkedAt,
177
+ evidence: { kind: 'capability-matrix', checks: {}, checkedAt },
178
+ };
179
+ }
180
+ const cdpAttached = Boolean(runtime.cdp && runtime.cdp.closed !== true);
181
+ const checks = {
182
+ // Structure (T2 snapshot / DOM reads) needs an attached page session.
183
+ structure: cdpAttached
184
+ ? { status: READINESS_STATUS.OK }
185
+ : { status: READINESS_STATUS.FAILED, reason: 'cdp-not-attached' },
186
+ // Input is dispatched through the same session; it is a separate entry because a read-only
187
+ // transport (or a closed Input domain) must be able to report structure without input.
188
+ input: cdpAttached && typeof runtime.input === 'function'
189
+ ? { status: READINESS_STATUS.OK }
190
+ : { status: READINESS_STATUS.FAILED, reason: cdpAttached ? 'input-unsupported' : 'cdp-not-attached' },
191
+ // Network collection is an independent object; its absence is a real capability gap.
192
+ network: runtime.net
193
+ ? { status: READINESS_STATUS.OK }
194
+ : { status: READINESS_STATUS.FAILED, reason: 'collector-missing' },
195
+ // Frame source (screencast / screenshot loop) is what T3/T4 preview depends on.
196
+ frames: runtime.frames
197
+ ? { status: READINESS_STATUS.OK }
198
+ : { status: READINESS_STATUS.FAILED, reason: 'frame-source-missing' },
199
+ };
200
+ const okChecks = Object.values(checks).filter((check) => check.status === READINESS_STATUS.OK).length;
201
+ const total = Object.keys(checks).length;
202
+ const status = okChecks === total
203
+ ? READINESS_STATUS.OK
204
+ : (okChecks === 0 ? AVAILABILITY.UNAVAILABLE : 'partial');
205
+ const failed = Object.entries(checks).filter(([, check]) => check.status !== READINESS_STATUS.OK);
206
+ const busy = runtime.lease && runtime.lease.expiresAt > Date.now() && runtime.active > 0;
207
+ return {
208
+ status: busy && status === READINESS_STATUS.OK ? AVAILABILITY.BUSY : status,
209
+ reason: busy && status === READINESS_STATUS.OK
210
+ ? 'page-busy'
211
+ : (failed.length === 0 ? null : failed.map(([name]) => name).join(',')),
212
+ checkedAt,
213
+ target: {
214
+ targetId: entry?.tabId ?? runtime.runtimeId ?? null,
215
+ generation: Number.isInteger(runtime.generation) ? runtime.generation : null,
216
+ },
217
+ evidence: {
218
+ kind: 'capability-matrix',
219
+ checkedAt,
220
+ okCount: okChecks,
221
+ total,
222
+ checks,
223
+ runtime: { id: runtime.runtimeId ?? null, cdpAttached, url: typeof runtime.url === 'string' ? runtime.url : null },
224
+ },
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Web P0 provider: the single entry to the platform data plane for families A-E.
230
+ *
231
+ * The dispatch receives the invocation `context` (page runtime, exec, action meta) plus the
232
+ * bound `session`, so the provider — not the tool layer — decides which target/generation the
233
+ * data belongs to. Passing `session` is what makes generation/lease answers real instead of a
234
+ * static projection (A01/A05).
235
+ */
236
+ function createWebProvider() {
237
+ return createWebP0Provider({
238
+ log,
239
+ p0Dispatch: async (family, method, { args = {}, session = null, context = null, signal = null } = {}) => {
240
+ const runtime = context?.runtime;
241
+ if (!runtime) {
242
+ throw new AdapterError(ERROR_CODES.PROVIDER_UNAVAILABLE, 'web P0 provider has no runtime context', {
243
+ details: { family, method },
244
+ });
245
+ }
246
+ if (family === 'A' && method === 'screenshot') return runtime.screenshot(args, signal);
247
+ if (family === 'A' && method === 'capture') return runtime.capture(args, signal);
248
+ if (family === 'A' && method === 'product') return runtime.browserProduct(signal);
249
+ if (family === 'B') {
250
+ // `context` is the invocation view (a superset of the tool's `exec`), which is what the
251
+ // network methods expect for owner/session attribution.
252
+ if (method === 'start' || method === 'stop' || method === 'status' || method === 'reset') {
253
+ return runtime.netSession({ ...args, action: method }, context, signal);
254
+ }
255
+ if (method === 'events') return runtime.netEvents(args);
256
+ if (method === 'detail') return runtime.netDetail(args);
257
+ if (method === 'control') return runtime.netControl(args, context, signal);
258
+ if (method === 'trace') return runtime.netTrace(args);
259
+ if (method === 'evidence') return runtime.netEvidence(args, context);
260
+ if (method === 'subscribe') return runtime.netSubscribe(args, context.meta?.sessionId);
261
+ }
262
+ if (family === 'C' && method === 'read') return runtime.viewProjection();
263
+ if (family === 'C' && method === 'set') return runtime.applyView(args.patch ?? args, { signal, source: 'agent' });
264
+ if (family === 'D' && method === 'annotationRead') return runtime.annotationsDocument();
265
+ if (family === 'D' && method === 'annotationWrite') return runtime.acceptAnnotations(args);
266
+ if (family === 'E' && method === 'list') {
267
+ return registry.listTabs(context.primaryEntry ?? context.entry, context.meta?.sessionId);
268
+ }
269
+ if (family === 'E') {
270
+ return leaseFamily(method, { args, session, runtime, context });
271
+ }
272
+ throw new AdapterError(ERROR_CODES.CAPABILITY_UNSUPPORTED, `web P0 method ${family}.${method} is not wired`, {
273
+ details: { family, method },
274
+ });
275
+ },
276
+ });
277
+ }
278
+
279
+ /**
280
+ * P0-E lease lifecycle against the *real* page lease (A05).
281
+ *
282
+ * The page runtime owns the single-owner lease (`runtime.lease`). `leaseAcquire/Release/Status`
283
+ * used to be wired to one read-only projection, with `generation: null`, so a caller could not
284
+ * tell which target the lease covered. Each method now answers for the bound target and
285
+ * generation the calling session actually holds, and a foreign owner is a BUSY refusal, not a
286
+ * silent sharing of someone else's lease.
287
+ */
288
+ function leaseFamily(method, { args = {}, session = null, runtime, context = null }) {
289
+ const targetId = session?.target?.targetId ?? runtime?.runtimeId ?? null;
290
+ const generation = Number.isInteger(session?.generation) ? session.generation : null;
291
+ const sessionId = context?.meta?.sessionId ?? session?.sessionId ?? null;
292
+ const leaseIdOf = (owner) => (targetId && generation !== null ? `web:${targetId}:${generation}:${owner ?? 'none'}` : null);
293
+ if (method === 'leaseAcquire') {
294
+ if (!targetId) {
295
+ throw new AdapterError(ERROR_CODES.TARGET_NOT_BOUND, 'leaseAcquire requires a bound target', {
296
+ details: { operation: 'E.leaseAcquire' },
297
+ });
298
+ }
299
+ if (typeof runtime.claim !== 'function') {
300
+ throw new AdapterError(ERROR_CODES.PROVIDER_UNAVAILABLE, 'leaseAcquire requires a page runtime lease', {
301
+ details: { operation: 'E.leaseAcquire' },
302
+ });
303
+ }
304
+ runtime.claim(sessionId ?? 'anonymous');
305
+ const expiresAt = Number.isFinite(runtime.lease?.expiresAt) ? runtime.lease.expiresAt : null;
306
+ return {
307
+ leaseId: leaseIdOf(sessionId),
308
+ targetId,
309
+ generation,
310
+ owner: { kind: 'session', id: sessionId ?? 'anonymous' },
311
+ expiresAt,
312
+ expired: false,
313
+ remainingMs: expiresAt === null ? null : Math.max(0, expiresAt - Date.now()),
314
+ };
315
+ }
316
+ if (method === 'leaseRelease') {
317
+ if (typeof runtime.release !== 'function') {
318
+ throw new AdapterError(ERROR_CODES.PROVIDER_UNAVAILABLE, 'leaseRelease requires a page runtime lease', {
319
+ details: { operation: 'E.leaseRelease' },
320
+ });
321
+ }
322
+ const owner = runtime.lease?.sessionId ?? null;
323
+ const requested = typeof args.leaseId === 'string' ? args.leaseId : null;
324
+ if (requested !== null && leaseIdOf(owner) !== null && requested !== leaseIdOf(owner)) {
325
+ throw new AdapterError(ERROR_CODES.GENERATION_MISMATCH, 'leaseRelease targets a different lease', {
326
+ details: { operation: 'E.leaseRelease', requested, current: leaseIdOf(owner) },
327
+ });
328
+ }
329
+ runtime.release(owner ?? sessionId ?? 'anonymous');
330
+ return { released: true, leaseId: requested ?? leaseIdOf(owner), targetId, generation };
331
+ }
332
+ // leaseStatus
333
+ const lease = runtime.lease ?? null;
334
+ const owner = lease?.sessionId ?? null;
335
+ const expiresAt = Number.isFinite(lease?.expiresAt) ? lease.expiresAt : null;
336
+ return {
337
+ lease: lease === null ? null : {
338
+ leaseId: leaseIdOf(owner),
339
+ targetId,
340
+ generation,
341
+ owner: { kind: 'session', id: owner },
342
+ expiresAt,
343
+ },
344
+ expired: expiresAt === null ? true : expiresAt <= Date.now(),
345
+ remainingMs: expiresAt === null ? 0 : Math.max(0, expiresAt - Date.now()),
346
+ targetId,
347
+ generation,
348
+ };
349
+ }
350
+
351
+ /*
352
+ * Lazy Web faces (A14 按需加载 / 生命周期接线).
353
+ *
354
+ * The three Web adapters used to be created and registered unconditionally at composition-root
355
+ * construction, so a Host that only ever runs the fake environment still instantiated the whole
356
+ * Web surface. They are now created on first demand — the first Web-scoped admission, readiness
357
+ * probe or tool call — and `dispose()` handles the never-created case.
358
+ */
359
+ let webFaces = null;
360
+ let webFaceError = null;
361
+
362
+ function createWebFaces() {
363
+ const targetAdapter = createWebTargetAdapter({
364
+ registry,
365
+ log,
366
+ capabilityAvailability: ({ entry, snapshot }) => probeWebCapabilities({
367
+ entry: entry ?? findEntryForSnapshot(snapshot),
368
+ checkedAt: Date.now(),
369
+ }),
370
+ });
371
+ const capabilityAdapter = createWebCapabilityAdapter({
372
+ log,
373
+ dispatch: async ({ context }) => {
374
+ if (!context || typeof context.execute !== 'function') {
375
+ throw new AdapterError(ERROR_CODES.PROVIDER_UNAVAILABLE, 'web dispatch requires context.execute');
376
+ }
377
+ // Keep the complete tool result. Some tools (browser_eval) legitimately have a `value`
378
+ // field; the adapter's envelope unwrapping must not change the tool's own result shape.
379
+ const raw = await context.execute();
380
+ context.rawResult = raw;
381
+ return raw;
382
+ },
383
+ });
384
+ const provider = createWebProvider();
385
+ adapterRegistry.registerTargetAdapter(targetAdapter);
386
+ adapterRegistry.registerCapabilityAdapter(capabilityAdapter);
387
+ adapterRegistry.registerP0Provider(provider);
388
+ return { targetAdapter, capabilityAdapter, provider };
389
+ }
390
+
391
+ /** Ensure the Web faces exist. Returns null when the Web environment is not in play. */
392
+ function ensureWebRegistered() {
393
+ if (webFaces) return webFaces;
394
+ if (webFaceError) return null;
395
+ try {
396
+ webFaces = createWebFaces();
397
+ return webFaces;
398
+ } catch (error) {
399
+ webFaceError = String((error && error.message) || error);
400
+ log('ui-testing: web faces unavailable: ' + webFaceError);
401
+ return null;
402
+ }
403
+ }
404
+
405
+ /**
406
+ * A snapshot needs the Web faces when it resolves to (or can select) the Web environment.
407
+ * Anything else — a fake-only host, the planned environments — stays away from them.
408
+ */
409
+ function snapshotNeedsWeb(snapshot) {
410
+ // Only an enabled Web allow-list justifies instantiating the Web faces: the resolver reports a
411
+ // fallback environment even when nothing is enabled, and that is not a demand for Web (A14).
412
+ return Boolean(snapshot) && Array.isArray(snapshot.enabledEnvironments)
413
+ && snapshot.enabledEnvironments.includes(ENVIRONMENTS.WEB);
414
+ }
415
+
416
+ /** Load the Web faces for one resolved snapshot (no-op when Web is not involved). */
417
+ function prepareWeb(snapshot) {
418
+ if (!snapshotNeedsWeb(snapshot)) return null;
419
+ return ensureWebRegistered();
420
+ }
421
+
422
+ let fakeEnvironment = null;
423
+ if (allowTestEnvironments) {
424
+ fakeEnvironment = createFakeEnvironment();
425
+ adapterRegistry.registerTargetAdapter(fakeEnvironment.targetAdapter);
426
+ adapterRegistry.registerCapabilityAdapter(fakeEnvironment.capabilityAdapter);
427
+ adapterRegistry.registerP0Provider(fakeEnvironment.p0Provider);
428
+ }
429
+
430
+ const configListeners = new Set();
431
+
432
+ configStore.subscribe(() => {
433
+ try {
434
+ invalidateSkills();
435
+ } catch (error) {
436
+ log('ui-testing: skill invalidation failed: ' + String((error && error.message) || error));
437
+ }
438
+ for (const listener of [...configListeners]) {
439
+ try {
440
+ listener();
441
+ } catch (error) {
442
+ log('ui-testing: config listener failed: ' + String((error && error.message) || error));
443
+ }
444
+ }
445
+ });
446
+
447
+ /** Subscribe to successful config writes (used to re-admit the tool surface). */
448
+ function onConfigChange(fn) {
449
+ if (typeof fn !== 'function') return () => {};
450
+ configListeners.add(fn);
451
+ return () => { configListeners.delete(fn); };
452
+ }
453
+
454
+ function leaseProjection(runtime) {
455
+ const lease = runtime?.lease ?? null;
456
+ return {
457
+ leaseId: lease?.sessionId ? `web:${lease.sessionId}` : null,
458
+ targetId: runtime?.runtimeId ?? null,
459
+ generation: null,
460
+ owner: lease?.sessionId ?? null,
461
+ expiresAt: Number.isFinite(lease?.expiresAt) ? lease.expiresAt : null,
462
+ };
463
+ }
464
+
465
+ function findEntryForSnapshot(snapshot) {
466
+ const targetId = snapshot?.targetId;
467
+ if (typeof targetId === 'string' && targetId) {
468
+ for (const entry of registry?.entries?.values?.() ?? []) {
469
+ if (entry?.tabId === targetId || entry?.runtimeId === targetId) return entry;
470
+ }
471
+ }
472
+ for (const entry of registry?.entries?.values?.() ?? []) {
473
+ if (entry?.runtime?.url) return entry;
474
+ }
475
+ return null;
476
+ }
477
+
478
+ function fallbackSnapshot(agent, error) {
479
+ if (lastKnownGood) return lastKnownGood;
480
+ try {
481
+ return resolveSnapshot(DEFAULT_CONFIG, { agent }, { allowTestEnvironments });
482
+ } catch {
483
+ return {
484
+ revision: 0,
485
+ environment: DEFAULT_ENVIRONMENT,
486
+ adapterId: DEFAULT_ENVIRONMENT,
487
+ provider: DEFAULT_WEB_PROVIDER,
488
+ executionAdapterId: `${DEFAULT_ENVIRONMENT}-${DEFAULT_WEB_PROVIDER}`,
489
+ enabledProviders: [DEFAULT_WEB_PROVIDER],
490
+ providerConfig: {
491
+ provider: DEFAULT_WEB_PROVIDER,
492
+ enabled: true,
493
+ executable: null,
494
+ connection: null,
495
+ implemented: true,
496
+ },
497
+ enabledEnvironments: [DEFAULT_ENVIRONMENT],
498
+ capabilities: [],
499
+ environmentConfig: {},
500
+ targetId: null,
501
+ source: { degraded: true, reason: String((error && error.message) || error) },
502
+ };
503
+ }
504
+ }
505
+
506
+ /** Resolve a snapshot without ever throwing: `when` must not break step preparation. */
507
+ function snapshotForAgent(agent) {
508
+ try {
509
+ const snapshot = configStore.resolveForAgent(agent);
510
+ lastKnownGood = snapshot;
511
+ return snapshot;
512
+ } catch (error) {
513
+ log('ui-testing: config resolve failed; using last-known-good/default: ' + String((error && error.message) || error));
514
+ const snapshot = fallbackSnapshot(agent, error);
515
+ return { ...snapshot, source: { ...(snapshot.source || {}), degraded: true } };
516
+ }
517
+ }
518
+
519
+ function snapshotForScope(scope) {
520
+ try {
521
+ const snapshot = configStore.resolveForScope(scope);
522
+ lastKnownGood = snapshot;
523
+ return snapshot;
524
+ } catch (error) {
525
+ log('ui-testing: config resolve failed for scope; using fallback: ' + String((error && error.message) || error));
526
+ return fallbackSnapshot(null, error);
527
+ }
528
+ }
529
+
530
+ function toolAdmitted(agent, name) {
531
+ // Environment-neutral diagnostics answer "what is here / why was it refused"; they must stay
532
+ // available in every environment, including a disabled one (they never start a target).
533
+ if (isNeutralTool(name)) return true;
534
+ const capability = capabilityForTool(name);
535
+ if (capability === null) return false;
536
+ const snapshot = snapshotForAgent(agent);
537
+ const environmentEnabled = Array.isArray(snapshot.enabledEnvironments)
538
+ && snapshot.enabledEnvironments.includes(snapshot.environment);
539
+ if (!environmentEnabled) return false;
540
+ if (!Array.isArray(snapshot.capabilities) || !snapshot.capabilities.includes(capability)) return false;
541
+ if (name === FAKE_TOOL) return snapshot.environment === ENVIRONMENTS.FAKE;
542
+ // §4.3: admission also answers the provider question. A disabled selection, an unknown name or
543
+ // a provider without a driver refuses the Web operations (never a silent browser swap).
544
+ if (snapshot.environment === ENVIRONMENTS.WEB && WEB_TOOL_OPERATIONS[name] !== undefined) {
545
+ if (resolveWebDriver(snapshot).blocked !== null) return false;
546
+ }
547
+ return true;
548
+ }
549
+
550
+ /**
551
+ * Web provider switches for one resolved snapshot (derived, never a second stored list).
552
+ * `implementedProviders` is the subset a driver actually exists for.
553
+ */
554
+ function webProvidersFor(snapshot) {
555
+ const entry = isPlainObject(snapshot?.environmentConfig) ? snapshot.environmentConfig : {};
556
+ const providers = isPlainObject(entry.providers) ? entry.providers : {};
557
+ const enabledProviders = WEB_PROVIDER_IDS.filter((id) => providers[id]?.enabled === true);
558
+ return {
559
+ providers,
560
+ enabledProviders,
561
+ implementedProviders: enabledProviders.filter((id) => isImplementedWebProvider(id)),
562
+ totalEnabled: entry.enabled === true,
563
+ };
564
+ }
565
+
566
+ /**
567
+ * Providers enabled by *any* selectable scope (global + workspace switches). A session may only
568
+ * choose between these, so this union is the registration ceiling (§4.3).
569
+ */
570
+ function enabledProviderUnion() {
571
+ const config = configStore.getConfig();
572
+ const consider = (entry) => {
573
+ const web = isPlainObject(entry) ? entry.environments?.[ENVIRONMENTS.WEB] : null;
574
+ if (!isPlainObject(web) || !isPlainObject(web.providers)) return;
575
+ for (const id of WEB_PROVIDER_IDS) {
576
+ if (web.providers[id]?.enabled === true) union.add(id);
577
+ }
578
+ };
579
+ const union = new Set();
580
+ consider(config.global);
581
+ for (const entry of Object.values(config.workspaces || {})) consider(entry);
582
+ return union;
583
+ }
584
+
585
+ /** True when the Web tools may exist at all: some implemented provider is enabled somewhere. */
586
+ function webProviderSurfaceAvailable() {
587
+ const union = enabledProviderUnion();
588
+ return IMPLEMENTED_WEB_PROVIDERS.some((id) => union.has(id));
589
+ }
590
+
591
+ /** The provider/execution identity block exposed by /config, /config/select and capabilities. */
592
+ function resolvedBlock(snapshot) {
593
+ return {
594
+ environment: snapshot.environment,
595
+ adapterId: snapshot.adapterId,
596
+ provider: snapshot.provider ?? null,
597
+ executionAdapterId: snapshot.executionAdapterId ?? snapshot.adapterId,
598
+ enabledProviders: Array.isArray(snapshot.enabledProviders) ? [...snapshot.enabledProviders] : [],
599
+ providerConfig: snapshot.providerConfig ?? null,
600
+ enabledEnvironments: snapshot.enabledEnvironments,
601
+ capabilities: snapshot.capabilities,
602
+ targetId: snapshot.targetId,
603
+ source: snapshot.source,
604
+ };
605
+ }
606
+
607
+ /** Every environment some scope is allowed to select (global + workspace allow-lists). */
608
+ function enabledEnvironmentUnion() {
609
+ const config = configStore.getConfig();
610
+ const set = new Set(Array.isArray(config.global?.enabledEnvironments) ? config.global.enabledEnvironments : []);
611
+ for (const entry of Object.values(config.workspaces || {})) {
612
+ if (!isPlainObject(entry) || !Array.isArray(entry.enabledEnvironments)) continue;
613
+ for (const environment of entry.enabledEnvironments) set.add(environment);
614
+ }
615
+ return set;
616
+ }
617
+
618
+ /**
619
+ * Tool names to *register* — the declaration set (A08).
620
+ *
621
+ * Measured on the running host (2026-11-07, `request/header` events of a live session): a
622
+ * per-step `when` that returned false did **not** remove the tool from the model-facing list,
623
+ * and the call still reached the plugin body. This composition therefore builds the request from
624
+ * what is *registered*, exactly like the pre-migration behaviour: registration scope is the
625
+ * effective isolation, and `when` is only a second gate for compositions that do apply step
626
+ * admission to declarations.
627
+ *
628
+ * So the union is limited to the environments a scope may actually select (plus the neutral
629
+ * diagnostics): a disabled environment's tools are unregistered and cannot appear in any
630
+ * request, while an enabled one stays available to the agents that select it.
631
+ */
632
+ function declarableToolNames() {
633
+ const names = new Set(ENV_NEUTRAL_TOOLS);
634
+ const enabled = enabledEnvironmentUnion();
635
+ // §4.3: the Web tools exist while the total switch is valid AND at least one *implemented*
636
+ // provider is enabled. The 36 names are registered once; provider routing happens per call, so
637
+ // enabling one or two providers never changes the registered set.
638
+ if (enabled.has(ENVIRONMENTS.WEB) && webProviderSurfaceAvailable()) {
639
+ for (const name of Object.keys(WEB_TOOL_OPERATIONS)) names.add(name);
640
+ }
641
+ // The test-only probe follows the same rule: registered only while the fake environment is
642
+ // selectable, so it can never leak into a Web-only request.
643
+ if (allowTestEnvironments && enabled.has(ENVIRONMENTS.FAKE)) names.add(FAKE_TOOL);
644
+ return names;
645
+ }
646
+
647
+ /**
648
+ * Per-agent double-check of the declarable set (diagnostics, tests and the HTTP surface use it).
649
+ * Omitted `agent` resolves the global selection, exactly like the settings page.
650
+ */
651
+ function admissibleToolNames(agent = null) {
652
+ const names = new Set();
653
+ for (const name of declarableToolNames()) {
654
+ if (toolAdmitted(agent, name)) names.add(name);
655
+ }
656
+ return names;
657
+ }
658
+
659
+ function assertToolAllowed(agent, name) {
660
+ if (toolAdmitted(agent, name)) return true;
661
+ const snapshot = snapshotForAgent(agent);
662
+ throw new BrowserError(ERROR_CODES.ADAPTER_DISABLED, `工具 ${name} 不属于当前环境 ${snapshot.environment}`, {
663
+ environment: snapshot.environment,
664
+ capability: capabilityForTool(name),
665
+ configRevision: snapshot.revision,
666
+ });
667
+ }
668
+
669
+ function adapterSessionFor({ snapshot, resolvedScope, exec }) {
670
+ const { targetAdapter } = adapterRegistry.resolve(snapshot);
671
+ const scopeId = resolvedScope?.scope?.id || resolvedScope?.id || exec?.meta?.scope?.id || 'legacy:default';
672
+ const workspaceId = resolvedScope?.scope?.workspaceId || resolvedScope?.workspaceId || null;
673
+ return targetAdapter.createSession({
674
+ scope: { id: scopeId, workspaceId },
675
+ snapshot,
676
+ signal: exec?.signal,
677
+ });
678
+ }
679
+
680
+ const P0_FAMILY_SURFACE = Object.freeze({ A: 'capture', B: 'network', C: 'display', D: 'canvas', E: 'targets' });
681
+
682
+ /**
683
+ * Dispatch one P0 data operation through the environment's P0 provider (A01).
684
+ *
685
+ * Tool implementations used to call `runtime.*` directly, which left the provider as a parallel,
686
+ * unused surface. Every采集/网络/显示/标注/目标读 now goes through this one entry: the provider
687
+ * validates admission, binds the operations to the calling session's target/generation and
688
+ * attaches the evidence envelope, while the tool keeps its public result shape (`raw: true`
689
+ * skips neutral normalization for platform-shaped payloads).
690
+ *
691
+ * @returns {Promise<{value: any, evidence: object|null}>}
692
+ */
693
+ async function p0Call({
694
+ family,
695
+ method,
696
+ args = {},
697
+ exec = {},
698
+ resolvedScope = null,
699
+ runtime = null,
700
+ entry = null,
701
+ meta = null,
702
+ selection = null,
703
+ raw = true,
704
+ }) {
705
+ const surfaceName = P0_FAMILY_SURFACE[family];
706
+ if (!surfaceName) {
707
+ throw new BrowserError(ERROR_CODES.INVALID_ARGUMENT, `unknown P0 family ${String(family)}`, { family });
708
+ }
709
+ const snapshot = snapshotForAgent(exec?.agent);
710
+ prepareWeb(snapshot);
711
+ // §4.4 provider gate for the P0 surface: a disabled / unknown / unimplemented provider refuses
712
+ // the call instead of being answered by whichever browser happens to be running.
713
+ const driver = resolveWebDriver(snapshot);
714
+ if (driver.blocked !== null) {
715
+ const disabled = driver.blocked === 'environment-disabled' || driver.blocked === 'provider-disabled';
716
+ throw new BrowserError(
717
+ disabled ? ERROR_CODES.ADAPTER_DISABLED : ERROR_CODES.ADAPTER_UNSUPPORTED,
718
+ disabled
719
+ ? `当前 Web 运行环境(${driver.provider})已禁用:${driver.blocked}`
720
+ : `当前 Web 运行环境(${driver.provider})不可执行:${driver.blocked}`,
721
+ { provider: driver.provider, reason: driver.blocked, family, method, configRevision: snapshot.revision },
722
+ );
723
+ }
724
+ const { providers } = adapterRegistry.resolve(snapshot);
725
+ const provider = providers?.[0];
726
+ if (!provider || typeof provider[surfaceName]?.[method] !== 'function') {
727
+ throw new BrowserError(ERROR_CODES.PROVIDER_UNAVAILABLE, `环境 ${snapshot.environment} 没有 P0 提供者方法 ${family}.${method}`, {
728
+ environment: snapshot.environment,
729
+ family,
730
+ method,
731
+ });
732
+ }
733
+ const session = await adapterSessionFor({ snapshot, resolvedScope, exec });
734
+ try {
735
+ const targetId = selection?.tabId || entry?.tabId || snapshot.targetId || null;
736
+ if (targetId) {
737
+ try {
738
+ await session.bind(targetId, { signal: exec?.signal });
739
+ } catch (error) {
740
+ if (error?.code !== ERROR_CODES.TARGET_NOT_BOUND) throw error;
741
+ }
742
+ }
743
+ const result = await provider[surfaceName][method]({
744
+ args: isPlainObject(args) ? args : {},
745
+ session,
746
+ snapshot,
747
+ signal: exec?.signal,
748
+ context: { ...exec, runtime, meta, entry, primaryEntry: entry, selection },
749
+ raw,
750
+ });
751
+ return { value: result?.value ?? null, evidence: result?.evidence ?? null, targetId, generation: session.generation ?? null };
752
+ } catch (error) {
753
+ // The platform's own error vocabulary must survive the provider boundary: a NET_*/VISION_*/
754
+ // OUTPUT_* failure is exactly what the tool contract documents, and collapsing it to INTERNAL
755
+ // would erase the classification the caller branches on. Prefer the original cause, then the
756
+ // adapter code, then a classified BrowserError.
757
+ const cause = error?.cause;
758
+ if (cause instanceof BrowserError) throw cause;
759
+ if (cause && typeof cause.code === 'string' && cause.code !== 'INTERNAL') throw cause;
760
+ if (error instanceof AdapterError && error.code !== ERROR_CODES.INTERNAL) {
761
+ const prefix = `[${error.code}] `;
762
+ const rawMessage = String(error.message || '');
763
+ throw new BrowserError(error.code, rawMessage.startsWith(prefix) ? rawMessage.slice(prefix.length) : rawMessage, error.details || {});
764
+ }
765
+ throw classify(error);
766
+ } finally {
767
+ try { await session.dispose(); } catch { /* session cleanup never masks the result */ }
768
+ }
769
+ }
770
+
771
+ /**
772
+ * Run one tool implementation through the capability adapter.
773
+ *
774
+ * The caller has already chosen the execution class branch (page/readOnly/hostOnly/...), so the
775
+ * adapter only validates admission/identity and forwards `context.execute`. That keeps the
776
+ * existing Web branching (queue/ensure/lease) intact while making the adapter the only dispatch
777
+ * path for the 36 tools.
778
+ */
779
+ async function invokeTool({
780
+ name,
781
+ args,
782
+ exec = {},
783
+ thunk,
784
+ executionClass,
785
+ resolvedScope = null,
786
+ runtime = null,
787
+ entry = null,
788
+ selection = null,
789
+ meta = null,
790
+ }) {
791
+ // Cancellation before any adapter work is a first-class ABORTED, not an adapter-internal
792
+ // failure: the existing tool contract reports ABORTED/cancelled/retryable for a pre-aborted
793
+ // signal, and a session must not be created for a call that already knows it is cancelled.
794
+ if (exec?.signal?.aborted) throw new BrowserError('ABORTED', '操作已取消');
795
+ const snapshot = snapshotForAgent(exec.agent);
796
+ assertToolAllowed(exec.agent, name);
797
+ // Lazily bring the Web faces up for a Web-resolved call (A14); other environments never
798
+ // instantiate them.
799
+ prepareWeb(snapshot);
800
+ if (isNeutralTool(name)) {
801
+ // Environment-neutral diagnostics do not belong to a capability adapter and must answer even
802
+ // when the environment is disabled (no target is started): run the implementation directly.
803
+ const neutral = await thunk();
804
+ if (meta && typeof meta === 'object') {
805
+ meta.configRevision = snapshot.revision;
806
+ meta.environment = snapshot.environment;
807
+ meta.adapterId = snapshot.adapterId;
808
+ }
809
+ return neutral;
810
+ }
811
+ const { capabilityAdapters } = adapterRegistry.resolve(snapshot);
812
+ const capabilityAdapter = capabilityAdapters[0];
813
+ if (!capabilityAdapter) {
814
+ throw new BrowserError(ERROR_CODES.ADAPTER_NOT_FOUND, '当前环境没有可用的能力适配器', {
815
+ environment: snapshot.environment,
816
+ });
817
+ }
818
+ const session = await adapterSessionFor({ snapshot, resolvedScope, exec });
819
+ try {
820
+ const classForCall = executionClass || resolveExecutionClass(name, args) || 'page';
821
+ if (name === FAKE_TOOL) {
822
+ const targets = await session.listTargets({ signal: exec.signal });
823
+ if (targets[0]) await session.bind(targets[0].targetId, { signal: exec.signal });
824
+ } else if (runtime && (classForCall === 'page' || classForCall === 'tabs')) {
825
+ const targetId = selection?.tabId || entry?.tabId || 'primary';
826
+ try {
827
+ await session.bind(targetId, { signal: exec.signal });
828
+ } catch (error) {
829
+ if (error?.code !== ERROR_CODES.TARGET_NOT_BOUND) throw error;
830
+ }
831
+ }
832
+ const context = {
833
+ ...exec,
834
+ runtime,
835
+ meta,
836
+ entry,
837
+ primaryEntry: entry,
838
+ selection,
839
+ execute: thunk,
840
+ };
841
+ let result;
842
+ try {
843
+ result = await capabilityAdapter.invoke({
844
+ operation: operationForTool(name),
845
+ args: name === FAKE_TOOL ? (isPlainObject(args) ? args : {}) : args,
846
+ session,
847
+ snapshot,
848
+ signal: exec.signal,
849
+ executionClass: classForCall,
850
+ context,
851
+ });
852
+ } catch (error) {
853
+ // Preserve the stable machine code across the adapter boundary. `asBrowserError` only
854
+ // recognizes BrowserError, so an AdapterError must be translated explicitly instead of
855
+ // collapsing every typed failure into INTERNAL.
856
+ if (error instanceof AdapterError) {
857
+ const prefix = `[${error.code}] `;
858
+ const raw = String(error.message || '');
859
+ throw new BrowserError(error.code, raw.startsWith(prefix) ? raw.slice(prefix.length) : raw, error.details || {});
860
+ }
861
+ throw error;
862
+ }
863
+ if (meta && typeof meta === 'object') {
864
+ meta.configRevision = snapshot.revision;
865
+ meta.environment = snapshot.environment;
866
+ meta.adapterId = snapshot.adapterId;
867
+ }
868
+ // `context.rawResult` is the tool's complete result; `result.value` is the adapter's
869
+ // normalized view (used when the platform dispatch did not go through the thunk).
870
+ return Object.hasOwn(context, 'rawResult') ? context.rawResult : result.value;
871
+ } finally {
872
+ try {
873
+ await session.dispose();
874
+ } catch {
875
+ // Session cleanup must never mask the operation result.
876
+ }
877
+ }
878
+ }
879
+
880
+ function executionClassFor(name, args) {
881
+ if (name === FAKE_TOOL) return 'page';
882
+ return resolveExecutionClass(name, args);
883
+ }
884
+
885
+ function resolveRequestScope(scopeRequest) {
886
+ if (!scopeRequest || scopeRequest.mode === 'legacy') return { id: 'legacy:default', kind: 'legacy', workspaceId: null };
887
+ if (scopeRequest.mode === 'unassigned') return { id: 'unassigned:owner', kind: 'unassigned', workspaceId: null };
888
+ if (scopeRequest.mode === 'session') {
889
+ return { id: String(scopeRequest.sessionId || 'session'), kind: 'session', workspaceId: null, cwd: scopeRequest.cwd };
890
+ }
891
+ return { id: 'recent', kind: scopeRequest.mode || 'scope', workspaceId: null, cwd: scopeRequest.cwd };
892
+ }
893
+
894
+ /**
895
+ * Rebuild the agent shape `resolveSnapshot` expects from an HTTP/Skill scope request, so a
896
+ * scope-scoped caller gets the same admission answer a model step would get (A08 / A11).
897
+ */
898
+ function agentForScope(scopeRequest, resolvedScope = null) {
899
+ const scope = resolvedScope || resolveRequestScope(scopeRequest);
900
+ if (!scope || (scope.kind !== 'session' && scope.kind !== 'unassigned')) return null;
901
+ return { id: scope.id, session: { header: { cwd: scope.cwd ?? null } } };
902
+ }
903
+
904
+ /**
905
+ * Six-axis readiness for one resolved snapshot.
906
+ *
907
+ * @param {object} snapshot resolved configuration snapshot
908
+ * @param {object|null} [liveTarget] the *current session's* bound target, when the caller has one:
909
+ * `{ sessionId, runtimeId, url, transport, pageGeneration, documentEpoch }`. With it the
910
+ * connection/target/capability axes report what is actually bound plus a bounded freshness
911
+ * window, instead of a static `not-probed`/`unbound` projection (plan §6.2, A13).
912
+ */
913
+ async function readinessFor(snapshot, liveTarget = null) {
914
+ // Readiness for an enabled/selected Web environment needs its adapter; a disabled one is
915
+ // answered from static metadata without loading it (A13 + A14).
916
+ prepareWeb(snapshot);
917
+ const checkedAt = Date.now();
918
+ const expiresAt = checkedAt + READINESS_TTL_MS;
919
+ const live = liveTarget && typeof liveTarget === 'object' && liveTarget.runtimeId
920
+ ? {
921
+ sessionId: liveTarget.sessionId ?? null,
922
+ runtimeId: String(liveTarget.runtimeId),
923
+ url: liveTarget.url ?? null,
924
+ transport: liveTarget.transport ?? null,
925
+ pageGeneration: Number.isInteger(liveTarget.pageGeneration) ? liveTarget.pageGeneration : null,
926
+ documentEpoch: Number.isInteger(liveTarget.documentEpoch) ? liveTarget.documentEpoch : null,
927
+ }
928
+ : null;
929
+ const liveAxes = live
930
+ ? {
931
+ connection: axis(READINESS_STATUS.OK, {
932
+ checkedAt,
933
+ expiresAt,
934
+ evidence: { id: live.runtimeId, kind: 'runtime', transport: live.transport, url: live.url },
935
+ }),
936
+ target: axis(READINESS_STATUS.BOUND, {
937
+ checkedAt,
938
+ expiresAt,
939
+ target: { id: live.runtimeId, generation: live.pageGeneration, documentEpoch: live.documentEpoch, url: live.url },
940
+ evidence: { id: live.runtimeId, kind: 'runtime' },
941
+ }),
942
+ capability: axis(READINESS_STATUS.OK, {
943
+ checkedAt,
944
+ expiresAt,
945
+ target: { id: live.runtimeId, generation: live.pageGeneration, documentEpoch: live.documentEpoch, url: live.url },
946
+ evidence: { id: live.runtimeId, kind: 'page-bound', sessionId: live.sessionId },
947
+ }),
948
+ }
949
+ : null;
950
+ const config = configStore.getConfig();
951
+ const environments = [];
952
+ const known = Object.keys(KNOWN_ENVIRONMENTS);
953
+ for (const id of known) {
954
+ const meta = KNOWN_ENVIRONMENTS[id] || {};
955
+ const enabled = config.global.environments?.[id]?.enabled === true;
956
+ const selected = snapshot.environment === id;
957
+ // A14: a disabled environment is *not* probed and its adapter is not instantiated, so the
958
+ // settings page cannot pull in a platform's dependency chain by being opened. Its axes are
959
+ // answered from static engineering metadata plus the user-policy switch, which keeps
960
+ // "supported but disabled" distinguishable from "planned" and from "unavailable".
961
+ // Probe only an environment the user actually enabled: a disabled one must not be probed
962
+ // (that is what "打开设置页不扫描未启用环境" means). `selected` alone is not enough — when
963
+ // every environment is disabled the resolver still reports a fallback, which is not a
964
+ // request to probe anything.
965
+ const adapter = meta.planned ? null : (enabled ? safeResolveAdapter(id, snapshot) : null);
966
+ if (!adapter) {
967
+ if (!meta.planned && !enabled) {
968
+ const support = meta.status === 'supported' ? SUPPORT.SUPPORTED : SUPPORT.UNSUPPORTED;
969
+ environments.push({
970
+ id,
971
+ displayName: meta.displayName || id,
972
+ support,
973
+ enabled,
974
+ selected,
975
+ checkedAt,
976
+ configRevision: snapshot.revision,
977
+ axes: {
978
+ support: axis(support, { checkedAt }),
979
+ enabled: axis('false', { reason: 'environment-disabled', checkedAt }),
980
+ dependency: axis(READINESS_STATUS.NOT_PROBED, { reason: 'environment-disabled', checkedAt }),
981
+ connection: axis(READINESS_STATUS.NOT_PROBED, { reason: 'environment-disabled', checkedAt }),
982
+ target: axis(READINESS_STATUS.UNBOUND, { reason: 'environment-disabled', checkedAt }),
983
+ capability: axis(READINESS_STATUS.NOT_PROBED, { reason: 'environment-disabled', checkedAt }),
984
+ },
985
+ });
986
+ continue;
987
+ }
988
+ environments.push({
989
+ id,
990
+ displayName: meta.displayName || id,
991
+ support: meta.planned ? SUPPORT.PLANNED : SUPPORT.UNSUPPORTED,
992
+ enabled,
993
+ selected,
994
+ checkedAt,
995
+ configRevision: snapshot.revision,
996
+ axes: {
997
+ support: axis(meta.planned ? SUPPORT.PLANNED : SUPPORT.UNSUPPORTED, { reason: 'adapter-not-registered', checkedAt }),
998
+ enabled: axis(enabled ? 'true' : 'false', { reason: enabled ? null : 'environment-disabled', checkedAt }),
999
+ dependency: axis(READINESS_STATUS.NOT_PROBED, { reason: 'not-probed', checkedAt }),
1000
+ connection: axis(READINESS_STATUS.NOT_PROBED, { reason: 'not-probed', checkedAt }),
1001
+ target: axis(READINESS_STATUS.UNBOUND, { reason: 'no-target', checkedAt }),
1002
+ capability: axis(SUPPORT.UNSUPPORTED, { reason: 'adapter-not-registered', checkedAt }),
1003
+ },
1004
+ });
1005
+ continue;
1006
+ }
1007
+ let report;
1008
+ try {
1009
+ report = await adapter.probe({ scope: resolveRequestScope({ mode: 'legacy' }), snapshot, signal: AbortSignal.timeout(2000) });
1010
+ } catch (error) {
1011
+ report = createReadinessReport({
1012
+ environment: id,
1013
+ snapshot,
1014
+ adapter,
1015
+ axes: {
1016
+ support: axis(SUPPORT.SUPPORTED, { checkedAt }),
1017
+ dependency: axis(READINESS_STATUS.NOT_PROBED, { reason: 'probe-failed', checkedAt }),
1018
+ },
1019
+ now: checkedAt,
1020
+ });
1021
+ }
1022
+ environments.push({
1023
+ id,
1024
+ displayName: adapter.manifest.displayName || id,
1025
+ support: adapter.manifest.contractVersion === CONTRACT_VERSION ? SUPPORT.SUPPORTED : SUPPORT.UNSUPPORTED,
1026
+ enabled,
1027
+ selected,
1028
+ checkedAt,
1029
+ configRevision: snapshot.revision,
1030
+ axes: {
1031
+ ...report.axes,
1032
+ ...(selected && liveAxes ? liveAxes : {}),
1033
+ // "enabled" answers the user-policy question with true/false, not a readiness status.
1034
+ enabled: axis(enabled ? 'true' : 'false', {
1035
+ reason: enabled ? null : 'environment-disabled',
1036
+ checkedAt,
1037
+ }),
1038
+ },
1039
+ });
1040
+ }
1041
+ // Per-provider cards (§7): each provider answers with its own axes; the environment-level
1042
+ // axes stay as the summary the earlier pages already rely on.
1043
+ const webEntry = config.global.environments?.[ENVIRONMENTS.WEB] ?? null;
1044
+ for (const environment of environments) {
1045
+ if (environment.id !== ENVIRONMENTS.WEB) continue;
1046
+ environment.providers = providerReadinessBlock({
1047
+ snapshot,
1048
+ webEntry,
1049
+ checkedAt,
1050
+ axes: environment.axes,
1051
+ });
1052
+ }
1053
+ return { checkedAt, configRevision: snapshot.revision, environments };
1054
+ }
1055
+
1056
+ /**
1057
+ * Per-provider readiness for the web environment (§7).
1058
+ *
1059
+ * Structural rule: a provider that is disabled (or has no driver) is **never probed** and never
1060
+ * started — its axes are answered from config facts. An enabled, implemented provider **is** probed
1061
+ * for its dependency with *its own* executable candidates, so "not installed" is reported as
1062
+ * `failed / executable-missing` instead of an unverified state, and never by borrowing chrome's
1063
+ * evidence (§8: code reuse is not verification). Session-level axes (connection/target/capability)
1064
+ * belong to the selected provider only; every other provider reports `provider-not-selected`.
1065
+ *
1066
+ * @param {{snapshot:object, webEntry:object, checkedAt:number, axes:object}} input
1067
+ */
1068
+ function providerReadinessBlock({ snapshot, webEntry, checkedAt, axes }) {
1069
+ const switches = isPlainObject(webEntry?.providers) ? webEntry.providers : {};
1070
+ const totalEnabled = webEntry?.enabled === true;
1071
+ const selectedProvider = typeof snapshot?.provider === 'string' ? snapshot.provider : null;
1072
+ const notProbed = (reason) => axis(READINESS_STATUS.NOT_PROBED, { reason, checkedAt });
1073
+ const out = {};
1074
+ for (const id of WEB_PROVIDER_IDS) {
1075
+ const enabled = switches[id]?.enabled === true;
1076
+ const support = webProviderSupport(id);
1077
+ const implemented = isImplementedWebProvider(id);
1078
+ const selected = selectedProvider === id;
1079
+ const blockedReason = !totalEnabled
1080
+ ? 'environment-disabled'
1081
+ : (!enabled
1082
+ ? 'provider-disabled'
1083
+ : (support === 'pending'
1084
+ ? 'provider-driver-pending'
1085
+ : (!implemented ? 'provider-not-implemented' : null)));
1086
+ if (blockedReason !== null) {
1087
+ out[id] = {
1088
+ id,
1089
+ selected,
1090
+ enabled,
1091
+ support,
1092
+ implemented,
1093
+ axes: {
1094
+ support: axis(implemented ? SUPPORT.SUPPORTED : SUPPORT.UNSUPPORTED, {
1095
+ reason: support === 'pending' ? 'provider-driver-pending' : (implemented ? null : 'provider-not-implemented'),
1096
+ checkedAt,
1097
+ }),
1098
+ enabled: axis('false', { reason: blockedReason, checkedAt }),
1099
+ dependency: notProbed(blockedReason),
1100
+ connection: notProbed(blockedReason),
1101
+ target: axis(READINESS_STATUS.UNBOUND, { reason: blockedReason, checkedAt }),
1102
+ capability: notProbed(blockedReason),
1103
+ },
1104
+ };
1105
+ continue;
1106
+ }
1107
+ // Dependency is a filesystem fact about *this* provider's own executable, so it does not
1108
+ // depend on which provider is currently selected: a machine without Edge must show that on
1109
+ // the Edge card even while Chrome is the one executing.
1110
+ let resolution = null;
1111
+ try {
1112
+ resolution = resolveProviderExecutable({ provider: id });
1113
+ } catch (error) {
1114
+ resolution = null;
1115
+ }
1116
+ const dependencyEvidence = {
1117
+ id: id === DEFAULT_WEB_PROVIDER ? 'chromium' : id,
1118
+ kind: 'executable',
1119
+ provider: id,
1120
+ path: resolution ? resolution.path : null,
1121
+ // Version is never invented: null means "not observed" (probe must not launch anything).
1122
+ version: null,
1123
+ };
1124
+ const dependencyAxis = resolution === null
1125
+ ? notProbed('provider-dependency-not-verified')
1126
+ : (resolution.path
1127
+ ? axis(READINESS_STATUS.OK, { checkedAt, evidence: dependencyEvidence })
1128
+ : axis(READINESS_STATUS.FAILED, {
1129
+ reason: resolution.reason || 'executable-missing',
1130
+ checkedAt,
1131
+ evidence: dependencyEvidence,
1132
+ }));
1133
+ out[id] = {
1134
+ id,
1135
+ selected,
1136
+ enabled,
1137
+ support,
1138
+ implemented,
1139
+ axes: {
1140
+ support: axis(SUPPORT.SUPPORTED, { checkedAt }),
1141
+ enabled: axis('true', { reason: null, checkedAt }),
1142
+ dependency: dependencyAxis,
1143
+ connection: selected && axes?.connection ? axes.connection : notProbed('provider-not-selected'),
1144
+ target: selected && axes?.target
1145
+ ? axes.target
1146
+ : axis(READINESS_STATUS.UNBOUND, { reason: 'provider-not-selected', checkedAt }),
1147
+ capability: selected && axes?.capability ? axes.capability : notProbed('provider-not-selected'),
1148
+ },
1149
+ };
1150
+ }
1151
+ return out;
1152
+ }
1153
+
1154
+ function safeResolveAdapter(id, snapshot) {
1155
+ try {
1156
+ const resolved = adapterRegistry.resolve({ ...snapshot, environment: id, adapterId: id }, { ignoreEnabled: true });
1157
+ return resolved.targetAdapter;
1158
+ } catch {
1159
+ return null;
1160
+ }
1161
+ }
1162
+
1163
+ async function configPayload(view, scopeRequest) {
1164
+ const config = configStore.getConfig();
1165
+ const scope = resolveRequestScope(scopeRequest);
1166
+ const snapshot = view === 'global' || !scopeRequest || scopeRequest.mode === 'legacy'
1167
+ ? resolveSnapshot(config, {}, { allowTestEnvironments })
1168
+ : configStore.resolveForScope(scope);
1169
+ return {
1170
+ ok: true,
1171
+ revision: config.revision,
1172
+ config,
1173
+ resolved: resolvedBlock(snapshot),
1174
+ readiness: await readinessFor(snapshot),
1175
+ };
1176
+ }
1177
+
1178
+ function statusForAdapterError(error) {
1179
+ if (error?.code === ERROR_CODES.CONFIG_CONFLICT) return 409;
1180
+ if (error?.code === ERROR_CODES.CONFIG_INVALID || error?.code === ERROR_CODES.INVALID_ARGUMENT) return 400;
1181
+ if (error?.code === ERROR_CODES.ADAPTER_DISABLED) return 409;
1182
+ return 500;
1183
+ }
1184
+
1185
+ /**
1186
+ * Handle `/config`, `/config/select` and `/readiness`. Returns true when the request was
1187
+ * consumed. The caller has already applied the scoped authentication gate.
1188
+ */
1189
+ async function handleHttp(req, res, options = {}) {
1190
+ const { action, url, scopeRequest, scoped, requestRejection, rejectUi, readJson, sendJson, resolveScopeRequest } = options;
1191
+ if (!['/config', '/config/select', '/readiness'].includes(action)) return false;
1192
+ if (!scoped) {
1193
+ const rejection = typeof requestRejection === 'function' ? requestRejection(req) : 0;
1194
+ if (rejection) {
1195
+ if (typeof rejectUi === 'function') rejectUi(res, rejection);
1196
+ else sendJson(res, 401, { ok: false, code: 'SCOPE_FORBIDDEN' });
1197
+ return true;
1198
+ }
1199
+ }
1200
+ try {
1201
+ if (action === '/config' && req.method === 'GET') {
1202
+ const view = url.searchParams.get('view') || 'global';
1203
+ sendJson(res, 200, await configPayload(view, scopeRequest));
1204
+ return true;
1205
+ }
1206
+ if (action === '/readiness' && req.method === 'GET') {
1207
+ const config = configStore.getConfig();
1208
+ const scope = resolveRequestScope(scopeRequest);
1209
+ const snapshot = scopeRequest?.mode === 'session'
1210
+ ? configStore.resolveForScope(scope)
1211
+ : resolveSnapshot(config, {}, { allowTestEnvironments });
1212
+ sendJson(res, 200, { ok: true, revision: config.revision, ...(await readinessFor(snapshot)) });
1213
+ return true;
1214
+ }
1215
+ if (action === '/config' && req.method === 'PUT') {
1216
+ const body = await readJson(req);
1217
+ if (!isPlainObject(body)) throw new AdapterError(ERROR_CODES.CONFIG_INVALID, 'body must be an object');
1218
+ const { expectedRevision, scope } = body;
1219
+ let { patch } = body;
1220
+ if (!['global', 'workspace', 'session'].includes(scope)) {
1221
+ throw new AdapterError(ERROR_CODES.CONFIG_INVALID, 'scope must be global|workspace|session', { scope });
1222
+ }
1223
+ if (!isPlainObject(patch)) {
1224
+ throw new AdapterError(ERROR_CODES.CONFIG_INVALID, 'patch must be an object', { field: 'patch' });
1225
+ }
1226
+ // Keep the allow-list and the per-environment switch consistent: the settings page toggles
1227
+ // `environments.<env>.enabled`, while execution/schema admission reads enabledEnvironments.
1228
+ if (isPlainObject(patch.environments)) {
1229
+ const current = configStore.getConfig();
1230
+ const nextEnabled = new Set(current.global.enabledEnvironments || []);
1231
+ for (const [environment, entry] of Object.entries(patch.environments)) {
1232
+ if (!isPlainObject(entry)) continue;
1233
+ // `enabledEnvironments` 是**可执行环境集合**(保持严格:只收已实现的环境);
1234
+ // 规划中的环境允许被"启用"(开关写进 environments 映射),但不混进执行集合——
1235
+ // 「启用 ≠ 可执行」,否则这里会用一个注定被执行侧拒绝的值污染 allow-list。
1236
+ const planned = isPlannedEnvironment(environment);
1237
+ if (entry.enabled === false) nextEnabled.delete(environment);
1238
+ else if (entry.enabled === true && !planned) nextEnabled.add(environment);
1239
+ }
1240
+ patch = { ...patch, enabledEnvironments: [...nextEnabled] };
1241
+ }
1242
+ const requestScope = resolveRequestScope(scopeRequest);
1243
+ const written = configStore.write({
1244
+ expectedRevision,
1245
+ scope,
1246
+ patch,
1247
+ workspaceKey: requestScope.workspaceId ? `workspace:${requestScope.workspaceId}` : undefined,
1248
+ agentId: scope === 'session' ? requestScope.id : undefined,
1249
+ });
1250
+ const snapshot = resolveSnapshot(written.config, {}, { allowTestEnvironments });
1251
+ sendJson(res, 200, {
1252
+ ok: true,
1253
+ revision: written.revision,
1254
+ config: written.config,
1255
+ resolved: resolvedBlock(snapshot),
1256
+ });
1257
+ return true;
1258
+ }
1259
+ if (action === '/config/select' && req.method === 'POST') {
1260
+ const body = await readJson(req);
1261
+ if (!isPlainObject(body)) throw new AdapterError(ERROR_CODES.CONFIG_INVALID, 'body must be an object');
1262
+ const scope = body.scope === 'workspace' || body.scope === 'global' ? body.scope : 'session';
1263
+ if (scope === 'session' && (!scopeRequest || scopeRequest.mode !== 'session')) {
1264
+ throw new AdapterError(ERROR_CODES.SCOPE_INVALID, 'session selection requires a session-scoped request');
1265
+ }
1266
+ const requestScope = resolveRequestScope(scopeRequest);
1267
+ const written = configStore.select({
1268
+ expectedRevision: body.expectedRevision,
1269
+ scope,
1270
+ environment: body.environment,
1271
+ targetId: body.targetId,
1272
+ agentId: scope === 'session' ? requestScope.id : undefined,
1273
+ workspaceKey: requestScope.workspaceId ? `workspace:${requestScope.workspaceId}` : undefined,
1274
+ cwd: requestScope.cwd,
1275
+ });
1276
+ const snapshot = scope === 'session'
1277
+ ? configStore.resolveForScope(requestScope)
1278
+ : resolveSnapshot(written.config, {}, { allowTestEnvironments });
1279
+ sendJson(res, 200, {
1280
+ ok: true,
1281
+ revision: written.revision,
1282
+ resolved: resolvedBlock(snapshot),
1283
+ });
1284
+ return true;
1285
+ }
1286
+ return false;
1287
+ } catch (error) {
1288
+ // AdapterError already carries the stable code; `asBrowserError` would collapse it to
1289
+ // INTERNAL and turn a CAS conflict into a 500.
1290
+ const classified = error instanceof AdapterError ? error : classify(error);
1291
+ const status = statusForAdapterError(classified);
1292
+ const payload = { ok: false, ...classified.payload?.(), code: classified.code, error: classified.message };
1293
+ if (classified.code === ERROR_CODES.CONFIG_CONFLICT) {
1294
+ const config = configStore.getConfig();
1295
+ payload.revision = config.revision;
1296
+ payload.config = config;
1297
+ }
1298
+ sendJson(res, status, payload);
1299
+ return true;
1300
+ }
1301
+ }
1302
+
1303
+ /** GUI/legacy page-write guard: diagnostics stay readable, writes follow the same config. */
1304
+ function assertGuiAllowed(scopeRequest) {
1305
+ const snapshot = scopeRequest?.mode === 'session'
1306
+ ? configStore.resolveForScope(resolveRequestScope(scopeRequest))
1307
+ : resolveSnapshot(configStore.getConfig(), {}, { allowTestEnvironments });
1308
+ if (snapshot.environment !== ENVIRONMENTS.WEB || !snapshot.enabledEnvironments.includes(ENVIRONMENTS.WEB)) {
1309
+ throw new BrowserError(ERROR_CODES.ADAPTER_DISABLED, '当前环境未启用 Web 页面操作', {
1310
+ environment: snapshot.environment,
1311
+ configRevision: snapshot.revision,
1312
+ });
1313
+ }
1314
+ return snapshot;
1315
+ }
1316
+
1317
+ async function capabilitiesBlock(scopeRequest = null, liveTarget = null, snapshotOverride = null) {
1318
+ // The tool path already resolved the calling agent's snapshot; passing it in keeps the adapter
1319
+ // block on the SAME snapshot as execution (a session-scoped selection must not be reported as
1320
+ // the global environment).
1321
+ const snapshot = snapshotOverride && typeof snapshotOverride === 'object' && typeof snapshotOverride.environment === 'string'
1322
+ ? snapshotOverride
1323
+ : (scopeRequest?.mode === 'session'
1324
+ ? configStore.resolveForScope(resolveRequestScope(scopeRequest))
1325
+ : resolveSnapshot(configStore.getConfig(), {}, { allowTestEnvironments }));
1326
+ prepareWeb(snapshot);
1327
+ let capabilities = [];
1328
+ let p0 = {};
1329
+ try {
1330
+ capabilities = adapterRegistry.capabilitiesFor(snapshot);
1331
+ const resolved = adapterRegistry.resolve(snapshot);
1332
+ const provider = resolved.providers?.[0];
1333
+ if (provider && typeof provider.familyStatus === 'function') {
1334
+ p0 = provider.familyStatus({ session: null, snapshot });
1335
+ }
1336
+ } catch {
1337
+ // Capability discovery is advisory; the tool surface stays usable.
1338
+ }
1339
+ // Declared-vs-executed surface (A08 / F6): the plugin registers the union of tool definitions
1340
+ // and lets the host resolve `when` per step, so the model-facing declaration is computed from
1341
+ // the *same* snapshot that admits the call. The block below states the admitted names for this
1342
+ // scope so a caller can compare declaration and execution without inferring it.
1343
+ let declaration;
1344
+ try {
1345
+ const scopeInfo = resolveRequestScope(scopeRequest);
1346
+ const agent = agentForScope(scopeRequest, scopeInfo);
1347
+ const registered = [...declarableToolNames()].sort();
1348
+ const admitted = [...admissibleToolNames(agent)].sort();
1349
+ const isolated = registered.length === admitted.length
1350
+ && registered.every((name, index) => name === admitted[index]);
1351
+ declaration = {
1352
+ surface: 'host-tool-registration',
1353
+ registration: 'enabled-union',
1354
+ environment: snapshot.environment,
1355
+ sessionEnvironment: snapshot.environment,
1356
+ registeredTools: registered,
1357
+ registeredCount: registered.length,
1358
+ admittedTools: admitted,
1359
+ admittedCount: admitted.length,
1360
+ isolated,
1361
+ note: isolated
1362
+ ? '注册面 = 当前环境和公共诊断,模型请求里的插件 schema 与执行准入一致'
1363
+ : '本机组合的模型请求按“已注册”构建(实测:when 返回 false 的工具仍在 request/header 里),'
1364
+ + '因此隔离由注册范围保证;admittedTools 只反映逐 Agent 的第二道闸门',
1365
+ };
1366
+ } catch {
1367
+ declaration = null;
1368
+ }
1369
+ return {
1370
+ contractVersion: CONTRACT_VERSION,
1371
+ environment: snapshot.environment,
1372
+ adapterId: snapshot.adapterId,
1373
+ configRevision: snapshot.revision,
1374
+ capabilities,
1375
+ p0,
1376
+ declaration,
1377
+ readiness: await readinessFor(snapshot, liveTarget),
1378
+ };
1379
+ }
1380
+
1381
+ /**
1382
+ * Register the environment-aware Skill provider. Falls back to the historical single-skill
1383
+ * registration when the DSH build has no provider API.
1384
+ */
1385
+ function registerSkills(sub) {
1386
+ if (!sub || !sub.skills) return;
1387
+ if (typeof sub.skills.registerProvider === 'function') {
1388
+ sub.effect(() => sub.skills.registerProvider((control) => {
1389
+ invalidateSkills = () => control.invalidate();
1390
+ const list = async (options = {}) => {
1391
+ const snapshot = skillSnapshot(options);
1392
+ const out = [];
1393
+ for (const entry of SKILL_DEFINITIONS) {
1394
+ if (!skillVisible(entry, snapshot)) continue;
1395
+ const parsed = readSkillDefinition(entry);
1396
+ if (!parsed) continue;
1397
+ out.push({
1398
+ name: parsed.name,
1399
+ description: parsed.description,
1400
+ invocation: { modelInvocable: true, userInvocable: true },
1401
+ source: 'bundled',
1402
+ provider: 'ui-testing',
1403
+ rank: 500,
1404
+ locator: { dir: entry.dir },
1405
+ resourceBase: { kind: 'directory', path: skillDirPath(entry) },
1406
+ });
1407
+ }
1408
+ return out;
1409
+ };
1410
+ const get = async (candidate, options = {}) => {
1411
+ const dir = candidate?.locator?.dir;
1412
+ const entry = SKILL_DEFINITIONS.find((item) => item.dir === dir);
1413
+ if (!entry) return undefined;
1414
+ const snapshot = skillSnapshot(options);
1415
+ if (!skillVisible(entry, snapshot)) return undefined;
1416
+ const parsed = readSkillDefinition(entry);
1417
+ if (!parsed) return undefined;
1418
+ return {
1419
+ name: parsed.name,
1420
+ description: parsed.description,
1421
+ content: parsed.content,
1422
+ invocation: { modelInvocable: true, userInvocable: true },
1423
+ source: 'bundled',
1424
+ provider: 'ui-testing',
1425
+ resourceBase: { kind: 'directory', path: skillDirPath(entry) },
1426
+ };
1427
+ };
1428
+ return { name: 'ui-testing', list, get };
1429
+ }), 'ui-testing: skill provider');
1430
+ return;
1431
+ }
1432
+ // Legacy fallback: keep the Web skill registered when no provider API exists.
1433
+ const parsed = readSkillDefinition(SKILL_DEFINITIONS[1]);
1434
+ if (parsed) {
1435
+ sub.effect(() => sub.skills.register({
1436
+ name: parsed.name,
1437
+ description: parsed.description,
1438
+ content: parsed.content,
1439
+ resourceBase: { kind: 'directory', path: skillDirPath(SKILL_DEFINITIONS[1]) },
1440
+ }), 'ui-testing: bundled skill');
1441
+ }
1442
+ }
1443
+
1444
+ function skillSnapshot(options = {}) {
1445
+ const scope = options.scope;
1446
+ const agent = scope && typeof scope === 'object' && typeof scope.id === 'string'
1447
+ ? { id: scope.id, session: scope.session }
1448
+ : null;
1449
+ if (agent) {
1450
+ try {
1451
+ return configStore.resolveForAgent(agent);
1452
+ } catch {
1453
+ // Fall through to cwd/global.
1454
+ }
1455
+ }
1456
+ if (typeof options.cwd === 'string' && options.cwd) {
1457
+ try {
1458
+ return configStore.resolveForScope({ cwd: options.cwd });
1459
+ } catch {
1460
+ // Fall through to global.
1461
+ }
1462
+ }
1463
+ try {
1464
+ return resolveSnapshot(configStore.getConfig(), {}, { allowTestEnvironments });
1465
+ } catch {
1466
+ return fallbackSnapshot(null, null);
1467
+ }
1468
+ }
1469
+
1470
+ /**
1471
+ * Skill discovery/load admission (A09).
1472
+ *
1473
+ * Environment-specific material is visible only when the entry belongs to the resolved *and
1474
+ * enabled* environment. Checking `snapshot.environment` alone was not enough: a disabled
1475
+ * environment still resolves a fallback environment, and an entry for a non-enabled environment
1476
+ * stayed discoverable and loadable (`list`/`get` both returned the Web body after the Web
1477
+ * environment was disabled). Both checks live here so the two provider methods cannot drift.
1478
+ */
1479
+ function skillVisible(entry, snapshot) {
1480
+ if (entry.testOnly && !allowTestEnvironments) return false;
1481
+ if (entry.environment === null) return true;
1482
+ if (!snapshot || snapshot.environment !== entry.environment) return false;
1483
+ const enabled = Array.isArray(snapshot.enabledEnvironments)
1484
+ && snapshot.enabledEnvironments.includes(entry.environment);
1485
+ return enabled;
1486
+ }
1487
+
1488
+ async function dispose() {
1489
+ if (disposed) return;
1490
+ disposed = true;
1491
+ if (webFaces) {
1492
+ try { await webFaces.targetAdapter.dispose(); } catch { /* best effort */ }
1493
+ try { await webFaces.capabilityAdapter.dispose(); } catch { /* best effort */ }
1494
+ try { await webFaces.provider.dispose(); } catch { /* best effort */ }
1495
+ webFaces = null;
1496
+ }
1497
+ if (fakeEnvironment) {
1498
+ try { await fakeEnvironment.dispose(); } catch { /* best effort */ }
1499
+ }
1500
+ try { await adapterRegistry.dispose(); } catch { /* best effort */ }
1501
+ try { configStore.dispose(); } catch { /* best effort */ }
1502
+ }
1503
+
1504
+ return {
1505
+ configStore,
1506
+ adapterRegistry,
1507
+ // Lazy Web faces: null until the Web environment is actually used (A14).
1508
+ get webTargetAdapter() { return webFaces?.targetAdapter ?? null; },
1509
+ get webCapabilityAdapter() { return webFaces?.capabilityAdapter ?? null; },
1510
+ get webP0Provider() { return webFaces?.provider ?? null; },
1511
+ ensureWebRegistered,
1512
+ prepareWeb,
1513
+ allowTestEnvironments,
1514
+ snapshotForAgent,
1515
+ snapshotForScope,
1516
+ toolAdmitted,
1517
+ assertToolAllowed,
1518
+ executionClassFor,
1519
+ invokeTool,
1520
+ p0Call,
1521
+ handleHttp,
1522
+ assertGuiAllowed,
1523
+ capabilitiesBlock,
1524
+ onConfigChange,
1525
+ admissibleToolNames,
1526
+ declarableToolNames,
1527
+ agentForScope,
1528
+ isNeutralTool,
1529
+ registerSkills,
1530
+ readinessFor,
1531
+ dispose,
1532
+ // Exposed for tests/diagnostics only.
1533
+ _internals: { configDirectory, SKILL_DEFINITIONS, FAKE_TOOL },
1534
+ };
1535
+ }