@effect-agent/platform-cloudflare 0.1.0-beta.122 → 0.1.0-beta.123

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 (34) hide show
  1. package/dist/BrowserCredentials.d.mts +91 -0
  2. package/dist/BrowserCredentials.mjs +120 -0
  3. package/dist/BrowserCredentials.mjs.map +1 -0
  4. package/dist/BrowserSession-DuOUcC6G.mjs +442 -0
  5. package/dist/BrowserSession-DuOUcC6G.mjs.map +1 -0
  6. package/dist/BrowserSession-DxCN-5F9.d.mts +109 -0
  7. package/dist/BrowserSession.d.mts +3 -0
  8. package/dist/BrowserSession.mjs +4 -0
  9. package/dist/CloudflareThreadClient.d.mts +50 -50
  10. package/dist/{InteractiveBrowser-Cm4xgiGy.d.mts → InteractiveBrowser-BnqY_Y-F.d.mts} +2 -2
  11. package/dist/{InteractiveBrowser-DXwmvWdA.mjs → InteractiveBrowser-DeOlcu-2.mjs} +4 -5
  12. package/dist/InteractiveBrowser-DeOlcu-2.mjs.map +1 -0
  13. package/dist/InteractiveBrowser.d.mts +1 -1
  14. package/dist/InteractiveBrowser.mjs +1 -1
  15. package/dist/{ThreadObject-mt0uxvZZ.d.mts → ThreadObject-BTo1jqab.d.mts} +33 -33
  16. package/dist/ThreadObject.d.mts +1 -1
  17. package/dist/index.d.mts +4 -2
  18. package/dist/index.mjs +3 -1
  19. package/package.json +1 -1
  20. package/src/BrowserCredentials.ts +146 -0
  21. package/src/BrowserSession.ts +589 -0
  22. package/src/index.ts +2 -0
  23. package/src/internal/browser-binding.ts +3 -3
  24. package/src/internal/browser-credentials.ts +362 -0
  25. package/dist/InteractiveBrowser-DXwmvWdA.mjs.map +0 -1
  26. package/dist/ProtectedBrowser.d.mts +0 -142
  27. package/dist/ProtectedBrowser.mjs +0 -1242
  28. package/dist/ProtectedBrowser.mjs.map +0 -1
  29. package/src/ProtectedBrowser.ts +0 -9
  30. package/src/protected-browser/binding.ts +0 -445
  31. package/src/protected-browser/host.ts +0 -398
  32. package/src/protected-browser/inspect-frame.ts +0 -140
  33. package/src/protected-browser/native.ts +0 -483
  34. package/src/protected-browser/policy.ts +0 -872
