@effect-agent/platform-cloudflare 0.1.0-beta.106 → 0.1.0-beta.108

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.
@@ -1,9 +1,16 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
- import puppeteer, { type Browser, type Page, type CDPSession } from "@cloudflare/puppeteer";
3
- import { Clock, Context, Crypto, Effect, Layer, Redacted, Schema, type Scope } from "effect";
2
+ import { type Browser, type Page, type CDPSession } from "@cloudflare/puppeteer";
3
+ import { Cause, Context, Crypto, Effect, Layer, Redacted, Schema, type Scope } from "effect";
4
4
  import { type InteractiveBrowserPolicy } from "effect-agent/interactive-browser";
5
5
  import { ProtectedBrowserError } from "effect-agent/protected-browser";
6
6
 
7
+ import { BrowserRunBinding } from "../internal/browser-binding.ts";
8
+ import {
9
+ browserFailure,
10
+ BrowserRunFailure,
11
+ reportBrowserCause,
12
+ reportedBrowserError,
13
+ } from "../internal/browser-failure.ts";
7
14
  import { BrowserRunSessionLifecycle } from "../internal/browser-session-lifecycle.ts";
8
15
  import { makeProtectedNativeTransport, ProtectedNativeSession } from "./native.ts";
9
16
  import {
@@ -50,6 +57,7 @@ const protectedBindingLayer = (options: { readonly browser: Pick<BrowserRun, "fe
50
57
  Layer.effect(BrowserRunProtectedBinding)(
51
58
  Effect.gen(function* () {
52
59
  const lifecycle = yield* BrowserRunSessionLifecycle;
60
+ const binding = yield* BrowserRunBinding;
53
61
  const crypto = yield* Crypto.Crypto;
54
62
 
55
63
  const open = Effect.fn("BrowserRunProtectedTransport.open")(function* (
@@ -73,8 +81,8 @@ const protectedBindingLayer = (options: { readonly browser: Pick<BrowserRun, "fe
73
81
  let driver: ProtectedBrowserTransport | undefined;
74
82
  let invalid = false;
75
83
  // SDK acquisition may finish after interruption. Its late-reply callback must await cleanup,
76
- // using this pass's clock rather than starting an Effect runtime with default services.
77
- const runCleanup = Effect.runPromiseWith(Context.make(Clock.Clock, yield* Clock.Clock));
84
+ // retaining this pass's clock and private reporting scope.
85
+ const runCleanup = Effect.runPromiseWith(yield* Effect.context<never>());
78
86
 
79
87
  const terminate = Effect.gen(function* () {
80
88
  invalid = true;
@@ -83,11 +91,19 @@ const protectedBindingLayer = (options: { readonly browser: Pick<BrowserRun, "fe
83
91
 
84
92
  const cleanup = yield* lifecycle.close(sessionId).pipe(
85
93
  Effect.as("confirmed" as const),
86
- Effect.catchCause(() => Effect.succeed("unconfirmed" as const)),
94
+ Effect.catchCause((cause) =>
95
+ reportBrowserCause("protected.close", cause).pipe(Effect.as("unconfirmed" as const)),
96
+ ),
87
97
  Effect.interruptible,
88
98
  Effect.timeoutOrElse({
89
99
  duration: "10 seconds",
90
- orElse: () => Effect.succeed("unconfirmed" as const),
100
+ orElse: () =>
101
+ reportBrowserCause(
102
+ "protected.close",
103
+ Cause.fail(
104
+ new BrowserRunFailure({ operation: "protected.close", reason: "timeout" }),
105
+ ),
106
+ ).pipe(Effect.as("unconfirmed" as const)),
91
107
  }),
92
108
  );
93
109
 
@@ -96,9 +112,21 @@ const protectedBindingLayer = (options: { readonly browser: Pick<BrowserRun, "fe
96
112
 
97
113
  if (connected !== undefined)
98
114
  yield* Effect.promise(() => connected.disconnect()).pipe(
99
- Effect.catchCause(() => Effect.void),
115
+ Effect.catchCause((cause) => reportBrowserCause("protected.disconnect", cause)),
100
116
  Effect.interruptible,
101
- Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.void }),
117
+ Effect.timeoutOrElse({
118
+ duration: "1 second",
119
+ orElse: () =>
120
+ reportBrowserCause(
121
+ "protected.disconnect",
122
+ Cause.fail(
123
+ new BrowserRunFailure({
124
+ operation: "protected.disconnect",
125
+ reason: "timeout",
126
+ }),
127
+ ),
128
+ ),
129
+ }),
102
130
  );
103
131
 
104
132
  return cleanup;
@@ -106,144 +134,138 @@ const protectedBindingLayer = (options: { readonly browser: Pick<BrowserRun, "fe
106
134
 
107
135
  const close = yield* Effect.cached(terminate);
108
136
 
109
- yield* Effect.addFinalizer(() =>
110
- detached
111
- ? Effect.void
112
- : close.pipe(
113
- Effect.flatMap((state) =>
114
- state === "confirmed"
115
- ? Effect.void
116
- : Effect.logWarning("Protected browser exact-session closure unconfirmed"),
117
- ),
118
- ),
119
- );
137
+ yield* Effect.addFinalizer(() => (detached ? Effect.void : close));
138
+ let stage = identity === undefined ? "protected.acquire" : "protected.connect";
120
139
 
121
140
  const acquired = yield* Effect.tryPromise({
122
141
  try: async (signal) => {
123
- let recordingDisabled = false;
124
-
125
- const binding = {
126
- fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
127
- const request = new Request(input, init);
128
- const url = new URL(request.url);
129
-
130
- if (url.pathname === "/v1/devtools/browser" && request.method === "POST") {
131
- url.searchParams.set("recording", "false");
132
- recordingDisabled = url.searchParams.get("recording") === "false";
142
+ try {
143
+ sessionId =
144
+ identity === undefined
145
+ ? Redacted.make(
146
+ await binding.acquire(
147
+ Math.min(600_000, Math.max(10_000, policy.maxElapsedMillis)),
148
+ "protected.acquire",
149
+ ),
150
+ )
151
+ : identity.sessionId;
152
+ if (signal.aborted || invalid) {
153
+ await runCleanup(terminate);
154
+ throw new ProtectedTransportError({ reason: "stale-reference" });
155
+ }
156
+ // Resume only a host-persisted exact page. Never open a replacement page or context.
157
+ stage = "protected.connect";
158
+ browser = await binding.connect(Redacted.value(sessionId), stage);
159
+ if (signal.aborted || invalid) {
160
+ await runCleanup(terminate);
161
+ throw new ProtectedTransportError({ reason: "stale-reference" });
162
+ }
163
+ let page: Page;
133
164
 
134
- return options.browser.fetch(new Request(url, request));
135
- }
165
+ if (identity === undefined) {
166
+ stage = "protected.context";
167
+ const context = await browser.createBrowserContext();
136
168
 
137
- return options.browser.fetch(request);
138
- },
139
- };
140
-
141
- const acquired =
142
- identity === undefined
143
- ? await puppeteer.acquire(binding, {
144
- recording: false,
145
- // Provider inactivity timeout, independent of the finite total pass deadline.
146
- keep_alive: Math.min(600_000, Math.max(10_000, policy.maxElapsedMillis)),
147
- })
148
- : { sessionId: Redacted.value(identity.sessionId) };
149
-
150
- sessionId = Redacted.make(
151
- Schema.decodeSync(Schema.String.check(Schema.isUUID()))(acquired.sessionId),
152
- );
153
- if ((identity === undefined && !recordingDisabled) || signal.aborted || invalid) {
154
- await runCleanup(terminate);
155
- throw new ProtectedTransportError({ reason: "stale-reference" });
156
- }
157
- // Resume only a host-persisted exact page. Never open a replacement page or context.
158
- browser = await puppeteer.connect(options.browser, acquired.sessionId);
159
- if (signal.aborted || invalid) {
160
- await runCleanup(terminate);
161
- throw new ProtectedTransportError({ reason: "stale-reference" });
162
- }
163
- let page: Page;
169
+ stage = "protected.page";
170
+ page = await context.newPage();
171
+ } else {
172
+ stage = "protected.resume";
164
173
 
165
- if (identity === undefined) {
166
- const context = await browser.createBrowserContext();
174
+ const context = browser
175
+ .browserContexts()
176
+ .find((candidate) => candidate.id === Redacted.value(identity.contextId));
167
177
 
168
- page = await context.newPage();
169
- } else {
170
- const context = browser
171
- .browserContexts()
172
- .find((candidate) => candidate.id === Redacted.value(identity.contextId));
178
+ if (context === undefined)
179
+ throw new ProtectedTransportError({ reason: "stale-reference" });
180
+ const pages = await context.pages();
181
+ let found: Page | undefined;
173
182
 
174
- if (context === undefined)
175
- throw new ProtectedTransportError({ reason: "stale-reference" });
176
- const pages = await context.pages();
177
- let found: Page | undefined;
183
+ for (const candidate of pages) {
184
+ const client = await candidate.createCDPSession();
185
+ const info = await client.send("Target.getTargetInfo");
178
186
 
179
- for (const candidate of pages) {
180
- const client = await candidate.createCDPSession();
181
- const info = await client.send("Target.getTargetInfo");
182
-
183
- await client.detach();
184
- if (info.targetInfo.targetId === Redacted.value(identity.targetId))
185
- found = candidate;
187
+ await client.detach();
188
+ if (info.targetInfo.targetId === Redacted.value(identity.targetId))
189
+ found = candidate;
190
+ }
191
+ if (found === undefined || pages.length !== 1)
192
+ throw new ProtectedTransportError({ reason: "stale-reference" });
193
+ page = found;
186
194
  }
187
- if (found === undefined || pages.length !== 1)
195
+ stage = "protected.control";
196
+ control = await page.createCDPSession();
197
+ const info = await control.send("Target.getTargetInfo");
198
+
199
+ stage = "protected.identity";
200
+ providerIdentity = Schema.decodeSync(ProtectedProviderIdentity)({
201
+ sessionId,
202
+ contextId: Redacted.make(page.browserContext().id ?? ""),
203
+ targetId: Redacted.make(info.targetInfo.targetId),
204
+ });
205
+
206
+ stage = "protected.interception";
207
+ await page.setBypassServiceWorker(true);
208
+ await page.setRequestInterception(true);
209
+ page.on("request", (request) => {
210
+ let allowed = policy.network._tag === "Unrestricted";
211
+
212
+ try {
213
+ const url = new URL(request.url());
214
+
215
+ allowed ||=
216
+ policy.network._tag === "ExactHosts" &&
217
+ url.protocol === "https:" &&
218
+ !url.username &&
219
+ !url.password &&
220
+ policy.network.allowedHosts.includes(url.host);
221
+ } catch {
222
+ /* Refuse malformed destinations. */
223
+ }
224
+ void (
225
+ allowed && !invalid ? request.continue() : request.abort("blockedbyclient")
226
+ ).catch(async (cause) => {
227
+ invalid = true;
228
+ driver?.invalidate();
229
+ await runCleanup(reportBrowserCause("protected.interception", Cause.fail(cause)));
230
+ });
231
+ });
232
+ if (signal.aborted || invalid) {
233
+ await runCleanup(terminate);
188
234
  throw new ProtectedTransportError({ reason: "stale-reference" });
189
- page = found;
190
- }
191
- control = await page.createCDPSession();
192
- const info = await control.send("Target.getTargetInfo");
193
-
194
- providerIdentity = Schema.decodeSync(ProtectedProviderIdentity)({
195
- sessionId,
196
- contextId: Redacted.make(page.browserContext().id ?? ""),
197
- targetId: Redacted.make(info.targetInfo.targetId),
198
- });
199
-
200
- await page.setBypassServiceWorker(true);
201
- await page.setRequestInterception(true);
202
- page.on("request", (request) => {
203
- let allowed = policy.network._tag === "Unrestricted";
204
-
205
- try {
206
- const url = new URL(request.url());
207
-
208
- allowed ||=
209
- policy.network._tag === "ExactHosts" &&
210
- url.protocol === "https:" &&
211
- !url.username &&
212
- !url.password &&
213
- policy.network.allowedHosts.includes(url.host);
214
- } catch {
215
- /* Refuse malformed destinations. */
216
235
  }
217
- void (
218
- allowed && !invalid ? request.continue() : request.abort("blockedbyclient")
219
- ).catch(() => {
220
- invalid = true;
221
- driver?.invalidate();
236
+
237
+ return ProtectedNativeSession.of({
238
+ browser,
239
+ page,
240
+ close,
241
+ release: Effect.suspend(() => (detached ? Effect.void : close.pipe(Effect.asVoid))),
222
242
  });
223
- });
224
- if (signal.aborted || invalid) {
225
- await runCleanup(terminate);
226
- throw new ProtectedTransportError({ reason: "stale-reference" });
243
+ } catch (cause) {
244
+ if (signal.aborted && !(cause instanceof ProtectedTransportError))
245
+ await runCleanup(reportBrowserCause(stage, Cause.fail(cause)));
246
+ throw cause;
227
247
  }
228
-
229
- return ProtectedNativeSession.of({
230
- browser,
231
- page,
232
- close,
233
- release: Effect.suspend(() => (detached ? Effect.void : close.pipe(Effect.asVoid))),
234
- });
235
248
  },
236
- catch: failure,
249
+ catch: (cause) => browserFailure(stage, cause),
237
250
  }).pipe(
238
251
  Effect.timeoutOrElse({
239
252
  duration: Math.min(policy.maxElapsedMillis, 30_000),
240
253
  orElse: () =>
241
- Effect.fail(new ProtectedBrowserError({ ...failure(), reason: "timeout" })),
254
+ Effect.fail(new BrowserRunFailure({ operation: stage, reason: "timeout" })),
242
255
  }),
243
256
  Effect.catch((error) =>
244
- close.pipe(
257
+ reportBrowserCause(stage, Cause.fail(error)).pipe(
258
+ Effect.andThen(close),
245
259
  Effect.flatMap((cleanup) =>
246
- Effect.fail(new ProtectedBrowserError({ ...error, cleanup })),
260
+ Effect.fail(
261
+ reportedBrowserError(
262
+ new ProtectedBrowserError({
263
+ ...failure(),
264
+ reason: error.reason === "timeout" ? "timeout" : "provider",
265
+ cleanup,
266
+ }),
267
+ ),
268
+ ),
247
269
  ),
248
270
  ),
249
271
  ),
@@ -271,12 +293,20 @@ const protectedBindingLayer = (options: { readonly browser: Pick<BrowserRun, "fe
271
293
 
272
294
  return await Reflect.apply(send, control, [method, parameters]);
273
295
  },
274
- catch: failure,
296
+ catch: (cause) => browserFailure("protected.command", cause),
275
297
  }).pipe(
276
298
  Effect.timeoutOrElse({
277
299
  duration: "10 seconds",
278
- orElse: () => Effect.fail(failure()),
300
+ orElse: () =>
301
+ Effect.fail(
302
+ new BrowserRunFailure({ operation: "protected.command", reason: "timeout" }),
303
+ ),
279
304
  }),
305
+ Effect.catch((error) =>
306
+ reportBrowserCause("protected.command", Cause.fail(error)).pipe(
307
+ Effect.andThen(Effect.fail(reportedBrowserError(failure()))),
308
+ ),
309
+ ),
280
310
  ),
281
311
  detach: Effect.tryPromise({
282
312
  try: async () => {
@@ -286,16 +316,27 @@ const protectedBindingLayer = (options: { readonly browser: Pick<BrowserRun, "fe
286
316
  detached = true;
287
317
  driver?.invalidate();
288
318
  },
289
- catch: failure,
319
+ catch: (cause) => browserFailure("protected.detach", cause),
290
320
  }).pipe(
291
- Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.fail(failure()) }),
321
+ Effect.timeoutOrElse({
322
+ duration: "10 seconds",
323
+ orElse: () =>
324
+ Effect.fail(
325
+ new BrowserRunFailure({ operation: "protected.detach", reason: "timeout" }),
326
+ ),
327
+ }),
328
+ Effect.catch((error) =>
329
+ reportBrowserCause("protected.detach", Cause.fail(error)).pipe(
330
+ Effect.andThen(Effect.fail(reportedBrowserError(failure()))),
331
+ ),
332
+ ),
292
333
  ),
293
334
  };
294
335
  }, Effect.withTracerEnabled(false));
295
336
 
296
337
  return { open };
297
338
  }),
298
- );
339
+ ).pipe(Layer.provide(BrowserRunBinding.layer(options.browser)));
299
340
 
300
341
  /** One binding implementation serves scoped tools and host-managed protected handoffs. */
301
342
  export const browserRunProtectedBindingLayer = (options: {
@@ -1,4 +1,5 @@
1
1
  import {
2
+ Cause,
2
3
  Clock,
3
4
  Context,
4
5
  Crypto,
@@ -25,6 +26,11 @@ import {
25
26
  BrowserRunLiveViewRequest,
26
27
  BrowserRunLiveViewResult,
27
28
  } from "../InteractiveBrowser.ts";
29
+ import {
30
+ inheritBrowserReport,
31
+ reportBrowserCause,
32
+ reportedBrowserError,
33
+ } from "../internal/browser-failure.ts";
28
34
  import { BrowserRunSessionLifecycle } from "../internal/browser-session-lifecycle.ts";
29
35
  import { BrowserRunProtectedBinding, ProtectedProviderIdentity } from "./binding.ts";
30
36
  import { BrowserRunProtectedTransport, makeProtectedBrowserPolicy } from "./policy.ts";
@@ -184,7 +190,11 @@ export const browserRunProtectedHostLayer = () =>
184
190
  const raw = yield* provider.command("Cloudflare.getHandoffState", {});
185
191
 
186
192
  const result = yield* Schema.decodeUnknownEffect(HandoffState)(raw).pipe(
187
- Effect.mapError(() => failure("provider")),
193
+ Effect.catch((error) =>
194
+ reportBrowserCause("protected.host.decode", Cause.fail(error)).pipe(
195
+ Effect.andThen(Effect.fail(reportedBrowserError(failure("provider")))),
196
+ ),
197
+ ),
188
198
  );
189
199
 
190
200
  if (
@@ -243,20 +253,25 @@ export const browserRunProtectedHostLayer = () =>
243
253
  })
244
254
  .pipe(
245
255
  Effect.flatMap(Schema.decodeUnknownEffect(Handoff)),
246
- Effect.catch(() =>
247
- state.handle.close.pipe(
248
- Effect.flatMap((cleanup) =>
249
- Effect.fail(
250
- new ProtectedBrowserError({
251
- reason: "outcome-unknown",
252
- dispatch: "possibly-dispatched",
253
- milestone: suspended.milestone,
254
- observation: "closed",
255
- cleanup,
256
- }),
256
+ Effect.catchCause((cause) =>
257
+ Cause.hasInterruptsOnly(cause)
258
+ ? Effect.interrupt
259
+ : reportBrowserCause("protected.handoff", cause).pipe(
260
+ Effect.andThen(state.handle.close),
261
+ Effect.flatMap((cleanup) =>
262
+ Effect.fail(
263
+ reportedBrowserError(
264
+ new ProtectedBrowserError({
265
+ reason: "outcome-unknown",
266
+ dispatch: "possibly-dispatched",
267
+ milestone: suspended.milestone,
268
+ observation: "closed",
269
+ cleanup,
270
+ }),
271
+ ),
272
+ ),
273
+ ),
257
274
  ),
258
- ),
259
- ),
260
275
  ),
261
276
  Effect.onInterrupt(() => state.handle.close),
262
277
  );
@@ -287,12 +302,22 @@ export const browserRunProtectedHostLayer = () =>
287
302
  });
288
303
 
289
304
  const result = yield* Schema.decodeUnknownEffect(LiveView)(raw).pipe(
290
- Effect.mapError(() => failure("provider")),
305
+ Effect.catch((error) =>
306
+ reportBrowserCause("protected.host.decode", Cause.fail(error)).pipe(
307
+ Effect.andThen(Effect.fail(reportedBrowserError(failure("provider")))),
308
+ ),
309
+ ),
291
310
  );
292
311
 
293
312
  return yield* Schema.decodeEffect(BrowserRunLiveViewResult)({
294
313
  devtoolsFrontendUrl: Redacted.make(result.devtoolsFrontendUrl),
295
- }).pipe(Effect.mapError(() => failure("provider")));
314
+ }).pipe(
315
+ Effect.catch((error) =>
316
+ reportBrowserCause("protected.host.decode", Cause.fail(error)).pipe(
317
+ Effect.andThen(Effect.fail(reportedBrowserError(failure("provider")))),
318
+ ),
319
+ ),
320
+ );
296
321
  }),
297
322
  ),
298
323
  getHandoffState: control(getHandoffState),
@@ -301,11 +326,15 @@ export const browserRunProtectedHostLayer = () =>
301
326
  if ((yield* getHandoffState).active) return yield* failure("busy");
302
327
 
303
328
  const context = yield* provider.driver.context.pipe(
304
- Effect.mapError((error) => failure(error.reason)),
329
+ Effect.mapError((error) => inheritBrowserReport(error, failure(error.reason))),
305
330
  );
306
331
 
307
332
  return yield* Schema.decodeEffect(CredentialOrigin)(context.topOrigin).pipe(
308
- Effect.mapError(() => failure("provider")),
333
+ Effect.catch((error) =>
334
+ reportBrowserCause("protected.host.decode", Cause.fail(error)).pipe(
335
+ Effect.andThen(Effect.fail(reportedBrowserError(failure("provider")))),
336
+ ),
337
+ ),
309
338
  );
310
339
  }),
311
340
  ),
@@ -337,7 +366,11 @@ export const browserRunProtectedHostLayer = () =>
337
366
  closeSession: (id: Redacted.Redacted<string>) =>
338
367
  lifecycle.close(id).pipe(
339
368
  Effect.as("confirmed" as const),
340
- Effect.catchCause(() => Effect.succeed("unconfirmed" as const)),
369
+ Effect.catchCause((cause) =>
370
+ reportBrowserCause("protected.closeSession", cause).pipe(
371
+ Effect.as("unconfirmed" as const),
372
+ ),
373
+ ),
341
374
  ),
342
375
  };
343
376
  }),