@effect-agent/platform-cloudflare 0.1.0-beta.9 → 0.1.0-beta.90

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 (100) hide show
  1. package/dist/Alarm.d.mts +188 -0
  2. package/dist/Alarm.mjs +451 -0
  3. package/dist/Alarm.mjs.map +1 -0
  4. package/dist/BrowserRestCapture.d.mts +35 -0
  5. package/dist/BrowserRestCapture.mjs +238 -0
  6. package/dist/BrowserRestCapture.mjs.map +1 -0
  7. package/dist/BrowserRestCrawl.d.mts +17 -0
  8. package/dist/BrowserRestCrawl.mjs +244 -0
  9. package/dist/BrowserRestCrawl.mjs.map +1 -0
  10. package/dist/CloudflareAiGateway.d.mts +64 -0
  11. package/dist/CloudflareAiGateway.mjs +70 -0
  12. package/dist/CloudflareAiGateway.mjs.map +1 -0
  13. package/dist/CloudflareBindings.d.mts +115 -0
  14. package/dist/CloudflareBindings.mjs +113 -0
  15. package/dist/CloudflareBindings.mjs.map +1 -0
  16. package/dist/CloudflareBrowser-DV6kya1H.mjs +495 -0
  17. package/dist/CloudflareBrowser-DV6kya1H.mjs.map +1 -0
  18. package/dist/CloudflareBrowser-Doe1M7R5.d.mts +80 -0
  19. package/dist/CloudflareBrowser.d.mts +2 -0
  20. package/dist/CloudflareBrowser.mjs +2 -0
  21. package/dist/CloudflareCodeMode.d.mts +44 -0
  22. package/dist/CloudflareCodeMode.mjs +616 -0
  23. package/dist/CloudflareCodeMode.mjs.map +1 -0
  24. package/dist/CloudflareConfig-f3CqTel1.d.mts +101 -0
  25. package/dist/CloudflareConfig.d.mts +2 -0
  26. package/dist/CloudflareConfig.mjs +122 -0
  27. package/dist/CloudflareConfig.mjs.map +1 -0
  28. package/dist/CloudflareMemory.d.mts +142 -0
  29. package/dist/CloudflareMemory.mjs +202 -0
  30. package/dist/CloudflareMemory.mjs.map +1 -0
  31. package/dist/CloudflareScheduling.d.mts +52 -0
  32. package/dist/CloudflareScheduling.mjs +396 -0
  33. package/dist/CloudflareScheduling.mjs.map +1 -0
  34. package/dist/CloudflareSubscriptions.d.mts +87 -0
  35. package/dist/CloudflareSubscriptions.mjs +530 -0
  36. package/dist/CloudflareSubscriptions.mjs.map +1 -0
  37. package/dist/CloudflareThreadClient.d.mts +1496 -0
  38. package/dist/CloudflareThreadClient.mjs +382 -0
  39. package/dist/CloudflareThreadClient.mjs.map +1 -0
  40. package/dist/InteractiveBrowser-DUE_m12-.d.mts +161 -0
  41. package/dist/InteractiveBrowser-DupDtfw2.mjs +1169 -0
  42. package/dist/InteractiveBrowser-DupDtfw2.mjs.map +1 -0
  43. package/dist/InteractiveBrowser.d.mts +2 -0
  44. package/dist/InteractiveBrowser.mjs +2 -0
  45. package/dist/ProtectedBrowser.d.mts +123 -0
  46. package/dist/ProtectedBrowser.mjs +1135 -0
  47. package/dist/ProtectedBrowser.mjs.map +1 -0
  48. package/dist/ThreadObject-CIbmXZCY.d.mts +812 -0
  49. package/dist/ThreadObject-DvRG0dDz.mjs +835 -0
  50. package/dist/ThreadObject-DvRG0dDz.mjs.map +1 -0
  51. package/dist/ThreadObject.d.mts +2 -0
  52. package/dist/ThreadObject.mjs +3 -0
  53. package/dist/WakeScheduler.d.mts +23 -0
  54. package/dist/WakeScheduler.mjs +57 -0
  55. package/dist/WakeScheduler.mjs.map +1 -0
  56. package/dist/boundary-BguazVkh.mjs +42 -0
  57. package/dist/boundary-BguazVkh.mjs.map +1 -0
  58. package/dist/index.d.mts +13 -1548
  59. package/dist/index.mjs +13 -1636
  60. package/dist/prepared-admission-DU0_T-ev.mjs +73 -0
  61. package/dist/prepared-admission-DU0_T-ev.mjs.map +1 -0
  62. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  63. package/package.json +1 -53
  64. package/src/Alarm.ts +973 -0
  65. package/src/BrowserRestCapture.ts +477 -0
  66. package/src/BrowserRestCrawl.ts +540 -0
  67. package/src/CloudflareAiGateway.ts +170 -0
  68. package/src/CloudflareBindings.ts +199 -0
  69. package/src/CloudflareBrowser.ts +15 -0
  70. package/src/{code-mode-executor.ts → CloudflareCodeMode.ts} +372 -204
  71. package/src/{config.ts → CloudflareConfig.ts} +9 -8
  72. package/src/CloudflareMemory.ts +420 -0
  73. package/src/CloudflareScheduling.ts +747 -0
  74. package/src/CloudflareSubscriptions.ts +1041 -0
  75. package/src/CloudflareThreadClient.ts +809 -0
  76. package/src/InteractiveBrowser.ts +2354 -0
  77. package/src/ProtectedBrowser.ts +9 -0
  78. package/src/ThreadObject.ts +1179 -0
  79. package/src/{wake-scheduler.ts → WakeScheduler.ts} +32 -26
  80. package/src/index.ts +12 -23
  81. package/src/internal/boundary.ts +55 -0
  82. package/src/internal/browser-quick-action.ts +857 -0
  83. package/src/internal/browser-session-lifecycle.ts +160 -0
  84. package/src/internal/layers.ts +751 -0
  85. package/src/internal/message-delivery.ts +152 -0
  86. package/src/internal/prepared-admission.ts +116 -0
  87. package/src/internal/progress-wait.ts +121 -0
  88. package/src/internal/transport.ts +44 -0
  89. package/src/protected-browser/binding.ts +313 -0
  90. package/src/protected-browser/host.ts +344 -0
  91. package/src/protected-browser/inspect-frame.ts +125 -0
  92. package/src/protected-browser/native.ts +445 -0
  93. package/src/protected-browser/policy.ts +821 -0
  94. package/dist/index.mjs.map +0 -1
  95. package/src/alarm.ts +0 -334
  96. package/src/bindings.ts +0 -145
  97. package/src/client.ts +0 -646
  98. package/src/conversation-object.ts +0 -768
  99. package/src/layers.ts +0 -376
  100. package/src/transport.ts +0 -38
