@effect-agent/platform-cloudflare 0.1.0-beta.42 → 0.1.0-beta.45

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 (47) hide show
  1. package/dist/browser-quick-action.mjs.map +1 -1
  2. package/dist/browser-rest-capture.mjs.map +1 -1
  3. package/dist/browser-rest-crawl.mjs.map +1 -1
  4. package/dist/browser-session-lifecycle-DqntvG-Y.d.mts +21 -0
  5. package/dist/browser-session-lifecycle-ZGgb3pnK.mjs +84 -0
  6. package/dist/browser-session-lifecycle-ZGgb3pnK.mjs.map +1 -0
  7. package/dist/index.d.mts +47 -34
  8. package/dist/index.mjs +3 -3
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/interactive-browser.d.mts +1 -18
  11. package/dist/interactive-browser.mjs +2 -81
  12. package/dist/interactive-browser.mjs.map +1 -1
  13. package/dist/{prepared-admission-BKp_Upw2.mjs → prepared-admission-BhW2eT_a.mjs} +3 -3
  14. package/dist/prepared-admission-BhW2eT_a.mjs.map +1 -0
  15. package/dist/protected-browser.d.mts +62 -0
  16. package/dist/protected-browser.mjs +714 -0
  17. package/dist/protected-browser.mjs.map +1 -0
  18. package/dist/scheduling.mjs +1 -1
  19. package/dist/scheduling.mjs.map +1 -1
  20. package/dist/subscriptions.mjs +1 -1
  21. package/dist/subscriptions.mjs.map +1 -1
  22. package/package.json +1 -81
  23. package/src/alarm.ts +277 -242
  24. package/src/bindings.ts +5 -0
  25. package/src/boundary.ts +4 -0
  26. package/src/browser-quick-action.ts +52 -0
  27. package/src/browser-rest-capture.ts +30 -0
  28. package/src/browser-rest-crawl.ts +43 -0
  29. package/src/browser-session-lifecycle.ts +18 -0
  30. package/src/client.ts +40 -0
  31. package/src/code-mode-executor.ts +50 -0
  32. package/src/interactive-browser.ts +176 -0
  33. package/src/layers.ts +13 -3
  34. package/src/memory.ts +42 -3
  35. package/src/prepared-admission.ts +1 -0
  36. package/src/progress-wait.ts +14 -0
  37. package/src/protected-browser/binding.ts +185 -0
  38. package/src/protected-browser/inspect-frame.ts +82 -0
  39. package/src/protected-browser/native.ts +384 -0
  40. package/src/protected-browser/policy.ts +594 -0
  41. package/src/protected-browser.ts +2 -0
  42. package/src/scheduling.ts +34 -0
  43. package/src/subscriptions.ts +50 -0
  44. package/src/thread-object.ts +55 -1
  45. package/src/transport.ts +1 -0
  46. package/src/wake-scheduler.ts +1 -0
  47. package/dist/prepared-admission-BKp_Upw2.mjs.map +0 -1
package/src/layers.ts CHANGED
@@ -70,7 +70,7 @@ import { cloudflareWakeSchedulerLayer } from "./wake-scheduler.ts";
70
70
 
71
71
  /**
72
72
  * Raw (unvalidated) construction options for `ThreadObject.make`, mirroring
73
- * `NodeDurableRuntimeOptions`. Optional fields default to the documented production values
73
+ * `NodeDurableAgentRuntimeOptions`. Optional fields default to the documented production values
74
74
  * (`CLOUDFLARE_RUNTIME_DEFAULTS`); everything is schema-decoded into
75
75
  * `CloudflareDurableRuntimeConfigValue` before any resource opens (deployment §5 gate 1).
76
76
  */
