@effect-agent/platform-cloudflare 0.1.0-beta.9 → 0.1.0-beta.91
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/Alarm.d.mts +188 -0
- package/dist/Alarm.mjs +451 -0
- package/dist/Alarm.mjs.map +1 -0
- package/dist/BrowserRestCapture.d.mts +35 -0
- package/dist/BrowserRestCapture.mjs +238 -0
- package/dist/BrowserRestCapture.mjs.map +1 -0
- package/dist/BrowserRestCrawl.d.mts +17 -0
- package/dist/BrowserRestCrawl.mjs +244 -0
- package/dist/BrowserRestCrawl.mjs.map +1 -0
- package/dist/CloudflareAiGateway.d.mts +64 -0
- package/dist/CloudflareAiGateway.mjs +70 -0
- package/dist/CloudflareAiGateway.mjs.map +1 -0
- package/dist/CloudflareBindings.d.mts +115 -0
- package/dist/CloudflareBindings.mjs +113 -0
- package/dist/CloudflareBindings.mjs.map +1 -0
- package/dist/CloudflareBrowser-DV6kya1H.mjs +495 -0
- package/dist/CloudflareBrowser-DV6kya1H.mjs.map +1 -0
- package/dist/CloudflareBrowser-Doe1M7R5.d.mts +80 -0
- package/dist/CloudflareBrowser.d.mts +2 -0
- package/dist/CloudflareBrowser.mjs +2 -0
- package/dist/CloudflareCodeMode.d.mts +44 -0
- package/dist/CloudflareCodeMode.mjs +616 -0
- package/dist/CloudflareCodeMode.mjs.map +1 -0
- package/dist/CloudflareConfig-f3CqTel1.d.mts +101 -0
- package/dist/CloudflareConfig.d.mts +2 -0
- package/dist/CloudflareConfig.mjs +122 -0
- package/dist/CloudflareConfig.mjs.map +1 -0
- package/dist/CloudflareMemory.d.mts +142 -0
- package/dist/CloudflareMemory.mjs +202 -0
- package/dist/CloudflareMemory.mjs.map +1 -0
- package/dist/CloudflareScheduling.d.mts +52 -0
- package/dist/CloudflareScheduling.mjs +396 -0
- package/dist/CloudflareScheduling.mjs.map +1 -0
- package/dist/CloudflareSubscriptions.d.mts +87 -0
- package/dist/CloudflareSubscriptions.mjs +530 -0
- package/dist/CloudflareSubscriptions.mjs.map +1 -0
- package/dist/CloudflareThreadClient.d.mts +1496 -0
- package/dist/CloudflareThreadClient.mjs +382 -0
- package/dist/CloudflareThreadClient.mjs.map +1 -0
- package/dist/InteractiveBrowser-DUE_m12-.d.mts +161 -0
- package/dist/InteractiveBrowser-DupDtfw2.mjs +1169 -0
- package/dist/InteractiveBrowser-DupDtfw2.mjs.map +1 -0
- package/dist/InteractiveBrowser.d.mts +2 -0
- package/dist/InteractiveBrowser.mjs +2 -0
- package/dist/ProtectedBrowser.d.mts +123 -0
- package/dist/ProtectedBrowser.mjs +1135 -0
- package/dist/ProtectedBrowser.mjs.map +1 -0
- package/dist/ThreadObject-CIbmXZCY.d.mts +812 -0
- package/dist/ThreadObject-DvRG0dDz.mjs +835 -0
- package/dist/ThreadObject-DvRG0dDz.mjs.map +1 -0
- package/dist/ThreadObject.d.mts +2 -0
- package/dist/ThreadObject.mjs +3 -0
- package/dist/WakeScheduler.d.mts +23 -0
- package/dist/WakeScheduler.mjs +57 -0
- package/dist/WakeScheduler.mjs.map +1 -0
- package/dist/boundary-BguazVkh.mjs +42 -0
- package/dist/boundary-BguazVkh.mjs.map +1 -0
- package/dist/index.d.mts +13 -1548
- package/dist/index.mjs +13 -1636
- package/dist/prepared-admission-DU0_T-ev.mjs +73 -0
- package/dist/prepared-admission-DU0_T-ev.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +1 -53
- package/src/Alarm.ts +973 -0
- package/src/BrowserRestCapture.ts +477 -0
- package/src/BrowserRestCrawl.ts +540 -0
- package/src/CloudflareAiGateway.ts +170 -0
- package/src/CloudflareBindings.ts +199 -0
- package/src/CloudflareBrowser.ts +15 -0
- package/src/{code-mode-executor.ts → CloudflareCodeMode.ts} +372 -204
- package/src/{config.ts → CloudflareConfig.ts} +9 -8
- package/src/CloudflareMemory.ts +420 -0
- package/src/CloudflareScheduling.ts +747 -0
- package/src/CloudflareSubscriptions.ts +1041 -0
- package/src/CloudflareThreadClient.ts +809 -0
- package/src/InteractiveBrowser.ts +2354 -0
- package/src/ProtectedBrowser.ts +9 -0
- package/src/ThreadObject.ts +1179 -0
- package/src/{wake-scheduler.ts → WakeScheduler.ts} +32 -26
- package/src/index.ts +12 -23
- package/src/internal/boundary.ts +55 -0
- package/src/internal/browser-quick-action.ts +857 -0
- package/src/internal/browser-session-lifecycle.ts +160 -0
- package/src/internal/layers.ts +751 -0
- package/src/internal/message-delivery.ts +152 -0
- package/src/internal/prepared-admission.ts +116 -0
- package/src/internal/progress-wait.ts +121 -0
- package/src/internal/transport.ts +44 -0
- package/src/protected-browser/binding.ts +313 -0
- package/src/protected-browser/host.ts +344 -0
- package/src/protected-browser/inspect-frame.ts +125 -0
- package/src/protected-browser/native.ts +445 -0
- package/src/protected-browser/policy.ts +821 -0
- package/dist/index.mjs.map +0 -1
- package/src/alarm.ts +0 -334
- package/src/bindings.ts +0 -145
- package/src/client.ts +0 -646
- package/src/conversation-object.ts +0 -768
- package/src/layers.ts +0 -376
- package/src/transport.ts +0 -38
|
@@ -0,0 +1,313 @@
|
|
|
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";
|
|
4
|
+
import { type InteractiveBrowserPolicy } from "effect-agent/interactive-browser";
|
|
5
|
+
import { ProtectedBrowserError } from "effect-agent/protected-browser";
|
|
6
|
+
|
|
7
|
+
import { BrowserRunSessionLifecycle } from "../internal/browser-session-lifecycle.ts";
|
|
8
|
+
import { makeProtectedNativeTransport, ProtectedNativeSession } from "./native.ts";
|
|
9
|
+
import {
|
|
10
|
+
BrowserRunProtectedTransport,
|
|
11
|
+
ProtectedTransportError,
|
|
12
|
+
type ProtectedBrowserTransport,
|
|
13
|
+
} from "./policy.ts";
|
|
14
|
+
|
|
15
|
+
/** Host-private exact page identity; never include it in model-visible results. */
|
|
16
|
+
export const ProtectedProviderIdentity = Schema.Struct({
|
|
17
|
+
sessionId: Schema.Redacted(Schema.String.check(Schema.isUUID())),
|
|
18
|
+
contextId: Schema.Redacted(Schema.NonEmptyString.check(Schema.isMaxLength(256))),
|
|
19
|
+
targetId: Schema.Redacted(Schema.NonEmptyString.check(Schema.isMaxLength(256))),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type ProtectedProviderIdentity = typeof ProtectedProviderIdentity.Type;
|
|
23
|
+
|
|
24
|
+
export interface ProtectedProviderSession {
|
|
25
|
+
readonly identity: ProtectedProviderIdentity;
|
|
26
|
+
readonly driver: ProtectedBrowserTransport;
|
|
27
|
+
readonly command: (
|
|
28
|
+
method: "Cloudflare.getLiveView" | "Cloudflare.handoff" | "Cloudflare.getHandoffState",
|
|
29
|
+
parameters: Readonly<Record<string, string | number>>,
|
|
30
|
+
) => Effect.Effect<unknown, ProtectedBrowserError>;
|
|
31
|
+
readonly detach: Effect.Effect<void, ProtectedBrowserError>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class BrowserRunProtectedBinding extends Context.Service<
|
|
35
|
+
BrowserRunProtectedBinding,
|
|
36
|
+
{
|
|
37
|
+
readonly open: (
|
|
38
|
+
policy: InteractiveBrowserPolicy,
|
|
39
|
+
identity?: ProtectedProviderIdentity,
|
|
40
|
+
) => Effect.Effect<ProtectedProviderSession, ProtectedBrowserError, Scope.Scope>;
|
|
41
|
+
}
|
|
42
|
+
>()("@effect-agent/platform-cloudflare/BrowserRunProtectedBinding") {}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Acquires through BROWSER with recording=false explicitly on the wire. The trusted provider's
|
|
46
|
+
* documented opt-in semantics are the recording guarantee; no recording-status API exists.
|
|
47
|
+
* Account operators remain trusted, and must not attach external observers to private passes.
|
|
48
|
+
*/
|
|
49
|
+
const protectedBindingLayer = (options: { readonly browser: Pick<BrowserRun, "fetch"> }) =>
|
|
50
|
+
Layer.effect(BrowserRunProtectedBinding)(
|
|
51
|
+
Effect.gen(function* () {
|
|
52
|
+
const lifecycle = yield* BrowserRunSessionLifecycle;
|
|
53
|
+
const crypto = yield* Crypto.Crypto;
|
|
54
|
+
|
|
55
|
+
const open = Effect.fn("BrowserRunProtectedTransport.open")(function* (
|
|
56
|
+
policy: InteractiveBrowserPolicy,
|
|
57
|
+
identity?: ProtectedProviderIdentity,
|
|
58
|
+
): Effect.fn.Return<ProtectedProviderSession, ProtectedBrowserError, Scope.Scope> {
|
|
59
|
+
const failure = () =>
|
|
60
|
+
new ProtectedBrowserError({
|
|
61
|
+
reason: "provider",
|
|
62
|
+
dispatch: "not-dispatched",
|
|
63
|
+
milestone: "none",
|
|
64
|
+
observation: "closed",
|
|
65
|
+
cleanup: "unconfirmed",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
let sessionId: Redacted.Redacted<string> | undefined = identity?.sessionId;
|
|
69
|
+
let detached = false;
|
|
70
|
+
let control: CDPSession | undefined;
|
|
71
|
+
let providerIdentity: ProtectedProviderIdentity | undefined;
|
|
72
|
+
let browser: Browser | undefined;
|
|
73
|
+
let driver: ProtectedBrowserTransport | undefined;
|
|
74
|
+
let invalid = false;
|
|
75
|
+
// 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));
|
|
78
|
+
|
|
79
|
+
const terminate = Effect.gen(function* () {
|
|
80
|
+
invalid = true;
|
|
81
|
+
driver?.invalidate();
|
|
82
|
+
if (sessionId === undefined) return "unconfirmed" as const;
|
|
83
|
+
|
|
84
|
+
const cleanup = yield* lifecycle.close(sessionId).pipe(
|
|
85
|
+
Effect.as("confirmed" as const),
|
|
86
|
+
Effect.catchCause(() => Effect.succeed("unconfirmed" as const)),
|
|
87
|
+
Effect.interruptible,
|
|
88
|
+
Effect.timeoutOrElse({
|
|
89
|
+
duration: "10 seconds",
|
|
90
|
+
orElse: () => Effect.succeed("unconfirmed" as const),
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Local disconnect is not remote-closure evidence. Do it even when confirmation fails.
|
|
95
|
+
const connected = browser;
|
|
96
|
+
|
|
97
|
+
if (connected !== undefined)
|
|
98
|
+
yield* Effect.promise(() => connected.disconnect()).pipe(
|
|
99
|
+
Effect.catchCause(() => Effect.void),
|
|
100
|
+
Effect.interruptible,
|
|
101
|
+
Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.void }),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return cleanup;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const close = yield* Effect.cached(terminate);
|
|
108
|
+
|
|
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
|
+
);
|
|
120
|
+
|
|
121
|
+
const acquired = yield* Effect.tryPromise({
|
|
122
|
+
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";
|
|
133
|
+
|
|
134
|
+
return options.browser.fetch(new Request(url, request));
|
|
135
|
+
}
|
|
136
|
+
|
|
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;
|
|
164
|
+
|
|
165
|
+
if (identity === undefined) {
|
|
166
|
+
const context = await browser.createBrowserContext();
|
|
167
|
+
|
|
168
|
+
page = await context.newPage();
|
|
169
|
+
} else {
|
|
170
|
+
const context = browser
|
|
171
|
+
.browserContexts()
|
|
172
|
+
.find((candidate) => candidate.id === Redacted.value(identity.contextId));
|
|
173
|
+
|
|
174
|
+
if (context === undefined)
|
|
175
|
+
throw new ProtectedTransportError({ reason: "stale-reference" });
|
|
176
|
+
const pages = await context.pages();
|
|
177
|
+
let found: Page | undefined;
|
|
178
|
+
|
|
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;
|
|
186
|
+
}
|
|
187
|
+
if (found === undefined || pages.length !== 1)
|
|
188
|
+
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
|
+
}
|
|
217
|
+
void (
|
|
218
|
+
allowed && !invalid ? request.continue() : request.abort("blockedbyclient")
|
|
219
|
+
).catch(() => {
|
|
220
|
+
invalid = true;
|
|
221
|
+
driver?.invalidate();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
if (signal.aborted || invalid) {
|
|
225
|
+
await runCleanup(terminate);
|
|
226
|
+
throw new ProtectedTransportError({ reason: "stale-reference" });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return ProtectedNativeSession.of({
|
|
230
|
+
browser,
|
|
231
|
+
page,
|
|
232
|
+
close,
|
|
233
|
+
release: Effect.suspend(() => (detached ? Effect.void : close.pipe(Effect.asVoid))),
|
|
234
|
+
});
|
|
235
|
+
},
|
|
236
|
+
catch: failure,
|
|
237
|
+
}).pipe(
|
|
238
|
+
Effect.timeoutOrElse({
|
|
239
|
+
duration: Math.min(policy.maxElapsedMillis, 30_000),
|
|
240
|
+
orElse: () =>
|
|
241
|
+
Effect.fail(new ProtectedBrowserError({ ...failure(), reason: "timeout" })),
|
|
242
|
+
}),
|
|
243
|
+
Effect.catch((error) =>
|
|
244
|
+
close.pipe(
|
|
245
|
+
Effect.flatMap((cleanup) =>
|
|
246
|
+
Effect.fail(new ProtectedBrowserError({ ...error, cleanup })),
|
|
247
|
+
),
|
|
248
|
+
),
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
driver = yield* makeProtectedNativeTransport(policy).pipe(
|
|
253
|
+
Effect.provideService(ProtectedNativeSession, acquired),
|
|
254
|
+
Effect.provideService(Crypto.Crypto, crypto),
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const exact = providerIdentity;
|
|
258
|
+
|
|
259
|
+
if (exact === undefined) return yield* failure();
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
identity: exact,
|
|
263
|
+
driver,
|
|
264
|
+
command: (method, parameters) =>
|
|
265
|
+
Effect.tryPromise({
|
|
266
|
+
try: async () => {
|
|
267
|
+
if (invalid || detached || control === undefined) throw undefined;
|
|
268
|
+
|
|
269
|
+
// Cloudflare extends CDP with host-only methods absent from upstream ProtocolMapping.
|
|
270
|
+
const send = Reflect.get(control, "send");
|
|
271
|
+
|
|
272
|
+
return await Reflect.apply(send, control, [method, parameters]);
|
|
273
|
+
},
|
|
274
|
+
catch: failure,
|
|
275
|
+
}).pipe(
|
|
276
|
+
Effect.timeoutOrElse({
|
|
277
|
+
duration: "10 seconds",
|
|
278
|
+
orElse: () => Effect.fail(failure()),
|
|
279
|
+
}),
|
|
280
|
+
),
|
|
281
|
+
detach: Effect.tryPromise({
|
|
282
|
+
try: async () => {
|
|
283
|
+
if (invalid || detached || browser === undefined) throw undefined;
|
|
284
|
+
// Stop this attachment before another controller can attach. The browser remains live.
|
|
285
|
+
await browser.disconnect();
|
|
286
|
+
detached = true;
|
|
287
|
+
driver?.invalidate();
|
|
288
|
+
},
|
|
289
|
+
catch: failure,
|
|
290
|
+
}).pipe(
|
|
291
|
+
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.fail(failure()) }),
|
|
292
|
+
),
|
|
293
|
+
};
|
|
294
|
+
}, Effect.withTracerEnabled(false));
|
|
295
|
+
|
|
296
|
+
return { open };
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
/** One binding implementation serves scoped tools and host-managed protected handoffs. */
|
|
301
|
+
export const browserRunProtectedBindingLayer = (options: {
|
|
302
|
+
readonly browser: Pick<BrowserRun, "fetch">;
|
|
303
|
+
}) =>
|
|
304
|
+
Layer.effect(BrowserRunProtectedTransport)(
|
|
305
|
+
Effect.gen(function* () {
|
|
306
|
+
const binding = yield* BrowserRunProtectedBinding;
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
open: (policy: InteractiveBrowserPolicy) =>
|
|
310
|
+
binding.open(policy).pipe(Effect.map((session) => session.driver)),
|
|
311
|
+
};
|
|
312
|
+
}),
|
|
313
|
+
).pipe(Layer.provideMerge(protectedBindingLayer(options)));
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Clock,
|
|
3
|
+
Context,
|
|
4
|
+
Crypto,
|
|
5
|
+
Effect,
|
|
6
|
+
Layer,
|
|
7
|
+
Redacted,
|
|
8
|
+
Schema,
|
|
9
|
+
Semaphore,
|
|
10
|
+
type Scope,
|
|
11
|
+
} from "effect";
|
|
12
|
+
import { InteractiveBrowserPolicy } from "effect-agent/interactive-browser";
|
|
13
|
+
import {
|
|
14
|
+
type BrowserCredentialAccess,
|
|
15
|
+
CredentialOrigin,
|
|
16
|
+
ProtectedBrowserCheckpoint,
|
|
17
|
+
ProtectedBrowserError,
|
|
18
|
+
type ProtectedBrowserHandle,
|
|
19
|
+
type ProtectedCleanup,
|
|
20
|
+
} from "effect-agent/protected-browser";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
BrowserRunHandoffRequest,
|
|
24
|
+
BrowserRunHandoffState,
|
|
25
|
+
BrowserRunLiveViewRequest,
|
|
26
|
+
BrowserRunLiveViewResult,
|
|
27
|
+
} from "../InteractiveBrowser.ts";
|
|
28
|
+
import { BrowserRunSessionLifecycle } from "../internal/browser-session-lifecycle.ts";
|
|
29
|
+
import { BrowserRunProtectedBinding, ProtectedProviderIdentity } from "./binding.ts";
|
|
30
|
+
import { BrowserRunProtectedTransport, makeProtectedBrowserPolicy } from "./policy.ts";
|
|
31
|
+
|
|
32
|
+
/** Host-only receipt. Store atomically with the controller lease; never return it to a model. */
|
|
33
|
+
export class BrowserRunProtectedCheckpoint extends Schema.Class<BrowserRunProtectedCheckpoint>(
|
|
34
|
+
"BrowserRunProtectedCheckpoint",
|
|
35
|
+
)({
|
|
36
|
+
...ProtectedProviderIdentity.fields,
|
|
37
|
+
protected: ProtectedBrowserCheckpoint,
|
|
38
|
+
handoffId: Schema.optionalKey(
|
|
39
|
+
Schema.Redacted(Schema.NonEmptyString.check(Schema.isMaxLength(1024))),
|
|
40
|
+
),
|
|
41
|
+
}) {}
|
|
42
|
+
|
|
43
|
+
export interface BrowserRunProtectedSession {
|
|
44
|
+
readonly handle: ProtectedBrowserHandle;
|
|
45
|
+
readonly sessionId: Redacted.Redacted<string>;
|
|
46
|
+
/** Quiesce before publishing a durable human-control request. Persist before detaching. */
|
|
47
|
+
readonly suspend: Effect.Effect<BrowserRunProtectedCheckpoint, ProtectedBrowserError>;
|
|
48
|
+
/** Fences agent tools before dispatch. Persist the returned receipt before detaching. */
|
|
49
|
+
readonly handoff: (
|
|
50
|
+
request: BrowserRunHandoffRequest,
|
|
51
|
+
) => Effect.Effect<BrowserRunProtectedCheckpoint, ProtectedBrowserError>;
|
|
52
|
+
/** Available only while human control is held; the host must authorize the specific human. */
|
|
53
|
+
readonly getLiveView: (
|
|
54
|
+
request: BrowserRunLiveViewRequest,
|
|
55
|
+
) => Effect.Effect<BrowserRunLiveViewResult, ProtectedBrowserError>;
|
|
56
|
+
readonly getHandoffState: Effect.Effect<BrowserRunHandoffState, ProtectedBrowserError>;
|
|
57
|
+
/** Read only the exact page’s current origin after its recorded handoff completes; tools remain paused. */
|
|
58
|
+
readonly getReturnOrigin: Effect.Effect<typeof CredentialOrigin.Type, ProtectedBrowserError>;
|
|
59
|
+
/** Requires provider completion and a renewed observation grant. Tools must observe before acting. */
|
|
60
|
+
readonly returnControl: Effect.Effect<void, ProtectedBrowserError>;
|
|
61
|
+
/** Releases this attachment without terminating the remote browser. Only a committed handoff may detach. */
|
|
62
|
+
readonly detach: Effect.Effect<void, ProtectedBrowserError>;
|
|
63
|
+
readonly close: Effect.Effect<typeof ProtectedCleanup.Type>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Host authority for one protected page. The consumer owns durable generation fencing, authorized
|
|
68
|
+
* human recipients, checkpoint integrity, vault exposure metadata, and expiry cleanup. Resume only
|
|
69
|
+
* the latest committed suspended receipt; it never replays browser mutations or creates a page.
|
|
70
|
+
* Hosted passes require Unrestricted network policy: attachment-local request interception
|
|
71
|
+
* cannot enforce an ExactHosts policy during detachment. Current credential grants still apply.
|
|
72
|
+
* Provider inactivity/session deadlines still apply while the host is detached.
|
|
73
|
+
*/
|
|
74
|
+
export class BrowserRunProtectedHost extends Context.Service<
|
|
75
|
+
BrowserRunProtectedHost,
|
|
76
|
+
{
|
|
77
|
+
readonly open: (
|
|
78
|
+
policy: InteractiveBrowserPolicy,
|
|
79
|
+
) => Effect.Effect<
|
|
80
|
+
BrowserRunProtectedSession,
|
|
81
|
+
ProtectedBrowserError,
|
|
82
|
+
Scope.Scope | BrowserCredentialAccess
|
|
83
|
+
>;
|
|
84
|
+
readonly resume: (
|
|
85
|
+
checkpoint: BrowserRunProtectedCheckpoint,
|
|
86
|
+
) => Effect.Effect<
|
|
87
|
+
BrowserRunProtectedSession,
|
|
88
|
+
ProtectedBrowserError,
|
|
89
|
+
Scope.Scope | BrowserCredentialAccess
|
|
90
|
+
>;
|
|
91
|
+
readonly closeSession: (
|
|
92
|
+
sessionId: Redacted.Redacted<string>,
|
|
93
|
+
) => Effect.Effect<typeof ProtectedCleanup.Type>;
|
|
94
|
+
}
|
|
95
|
+
>()("@effect-agent/platform-cloudflare/BrowserRunProtectedHost") {}
|
|
96
|
+
|
|
97
|
+
const failure = (reason: ProtectedBrowserError["reason"]) =>
|
|
98
|
+
new ProtectedBrowserError({
|
|
99
|
+
reason,
|
|
100
|
+
dispatch: "not-dispatched",
|
|
101
|
+
milestone: "none",
|
|
102
|
+
observation: "protected",
|
|
103
|
+
cleanup: "not-requested",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const Handoff = Schema.Struct({ handoffId: Schema.NonEmptyString.check(Schema.isMaxLength(1024)) });
|
|
107
|
+
|
|
108
|
+
const HandoffState = Schema.Struct({
|
|
109
|
+
active: Schema.Boolean,
|
|
110
|
+
handoffId: Schema.optionalKey(Schema.NonEmptyString.check(Schema.isMaxLength(1024))),
|
|
111
|
+
durationMs: Schema.optionalKey(Schema.Natural),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const LiveView = Schema.Struct({ devtoolsFrontendUrl: Schema.String });
|
|
115
|
+
|
|
116
|
+
/** Compose with browserRunProtectedBindingLayer and BrowserRunSessionLifecycle. */
|
|
117
|
+
export const browserRunProtectedHostLayer = () =>
|
|
118
|
+
Layer.effect(BrowserRunProtectedHost)(
|
|
119
|
+
Effect.gen(function* () {
|
|
120
|
+
const binding = yield* BrowserRunProtectedBinding;
|
|
121
|
+
const lifecycle = yield* BrowserRunSessionLifecycle;
|
|
122
|
+
const crypto = yield* Crypto.Crypto;
|
|
123
|
+
|
|
124
|
+
const open = Effect.fn("BrowserRunProtectedHost.open")(function* (
|
|
125
|
+
input: InteractiveBrowserPolicy,
|
|
126
|
+
checkpoint?: BrowserRunProtectedCheckpoint,
|
|
127
|
+
): Effect.fn.Return<
|
|
128
|
+
BrowserRunProtectedSession,
|
|
129
|
+
ProtectedBrowserError,
|
|
130
|
+
Scope.Scope | BrowserCredentialAccess
|
|
131
|
+
> {
|
|
132
|
+
const policy = yield* Schema.decodeEffect(InteractiveBrowserPolicy)(input).pipe(
|
|
133
|
+
Effect.mapError(() => failure("denied")),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (policy.network._tag !== "Unrestricted") return yield* failure("unsupported");
|
|
137
|
+
|
|
138
|
+
const remaining =
|
|
139
|
+
policy.maxElapsedMillis -
|
|
140
|
+
((yield* Clock.currentTimeMillis) -
|
|
141
|
+
(checkpoint?.protected.startedAt ?? (yield* Clock.currentTimeMillis)));
|
|
142
|
+
|
|
143
|
+
if (
|
|
144
|
+
remaining <= 0 ||
|
|
145
|
+
(checkpoint !== undefined &&
|
|
146
|
+
checkpoint.protected.startedAt > (yield* Clock.currentTimeMillis))
|
|
147
|
+
)
|
|
148
|
+
return yield* failure("timeout");
|
|
149
|
+
if (checkpoint !== undefined && checkpoint.protected.actions > policy.maxActions)
|
|
150
|
+
return yield* failure("denied");
|
|
151
|
+
const provider = yield* binding.open(policy, checkpoint);
|
|
152
|
+
|
|
153
|
+
const state = yield* makeProtectedBrowserPolicy(policy, checkpoint?.protected).pipe(
|
|
154
|
+
Effect.provideService(BrowserRunProtectedTransport, {
|
|
155
|
+
open: () => Effect.succeed({ ...provider.driver, detach: provider.detach }),
|
|
156
|
+
}),
|
|
157
|
+
Effect.provideService(Crypto.Crypto, crypto),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const lock = yield* Semaphore.make(1);
|
|
161
|
+
let handoff = checkpoint;
|
|
162
|
+
let detached = false;
|
|
163
|
+
|
|
164
|
+
const control = <A>(effect: Effect.Effect<A, ProtectedBrowserError>) =>
|
|
165
|
+
lock
|
|
166
|
+
.withPermitsIfAvailable(1)(
|
|
167
|
+
Effect.suspend(() => (detached ? Effect.fail(failure("closed")) : effect)),
|
|
168
|
+
)
|
|
169
|
+
.pipe(Effect.flatMap(Effect.fromOption(() => failure("busy"))));
|
|
170
|
+
|
|
171
|
+
const fits = (millis: number) =>
|
|
172
|
+
Effect.gen(function* () {
|
|
173
|
+
const started = handoff?.protected.startedAt ?? startedAt;
|
|
174
|
+
|
|
175
|
+
if (millis > policy.maxElapsedMillis - ((yield* Clock.currentTimeMillis) - started))
|
|
176
|
+
return yield* failure("timeout");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const startedAt = checkpoint?.protected.startedAt ?? (yield* Clock.currentTimeMillis);
|
|
180
|
+
|
|
181
|
+
const getHandoffState = Effect.gen(function* () {
|
|
182
|
+
if (handoff?.handoffId === undefined) return yield* failure("denied");
|
|
183
|
+
yield* fits(0);
|
|
184
|
+
const raw = yield* provider.command("Cloudflare.getHandoffState", {});
|
|
185
|
+
|
|
186
|
+
const result = yield* Schema.decodeUnknownEffect(HandoffState)(raw).pipe(
|
|
187
|
+
Effect.mapError(() => failure("provider")),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
if (
|
|
191
|
+
(result.active && result.handoffId === undefined) ||
|
|
192
|
+
(result.handoffId !== undefined &&
|
|
193
|
+
result.handoffId !== Redacted.value(handoff.handoffId))
|
|
194
|
+
)
|
|
195
|
+
return yield* failure("denied");
|
|
196
|
+
|
|
197
|
+
return BrowserRunHandoffState.make({
|
|
198
|
+
active: result.active,
|
|
199
|
+
...(result.durationMs === undefined ? {} : { durationMs: result.durationMs }),
|
|
200
|
+
...(result.handoffId === undefined
|
|
201
|
+
? {}
|
|
202
|
+
: { handoffId: Redacted.make(result.handoffId) }),
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
handle: state.handle,
|
|
208
|
+
sessionId: provider.identity.sessionId,
|
|
209
|
+
suspend: control(
|
|
210
|
+
Effect.gen(function* () {
|
|
211
|
+
if (handoff !== undefined) {
|
|
212
|
+
yield* state.suspend();
|
|
213
|
+
|
|
214
|
+
return handoff;
|
|
215
|
+
}
|
|
216
|
+
const suspended = yield* state.suspend();
|
|
217
|
+
|
|
218
|
+
handoff = BrowserRunProtectedCheckpoint.make({
|
|
219
|
+
...provider.identity,
|
|
220
|
+
protected: suspended,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return handoff;
|
|
224
|
+
}),
|
|
225
|
+
),
|
|
226
|
+
handoff: (request) =>
|
|
227
|
+
control(
|
|
228
|
+
Effect.gen(function* () {
|
|
229
|
+
if (handoff?.handoffId !== undefined) return yield* failure("busy");
|
|
230
|
+
|
|
231
|
+
const decoded = yield* Schema.decodeEffect(BrowserRunHandoffRequest)(request).pipe(
|
|
232
|
+
Effect.mapError(() => failure("denied")),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
yield* fits(decoded.timeout);
|
|
236
|
+
const suspended = yield* state.suspend(true);
|
|
237
|
+
|
|
238
|
+
// An uncertain handoff is never retried; close exactly this provider session.
|
|
239
|
+
const result = yield* provider
|
|
240
|
+
.command("Cloudflare.handoff", {
|
|
241
|
+
instructions: decoded.instructions,
|
|
242
|
+
timeout: decoded.timeout,
|
|
243
|
+
})
|
|
244
|
+
.pipe(
|
|
245
|
+
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
|
+
}),
|
|
257
|
+
),
|
|
258
|
+
),
|
|
259
|
+
),
|
|
260
|
+
),
|
|
261
|
+
Effect.onInterrupt(() => state.handle.close),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
handoff = BrowserRunProtectedCheckpoint.make({
|
|
265
|
+
...provider.identity,
|
|
266
|
+
protected: suspended,
|
|
267
|
+
handoffId: Redacted.make(result.handoffId),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
return handoff;
|
|
271
|
+
}),
|
|
272
|
+
),
|
|
273
|
+
getLiveView: (request) =>
|
|
274
|
+
control(
|
|
275
|
+
Effect.gen(function* () {
|
|
276
|
+
if (handoff?.handoffId === undefined) return yield* failure("denied");
|
|
277
|
+
|
|
278
|
+
const decoded = yield* Schema.decodeEffect(BrowserRunLiveViewRequest)(request).pipe(
|
|
279
|
+
Effect.mapError(() => failure("denied")),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
yield* fits(decoded.expiresInMs);
|
|
283
|
+
|
|
284
|
+
const raw = yield* provider.command("Cloudflare.getLiveView", {
|
|
285
|
+
mode: decoded.mode,
|
|
286
|
+
expiresInMs: decoded.expiresInMs,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const result = yield* Schema.decodeUnknownEffect(LiveView)(raw).pipe(
|
|
290
|
+
Effect.mapError(() => failure("provider")),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
return yield* Schema.decodeEffect(BrowserRunLiveViewResult)({
|
|
294
|
+
devtoolsFrontendUrl: Redacted.make(result.devtoolsFrontendUrl),
|
|
295
|
+
}).pipe(Effect.mapError(() => failure("provider")));
|
|
296
|
+
}),
|
|
297
|
+
),
|
|
298
|
+
getHandoffState: control(getHandoffState),
|
|
299
|
+
getReturnOrigin: control(
|
|
300
|
+
Effect.gen(function* () {
|
|
301
|
+
if ((yield* getHandoffState).active) return yield* failure("busy");
|
|
302
|
+
|
|
303
|
+
const context = yield* provider.driver.context.pipe(
|
|
304
|
+
Effect.mapError((error) => failure(error.reason)),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
return yield* Schema.decodeEffect(CredentialOrigin)(context.topOrigin).pipe(
|
|
308
|
+
Effect.mapError(() => failure("provider")),
|
|
309
|
+
);
|
|
310
|
+
}),
|
|
311
|
+
),
|
|
312
|
+
returnControl: control(
|
|
313
|
+
Effect.gen(function* () {
|
|
314
|
+
if ((yield* getHandoffState).active) return yield* failure("busy");
|
|
315
|
+
yield* state.returnControl;
|
|
316
|
+
handoff = undefined;
|
|
317
|
+
}),
|
|
318
|
+
),
|
|
319
|
+
detach: control(
|
|
320
|
+
Effect.gen(function* () {
|
|
321
|
+
if (handoff === undefined) return yield* failure("denied");
|
|
322
|
+
yield* state.detach;
|
|
323
|
+
detached = true;
|
|
324
|
+
}),
|
|
325
|
+
),
|
|
326
|
+
close: state.handle.close,
|
|
327
|
+
};
|
|
328
|
+
}, Effect.withTracerEnabled(false));
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
open: (policy: InteractiveBrowserPolicy) => open(policy),
|
|
332
|
+
resume: (checkpoint: BrowserRunProtectedCheckpoint) =>
|
|
333
|
+
Schema.decodeEffect(BrowserRunProtectedCheckpoint)(checkpoint).pipe(
|
|
334
|
+
Effect.mapError(() => failure("denied")),
|
|
335
|
+
Effect.flatMap((decoded) => open(decoded.protected.policy, decoded)),
|
|
336
|
+
),
|
|
337
|
+
closeSession: (id: Redacted.Redacted<string>) =>
|
|
338
|
+
lifecycle.close(id).pipe(
|
|
339
|
+
Effect.as("confirmed" as const),
|
|
340
|
+
Effect.catchCause(() => Effect.succeed("unconfirmed" as const)),
|
|
341
|
+
),
|
|
342
|
+
};
|
|
343
|
+
}),
|
|
344
|
+
);
|