@@ -1,398 +0,0 @@
1
- import {
2
- Cause,
3
- Clock,
4
- Context,
5
- Crypto,
6
- Effect,
7
- Layer,
8
- Redacted,
9
- Schema,
10
- Semaphore,
11
- type Scope,
12
- } from "effect";
13
- import { InteractiveBrowserPolicy } from "effect-agent/interactive-browser";
14
- import {
15
- type BrowserCredentialAccess,
16
- CredentialOrigin,
17
- ProtectedBrowserCheckpoint,
18
- ProtectedBrowserError,
19
- type ProtectedBrowserHandle,
20
- type ProtectedCleanup,
21
- } from "effect-agent/protected-browser";
22
-
23
- import {
24
- BrowserRunHandoffRequest,
25
- BrowserRunHandoffState,
26
- BrowserRunLiveViewRequest,
27
- BrowserRunLiveViewResult,
28
- } from "../InteractiveBrowser.ts";
29
- import {
30
- inheritBrowserReport,
31
- reportBrowserCause,
32
- reportedBrowserError,
33
- } from "../internal/browser-failure.ts";
34
- import { BrowserRunSessionLifecycle } from "../internal/browser-session-lifecycle.ts";
35
- import { BrowserRunProtectedBinding, ProtectedProviderIdentity } from "./binding.ts";
36
- import { BrowserRunProtectedTransport, makeProtectedBrowserPolicy } from "./policy.ts";
37
-
38
- /** Host-only receipt. Store atomically with the controller lease; never return it to a model. */
39
- export class BrowserRunProtectedCheckpoint extends Schema.Class<BrowserRunProtectedCheckpoint>(
40
- "BrowserRunProtectedCheckpoint",
41
- )({
42
- ...ProtectedProviderIdentity.fields,
43
- protected: ProtectedBrowserCheckpoint,
44
- handoffId: Schema.optionalKey(
45
- Schema.Redacted(Schema.NonEmptyString.check(Schema.isMaxLength(1024))),
46
- ),
47
- }) {}
48
-
49
- export interface BrowserRunProtectedSession {
50
- readonly handle: ProtectedBrowserHandle;
51
- readonly sessionId: Redacted.Redacted<string>;
52
- /** Quiesce for a durable workflow pause or human-control request. Persist before detaching. */
53
- readonly suspend: Effect.Effect<BrowserRunProtectedCheckpoint, ProtectedBrowserError>;
54
- /** Fences agent tools before dispatch. Persist the returned receipt before detaching. */
55
- readonly handoff: (
56
- request: BrowserRunHandoffRequest,
57
- ) => Effect.Effect<BrowserRunProtectedCheckpoint, ProtectedBrowserError>;
58
- /** Available only while human control is held; the host must authorize the specific human. */
59
- readonly getLiveView: (
60
- request: BrowserRunLiveViewRequest,
61
- ) => Effect.Effect<BrowserRunLiveViewResult, ProtectedBrowserError>;
62
- readonly getHandoffState: Effect.Effect<BrowserRunHandoffState, ProtectedBrowserError>;
63
- /** Read only the exact page’s current origin after its recorded handoff completes; tools remain paused. */
64
- readonly getReturnOrigin: Effect.Effect<typeof CredentialOrigin.Type, ProtectedBrowserError>;
65
- /**
66
- * Resume a committed pause; human handoffs also require provider completion. Returns no page data.
67
- * A fresh authorized observation must succeed before page actions or credential operations.
68
- */
69
- readonly returnControl: Effect.Effect<void, ProtectedBrowserError>;
70
- /** Release this attachment only after committing its suspended checkpoint; preserve the remote browser. */
71
- readonly detach: Effect.Effect<void, ProtectedBrowserError>;
72
- readonly close: Effect.Effect<typeof ProtectedCleanup.Type>;
73
- }
74
-
75
- /**
76
- * Host authority for one protected page. The consumer owns durable generation fencing, authorized
77
- * human recipients, checkpoint integrity, vault exposure metadata, and expiry cleanup. Resume only
78
- * the latest committed suspended receipt; it never replays browser mutations or creates a page.
79
- * Hosted passes require Unrestricted network policy: attachment-local request interception
80
- * cannot enforce an ExactHosts policy during detachment. Current credential grants still apply.
81
- * Provider inactivity/session deadlines still apply while the host is detached.
82
- */
83
- export class BrowserRunProtectedHost extends Context.Service<
84
- BrowserRunProtectedHost,
85
- {
86
- /**
87
- * Refresh provider inactivity on an exact retained session with one browser-level command.
88
- * Internally scoped and bounded to ten seconds; releases only its own connection on every exit.
89
- * Does not acquire a page, transfer control, extend host expiry, or close the provider on failure.
90
- */
91
- readonly keepAlive: (
92
- sessionId: Redacted.Redacted<string>,
93
- ) => Effect.Effect<void, ProtectedBrowserError>;
94
- readonly open: (
95
- policy: InteractiveBrowserPolicy,
96
- ) => Effect.Effect<
97
- BrowserRunProtectedSession,
98
- ProtectedBrowserError,
99
- Scope.Scope | BrowserCredentialAccess
100
- >;
101
- /**
102
- * A pure pre-handle provider failure with not-dispatched/none/not-requested evidence
103
- * retires this attempt's SDK callbacks and confirms local socket closure before returning
104
- * (or never starts SDK initialization). Raw retirement failure remains a mixed defect.
105
- * This does not confirm provider health or undo already-sent CDP; the host still owns
106
- * atomic claim restoration and must exclude defects, interruption, and post-handle errors.
107
- */
108
- readonly resume: (
109
- checkpoint: BrowserRunProtectedCheckpoint,
110
- ) => Effect.Effect<
111
- BrowserRunProtectedSession,
112
- ProtectedBrowserError,
113
- Scope.Scope | BrowserCredentialAccess
114
- >;
115
- readonly closeSession: (
116
- sessionId: Redacted.Redacted<string>,
117
- ) => Effect.Effect<typeof ProtectedCleanup.Type>;
118
- }
119
- >()("@effect-agent/platform-cloudflare/BrowserRunProtectedHost") {}
120
-
121
- const failure = (reason: ProtectedBrowserError["reason"]) =>
122
- new ProtectedBrowserError({
123
- reason,
124
- dispatch: "not-dispatched",
125
- milestone: "none",
126
- observation: "protected",
127
- cleanup: "not-requested",
128
- });
129
-
130
- const Handoff = Schema.Struct({ handoffId: Schema.NonEmptyString.check(Schema.isMaxLength(1024)) });
131
-
132
- const HandoffState = Schema.Struct({
133
- active: Schema.Boolean,
134
- handoffId: Schema.optionalKey(Schema.NonEmptyString.check(Schema.isMaxLength(1024))),
135
- durationMs: Schema.optionalKey(Schema.Natural),
136
- });
137
-
138
- const LiveView = Schema.Struct({ devtoolsFrontendUrl: Schema.String });
139
-
140
- /** Compose with browserRunProtectedBindingLayer and BrowserRunSessionLifecycle. */
141
- export const browserRunProtectedHostLayer = () =>
142
- Layer.effect(BrowserRunProtectedHost)(
143
- Effect.gen(function* () {
144
- const binding = yield* BrowserRunProtectedBinding;
145
- const lifecycle = yield* BrowserRunSessionLifecycle;
146
- const crypto = yield* Crypto.Crypto;
147
-
148
- const open = Effect.fn("BrowserRunProtectedHost.open")(function* (
149
- input: InteractiveBrowserPolicy,
150
- checkpoint?: BrowserRunProtectedCheckpoint,
151
- ): Effect.fn.Return<
152
- BrowserRunProtectedSession,
153
- ProtectedBrowserError,
154
- Scope.Scope | BrowserCredentialAccess
155
- > {
156
- const policy = yield* Schema.decodeEffect(InteractiveBrowserPolicy, {
157
- onExcessProperty: "error",
158
- })(input).pipe(Effect.mapError(() => failure("denied")));
159
-
160
- if (policy.network._tag !== "Unrestricted") return yield* failure("unsupported");
161
-
162
- const remaining =
163
- policy.maxElapsedMillis -
164
- ((yield* Clock.currentTimeMillis) -
165
- (checkpoint?.protected.startedAt ?? (yield* Clock.currentTimeMillis)));
166
-
167
- if (
168
- remaining <= 0 ||
169
- (checkpoint !== undefined &&
170
- checkpoint.protected.startedAt > (yield* Clock.currentTimeMillis))
171
- )
172
- return yield* failure("timeout");
173
- if (checkpoint !== undefined && checkpoint.protected.actions > policy.maxActions)
174
- return yield* failure("denied");
175
- const provider = yield* binding.open(policy, checkpoint);
176
-
177
- const state = yield* makeProtectedBrowserPolicy(policy, checkpoint?.protected).pipe(
178
- Effect.provideService(BrowserRunProtectedTransport, {
179
- open: () => Effect.succeed({ ...provider.driver, detach: provider.detach }),
180
- }),
181
- Effect.provideService(Crypto.Crypto, crypto),
182
- );
183
-
184
- const lock = yield* Semaphore.make(1);
185
- let handoff = checkpoint;
186
- let detached = false;
187
-
188
- const control = <A>(effect: Effect.Effect<A, ProtectedBrowserError>) =>
189
- lock
190
- .withPermitsIfAvailable(1)(
191
- Effect.suspend(() => (detached ? Effect.fail(failure("closed")) : effect)),
192
- )
193
- .pipe(Effect.flatMap(Effect.fromOption(() => failure("busy"))));
194
-
195
- const fits = (millis: number) =>
196
- Effect.gen(function* () {
197
- const started = handoff?.protected.startedAt ?? startedAt;
198
-
199
- if (millis > policy.maxElapsedMillis - ((yield* Clock.currentTimeMillis) - started))
200
- return yield* failure("timeout");
201
- });
202
-
203
- const startedAt = checkpoint?.protected.startedAt ?? (yield* Clock.currentTimeMillis);
204
-
205
- const getHandoffState = Effect.gen(function* () {
206
- if (handoff?.handoffId === undefined) return yield* failure("denied");
207
- yield* fits(0);
208
- const raw = yield* provider.command("Cloudflare.getHandoffState", {});
209
-
210
- const result = yield* Schema.decodeUnknownEffect(HandoffState)(raw).pipe(
211
- Effect.catch((error) =>
212
- reportBrowserCause("protected.host.decode", Cause.fail(error)).pipe(
213
- Effect.andThen(Effect.fail(reportedBrowserError(failure("provider")))),
214
- ),
215
- ),
216
- );
217
-
218
- if (
219
- (result.active && result.handoffId === undefined) ||
220
- (result.handoffId !== undefined &&
221
- result.handoffId !== Redacted.value(handoff.handoffId))
222
- )
223
- return yield* failure("denied");
224
-
225
- return BrowserRunHandoffState.make({
226
- active: result.active,
227
- ...(result.durationMs === undefined ? {} : { durationMs: result.durationMs }),
228
- ...(result.handoffId === undefined
229
- ? {}
230
- : { handoffId: Redacted.make(result.handoffId) }),
231
- });
232
- });
233
-
234
- return {
235
- handle: state.handle,
236
- sessionId: provider.identity.sessionId,
237
- suspend: control(
238
- Effect.gen(function* () {
239
- if (handoff !== undefined) {
240
- yield* state.suspend();
241
-
242
- return handoff;
243
- }
244
- const suspended = yield* state.suspend();
245
-
246
- handoff = BrowserRunProtectedCheckpoint.make({
247
- ...provider.identity,
248
- protected: suspended,
249
- });
250
-
251
- return handoff;
252
- }),
253
- ),
254
- handoff: (request) =>
255
- control(
256
- Effect.gen(function* () {
257
- if (handoff?.handoffId !== undefined) return yield* failure("busy");
258
-
259
- const decoded = yield* Schema.decodeEffect(BrowserRunHandoffRequest)(request).pipe(
260
- Effect.mapError(() => failure("denied")),
261
- );
262
-
263
- yield* fits(decoded.timeout);
264
- const suspended = yield* state.suspend(true);
265
-
266
- // An uncertain handoff is never retried; close exactly this provider session.
267
- const result = yield* provider
268
- .command("Cloudflare.handoff", {
269
- instructions: decoded.instructions,
270
- timeout: decoded.timeout,
271
- })
272
- .pipe(
273
- Effect.flatMap(Schema.decodeUnknownEffect(Handoff)),
274
- Effect.catchCause((cause) =>
275
- Cause.hasInterruptsOnly(cause)
276
- ? Effect.interrupt
277
- : reportBrowserCause("protected.handoff", cause).pipe(
278
- Effect.andThen(state.handle.close),
279
- Effect.flatMap((cleanup) =>
280
- Effect.fail(
281
- reportedBrowserError(
282
- new ProtectedBrowserError({
283
- reason: "outcome-unknown",
284
- dispatch: "possibly-dispatched",
285
- milestone: suspended.milestone,
286
- observation: "closed",
287
- cleanup,
288
- }),
289
- ),
290
- ),
291
- ),
292
- ),
293
- ),
294
- Effect.onInterrupt(() => state.handle.close),
295
- );
296
-
297
- handoff = BrowserRunProtectedCheckpoint.make({
298
- ...provider.identity,
299
- protected: suspended,
300
- handoffId: Redacted.make(result.handoffId),
301
- });
302
-
303
- return handoff;
304
- }),
305
- ),
306
- getLiveView: (request) =>
307
- control(
308
- Effect.gen(function* () {
309
- if (handoff?.handoffId === undefined) return yield* failure("denied");
310
-
311
- const decoded = yield* Schema.decodeEffect(BrowserRunLiveViewRequest)(request).pipe(
312
- Effect.mapError(() => failure("denied")),
313
- );
314
-
315
- yield* fits(decoded.expiresInMs);
316
-
317
- const raw = yield* provider.command("Cloudflare.getLiveView", {
318
- mode: decoded.mode,
319
- expiresInMs: decoded.expiresInMs,
320
- });
321
-
322
- const result = yield* Schema.decodeUnknownEffect(LiveView)(raw).pipe(
323
- Effect.catch((error) =>
324
- reportBrowserCause("protected.host.decode", Cause.fail(error)).pipe(
325
- Effect.andThen(Effect.fail(reportedBrowserError(failure("provider")))),
326
- ),
327
- ),
328
- );
329
-
330
- return yield* Schema.decodeEffect(BrowserRunLiveViewResult)({
331
- devtoolsFrontendUrl: Redacted.make(result.devtoolsFrontendUrl),
332
- }).pipe(
333
- Effect.catch((error) =>
334
- reportBrowserCause("protected.host.decode", Cause.fail(error)).pipe(
335
- Effect.andThen(Effect.fail(reportedBrowserError(failure("provider")))),
336
- ),
337
- ),
338
- );
339
- }),
340
- ),
341
- getHandoffState: control(getHandoffState),
342
- getReturnOrigin: control(
343
- Effect.gen(function* () {
344
- if ((yield* getHandoffState).active) return yield* failure("busy");
345
-
346
- const context = yield* provider.driver.context.pipe(
347
- Effect.mapError((error) => inheritBrowserReport(error, failure(error.reason))),
348
- );
349
-
350
- return yield* Schema.decodeEffect(CredentialOrigin)(context.topOrigin).pipe(
351
- Effect.catch((error) =>
352
- reportBrowserCause("protected.host.decode", Cause.fail(error)).pipe(
353
- Effect.andThen(Effect.fail(reportedBrowserError(failure("provider")))),
354
- ),
355
- ),
356
- );
357
- }),
358
- ),
359
- returnControl: control(
360
- Effect.gen(function* () {
361
- yield* fits(0);
362
- if (handoff?.handoffId !== undefined && (yield* getHandoffState).active)
363
- return yield* failure("busy");
364
- yield* state.returnControl;
365
- handoff = undefined;
366
- }),
367
- ),
368
- detach: control(
369
- Effect.gen(function* () {
370
- if (handoff === undefined) return yield* failure("denied");
371
- yield* state.detach;
372
- detached = true;
373
- }),
374
- ),
375
- close: state.handle.close,
376
- };
377
- }, Effect.withTracerEnabled(false));
378
-
379
- return {
380
- keepAlive: binding.keepAlive,
381
- open: (policy: InteractiveBrowserPolicy) => open(policy),
382
- resume: (checkpoint: BrowserRunProtectedCheckpoint) =>
383
- Schema.decodeEffect(BrowserRunProtectedCheckpoint)(checkpoint).pipe(
384
- Effect.mapError(() => failure("denied")),
385
- Effect.flatMap((decoded) => open(decoded.protected.policy, decoded)),
386
- ),
387
- closeSession: (id: Redacted.Redacted<string>) =>
388
- lifecycle.close(id).pipe(
389
- Effect.as("confirmed" as const),
390
- Effect.catchCause((cause) =>
391
- reportBrowserCause("protected.closeSession", cause).pipe(
392
- Effect.as("unconfirmed" as const),
393
- ),
394
- ),
395
- ),
396
- };
397
- }),
398
- );
@@ -1,140 +0,0 @@
1
- // This fixed program runs in an isolated world. Pages cannot replace its native DOM methods.
2
- // Handles retain actual nodes; a selector is never reconstructed from an opaque reference.
3
- export const maxAttributeLength = 2048;
4
-
5
- export const inspectFrame = `(() => {
6
- const doc = document;
7
- const forms = [];
8
- const isChoice = el => el instanceof HTMLInputElement && ['radio','checkbox'].includes(el.type);
9
- const presented = el => {
10
- if (el.closest('[hidden],[aria-hidden="true"],[inert]') ||
11
- ['hidden','collapse'].includes(getComputedStyle(el).visibility)) return false;
12
- for (let parent = el; parent; parent = parent.parentElement) {
13
- if (getComputedStyle(parent).opacity === '0') return false;
14
- }
15
- return true;
16
- };
17
- const hasLayout = el => presented(el) &&
18
- [...el.getClientRects()].some(rect => rect.width > 0 && rect.height > 0);
19
- const available = el => el.type !== 'hidden' && !el.closest('[hidden],[aria-hidden="true"],[inert]') &&
20
- (hasLayout(el) || (isChoice(el) && [...(el.labels ?? [])].some(hasLayout)));
21
- const elements = [...doc.querySelectorAll('input,textarea,select,button,a[href]')].filter(available).slice(0, 65);
22
- const labelText = el => {
23
- const label = el.labels?.[0]?.cloneNode(true);
24
- label?.querySelectorAll('input,textarea,select,script,style,noscript').forEach(child => child.remove());
25
- return label?.textContent ?? el.getAttribute('aria-label');
26
- };
27
- const describe = (el) => {
28
- if (doc !== document || !el.isConnected || el.ownerDocument !== doc || !available(el)) return null;
29
- const form = el.form ?? null;
30
- let formIndex = forms.indexOf(form);
31
- if (formIndex < 0) { formIndex = forms.length; forms.push(form); }
32
- // Resolved href (including path/query/fragment and base-URL changes) is part of the fingerprint.
33
- const action = el instanceof HTMLAnchorElement ? el.href : form ? (el.hasAttribute('formaction') ? el.formAction : form.action || doc.URL) : doc.URL;
34
- const method = form ? (el.hasAttribute('formmethod') ? el.formMethod : form.method) : '';
35
- const enctype = form?.enctype ?? '';
36
- const name = el.name ?? '';
37
- const completion = el.getAttribute('autocomplete') ?? '';
38
- const inputType = el.type ?? '';
39
- const choiceValue = isChoice(el) ? el.value : '';
40
- // Reject before parsing, fingerprinting or CDP transfer. Truncation could hide a target change.
41
- if ([action, method, enctype, name, completion, inputType, choiceValue].some(value => value.length > ${maxAttributeLength})) return null;
42
- const type = inputType.toLowerCase();
43
- const autocomplete = completion.trim().toLowerCase().split(/\\s+/).at(-1);
44
- const cardRoles = { 'cc-name':'card-name', 'cc-number':'card-number', 'cc-exp':'card-expiry',
45
- 'cc-exp-month':'card-expiry-month', 'cc-exp-year':'card-expiry-year', 'cc-csc':'card-security-code' };
46
- let role = 'unsupported';
47
- const nativeField = el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement;
48
- if (nativeField && !['submit','button'].includes(type) && !el.matches(':disabled') && !el.readOnly && !(el instanceof HTMLSelectElement && el.multiple)) {
49
- if (isChoice(el)) role = type;
50
- else if (type === 'password' || ['current-password','new-password','one-time-code'].includes(autocomplete)) {
51
- if (type === 'password' && form) role = 'password';
52
- } else if (['text','email','tel','number','month','search','url','date','time','week','datetime-local','textarea',''].includes(type) || el instanceof HTMLSelectElement) {
53
- if (cardRoles[autocomplete]) { if (form) role = cardRoles[autocomplete]; }
54
- // Email autocomplete alone is contact data; explicit usernames and password forms identify login fields.
55
- else if (autocomplete === 'username') { if (form) role = 'username'; }
56
- else if (['text','email'].includes(type) && form && form.querySelector('input[type="password"]')) role = 'username';
57
- else if (el instanceof HTMLSelectElement) { if (!el.multiple) role = 'select'; }
58
- else role = 'text';
59
- }
60
- } else if (el instanceof HTMLButtonElement || (el instanceof HTMLInputElement && ['submit','button'].includes(type))) {
61
- if (!el.matches(':disabled')) role = type === 'submit' && form ? 'submit' : type === 'button' ? 'button' : 'unsupported';
62
- } else if (el instanceof HTMLAnchorElement) role = 'link';
63
- const options = [];
64
- let truncated = false;
65
- if (role === 'select') {
66
- for (let index = 0; index < el.options.length; index++) {
67
- if (index === 256) { truncated = true; break; }
68
- const option = el.options[index];
69
- const label = option.label.trim();
70
- // Never publish a shortened label as an exact fill choice.
71
- if (label.length > 200) { truncated = true; continue; }
72
- options.push({label, selected: option.selected, disabled: option.matches(':disabled')});
73
- }
74
- }
75
- const fingerprint = JSON.stringify([role, action, method, enctype, name, completion, type, choiceValue]);
76
- return { role, formIndex, action, fingerprint, ...(isChoice(el) ? {checked: el.checked} : {}),
77
- ...(role === 'select' ? {options, truncated} : {}),
78
- label: (labelText(el) ?? (nativeField ? '' : el.textContent) ?? '').slice(0,200) };
79
- };
80
- const expose = ({role, formIndex, action, label, checked, options, truncated}) =>
81
- ({role, formIndex, action, label, ...(checked === undefined ? {} : {checked}),
82
- ...(options === undefined ? {} : {options, truncated})});
83
- const original = elements.map(describe);
84
- const validate = (index) => {
85
- const current = describe(elements[index]);
86
- if (!current || !original[index] || current.fingerprint !== original[index].fingerprint ||
87
- current.formIndex !== original[index].formIndex) return null;
88
- return expose(current);
89
- };
90
- return {
91
- doc, elements, original: original.map(current => current && expose(current)), validate,
92
- text: () => {
93
- if (doc !== document) return null;
94
- if (!doc.body) return '';
95
- const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
96
- const range = doc.createRange();
97
- let text = '';
98
- for (let node = walker.nextNode(); node && text.length < 65536; node = walker.nextNode()) {
99
- const parent = node.parentElement;
100
- if (!parent || parent.closest('input,textarea,select,script,style,noscript,iframe,object,embed')) continue;
101
- if (!presented(parent)) continue;
102
- range.selectNodeContents(node);
103
- if (![...range.getClientRects()].some(rect => rect.width > 0 && rect.height > 0)) continue;
104
- text += (node.textContent ?? '').replace(/\\s+/g, ' ') + ' ';
105
- }
106
- return text.slice(0,65536);
107
- },
108
- fill: (index, role, value) => {
109
- const current = validate(index);
110
- if (!current || current.role !== role) return false;
111
- const el = elements[index];
112
- if (el instanceof HTMLSelectElement) {
113
- const options = [...el.options].filter(option => !option.matches(':disabled'));
114
- // Ordinary fills name the visible choice; credential material stays a private value.
115
- const matches = options.filter(option => (role === 'select' ? option.label.trim() : option.value) === value);
116
- if (matches.length !== 1) return 'unsupported-before-write';
117
- value = matches[0].value;
118
- if ([...el.options].filter(option => option.value === value).length !== 1) return 'unsupported-before-write';
119
- }
120
- let prototype = Object.getPrototypeOf(el);
121
- let setter;
122
- while (prototype && !setter) { setter = Object.getOwnPropertyDescriptor(prototype,'value')?.set; prototype = Object.getPrototypeOf(prototype); }
123
- if (!setter) return false;
124
- // Validate and mutate in one isolated-world task, without focusing first and running
125
- // page handlers between validation and assignment. Native setters support controlled inputs.
126
- setter.call(el, value);
127
- if (el.value !== value) return 'unsupported';
128
- el.dispatchEvent(new Event('input', {bubbles:true}));
129
- el.dispatchEvent(new Event('change', {bubbles:true}));
130
- return true;
131
- },
132
- click: (index) => {
133
- const current = validate(index);
134
- if (!current || !['button','submit','link','radio','checkbox'].includes(current.role)) return false;
135
- if (current.role === 'submit' && elements[index].form && !elements[index].form.matches(':valid')) return 'needs-attention';
136
- elements[index].click();
137
- return true;
138
- }
139
- };
140
- })()`;