@effect-agent/platform-cloudflare 0.1.0-beta.121 → 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 (35) 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-DhOkdUx9.mjs → InteractiveBrowser-DeOlcu-2.mjs} +190 -50
  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/InteractiveBrowser.ts +1 -1
  23. package/src/index.ts +2 -0
  24. package/src/internal/browser-binding.ts +245 -57
  25. package/src/internal/browser-credentials.ts +362 -0
  26. package/dist/InteractiveBrowser-DhOkdUx9.mjs.map +0 -1
  27. package/dist/ProtectedBrowser.d.mts +0 -128
  28. package/dist/ProtectedBrowser.mjs +0 -1201
  29. package/dist/ProtectedBrowser.mjs.map +0 -1
  30. package/src/ProtectedBrowser.ts +0 -9
  31. package/src/protected-browser/binding.ts +0 -366
  32. package/src/protected-browser/host.ts +0 -382
  33. package/src/protected-browser/inspect-frame.ts +0 -140
  34. package/src/protected-browser/native.ts +0 -483
  35. package/src/protected-browser/policy.ts +0 -872
@@ -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
+ });