@@ -256,6 +256,7 @@ export const layerConfig = (
256
256
  const { ctx } = yield* DurableObjectContext;
257
257
  const config = yield* configFromOptions(options);
258
258
  const threadId = yield* threadIdFromState(ctx);
259
+
259
260
  const producerId = yield* decodeProducerId(`${config.producerPrefix}:${threadId}`).pipe(
260
261
  Effect.mapError((error) =>
261
262
  CloudflarePlatformConfigError.make({
@@ -264,6 +265,7 @@ export const layerConfig = (
264
265
  }),
265
266
  ),
266
267
  );
268
+
267
269
  return Layer.mergeAll(
268
270
  Layer.succeed(CloudflareDurableRuntimeConfig, config),
269
271
  Layer.succeed(ThreadObjectIdentity, { threadId, producerId }),
@@ -328,6 +330,7 @@ export const layerFromBindings = (
328
330
  const { ctx } = yield* DurableObjectContext;
329
331
  const config = yield* CloudflareDurableRuntimeConfig;
330
332
  const { threadId } = yield* ThreadObjectIdentity;
333
+
331
334
  const storageOptions: DoStorageOptions = {
332
335
  storage: ctx.storage,
333
336
  observationPollInterval: config.observationPollInterval,
@@ -335,6 +338,7 @@ export const layerFromBindings = (
335
338
  maxStoredValueBytes: config.maxStoredValueBytes,
336
339
  verifyOnOpen: config.verifyOnOpen,
337
340
  };
341
+
338
342
  const infrastructure = Layer.mergeAll(
339
343
  storageConfigLayer(storageOptions),
340
344
  SqliteClient.layer({ storage: ctx.storage }),
@@ -345,27 +349,33 @@ export const layerFromBindings = (
345
349
  const localPorts = Layer.mergeAll(threadStoreLayer, submissionLedgerLayer).pipe(
346
350
  Layer.provide(infrastructure),
347
351
  );
352
+
348
353
  const portsEndpointLayer = Layer.effect(ThreadObjectPorts)(
349
354
  Effect.gen(function* () {
350
355
  const local = yield* Effect.context<SubmissionLedger | ThreadStore>();
356
+
351
357
  return ThreadObjectPorts.of({
352
358
  handle: (request) => executePortRequest(request).pipe(Effect.provide(local)),
353
359
  });
354
360
  }),
355
361
  ).pipe(Layer.provide(localPorts));
362
+
356
363
  const routedPorts = Layer.mergeAll(
357
364
  routedSubmissionLedgerLayer({ localThreadId: threadId }),
358
365
  routedThreadStoreLayer({ localThreadId: threadId }),
359
366
  ).pipe(Layer.provide(localPorts), Layer.provide(threadPortTransportLayer));
367
+
360
368
  const base = Layer.mergeAll(DurableAlarmService.layer, ProgressWaitRegistry.layer);
361
- const runtimeStack = DurableAgentRuntime.layerWithServices.pipe(
369
+
370
+ const runtimeStack = DurableAgentRuntime.layerWithBindings(bindings).pipe(
362
371
  Layer.provideMerge(routedPorts),
363
372
  Layer.provideMerge(cloudflareWakeSchedulerLayer),
364
373
  Layer.provideMerge(base),
365
374
  );
375
+
366
376
  return Layer.mergeAll(
367
377
  runtimeStack,
368
- ThreadMaintenance.layer(bindings).pipe(Layer.provide(runtimeStack)),
378
+ ThreadMaintenance.layer.pipe(Layer.provide(runtimeStack)),
369
379
  portsEndpointLayer,
370
380
  );
371
381
  }),
package/src/memory.ts CHANGED
@@ -69,32 +69,41 @@ const makeMemoryClient = Effect.fn("CloudflareMemoryClient.make")(function* <
69
69
  const validated = yield* Schema.decodeUnknownEffect(MemoryRpcLimits)(rpcLimits).pipe(
70
70
  Effect.mapError(() => MemoryRpcError.make({ reason: "protocol" })),
71
71
  );
72
+
72
73
  const bound = yield* Schema.decodeUnknownEffect(MemoryAccess.Wire)(access).pipe(
73
74
  Effect.mapError(() => MemoryRpcError.make({ reason: "protocol" })),
74
75
  );
76
+
75
77
  principal = yield* Schema.decodeUnknownEffect(Principal)(principal).pipe(
76
78
  Effect.mapError(() => MemoryRpcError.make({ reason: "protocol" })),
77
79
  );
78
80
  const { namespace } = yield* MemoryObjectNamespace;
81
+
79
82
  const call = Effect.fn("CloudflareMemoryClient.call")(function* (request: MemoryOwnerRequest) {
80
83
  const decoded = yield* Schema.decodeUnknownEffect(MemoryOwnerRequest)(request).pipe(
81
84
  Effect.mapError(() => MemoryRpcError.make({ reason: "protocol" })),
82
85
  );
86
+
83
87
  const encoded = yield* encodeMemoryWire(MemoryOwnerRequest, decoded, validated.maxRequestBytes);
88
+
84
89
  const raw = yield* Effect.tryPromise({
85
90
  try: () =>
86
91
  namespace.get(namespace.idFromName(memoryObjectName(bound.namespace))).memory(encoded),
87
92
  catch: () => MemoryRpcError.make({ reason: "unavailable" }),
88
93
  });
94
+
89
95
  const response = yield* decodeMemoryWire(MemoryOwnerResponse, raw, validated.maxResponseBytes);
96
+
90
97
  if (response._tag === "Failed") return yield* response.failure;
91
98
  if (
92
99
  !MemoryNamespace.equals(response.access.namespace, bound.namespace) ||
93
100
  response.access.scope !== bound.scope
94
101
  )
95
102
  return yield* MemoryRpcError.make({ reason: "protocol" });
103
+
96
104
  return response;
97
105
  });
106
+
98
107
  const withinDeadline = <A, E, R>(effect: Effect.Effect<A, E, R>, timeoutMillis: number) =>
99
108
  effect.pipe(
100
109
  Effect.timeoutOrElse({
@@ -102,6 +111,7 @@ const makeMemoryClient = Effect.fn("CloudflareMemoryClient.make")(function* <
102
111
  orElse: () => Effect.fail(MemoryRpcError.make({ reason: "timeout" })),
103
112
  }),
104
113
  );
114
+
105
115
  const revalidate = Effect.fn("CloudflareMemoryClient.revalidate")(function* (
106
116
  lookup: MemoryLookup,
107
117
  limits: MemoryRecallLimits,
@@ -110,6 +120,7 @@ const makeMemoryClient = Effect.fn("CloudflareMemoryClient.make")(function* <
110
120
  Effect.mapError(() => MemoryRpcError.make({ reason: "protocol" })),
111
121
  );
112
122
  const timeoutMillis = Math.min(validated.timeoutMillis, limits.timeoutMillis);
123
+
113
124
  return yield* Effect.gen(function* () {
114
125
  const response = yield* call({
115
126
  _tag: "Revalidate",
@@ -120,15 +131,19 @@ const makeMemoryClient = Effect.fn("CloudflareMemoryClient.make")(function* <
120
131
  limits,
121
132
  deadlineMillis: (yield* Clock.currentTimeMillis) + timeoutMillis,
122
133
  });
134
+
123
135
  if (response._tag !== "Lookup") return yield* MemoryRpcError.make({ reason: "protocol" });
136
+
124
137
  return response.lookup;
125
138
  }).pipe((effect) => withinDeadline(effect, timeoutMillis));
126
139
  });
140
+
127
141
  const change = Effect.fn("CloudflareMemoryClient.change")(function* (
128
142
  write: MemoryWrite<Namespace>,
129
143
  ) {
130
144
  if (!MemoryNamespace.equals(write.key.namespace, bound.namespace))
131
145
  return yield* MemoryRpcError.make({ reason: "denied" });
146
+
132
147
  return yield* Effect.gen(function* () {
133
148
  const response = yield* call({
134
149
  _tag: "Change",
@@ -138,11 +153,14 @@ const makeMemoryClient = Effect.fn("CloudflareMemoryClient.make")(function* <
138
153
  write,
139
154
  deadlineMillis: (yield* Clock.currentTimeMillis) + validated.timeoutMillis,
140
155
  });
156
+
141
157
  if (response._tag !== "Changed" || response.document.key.id !== write.key.id)
142
158
  return yield* MemoryRpcError.make({ reason: "protocol" });
159
+
143
160
  return yield* MemoryDocument.restore(access.namespace, response.document);
144
161
  }).pipe((effect) => withinDeadline(effect, validated.timeoutMillis));
145
162
  });
163
+
146
164
  const revalidateSemantic = Effect.fn("CloudflareMemoryClient.revalidateSemantic")(function* (
147
165
  found: MemoryIndexSearch<Namespace>,
148
166
  profile: SemanticMemoryProfile,
@@ -159,10 +177,13 @@ const makeMemoryClient = Effect.fn("CloudflareMemoryClient.make")(function* <
159
177
  limits,
160
178
  deadlineMillis: (yield* Clock.currentTimeMillis) + validated.timeoutMillis,
161
179
  });
180
+
162
181
  if (response._tag !== "Semantic") return yield* MemoryRpcError.make({ reason: "protocol" });
182
+
163
183
  return response.result;
164
184
  }).pipe((effect) => withinDeadline(effect, validated.timeoutMillis));
165
185
  });
186
+
166
187
  /**
167
188
  * Revalidate in one owner RPC, then render whole passages within the caller's budget.
168
189
  * The bound source is essential: unavailable/stale results and matches that cannot fit
@@ -181,6 +202,7 @@ const makeMemoryClient = Effect.fn("CloudflareMemoryClient.make")(function* <
181
202
  estimateTokens,
182
203
  );
183
204
  });
205
+
184
206
  return { recall, revalidate, revalidateSemantic, change };
185
207
  });
186
208
 
@@ -218,6 +240,7 @@ export const cloudflareMemoryWriterLayer = (
218
240
  MemoryWriter,
219
241
  Effect.gen(function* () {
220
242
  const client = yield* CloudflareMemoryClient.make(access, principal, limits);
243
+
221
244
  return MemoryWriter.fromAdapter({
222
245
  change: (write) =>
223
246
  client.change(write).pipe(
@@ -243,11 +266,13 @@ export const cloudflareMemoryWriterLayer = (
243
266
  );
244
267
 
245
268
  type OwnerServices = MemoryReader | MemoryWriter | MemoryOwnerAuthorizer | MemoryOwnerIdentity;
269
+
246
270
  export interface MemoryObjectInstance extends InstanceType<
247
271
  EffectCfDurableObject.DurableObjectClass<Record<never, never>, OwnerServices>
248
272
  > {
249
273
  memory(encoded: string): Promise<string>;
250
274
  }
275
+
251
276
  export interface MemoryObjectClass {
252
277
  new (ctx: globalThis.DurableObjectState, env: Cloudflare.Env): MemoryObjectInstance;
253
278
  }
@@ -277,12 +302,15 @@ const makeMemoryObject = <E>(
277
302
  MemoryOwnerIdentity,
278
303
  Effect.gen(function* () {
279
304
  const state = yield* DurableObjectState.DurableObjectState;
305
+
280
306
  const address = yield* Schema.decodeUnknownEffect(MemoryNamespaceAddress)(
281
307
  state.raw.id.name,
282
308
  ).pipe(Effect.mapError(() => MemoryRpcError.make({ reason: "denied" })));
309
+
283
310
  return { namespace: MemoryNamespace.Any.make({ address }) };
284
311
  }),
285
312
  );
313
+
286
314
  const store = Layer.unwrap(
287
315
  Effect.map(DurableObjectState.DurableObjectState, (state) =>
288
316
  doMemoryStoreLayerWithFailpoints(
@@ -291,7 +319,9 @@ const makeMemoryObject = <E>(
291
319
  ),
292
320
  ),
293
321
  ).pipe(Layer.provide(options.failpoints ?? MemoryMutationFailpoint.layer));
322
+
294
323
  const application = Layer.merge(store, host).pipe(Layer.provideMerge(identity));
324
+
295
325
  const runtime: Layer.Layer<
296
326
  OwnerServices,
297
327
  E | MemoryOwnerFailure,
@@ -300,15 +330,24 @@ const makeMemoryObject = <E>(
300
330
  Effect.gen(function* () {
301
331
  const state = yield* DurableObjectState.DurableObjectState;
302
332
  const scope = yield* Effect.scope;
333
+
303
334
  yield* Schema.decodeUnknownEffect(MemoryRpcLimits)(
304
335
  options.rpcLimits ?? defaultMemoryRpcLimits,
305
336
  ).pipe(Effect.mapError(() => MemoryRpcError.make({ reason: "protocol" })));
337
+
306
338
  return yield* state.blockConcurrencyWhile(Layer.buildWithScope(application, scope));
307
339
  }),
308
340
  );
309
- return EffectCfDurableObject.make(runtime, {
310
- rpc: { memory: (encoded: string) => handleMemoryOwnerRequest(encoded, options.rpcLimits) },
311
- });
341
+
342
+ const rpc = { memory: (encoded: string) => handleMemoryOwnerRequest(encoded, options.rpcLimits) };
343
+
344
+ return EffectCfDurableObject.make<
345
+ OwnerServices,
346
+ E | MemoryOwnerFailure,
347
+ never,
348
+ never,
349
+ typeof rpc
350
+ >(runtime, { rpc });
312
351
  };
313
352
 
314
353
  export const MemoryObject = {
@@ -26,6 +26,7 @@ export const cloudflarePreparedInputAdmissionLayer: Layer.Layer<
26
26
  PreparedInputAdmission,
27
27
  Effect.gen(function* () {
28
28
  const client = yield* CloudflareThreadClient;
29
+
29
30
  return PreparedInputAdmission.of({
30
31
  submit: (envelope) =>
31
32
  client
@@ -31,17 +31,20 @@ export class ProgressWaitRegistry extends Context.Service<
31
31
  const remove = (waiterId: string, deferred: Deferred.Deferred<void>) =>
32
32
  Ref.update(registrations, (current) => {
33
33
  const existing = current.get(waiterId);
34
+
34
35
  if (existing === undefined || existing === "cancelled" || !existing.has(deferred)) {
35
36
  return current;
36
37
  }
37
38
  const next = new Map(current);
38
39
  const active = new Set(existing);
40
+
39
41
  active.delete(deferred);
40
42
  if (active.size === 0) {
41
43
  next.delete(waiterId);
42
44
  } else {
43
45
  next.set(waiterId, active);
44
46
  }
47
+
45
48
  return next;
46
49
  });
47
50
 
@@ -49,18 +52,24 @@ export class ProgressWaitRegistry extends Context.Service<
49
52
  (waiterId: string): Effect.Effect<Effect.Effect<void>, never, Scope.Scope> =>
50
53
  Effect.gen(function* () {
51
54
  const deferred = yield* Deferred.make<void>();
55
+
52
56
  yield* Effect.addFinalizer(() => remove(waiterId, deferred));
57
+
53
58
  const cancelled = yield* Ref.modify(registrations, (current) => {
54
59
  const existing = current.get(waiterId);
55
60
  const next = new Map(current);
61
+
56
62
  if (existing === "cancelled") {
57
63
  return [true, current] as const;
58
64
  }
59
65
  const active = new Set(existing ?? []);
66
+
60
67
  active.add(deferred);
61
68
  next.set(waiterId, active);
69
+
62
70
  return [false, next] as const;
63
71
  });
72
+
64
73
  return { cancelled, deferred };
65
74
  }).pipe(
66
75
  Effect.map(({ cancelled, deferred }) =>
@@ -73,9 +82,11 @@ export class ProgressWaitRegistry extends Context.Service<
73
82
  const waiters = yield* Ref.modify(registrations, (current) => {
74
83
  const existing = current.get(waiterId);
75
84
  const next = new Map(current);
85
+
76
86
  if (existing === undefined) {
77
87
  next.set(waiterId, "cancelled");
78
88
  let tombstones = 0;
89
+
79
90
  for (const registration of next.values()) {
80
91
  if (registration === "cancelled") tombstones += 1;
81
92
  }
@@ -86,12 +97,15 @@ export class ProgressWaitRegistry extends Context.Service<
86
97
  break;
87
98
  }
88
99
  }
100
+
89
101
  return [[], next] as const;
90
102
  }
91
103
  if (existing === "cancelled") return [[], current] as const;
92
104
  next.delete(waiterId);
105
+
93
106
  return [[...existing], next] as const;
94
107
  });
108
+
95
109
  yield* Effect.forEach(waiters, (waiter) => Deferred.succeed(waiter, undefined), {
96
110
  discard: true,
97
111
  });
@@ -0,0 +1,185 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import puppeteer, { type Browser } from "@cloudflare/puppeteer";
3
+ import { ProtectedBrowserError, type InteractiveBrowserPolicy } from "@effect-agent/sandbox";
4
+ import { Clock, Context, Crypto, Effect, Layer, Redacted, Schema, type Scope } from "effect";
5
+
6
+ import { BrowserRunSessionLifecycle } from "../browser-session-lifecycle.ts";
7
+ import { makeProtectedNativeTransport, ProtectedNativeSession } from "./native.ts";
8
+ import {
9
+ BrowserRunProtectedTransport,
10
+ ProtectedTransportError,
11
+ type ProtectedBrowserTransport,
12
+ } from "./policy.ts";
13
+
14
+ /**
15
+ * Acquires through BROWSER with recording=false explicitly on the wire. The trusted provider's
16
+ * documented opt-in semantics are the recording guarantee; no recording-status API exists.
17
+ * Account operators remain trusted, and must not attach external observers to private passes.
18
+ */
19
+ export const browserRunProtectedBindingLayer = (options: {
20
+ readonly browser: Pick<BrowserRun, "fetch">;
21
+ }) =>
22
+ Layer.effect(BrowserRunProtectedTransport)(
23
+ Effect.gen(function* () {
24
+ const lifecycle = yield* BrowserRunSessionLifecycle;
25
+ const crypto = yield* Crypto.Crypto;
26
+
27
+ const open = Effect.fn("BrowserRunProtectedTransport.open")(function* (
28
+ policy: InteractiveBrowserPolicy,
29
+ ): Effect.fn.Return<ProtectedBrowserTransport, ProtectedBrowserError, Scope.Scope> {
30
+ const failure = () =>
31
+ new ProtectedBrowserError({
32
+ reason: "provider",
33
+ dispatch: "not-dispatched",
34
+ milestone: "none",
35
+ observation: "closed",
36
+ cleanup: "unconfirmed",
37
+ });
38
+
39
+ let sessionId: Redacted.Redacted<string> | undefined;
40
+ let browser: Browser | undefined;
41
+ let driver: ProtectedBrowserTransport | undefined;
42
+ let invalid = false;
43
+ // SDK acquisition may finish after interruption. Its late-reply callback must await cleanup,
44
+ // using this pass's clock rather than starting an Effect runtime with default services.
45
+ const runCleanup = Effect.runPromiseWith(Context.make(Clock.Clock, yield* Clock.Clock));
46
+
47
+ const terminate = Effect.gen(function* () {
48
+ invalid = true;
49
+ driver?.invalidate();
50
+ if (sessionId === undefined) return "unconfirmed" as const;
51
+
52
+ const cleanup = yield* lifecycle.close(sessionId).pipe(
53
+ Effect.as("confirmed" as const),
54
+ Effect.catchCause(() => Effect.succeed("unconfirmed" as const)),
55
+ Effect.interruptible,
56
+ Effect.timeoutOrElse({
57
+ duration: "10 seconds",
58
+ orElse: () => Effect.succeed("unconfirmed" as const),
59
+ }),
60
+ );
61
+
62
+ // Local disconnect is not remote-closure evidence. Do it even when confirmation fails.
63
+ const connected = browser;
64
+
65
+ if (connected !== undefined)
66
+ yield* Effect.promise(() => connected.disconnect()).pipe(
67
+ Effect.catchCause(() => Effect.void),
68
+ Effect.interruptible,
69
+ Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.void }),
70
+ );
71
+
72
+ return cleanup;
73
+ });
74
+
75
+ const close = yield* Effect.cached(terminate);
76
+
77
+ yield* Effect.addFinalizer(() =>
78
+ close.pipe(
79
+ Effect.flatMap((state) =>
80
+ state === "confirmed"
81
+ ? Effect.void
82
+ : Effect.logWarning("Protected browser exact-session closure unconfirmed"),
83
+ ),
84
+ ),
85
+ );
86
+
87
+ const acquired = yield* Effect.tryPromise({
88
+ try: async (signal) => {
89
+ let recordingDisabled = false;
90
+
91
+ const binding = {
92
+ fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
93
+ const request = new Request(input, init);
94
+ const url = new URL(request.url);
95
+
96
+ if (url.pathname === "/v1/devtools/browser" && request.method === "POST") {
97
+ url.searchParams.set("recording", "false");
98
+ recordingDisabled = url.searchParams.get("recording") === "false";
99
+
100
+ return options.browser.fetch(new Request(url, request));
101
+ }
102
+
103
+ return options.browser.fetch(request);
104
+ },
105
+ };
106
+
107
+ const acquired = await puppeteer.acquire(binding, {
108
+ recording: false,
109
+ keep_alive: Math.max(10_000, policy.maxElapsedMillis),
110
+ });
111
+
112
+ sessionId = Redacted.make(
113
+ Schema.decodeUnknownSync(Schema.String.check(Schema.isUUID()))(acquired.sessionId),
114
+ );
115
+ if (!recordingDisabled || signal.aborted || invalid) {
116
+ await runCleanup(terminate);
117
+ throw new ProtectedTransportError({ reason: "stale-reference" });
118
+ }
119
+ // Initial attachment only. No recovery/reconnection path exists.
120
+ browser = await puppeteer.connect(options.browser, acquired.sessionId);
121
+ if (signal.aborted || invalid) {
122
+ await runCleanup(terminate);
123
+ throw new ProtectedTransportError({ reason: "stale-reference" });
124
+ }
125
+ const context = await browser.createBrowserContext();
126
+ const page = await context.newPage();
127
+
128
+ await page.setBypassServiceWorker(true);
129
+ await page.setRequestInterception(true);
130
+ page.on("request", (request) => {
131
+ let allowed = policy.network._tag === "Unrestricted";
132
+
133
+ try {
134
+ const url = new URL(request.url());
135
+
136
+ allowed ||=
137
+ policy.network._tag === "ExactHosts" &&
138
+ url.protocol === "https:" &&
139
+ !url.username &&
140
+ !url.password &&
141
+ policy.network.allowedHosts.includes(url.host);
142
+ } catch {
143
+ /* Refuse malformed destinations. */
144
+ }
145
+ void (
146
+ allowed && !invalid ? request.continue() : request.abort("blockedbyclient")
147
+ ).catch(() => {
148
+ invalid = true;
149
+ driver?.invalidate();
150
+ });
151
+ });
152
+ if (signal.aborted || invalid) {
153
+ await runCleanup(terminate);
154
+ throw new ProtectedTransportError({ reason: "stale-reference" });
155
+ }
156
+
157
+ return ProtectedNativeSession.of({ browser, page, close });
158
+ },
159
+ catch: failure,
160
+ }).pipe(
161
+ Effect.timeoutOrElse({
162
+ duration: Math.min(policy.maxElapsedMillis, 30_000),
163
+ orElse: () =>
164
+ Effect.fail(new ProtectedBrowserError({ ...failure(), reason: "timeout" })),
165
+ }),
166
+ Effect.catch((error) =>
167
+ close.pipe(
168
+ Effect.flatMap((cleanup) =>
169
+ Effect.fail(new ProtectedBrowserError({ ...error, cleanup })),
170
+ ),
171
+ ),
172
+ ),
173
+ );
174
+
175
+ driver = yield* makeProtectedNativeTransport(policy).pipe(
176
+ Effect.provideService(ProtectedNativeSession, acquired),
177
+ Effect.provideService(Crypto.Crypto, crypto),
178
+ );
179
+
180
+ return driver;
181
+ }, Effect.withTracerEnabled(false));
182
+
183
+ return { open };
184
+ }),
185
+ );
@@ -0,0 +1,82 @@
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 elements = [...doc.querySelectorAll('input,select,button,a[href]')].slice(0, 65);
9
+ const describe = (el) => {
10
+ if (doc !== document || !el.isConnected || el.ownerDocument !== doc) return null;
11
+ const form = el.form ?? null;
12
+ let formIndex = forms.indexOf(form);
13
+ if (formIndex < 0) { formIndex = forms.length; forms.push(form); }
14
+ const action = form ? (el.hasAttribute('formaction') ? el.formAction : form.action || doc.URL) : doc.URL;
15
+ const method = form ? (el.hasAttribute('formmethod') ? el.formMethod : form.method) : '';
16
+ const enctype = form?.enctype ?? '';
17
+ const name = el.name ?? '';
18
+ const completion = el.getAttribute('autocomplete') ?? '';
19
+ const inputType = el.type ?? '';
20
+ // Reject before parsing, fingerprinting or CDP transfer. Truncation could hide a target change.
21
+ if ([action, method, enctype, name, completion, inputType].some(value => value.length > ${maxAttributeLength})) return null;
22
+ const type = inputType.toLowerCase();
23
+ const autocomplete = completion.trim().toLowerCase().split(/\\s+/).at(-1);
24
+ const cardRoles = { 'cc-name':'card-name', 'cc-number':'card-number', 'cc-exp':'card-expiry',
25
+ 'cc-exp-month':'card-expiry-month', 'cc-exp-year':'card-expiry-year', 'cc-csc':'card-security-code' };
26
+ let role = 'unsupported';
27
+ const nativeField = el instanceof HTMLInputElement || el instanceof HTMLSelectElement;
28
+ if (nativeField && form && !['submit','button'].includes(type) && !el.disabled && !el.readOnly && el.getClientRects().length > 0) {
29
+ if (el instanceof HTMLInputElement && type === 'password') role = 'password';
30
+ else if (['text','email','tel','number','month',''].includes(type) || el instanceof HTMLSelectElement) {
31
+ if (cardRoles[autocomplete]) role = cardRoles[autocomplete];
32
+ else if (autocomplete === 'username' || autocomplete === 'email') role = 'username';
33
+ else if (['text','email'].includes(type) && form && form.querySelector('input[type="password"]')) role = 'username';
34
+ }
35
+ } else if (el instanceof HTMLButtonElement || (el instanceof HTMLInputElement && ['submit','button'].includes(type))) {
36
+ if (!el.disabled) role = type === 'submit' && form ? 'submit' : type === 'button' ? 'button' : 'unsupported';
37
+ } else if (el instanceof HTMLAnchorElement) role = 'link';
38
+ const fingerprint = JSON.stringify([role, action, method, enctype, name, completion, type]);
39
+ return { role, formIndex, action, fingerprint,
40
+ label: (el.labels?.[0]?.textContent ?? el.getAttribute('aria-label') ?? el.textContent ?? '').slice(0,200) };
41
+ };
42
+ const expose = ({role, formIndex, action, label}) => ({role, formIndex, action, label});
43
+ const original = elements.map(describe);
44
+ const validate = (index) => {
45
+ const current = describe(elements[index]);
46
+ if (!current || !original[index] || current.fingerprint !== original[index].fingerprint ||
47
+ current.formIndex !== original[index].formIndex) return null;
48
+ return expose(current);
49
+ };
50
+ return {
51
+ doc, elements, original: original.map(current => current && expose(current)), validate,
52
+ text: () => {
53
+ if (doc !== document) return null;
54
+ const clone = doc.body?.cloneNode(true);
55
+ clone?.querySelectorAll('input,textarea,select,script,style,noscript,iframe,object,embed').forEach(el => el.remove());
56
+ return (clone?.textContent ?? '').slice(0,65536);
57
+ },
58
+ fill: (index, role, value) => {
59
+ const current = validate(index);
60
+ if (!current || current.role !== role) return false;
61
+ const el = elements[index];
62
+ let prototype = Object.getPrototypeOf(el);
63
+ let setter;
64
+ while (prototype && !setter) { setter = Object.getOwnPropertyDescriptor(prototype,'value')?.set; prototype = Object.getPrototypeOf(prototype); }
65
+ if (!setter) return false;
66
+ // Validate and mutate in one isolated-world task, without focusing first and running
67
+ // page handlers between validation and assignment. Native setters support controlled inputs.
68
+ setter.call(el, value);
69
+ if (el.value !== value) return 'unsupported';
70
+ el.dispatchEvent(new Event('input', {bubbles:true}));
71
+ el.dispatchEvent(new Event('change', {bubbles:true}));
72
+ return true;
73
+ },
74
+ click: (index) => {
75
+ const current = validate(index);
76
+ if (!current || !['button','submit','link'].includes(current.role)) return false;
77
+ if (current.role === 'submit' && elements[index].form && !elements[index].form.matches(':valid')) return 'needs-attention';
78
+ elements[index].click();
79
+ return true;
80
+ }
81
+ };
82
+ })()`;