@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.
- package/dist/CloudflareThreadClient.d.mts +47 -47
- package/dist/{InteractiveBrowser-BR16UPbF.d.mts → InteractiveBrowser-CKp6pqmw.d.mts} +4 -2
- package/dist/{InteractiveBrowser-BCS9g0tn.mjs → InteractiveBrowser-l6J4eP6T.mjs} +286 -39
- package/dist/InteractiveBrowser-l6J4eP6T.mjs.map +1 -0
- package/dist/InteractiveBrowser.d.mts +1 -1
- package/dist/InteractiveBrowser.mjs +1 -1
- package/dist/ProtectedBrowser.d.mts +3 -3
- package/dist/ProtectedBrowser.mjs +160 -135
- package/dist/ProtectedBrowser.mjs.map +1 -1
- package/dist/{ThreadObject-CLBMWEPn.d.mts → ThreadObject-D_vvj7Qx.d.mts} +31 -31
- package/dist/ThreadObject.d.mts +1 -1
- package/dist/index.d.mts +1 -1
- package/package.json +1 -1
- package/src/InteractiveBrowser.ts +242 -48
- package/src/internal/browser-binding.ts +82 -0
- package/src/internal/browser-failure.ts +92 -0
- package/src/internal/browser-file-selection.ts +126 -0
- package/src/internal/browser-session-lifecycle.ts +38 -5
- package/src/protected-browser/binding.ts +170 -129
- package/src/protected-browser/host.ts +52 -19
- package/src/protected-browser/native.ts +45 -17
- package/src/protected-browser/policy.ts +75 -39
- package/dist/InteractiveBrowser-BCS9g0tn.mjs.map +0 -1
|
@@ -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(
|
|
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(
|
|
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: () =>
|
|
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(
|
|
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(
|
|
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)
|