@effect-agent/platform-cloudflare 0.1.0-beta.13 → 0.1.0-beta.130

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 (105) hide show
  1. package/dist/Alarm.d.mts +244 -0
  2. package/dist/Alarm.mjs +947 -0
  3. package/dist/Alarm.mjs.map +1 -0
  4. package/dist/BrowserCredentials.d.mts +91 -0
  5. package/dist/BrowserCredentials.mjs +120 -0
  6. package/dist/BrowserCredentials.mjs.map +1 -0
  7. package/dist/BrowserRestCapture.d.mts +34 -0
  8. package/dist/BrowserRestCapture.mjs +238 -0
  9. package/dist/BrowserRestCapture.mjs.map +1 -0
  10. package/dist/BrowserRestCrawl.d.mts +16 -0
  11. package/dist/BrowserRestCrawl.mjs +244 -0
  12. package/dist/BrowserRestCrawl.mjs.map +1 -0
  13. package/dist/BrowserSession-BplIoNfi.mjs +442 -0
  14. package/dist/BrowserSession-BplIoNfi.mjs.map +1 -0
  15. package/dist/BrowserSession-DxCN-5F9.d.mts +109 -0
  16. package/dist/BrowserSession.d.mts +3 -0
  17. package/dist/BrowserSession.mjs +4 -0
  18. package/dist/CloudflareAiGateway.d.mts +64 -0
  19. package/dist/CloudflareAiGateway.mjs +70 -0
  20. package/dist/CloudflareAiGateway.mjs.map +1 -0
  21. package/dist/CloudflareBindings.d.mts +115 -0
  22. package/dist/CloudflareBindings.mjs +113 -0
  23. package/dist/CloudflareBindings.mjs.map +1 -0
  24. package/dist/CloudflareBrowser-DV6kya1H.mjs +495 -0
  25. package/dist/CloudflareBrowser-DV6kya1H.mjs.map +1 -0
  26. package/dist/CloudflareBrowser-Doe1M7R5.d.mts +80 -0
  27. package/dist/CloudflareBrowser.d.mts +2 -0
  28. package/dist/CloudflareBrowser.mjs +2 -0
  29. package/dist/CloudflareCodeMode.d.mts +44 -0
  30. package/dist/CloudflareCodeMode.mjs +616 -0
  31. package/dist/CloudflareCodeMode.mjs.map +1 -0
  32. package/dist/CloudflareConfig-DKXGo8L3.d.mts +106 -0
  33. package/dist/CloudflareConfig.d.mts +2 -0
  34. package/dist/CloudflareConfig.mjs +128 -0
  35. package/dist/CloudflareConfig.mjs.map +1 -0
  36. package/dist/CloudflareMemory.d.mts +142 -0
  37. package/dist/CloudflareMemory.mjs +202 -0
  38. package/dist/CloudflareMemory.mjs.map +1 -0
  39. package/dist/CloudflareScheduling.d.mts +52 -0
  40. package/dist/CloudflareScheduling.mjs +396 -0
  41. package/dist/CloudflareScheduling.mjs.map +1 -0
  42. package/dist/CloudflareSubscriptions.d.mts +87 -0
  43. package/dist/CloudflareSubscriptions.mjs +530 -0
  44. package/dist/CloudflareSubscriptions.mjs.map +1 -0
  45. package/dist/CloudflareThreadClient.d.mts +1752 -0
  46. package/dist/CloudflareThreadClient.mjs +382 -0
  47. package/dist/CloudflareThreadClient.mjs.map +1 -0
  48. package/dist/InteractiveBrowser-BnqY_Y-F.d.mts +228 -0
  49. package/dist/InteractiveBrowser-D-xxDv_f.mjs +1923 -0
  50. package/dist/InteractiveBrowser-D-xxDv_f.mjs.map +1 -0
  51. package/dist/InteractiveBrowser.d.mts +2 -0
  52. package/dist/InteractiveBrowser.mjs +2 -0
  53. package/dist/ThreadObject-CVhZC-7l.mjs +849 -0
  54. package/dist/ThreadObject-CVhZC-7l.mjs.map +1 -0
  55. package/dist/ThreadObject-CcS4Q2iT.d.mts +839 -0
  56. package/dist/ThreadObject.d.mts +2 -0
  57. package/dist/ThreadObject.mjs +3 -0
  58. package/dist/WakeScheduler.d.mts +23 -0
  59. package/dist/WakeScheduler.mjs +57 -0
  60. package/dist/WakeScheduler.mjs.map +1 -0
  61. package/dist/boundary-BguazVkh.mjs +42 -0
  62. package/dist/boundary-BguazVkh.mjs.map +1 -0
  63. package/dist/index.d.mts +15 -1550
  64. package/dist/index.mjs +15 -1716
  65. package/dist/prepared-admission-DU0_T-ev.mjs +73 -0
  66. package/dist/prepared-admission-DU0_T-ev.mjs.map +1 -0
  67. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  68. package/package.json +1 -56
  69. package/src/Alarm.ts +1979 -0
  70. package/src/BrowserCredentials.ts +146 -0
  71. package/src/BrowserRestCapture.ts +477 -0
  72. package/src/BrowserRestCrawl.ts +540 -0
  73. package/src/BrowserSession.ts +591 -0
  74. package/src/CloudflareAiGateway.ts +170 -0
  75. package/src/CloudflareBindings.ts +199 -0
  76. package/src/CloudflareBrowser.ts +15 -0
  77. package/src/{code-mode-executor.ts → CloudflareCodeMode.ts} +308 -283
  78. package/src/{config.ts → CloudflareConfig.ts} +19 -12
  79. package/src/CloudflareMemory.ts +420 -0
  80. package/src/CloudflareScheduling.ts +747 -0
  81. package/src/CloudflareSubscriptions.ts +1041 -0
  82. package/src/CloudflareThreadClient.ts +809 -0
  83. package/src/InteractiveBrowser.ts +3246 -0
  84. package/src/ThreadObject.ts +1190 -0
  85. package/src/{wake-scheduler.ts → WakeScheduler.ts} +32 -26
  86. package/src/index.ts +14 -23
  87. package/src/internal/boundary.ts +55 -0
  88. package/src/internal/browser-binding.ts +322 -0
  89. package/src/internal/browser-credentials.ts +362 -0
  90. package/src/internal/browser-failure.ts +92 -0
  91. package/src/internal/browser-file-selection.ts +126 -0
  92. package/src/internal/browser-quick-action.ts +857 -0
  93. package/src/internal/browser-session-lifecycle.ts +193 -0
  94. package/src/internal/layers.ts +773 -0
  95. package/src/internal/message-delivery.ts +131 -0
  96. package/src/internal/prepared-admission.ts +116 -0
  97. package/src/internal/progress-wait.ts +121 -0
  98. package/src/internal/transport.ts +44 -0
  99. package/dist/index.mjs.map +0 -1
  100. package/src/alarm.ts +0 -334
  101. package/src/bindings.ts +0 -145
  102. package/src/client.ts +0 -646
  103. package/src/conversation-object.ts +0 -768
  104. package/src/layers.ts +0 -376
  105. package/src/transport.ts +0 -38
