@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.
@@ -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 type { Page } from "@cloudflare/puppeteer";
2
+ import { Encoding } from "effect";
3
+ import type { BrowserSelectFileRequest } from "effect-agent/interactive-browser";
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
+ };
@@ -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)