@effect-agent/platform-cloudflare 0.1.0-beta.107 → 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.
@@ -0,0 +1,82 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import puppeteer, { type Browser } from "@cloudflare/puppeteer";
3
+ import { Context, Layer, Schema } from "effect";
4
+
5
+ import { browserFailure, BrowserRunFailure } from "./browser-failure.ts";
6
+
7
+ const Acquired = Schema.Struct({ sessionId: Schema.String.check(Schema.isUUID()) });
8
+
9
+ /** Native transport port shared by both scoped adapters; the Layer owns the foreign binding. */
10
+ export class BrowserRunBinding extends Context.Service<
11
+ BrowserRunBinding,
12
+ {
13
+ readonly acquire: (
14
+ keepAliveMillis: number,
15
+ operation: "protected.acquire" | "interactive.acquire",
16
+ ) => Promise<string>;
17
+ readonly connect: (sessionId: string, operation: string) => Promise<Browser>;
18
+ }
19
+ >()("@effect-agent/platform-cloudflare/internal/BrowserRunBinding") {
20
+ static layer(browser: Pick<BrowserRun, "fetch">) {
21
+ return Layer.succeed(this)({
22
+ // One allocation path, with recording explicitly disabled for private sessions.
23
+ acquire: async (keepAliveMillis, operation) => {
24
+ const response = await browser.fetch(
25
+ `https://browser-rendering.cloudflare.com/v1/devtools/browser?keep_alive=${keepAliveMillis}&recording=false`,
26
+ { method: "POST" },
27
+ );
28
+
29
+ if (response.status !== 200) {
30
+ const failure = new BrowserRunFailure({
31
+ operation,
32
+ reason: "provider",
33
+ status: response.status,
34
+ });
35
+
36
+ try {
37
+ await response.body?.cancel();
38
+ } catch {
39
+ // Body cancellation cannot replace the acquisition refusal.
40
+ }
41
+ throw failure;
42
+ }
43
+
44
+ return Schema.decodeUnknownSync(Acquired)(await response.json()).sessionId;
45
+ },
46
+ // The SDK embeds bodies and session IDs in errors; retain status before that loss.
47
+ connect: async (sessionId, operation) => {
48
+ let failure: BrowserRunFailure | undefined;
49
+
50
+ try {
51
+ return await puppeteer.connect(
52
+ {
53
+ fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
54
+ try {
55
+ const response = await browser.fetch(input, init);
56
+
57
+ if (response.status !== 101) {
58
+ failure = new BrowserRunFailure({
59
+ operation,
60
+ reason: "provider",
61
+ status: response.status,
62
+ });
63
+ await response.body?.cancel();
64
+ throw failure;
65
+ }
66
+
67
+ return response;
68
+ } catch (cause) {
69
+ failure ??= browserFailure(operation, cause);
70
+ throw failure;
71
+ }
72
+ },
73
+ },
74
+ sessionId,
75
+ );
76
+ } catch (cause) {
77
+ throw failure ?? browserFailure(operation, cause);
78
+ }
79
+ },
80
+ });
81
+ }
82
+ }
@@ -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
+ );
@@ -1,6 +1,8 @@
1
1
  import { Context, Effect, Layer, Option, Redacted, Schema, Stream } from "effect";
2
2
  import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3
3
 
4
+ import { browserFailure } from "./browser-failure.ts";
5
+
4
6
  export class BrowserRunCleanupError extends Schema.TaggedError<BrowserRunCleanupError>()(
5
7
  "BrowserRunCleanupError",
6
8
  {
@@ -14,6 +16,7 @@ export class BrowserRunCleanupError extends Schema.TaggedError<BrowserRunCleanup
14
16
  "pending",
15
17
  ]),
16
18
  status: Schema.optionalKey(Schema.Int),
19
+ cause: Schema.optionalKey(Schema.Defect()),
17
20
  },
18
21
  ) {}
19
22
 
@@ -65,7 +68,13 @@ export class BrowserRunSessionLifecycle extends Context.Service<
65
68
  .pipe(
66
69
  // workerd supports manual/follow, not error. Reject every redirect below.
67
70
  Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }),
68
- Effect.mapError(() => new BrowserRunCleanupError({ reason: "provider" })),
71
+ Effect.mapError(
72
+ (cause) =>
73
+ new BrowserRunCleanupError({
74
+ reason: "provider",
75
+ cause: browserFailure("browser.close.request", cause),
76
+ }),
77
+ ),
69
78
  );
70
79
 
71
80
  if (response.status === 401 || response.status === 403)
@@ -99,11 +108,23 @@ export class BrowserRunSessionLifecycle extends Context.Service<
99
108
 
100
109
  return Effect.succeed(combined);
101
110
  },
102
- ).pipe(Effect.mapError(() => new BrowserRunCleanupError({ reason: "malformed" })));
111
+ ).pipe(
112
+ Effect.mapError(
113
+ (cause) =>
114
+ new BrowserRunCleanupError({
115
+ reason: "malformed",
116
+ cause: browserFailure("browser.close.response", cause),
117
+ }),
118
+ ),
119
+ );
103
120
 
104
121
  const body = yield* Effect.try({
105
122
  try: () => new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes),
106
- catch: () => new BrowserRunCleanupError({ reason: "malformed" }),
123
+ catch: (cause) =>
124
+ new BrowserRunCleanupError({
125
+ reason: "malformed",
126
+ cause: browserFailure("browser.close.decode", cause),
127
+ }),
107
128
  });
108
129
 
109
130
  if (response.status === 404) {
@@ -117,14 +138,26 @@ export class BrowserRunSessionLifecycle extends Context.Service<
117
138
  }
118
139
  if (method === "DELETE") {
119
140
  const result = yield* Schema.decodeEffect(Schema.fromJsonString(Closed))(body).pipe(
120
- Effect.mapError(() => new BrowserRunCleanupError({ reason: "malformed" })),
141
+ Effect.mapError(
142
+ (cause) =>
143
+ new BrowserRunCleanupError({
144
+ reason: "malformed",
145
+ cause: browserFailure("browser.close.response", cause),
146
+ }),
147
+ ),
121
148
  );
122
149
 
123
150
  return result.status === "closed";
124
151
  }
125
152
 
126
153
  const result = yield* Schema.decodeEffect(Schema.fromJsonString(Metadata))(body).pipe(
127
- Effect.mapError(() => new BrowserRunCleanupError({ reason: "malformed" })),
154
+ Effect.mapError(
155
+ (cause) =>
156
+ new BrowserRunCleanupError({
157
+ reason: "malformed",
158
+ cause: browserFailure("browser.close.response", cause),
159
+ }),
160
+ ),
128
161
  );
129
162
 
130
163
  if (result.sessionId !== sessionId)
@@ -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: {