@@ -0,0 +1,362 @@
1
+ import { Cause, Context, Effect, Redacted, Schema } from "effect";
2
+ import type { Frame, Page } from "puppeteer-core/lib/esm/puppeteer/puppeteer-core-browser.js";
3
+
4
+ import {
5
+ BrowserCredentialAccess,
6
+ type BrowserCredentialMaterial,
7
+ CardCredential,
8
+ type CredentialDispatch,
9
+ type CredentialFieldRole,
10
+ CredentialFillError,
11
+ CredentialFillResult,
12
+ CredentialOrigin,
13
+ CredentialTarget,
14
+ FillCredentialRequest,
15
+ LoginCredential,
16
+ type CredentialAccessRequest,
17
+ } from "../BrowserCredentials.ts";
18
+
19
+ /** Supplied only under the attachment lock, with the remaining command/session budget. */
20
+ export class BrowserSessionPage extends Context.Service<
21
+ BrowserSessionPage,
22
+ {
23
+ readonly page: Page;
24
+ readonly commandTimeoutMillis: number;
25
+ }
26
+ >()("@effect-agent/platform-cloudflare/internal/BrowserSessionPage") {}
27
+
28
+ const TargetReply = Schema.Union([
29
+ Schema.Struct({ action: Schema.String.check(Schema.isMaxLength(2048)) }),
30
+ Schema.Struct({ reason: Schema.Literals(["stale-target", "unsupported"]) }),
31
+ ]);
32
+
33
+ const WriteReply = Schema.Literals([
34
+ "filled",
35
+ "stale-target",
36
+ "unsupported-before-write",
37
+ "unsupported",
38
+ ]);
39
+
40
+ // One invocation-local isolated-world handle. Actual nodes and fingerprints never leave the page.
41
+ // This program neither discovers controls nor classifies roles nor observes page content.
42
+ const prepareFields = `(() => {
43
+ const doc = document;
44
+ let fields = [];
45
+ let form;
46
+ let snapshots = [];
47
+ const describe = el => {
48
+ if (!el || !el.isConnected || el.ownerDocument !== doc || doc !== document) return null;
49
+ const native = el instanceof HTMLTextAreaElement ||
50
+ (el instanceof HTMLSelectElement && !el.multiple) ||
51
+ (el instanceof HTMLInputElement && ['text','email','password','tel','number','month','search','url','date','time','week','datetime-local'].includes(el.type));
52
+ if (!native || el.matches(':disabled') || el.readOnly || !el.form) return null;
53
+ const values = [el.form.action, el.form.method, el.form.enctype, el.name, el.type, el.autocomplete ?? ''];
54
+ if (values.some(value => value.length > 2048)) return null;
55
+ return JSON.stringify(values);
56
+ };
57
+ const validate = () => fields.length > 0 && fields.every((el, index) =>
58
+ el.form === form && describe(el) !== null && describe(el) === snapshots[index]);
59
+ return {
60
+ prepare(selectors) {
61
+ try {
62
+ fields = selectors.map(selector => {
63
+ const matches = doc.querySelectorAll(selector);
64
+ return matches.length === 1 ? matches[0] : null;
65
+ });
66
+ } catch { return {reason: 'stale-target'}; }
67
+ if (fields.some(el => !el) || new Set(fields).size !== fields.length) return {reason: 'stale-target'};
68
+ snapshots = fields.map(describe);
69
+ form = fields[0].form;
70
+ if (!form || snapshots.some(value => value === null) || fields.some(el => el.form !== form)) return {reason: 'unsupported'};
71
+ return {action: form.action};
72
+ },
73
+ target() { return validate() ? {action: form.action} : {reason: 'stale-target'}; },
74
+ fill(index, value) {
75
+ if (!validate()) return 'stale-target';
76
+ const el = fields[index];
77
+ if (el instanceof HTMLSelectElement) {
78
+ const options = [...el.options].filter(option => option.value === value);
79
+ if (options.length !== 1 || options[0].matches(':disabled')) return 'unsupported-before-write';
80
+ }
81
+ let prototype = Object.getPrototypeOf(el);
82
+ let setter;
83
+ while (prototype && !setter) { setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set; prototype = Object.getPrototypeOf(prototype); }
84
+ if (!setter) return 'unsupported-before-write';
85
+ setter.call(el, value);
86
+ if (el.value !== value) return 'unsupported';
87
+ el.dispatchEvent(new Event('input', {bubbles: true}));
88
+ el.dispatchEvent(new Event('change', {bubbles: true}));
89
+ return 'filled';
90
+ }
91
+ };
92
+ })()`;
93
+
94
+ const secretFor = (material: BrowserCredentialMaterial, role: typeof CredentialFieldRole.Type) => {
95
+ if (material._tag === "LoginCredential")
96
+ return role === "username"
97
+ ? material.username
98
+ : role === "password"
99
+ ? material.password
100
+ : undefined;
101
+ switch (role) {
102
+ case "card-name":
103
+ return material.name;
104
+ case "card-number":
105
+ return material.number;
106
+ case "card-expiry":
107
+ return material.expiry;
108
+ case "card-expiry-month":
109
+ return material.expiryMonth;
110
+ case "card-expiry-year":
111
+ return material.expiryYear;
112
+ case "card-security-code":
113
+ return material.securityCode;
114
+ case "username":
115
+ case "password":
116
+ return undefined;
117
+ }
118
+ };
119
+
120
+ /**
121
+ * Fill explicit native fields on an existing page without submitting. The owning browser session
122
+ * serializes operations and closes on timeout, interruption or uncertain dispatch; this function releases
123
+ * only its local handles. Ordinary native observations remain available, including credential echoes.
124
+ */
125
+ export const fillCredential = Effect.fnUntraced(function* (
126
+ input: FillCredentialRequest,
127
+ ): Effect.fn.Return<
128
+ CredentialFillResult,
129
+ CredentialFillError,
130
+ BrowserCredentialAccess | BrowserSessionPage
131
+ > {
132
+ const { page, commandTimeoutMillis } = yield* BrowserSessionPage;
133
+ let dispatch: typeof CredentialDispatch.Type = "not-dispatched";
134
+ let filled = 0;
135
+
136
+ const fail = (reason: CredentialFillError["reason"]) =>
137
+ new CredentialFillError({ reason, dispatch, filled, cleanup: "not-requested" });
138
+
139
+ const remote = <A>(run: (signal: AbortSignal) => Promise<A>) =>
140
+ Effect.tryPromise({
141
+ try: async (signal) => {
142
+ try {
143
+ return { ok: true as const, value: await run(signal) };
144
+ } catch {
145
+ return { ok: false as const };
146
+ }
147
+ },
148
+ catch: () => fail("provider"),
149
+ }).pipe(
150
+ Effect.flatMap((result) =>
151
+ result.ok ? Effect.succeed(result.value) : Effect.fail(fail("provider")),
152
+ ),
153
+ );
154
+
155
+ const release = (handles: ReadonlyArray<{ dispose(): Promise<void> }>) =>
156
+ Effect.promise(async () => {
157
+ await Promise.all(handles.map((handle) => handle.dispose().catch(() => {})));
158
+ }).pipe(Effect.interruptible, Effect.timeoutOption("1 second"), Effect.asVoid);
159
+
160
+ const acquire = <A extends { dispose(): Promise<void> }>(run: () => Promise<ReadonlyArray<A>>) =>
161
+ Effect.acquireRelease(
162
+ remote(async (signal) => {
163
+ const handles = await run();
164
+
165
+ if (signal.aborted) {
166
+ await Promise.all(handles.map((handle) => handle.dispose().catch(() => {})));
167
+ throw fail("provider");
168
+ }
169
+
170
+ return handles;
171
+ }).pipe(Effect.interruptible),
172
+ release,
173
+ );
174
+
175
+ const origin = (value: string) =>
176
+ Effect.try({
177
+ try: () => {
178
+ const url = new URL(value);
179
+
180
+ if (url.username || url.password) throw fail("denied");
181
+
182
+ return url.origin;
183
+ },
184
+ catch: () => fail("denied"),
185
+ }).pipe(
186
+ Effect.flatMap(Schema.decodeUnknownEffect(CredentialOrigin)),
187
+ Effect.mapError(() => fail("denied")),
188
+ );
189
+
190
+ return yield* Effect.gen(function* () {
191
+ const request = yield* Schema.decodeEffect(FillCredentialRequest, {
192
+ onExcessProperty: "error",
193
+ })(input).pipe(Effect.mapError(() => fail("denied")));
194
+
195
+ const roles = request.fields.map((field) => field.role);
196
+
197
+ if (
198
+ new Set(roles).size !== roles.length ||
199
+ roles.some(
200
+ (role) => (request.kind === "login") !== (role === "username" || role === "password"),
201
+ )
202
+ )
203
+ return yield* fail("denied");
204
+ const access = yield* BrowserCredentialAccess;
205
+ let frame: Frame = page.mainFrame();
206
+
207
+ for (const selector of request.frame ?? []) {
208
+ const matches = yield* acquire(() => frame.$$(selector));
209
+ const element = matches[0];
210
+
211
+ if (matches.length !== 1 || element === undefined) return yield* fail("stale-target");
212
+ const child = yield* remote(() => element.contentFrame());
213
+
214
+ if (child === null) return yield* fail("unsupported");
215
+ frame = child;
216
+ }
217
+
218
+ const handles = yield* acquire(async () => [
219
+ await frame.isolatedRealm().evaluateHandle(prepareFields),
220
+ ]);
221
+
222
+ const held = handles[0];
223
+
224
+ if (held === undefined) return yield* fail("provider");
225
+
226
+ const inspect = (raw: unknown) =>
227
+ Schema.decodeUnknownEffect(TargetReply)(raw).pipe(
228
+ Effect.mapError(() => fail("provider")),
229
+ Effect.flatMap((reply) =>
230
+ "reason" in reply ? Effect.fail(fail(reply.reason)) : Effect.succeed(reply.action),
231
+ ),
232
+ );
233
+
234
+ const action = yield* remote(() =>
235
+ held.evaluate(
236
+ (state, selectors) => {
237
+ if (typeof state !== "object" || state === null) return null;
238
+
239
+ return Reflect.apply(Reflect.get(state, "prepare"), state, [selectors]);
240
+ },
241
+ request.fields.map((field) => field.selector),
242
+ ),
243
+ ).pipe(Effect.flatMap(inspect));
244
+
245
+ const target = CredentialTarget.make({
246
+ topOrigin: yield* origin(page.url()),
247
+ frameOrigin: yield* origin(frame.url()),
248
+ recipientOrigin: yield* origin(action),
249
+ });
250
+
251
+ const authorization: CredentialAccessRequest = {
252
+ credential: request.credential,
253
+ kind: request.kind,
254
+ target,
255
+ roles,
256
+ };
257
+
258
+ const authorize = Effect.suspend(() => access.authorize(authorization)).pipe(
259
+ Effect.mapError((error) => fail(error.reason)),
260
+ );
261
+
262
+ const validate = Effect.gen(function* () {
263
+ if (
264
+ frame.detached ||
265
+ (yield* origin(page.url())) !== target.topOrigin ||
266
+ (yield* origin(frame.url())) !== target.frameOrigin
267
+ )
268
+ return yield* fail("stale-target");
269
+
270
+ const current = yield* remote(() =>
271
+ held.evaluate((state) => {
272
+ if (typeof state !== "object" || state === null) return null;
273
+
274
+ return Reflect.apply(Reflect.get(state, "target"), state, []);
275
+ }),
276
+ ).pipe(Effect.flatMap(inspect));
277
+
278
+ if ((yield* origin(current)) !== target.recipientOrigin) return yield* fail("stale-target");
279
+ });
280
+
281
+ yield* authorize;
282
+ yield* validate;
283
+
284
+ const raw = yield* access
285
+ .resolve(authorization)
286
+ .pipe(Effect.mapError((error) => fail(error.reason)));
287
+
288
+ const material = yield* Schema.decodeEffect(
289
+ request.kind === "login" ? LoginCredential : CardCredential,
290
+ )(raw).pipe(Effect.mapError(() => fail("resolver")));
291
+
292
+ for (const role of roles)
293
+ if (secretFor(material, role) === undefined) return yield* fail("missing-credential");
294
+ for (const [index, role] of roles.entries()) {
295
+ yield* authorize;
296
+ yield* validate;
297
+ const value = secretFor(material, role);
298
+
299
+ if (value === undefined) return yield* fail("missing-credential");
300
+ const previous = dispatch;
301
+
302
+ const result = yield* remote((signal) => {
303
+ if (signal.aborted) return Promise.reject(fail("provider"));
304
+ dispatch = "possibly-dispatched";
305
+
306
+ return held.evaluate(
307
+ (state, field, secret) => {
308
+ if (typeof state !== "object" || state === null) return "stale-target";
309
+
310
+ return Reflect.apply(Reflect.get(state, "fill"), state, [field, secret]);
311
+ },
312
+ index,
313
+ Redacted.value(value),
314
+ );
315
+ }).pipe(
316
+ Effect.flatMap(Schema.decodeUnknownEffect(WriteReply)),
317
+ Effect.mapError(() => fail("provider")),
318
+ );
319
+
320
+ if (result === "stale-target" || result === "unsupported-before-write") {
321
+ dispatch = previous;
322
+
323
+ return yield* fail(result === "stale-target" ? "stale-target" : "unsupported");
324
+ }
325
+ if (result === "unsupported") return yield* fail("unsupported");
326
+ dispatch = "dispatched";
327
+ filled++;
328
+ }
329
+
330
+ return CredentialFillResult.make({ dispatch: "dispatched", filled });
331
+ }).pipe(
332
+ Effect.scoped,
333
+ Effect.timeoutOrElse({
334
+ duration: commandTimeoutMillis,
335
+ orElse: () => Effect.fail(fail("timeout")),
336
+ }),
337
+ Effect.catchCause((cause) => {
338
+ if (Cause.hasInterrupts(cause)) return Effect.interrupt;
339
+ if (
340
+ cause.reasons.some(
341
+ (reason) =>
342
+ reason._tag === "Die" ||
343
+ (reason._tag === "Fail" && !Schema.is(CredentialFillError)(reason.error)),
344
+ )
345
+ )
346
+ return Effect.fail(fail("provider"));
347
+
348
+ const expected = cause.reasons.find(
349
+ (reason) => reason._tag === "Fail" && Schema.is(CredentialFillError)(reason.error),
350
+ );
351
+
352
+ return Effect.fail(
353
+ fail(
354
+ expected?._tag === "Fail" && Schema.is(CredentialFillError)(expected.error)
355
+ ? expected.error.reason
356
+ : "provider",
357
+ ),
358
+ );
359
+ }),
360
+ Effect.withTracerEnabled(false),
361
+ );
362
+ });
@@ -0,0 +1,92 @@
1
+ import type { Effect } from "effect";
2
+ import { Cause, Data, ErrorReporter, Predicate, Schema } from "effect";
3
+
4
+ /** Source-authored stages and classifications only; never retain SDK text or session capabilities. */
5
+ export class BrowserRunFailure extends Data.TaggedError("BrowserRunFailure")<{
6
+ readonly operation: string;
7
+ readonly reason:
8
+ | "provider"
9
+ | "timeout"
10
+ | "malformed"
11
+ | "configuration"
12
+ | "authorization"
13
+ | "rate-limited"
14
+ | "pending";
15
+ readonly status?: number;
16
+ readonly cause?: BrowserRunFailure | { readonly name: string };
17
+ }> {
18
+ readonly [ErrorReporter.severity] = "Error" as const;
19
+ }
20
+
21
+ /** A public projection of an already reported private failure must not produce another issue. */
22
+ export const reportedBrowserError = <A extends object>(error: A): A =>
23
+ Object.assign(error, { [ErrorReporter.ignore]: true });
24
+
25
+ export const inheritBrowserReport = <A extends object>(original: unknown, error: A): A =>
26
+ ErrorReporter.isIgnored(original) ? reportedBrowserError(error) : error;
27
+
28
+ const reasonSchema = Schema.Literals([
29
+ "provider",
30
+ "timeout",
31
+ "malformed",
32
+ "configuration",
33
+ "authorization",
34
+ "rate-limited",
35
+ "pending",
36
+ ]);
37
+
38
+ const statusSchema = Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 }));
39
+
40
+ const failureNames = new Set([
41
+ "TypeError",
42
+ "RangeError",
43
+ "SyntaxError",
44
+ "TimeoutError",
45
+ "SchemaError",
46
+ ]);
47
+
48
+ export const browserFailure = (operation: string, cause?: unknown): BrowserRunFailure =>
49
+ cause instanceof BrowserRunFailure
50
+ ? cause
51
+ : new BrowserRunFailure({
52
+ operation,
53
+ reason:
54
+ Predicate.isObject(cause) && "reason" in cause && Schema.is(reasonSchema)(cause.reason)
55
+ ? cause.reason
56
+ : "provider",
57
+ ...(Predicate.isObject(cause) && "status" in cause && Schema.is(statusSchema)(cause.status)
58
+ ? { status: cause.status }
59
+ : {}),
60
+ ...(Predicate.isObject(cause) &&
61
+ "cause" in cause &&
62
+ cause.cause instanceof BrowserRunFailure
63
+ ? { cause: cause.cause }
64
+ : Predicate.isObject(cause) &&
65
+ "name" in cause &&
66
+ typeof cause.name === "string" &&
67
+ failureNames.has(cause.name)
68
+ ? { cause: { name: cause.name } }
69
+ : {}),
70
+ });
71
+
72
+ /** Keep mixed defects and interruptions distinct while discarding all foreign diagnostic content. */
73
+ export const reportBrowserCause = <E>(
74
+ operation: string,
75
+ cause: Cause.Cause<E>,
76
+ ): Effect.Effect<void> =>
77
+ ErrorReporter.report(
78
+ Cause.fromReasons(
79
+ cause.reasons
80
+ .filter((reason) => !Cause.isFailReason(reason) || !ErrorReporter.isIgnored(reason.error))
81
+ .map((reason) => {
82
+ switch (reason._tag) {
83
+ case "Fail":
84
+ return Cause.makeFailReason(browserFailure(operation, reason.error));
85
+ case "Die":
86
+ return Cause.makeDieReason(browserFailure(operation, reason.defect));
87
+ case "Interrupt":
88
+ return reason;
89
+ }
90
+ }),
91
+ ),
92
+ );
@@ -0,0 +1,126 @@
1
+ import { Encoding } from "effect";
2
+ import type { BrowserSelectFileRequest } from "effect-agent/interactive-browser";
3
+ import type { Page } from "puppeteer-core/lib/esm/puppeteer/puppeteer-core-browser.js";
4
+
5
+ type Element = NonNullable<Awaited<ReturnType<Page["$"]>>>;
6
+
7
+ // Both calls run this fixed function in Chromium. No caller-provided code or
8
+ // filesystem path is evaluated, and none of the file bytes enter diagnostics.
9
+ function setFileSelection(element: object, encoded: string, name: string, type: string): void {
10
+ if (
11
+ Reflect.get(element, "type") !== "file" ||
12
+ Reflect.get(element, "disabled") ||
13
+ Reflect.get(element, "webkitdirectory")
14
+ ) {
15
+ throw new Error("The target is not an enabled file input");
16
+ }
17
+ const bytes = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0));
18
+ const Transfer = Reflect.get(globalThis, "DataTransfer");
19
+ const transfer = new Transfer();
20
+
21
+ transfer.items.add(new File([bytes], name, { type }));
22
+ Reflect.set(element, "files", transfer.files);
23
+ const selected = Reflect.get(element, "files");
24
+
25
+ if (
26
+ selected?.length !== 1 ||
27
+ selected[0]?.size !== bytes.length ||
28
+ selected[0]?.name !== name ||
29
+ selected[0]?.type !== type
30
+ ) {
31
+ throw new Error("The input did not retain the selected file");
32
+ }
33
+ const dispatch = Reflect.get(element, "dispatchEvent");
34
+
35
+ Reflect.apply(dispatch, element, [new Event("input", { bubbles: true })]);
36
+ Reflect.apply(dispatch, element, [new Event("change", { bubbles: true })]);
37
+ }
38
+
39
+ export const makeFileSelection =
40
+ (page: Page) => async (request: BrowserSelectFileRequest, signal: AbortSignal) => {
41
+ const encoded = Encoding.encodeBase64(request.bytes);
42
+ const session = request.target === "chooser" ? await page.createCDPSession() : undefined;
43
+
44
+ if (session !== undefined) {
45
+ try {
46
+ await session.send("Page.enable");
47
+ await session.send("Page.setInterceptFileChooserDialog", { enabled: true });
48
+ } catch (cause) {
49
+ await session.detach();
50
+ throw cause;
51
+ }
52
+ }
53
+
54
+ const select = async (element: Element): Promise<void> => {
55
+ if (session === undefined)
56
+ return element.evaluate(setFileSelection, encoded, request.fileName, request.mediaType);
57
+ let onOpened: (event: { backendNodeId?: number }) => void = () => {};
58
+ let onAbort: () => void = () => {};
59
+ let timer: ReturnType<typeof setTimeout> | undefined;
60
+
61
+ const opened = new Promise<number>((resolve, reject) => {
62
+ onOpened = (event) =>
63
+ event.backendNodeId === undefined
64
+ ? reject(new Error("The chooser has no file input"))
65
+ : resolve(event.backendNodeId);
66
+ onAbort = () => reject(new Error("File selection interrupted"));
67
+ session.on("Page.fileChooserOpened", onOpened);
68
+ signal.addEventListener("abort", onAbort, { once: true });
69
+ timer = setTimeout(() => reject(new Error("No file chooser opened")), 5_000);
70
+ if (signal.aborted) onAbort();
71
+ });
72
+
73
+ try {
74
+ const [backendNodeId] = await Promise.all([opened, element.click()]);
75
+
76
+ if (signal.aborted) throw new Error("File selection interrupted");
77
+ const resolved = await session.send("DOM.resolveNode", { backendNodeId });
78
+
79
+ const objectId = resolved.object.objectId;
80
+
81
+ if (objectId === undefined) throw new Error("The chooser has no file input");
82
+ if (signal.aborted) throw new Error("File selection interrupted");
83
+
84
+ const result = await session.send("Runtime.callFunctionOn", {
85
+ objectId,
86
+ functionDeclaration: `function(encoded, name, type) { (${setFileSelection.toString()})(this, encoded, name, type); }`,
87
+ arguments: [
88
+ { value: encoded },
89
+ { value: request.fileName },
90
+ { value: request.mediaType },
91
+ ],
92
+ returnByValue: true,
93
+ });
94
+
95
+ if (result.exceptionDetails !== undefined)
96
+ throw new Error("The chooser did not select the file");
97
+ } finally {
98
+ clearTimeout(timer);
99
+ session.off("Page.fileChooserOpened", onOpened);
100
+ signal.removeEventListener("abort", onAbort);
101
+ // Detaching the owned session releases all remote object handles, including
102
+ // after timeout, interruption, or a navigation during change handlers.
103
+ }
104
+ };
105
+
106
+ return {
107
+ validate: async (element: Element): Promise<boolean> =>
108
+ request.target === "chooser" ||
109
+ (await element.evaluate(
110
+ (input) =>
111
+ Reflect.get(input, "type") === "file" &&
112
+ !Reflect.get(input, "disabled") &&
113
+ !Reflect.get(input, "webkitdirectory"),
114
+ )),
115
+ select,
116
+ close: async (): Promise<void> => {
117
+ if (session !== undefined) {
118
+ try {
119
+ await session.send("Page.setInterceptFileChooserDialog", { enabled: false });
120
+ } finally {
121
+ await session.detach();
122
+ }
123
+ }
124
+ },
125
+ };
126
+ };