@@ -0,0 +1,125 @@
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
+ else if (autocomplete === 'username' || autocomplete === 'email') { if (form) role = 'username'; }
55
+ else if (['text','email'].includes(type) && form && form.querySelector('input[type="password"]')) role = 'username';
56
+ else if (el instanceof HTMLSelectElement) { if (!el.multiple) role = 'select'; }
57
+ else role = 'text';
58
+ }
59
+ } else if (el instanceof HTMLButtonElement || (el instanceof HTMLInputElement && ['submit','button'].includes(type))) {
60
+ if (!el.matches(':disabled')) role = type === 'submit' && form ? 'submit' : type === 'button' ? 'button' : 'unsupported';
61
+ } else if (el instanceof HTMLAnchorElement) role = 'link';
62
+ const fingerprint = JSON.stringify([role, action, method, enctype, name, completion, type, choiceValue]);
63
+ return { role, formIndex, action, fingerprint, ...(isChoice(el) ? {checked: el.checked} : {}),
64
+ label: (labelText(el) ?? (nativeField ? '' : el.textContent) ?? '').slice(0,200) };
65
+ };
66
+ const expose = ({role, formIndex, action, label, checked}) =>
67
+ ({role, formIndex, action, label, ...(checked === undefined ? {} : {checked})});
68
+ const original = elements.map(describe);
69
+ const validate = (index) => {
70
+ const current = describe(elements[index]);
71
+ if (!current || !original[index] || current.fingerprint !== original[index].fingerprint ||
72
+ current.formIndex !== original[index].formIndex) return null;
73
+ return expose(current);
74
+ };
75
+ return {
76
+ doc, elements, original: original.map(current => current && expose(current)), validate,
77
+ text: () => {
78
+ if (doc !== document) return null;
79
+ if (!doc.body) return '';
80
+ const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
81
+ const range = doc.createRange();
82
+ let text = '';
83
+ for (let node = walker.nextNode(); node && text.length < 65536; node = walker.nextNode()) {
84
+ const parent = node.parentElement;
85
+ if (!parent || parent.closest('input,textarea,select,script,style,noscript,iframe,object,embed')) continue;
86
+ if (!presented(parent)) continue;
87
+ range.selectNodeContents(node);
88
+ if (![...range.getClientRects()].some(rect => rect.width > 0 && rect.height > 0)) continue;
89
+ text += (node.textContent ?? '').replace(/\\s+/g, ' ') + ' ';
90
+ }
91
+ return text.slice(0,65536);
92
+ },
93
+ fill: (index, role, value) => {
94
+ const current = validate(index);
95
+ if (!current || current.role !== role) return false;
96
+ const el = elements[index];
97
+ if (role === 'select') {
98
+ const options = [...el.options].filter(option => !option.matches(':disabled'));
99
+ const values = options.filter(option => option.value === value);
100
+ const matches = values.length > 0 ? values : options.filter(option => option.textContent?.trim() === value);
101
+ if (matches.length !== 1) return 'unsupported';
102
+ value = matches[0].value;
103
+ if ([...el.options].filter(option => option.value === value).length !== 1) return 'unsupported';
104
+ }
105
+ let prototype = Object.getPrototypeOf(el);
106
+ let setter;
107
+ while (prototype && !setter) { setter = Object.getOwnPropertyDescriptor(prototype,'value')?.set; prototype = Object.getPrototypeOf(prototype); }
108
+ if (!setter) return false;
109
+ // Validate and mutate in one isolated-world task, without focusing first and running
110
+ // page handlers between validation and assignment. Native setters support controlled inputs.
111
+ setter.call(el, value);
112
+ if (el.value !== value) return 'unsupported';
113
+ el.dispatchEvent(new Event('input', {bubbles:true}));
114
+ el.dispatchEvent(new Event('change', {bubbles:true}));
115
+ return true;
116
+ },
117
+ click: (index) => {
118
+ const current = validate(index);
119
+ if (!current || !['button','submit','link','radio','checkbox'].includes(current.role)) return false;
120
+ if (current.role === 'submit' && elements[index].form && !elements[index].form.matches(':valid')) return 'needs-attention';
121
+ elements[index].click();
122
+ return true;
123
+ }
124
+ };
125
+ })()`;
@@ -0,0 +1,445 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type { Browser, Frame, JSHandle, Page } from "@cloudflare/puppeteer";
3
+ import { Clock, Context, Crypto, Effect, Redacted, Schema, type Scope } from "effect";
4
+ import { type InteractiveBrowserPolicy } from "effect-agent/interactive-browser";
5
+ import {
6
+ CredentialOrigin,
7
+ CredentialTarget,
8
+ ProtectedBrowserControl,
9
+ } from "effect-agent/protected-browser";
10
+
11
+ import { inspectFrame, maxAttributeLength } from "./inspect-frame.ts";
12
+ import {
13
+ ProtectedBrowserDispatch,
14
+ ProtectedTransportError,
15
+ ProtectedDiscovery,
16
+ ProtectedPageContext,
17
+ type ProtectedBrowserTransport,
18
+ } from "./policy.ts";
19
+
20
+ // The pinned SDK implements this method but strips its internal declaration from lib/types.d.ts.
21
+ // Keep the declaration narrow and refuse runtimes without it. No main-world fallback is safe.
22
+ declare module "@cloudflare/puppeteer" {
23
+ interface Frame {
24
+ isolatedRealm(): { evaluateHandle(source: string): Promise<JSHandle<unknown>> };
25
+ }
26
+ }
27
+
28
+ const Description = Schema.Struct({
29
+ role: ProtectedBrowserControl.fields.role,
30
+ formIndex: Schema.Natural,
31
+ action: Schema.String.check(Schema.isMaxLength(maxAttributeLength)),
32
+ label: Schema.String.check(Schema.isMaxLength(200)),
33
+ checked: Schema.optionalKey(Schema.Boolean),
34
+ });
35
+
36
+ const Descriptions = Schema.Array(Schema.NullOr(Description)).check(Schema.isMaxLength(65));
37
+
38
+ interface FrameState {
39
+ readonly frame: Frame;
40
+ readonly handle: JSHandle<unknown>;
41
+ readonly ref: string;
42
+ readonly document: string;
43
+ readonly forms: Map<number, string>;
44
+ }
45
+ interface ControlState {
46
+ readonly frame: FrameState;
47
+ readonly index: number;
48
+ readonly control: ProtectedBrowserControl;
49
+ readonly expires: number;
50
+ }
51
+
52
+ /** One host-private acquired session. The SDK handles and exact-session cleanup have one owner. */
53
+ export class ProtectedNativeSession extends Context.Service<
54
+ ProtectedNativeSession,
55
+ {
56
+ readonly browser: Browser;
57
+ readonly page: Page;
58
+ readonly close: Effect.Effect<"confirmed" | "unconfirmed">;
59
+ /** Host-owned attachment release may detach a committed suspended session. */
60
+ readonly release?: Effect.Effect<void>;
61
+ }
62
+ >()("@effect-agent/platform-cloudflare/ProtectedNativeSession") {}
63
+
64
+ const transportError = (reason: ProtectedTransportError["reason"]) =>
65
+ new ProtectedTransportError({ reason });
66
+
67
+ const decode = <A>(schema: Schema.Codec<A>, value: unknown) =>
68
+ Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => transportError("provider")));
69
+
70
+ // Attach rejection handling inside the SDK callback before workerd can report foreign diagnostics.
71
+ const remote = <A>(run: (signal: AbortSignal) => Promise<A>) =>
72
+ Effect.tryPromise({
73
+ try: async (signal) => {
74
+ try {
75
+ return { ok: true as const, value: await run(signal) };
76
+ } catch {
77
+ return { ok: false as const };
78
+ }
79
+ },
80
+ catch: () => transportError("provider"),
81
+ }).pipe(
82
+ Effect.flatMap((result) =>
83
+ result.ok ? Effect.succeed(result.value) : Effect.fail(transportError("provider")),
84
+ ),
85
+ );
86
+
87
+ /** Host-private scoped transport. Session capabilities are requirements; policy is per-pass data. */
88
+ export const makeProtectedNativeTransport = Effect.fn("ProtectedNativeTransport.make")(function* (
89
+ policy: InteractiveBrowserPolicy,
90
+ ): Effect.fn.Return<
91
+ ProtectedBrowserTransport,
92
+ never,
93
+ ProtectedNativeSession | Crypto.Crypto | Scope.Scope
94
+ > {
95
+ const session = yield* ProtectedNativeSession;
96
+ const { browser, page } = session;
97
+ const crypto = yield* Crypto.Crypto;
98
+ const clock = yield* Clock.Clock;
99
+ const uuid = crypto.randomUUIDv4.pipe(Effect.mapError(() => transportError("provider")));
100
+ let closed = false;
101
+ let violation = false;
102
+ let documentRef: string | undefined;
103
+ let observationOrigins: ReadonlySet<string> | undefined;
104
+ const frames = new Map<Frame, FrameState>();
105
+ const controls = new Map<string, ControlState>();
106
+
107
+ const origin = (value: string) =>
108
+ Effect.try({
109
+ try: () => {
110
+ const url = new URL(value);
111
+
112
+ if (url.username || url.password) throw transportError("stale-reference");
113
+ if (!Schema.is(CredentialOrigin)(url.origin)) throw transportError("unsupported");
114
+ if (policy.network._tag === "ExactHosts" && !policy.network.allowedHosts.includes(url.host))
115
+ throw transportError("stale-reference");
116
+
117
+ return url.origin;
118
+ },
119
+ catch: (cause) =>
120
+ Schema.is(ProtectedTransportError)(cause) ? cause : transportError("provider"),
121
+ });
122
+
123
+ const check = Effect.suspend(() =>
124
+ closed || violation || !browser.isConnected()
125
+ ? Effect.fail(transportError("stale-reference"))
126
+ : Effect.void,
127
+ );
128
+
129
+ const clear = () => {
130
+ controls.clear();
131
+ // Browser event callbacks only invalidate. The next Effect operation allocates the new identity.
132
+ documentRef = undefined;
133
+ };
134
+
135
+ const onNavigated = () => {
136
+ clear();
137
+ };
138
+
139
+ const onTarget = (target: { type(): string }) => {
140
+ if (target.type() !== "page") return;
141
+ // No popup or additional page may inherit an observation channel.
142
+ violation = true;
143
+ clear();
144
+ };
145
+
146
+ const invalidate = () => {
147
+ closed = true;
148
+ clear();
149
+ };
150
+
151
+ const observationFrames = Effect.gen(function* () {
152
+ const list = page.frames();
153
+
154
+ if (list.length > 32) return yield* transportError("unsupported");
155
+
156
+ // Opaque/blank child documents have no observation channel or credential targets.
157
+ // The main document and every retained HTTPS child still require strict origin validation.
158
+ return yield* Effect.filter(list, (frame) =>
159
+ frame === page.mainFrame()
160
+ ? Effect.succeed(true)
161
+ : Effect.try({
162
+ try: () => frame.url() !== "" && new URL(frame.url()).protocol === "https:",
163
+ catch: () => transportError("provider"),
164
+ }),
165
+ );
166
+ });
167
+
168
+ const context = Effect.gen(function* () {
169
+ yield* check;
170
+ const list = yield* observationFrames;
171
+ const topOrigin = yield* origin(page.url());
172
+ const frameOrigins = [...new Set(yield* Effect.forEach(list, (frame) => origin(frame.url())))];
173
+
174
+ if (frameOrigins.length > 16) return yield* transportError("unsupported");
175
+
176
+ if (documentRef === undefined) documentRef = yield* uuid;
177
+
178
+ return yield* decode(ProtectedPageContext, { document: documentRef, topOrigin, frameOrigins });
179
+ });
180
+
181
+ const isCurrent = Effect.fn("ProtectedNativeTransport.isCurrent")(function* (state: FrameState) {
182
+ if (state.frame.detached) return false;
183
+
184
+ return yield* remote(() =>
185
+ state.handle.evaluate((held) => {
186
+ if (typeof held !== "object" || held === null) return false;
187
+
188
+ return Reflect.get(held, "doc") === Reflect.get(globalThis, "document");
189
+ }),
190
+ );
191
+ });
192
+
193
+ const get = Effect.fn("ProtectedNativeTransport.get")(function* (ref: string) {
194
+ yield* check;
195
+ const state = controls.get(ref);
196
+
197
+ if (
198
+ !state ||
199
+ (observationOrigins !== undefined &&
200
+ !observationOrigins.has(state.control.target.frameOrigin)) ||
201
+ state.expires <= (yield* clock.currentTimeMillis) ||
202
+ !(yield* isCurrent(state.frame))
203
+ )
204
+ return yield* transportError("stale-reference");
205
+
206
+ const current = yield* remote(() =>
207
+ state.frame.handle.evaluate((held, index) => {
208
+ if (typeof held !== "object" || held === null) return null;
209
+
210
+ return Reflect.apply(Reflect.get(held, "validate"), held, [index]);
211
+ }, state.index),
212
+ );
213
+
214
+ const description = yield* decode(Schema.NullOr(Description), current);
215
+
216
+ if (description === null) return yield* transportError("stale-reference");
217
+ const ctx = yield* context;
218
+
219
+ if (
220
+ ctx.topOrigin !== state.control.target.topOrigin ||
221
+ (yield* origin(state.frame.frame.url())) !== state.control.target.frameOrigin ||
222
+ (yield* origin(description.action)) !== state.control.target.recipientOrigin ||
223
+ description.role !== state.control.role
224
+ )
225
+ return yield* transportError("stale-reference");
226
+
227
+ return state;
228
+ });
229
+
230
+ const close = yield* Effect.cached(
231
+ Effect.gen(function* () {
232
+ invalidate();
233
+
234
+ return yield* session.close;
235
+ }).pipe(
236
+ Effect.ensuring(
237
+ Effect.sync(() => {
238
+ page.off("framenavigated", onNavigated);
239
+ page.off("framedetached", onNavigated);
240
+ browser.off("targetcreated", onTarget);
241
+ // Handles die with the exact session. Do not block remote termination on local disposal.
242
+ frames.clear();
243
+ }),
244
+ ),
245
+ ),
246
+ );
247
+
248
+ yield* Effect.acquireRelease(
249
+ Effect.sync(() => {
250
+ page.on("framenavigated", onNavigated);
251
+ page.on("framedetached", onNavigated);
252
+ browser.on("targetcreated", onTarget);
253
+ }),
254
+ () =>
255
+ session.release === undefined
256
+ ? close
257
+ : session.release.pipe(
258
+ Effect.ensuring(
259
+ Effect.sync(() => {
260
+ invalidate();
261
+ page.off("framenavigated", onNavigated);
262
+ page.off("framedetached", onNavigated);
263
+ browser.off("targetcreated", onTarget);
264
+ frames.clear();
265
+ }),
266
+ ),
267
+ ),
268
+ );
269
+
270
+ return {
271
+ restrictObservation: Effect.fn("ProtectedNativeTransport.restrictObservation")(
272
+ function* (origins) {
273
+ yield* check;
274
+ observationOrigins =
275
+ origins === undefined
276
+ ? undefined
277
+ : new Set(yield* decode(Schema.Array(CredentialOrigin), origins));
278
+ // A later grant expansion must not revive previously excluded references.
279
+ for (const [ref, state] of controls)
280
+ if (
281
+ observationOrigins !== undefined &&
282
+ !observationOrigins.has(state.control.target.frameOrigin)
283
+ )
284
+ controls.delete(ref);
285
+ },
286
+ ),
287
+ context,
288
+ invalidate,
289
+ resetReferences: clear,
290
+ close,
291
+ navigate: Effect.fn("ProtectedNativeTransport.navigate")(function* (url) {
292
+ yield* check;
293
+ yield* origin(url);
294
+ clear();
295
+ yield* remote(() => page.goto(url, { waitUntil: "load" }));
296
+ yield* context;
297
+ }),
298
+ target: (ref) => get(ref).pipe(Effect.map((state) => state.control)),
299
+ discover: Effect.gen(function* () {
300
+ const before = yield* context;
301
+
302
+ controls.clear();
303
+ for (const state of frames.values()) yield* remote(() => state.handle.dispose());
304
+ frames.clear();
305
+ const discovered: Array<ProtectedBrowserControl> = [];
306
+ let text = "";
307
+ let truncated = false;
308
+ const frameOrigins = new Set<string>();
309
+
310
+ for (const frame of yield* observationFrames) {
311
+ const frameOrigin = yield* origin(frame.url());
312
+
313
+ if (observationOrigins !== undefined && !observationOrigins.has(frameOrigin)) continue;
314
+ frameOrigins.add(frameOrigin);
315
+ if (typeof frame.isolatedRealm !== "function") return yield* transportError("unsupported");
316
+ const handle = yield* remote(() => frame.isolatedRealm().evaluateHandle(inspectFrame));
317
+
318
+ const state: FrameState = {
319
+ frame,
320
+ handle,
321
+ ref: yield* uuid,
322
+ document: yield* uuid,
323
+ forms: new Map(),
324
+ };
325
+
326
+ frames.set(frame, state);
327
+
328
+ const raw = yield* remote(() =>
329
+ handle.evaluate((held) => {
330
+ if (typeof held !== "object" || held === null) return null;
331
+
332
+ return Reflect.get(held, "original");
333
+ }),
334
+ );
335
+
336
+ const descriptions = yield* decode(Descriptions, raw);
337
+
338
+ for (let index = 0; index < descriptions.length; index++) {
339
+ const desc = descriptions[index];
340
+
341
+ if (!desc) continue;
342
+ if (discovered.length === 64) {
343
+ truncated = true;
344
+ break;
345
+ }
346
+ let form = state.forms.get(desc.formIndex);
347
+
348
+ if (form === undefined) {
349
+ form = yield* uuid;
350
+ state.forms.set(desc.formIndex, form);
351
+ }
352
+ // Unsupported destinations/controls are not offered as credential targets.
353
+ const recipient = yield* origin(desc.action).pipe(Effect.result);
354
+
355
+ if (recipient._tag === "Failure") continue;
356
+
357
+ const control = ProtectedBrowserControl.make({
358
+ ref: yield* uuid,
359
+ role: desc.role,
360
+ label: desc.label,
361
+ ...(desc.checked === undefined ? {} : { checked: desc.checked }),
362
+ ...(desc.role === "link" ? { url: desc.action } : {}),
363
+ target: CredentialTarget.make({
364
+ topOrigin: before.topOrigin,
365
+ frameOrigin: yield* origin(frame.url()),
366
+ recipientOrigin: recipient.success,
367
+ document: state.document,
368
+ frame: state.ref,
369
+ form,
370
+ }),
371
+ });
372
+
373
+ controls.set(control.ref, {
374
+ frame: state,
375
+ index,
376
+ control,
377
+ expires: (yield* clock.currentTimeMillis) + 60_000,
378
+ });
379
+ discovered.push(control);
380
+ }
381
+
382
+ const rawText = yield* remote(() =>
383
+ handle.evaluate((held) => {
384
+ if (typeof held !== "object" || held === null) return null;
385
+
386
+ return Reflect.apply(Reflect.get(held, "text"), held, []);
387
+ }),
388
+ );
389
+
390
+ text += yield* decode(Schema.String.check(Schema.isMaxLength(65536)), rawText);
391
+ if (text.length > 65536) {
392
+ text = text.slice(0, 65536);
393
+ truncated = true;
394
+ }
395
+ }
396
+ if ((yield* context).document !== before.document)
397
+ return yield* transportError("stale-reference");
398
+
399
+ return yield* decode(ProtectedDiscovery, {
400
+ ...before,
401
+ frameOrigins: [...frameOrigins],
402
+ text,
403
+ controls: discovered,
404
+ truncated,
405
+ });
406
+ }),
407
+ fill: Effect.fn("ProtectedNativeTransport.fill")(function* (ref, role, value) {
408
+ const dispatch = yield* ProtectedBrowserDispatch;
409
+ const state = yield* get(ref);
410
+
411
+ // CDP dispatch may mutate before its reply is lost, so uncertainty starts here.
412
+ yield* dispatch.mark;
413
+
414
+ const filled = yield* remote(() =>
415
+ state.frame.handle.evaluate(
416
+ (held, index, expectedRole, secret) => {
417
+ if (typeof held !== "object" || held === null) return false;
418
+
419
+ return Reflect.apply(Reflect.get(held, "fill"), held, [index, expectedRole, secret]);
420
+ },
421
+ state.index,
422
+ role,
423
+ Redacted.value(value),
424
+ ),
425
+ );
426
+
427
+ if (filled === "unsupported") return yield* transportError("unsupported");
428
+ if (filled !== true) return yield* transportError("stale-reference");
429
+ }),
430
+ click: Effect.fn("ProtectedNativeTransport.click")(function* (ref) {
431
+ const state = yield* get(ref);
432
+
433
+ const clicked = yield* remote(() =>
434
+ state.frame.handle.evaluate((held, index) => {
435
+ if (typeof held !== "object" || held === null) return false;
436
+
437
+ return Reflect.apply(Reflect.get(held, "click"), held, [index]);
438
+ }, state.index),
439
+ );
440
+
441
+ if (clicked === "needs-attention") return yield* transportError("needs-attention");
442
+ if (clicked !== true) return yield* transportError("stale-reference");
443
+ }),
444
+ };
445
+ }, Effect.withTracerEnabled(false));