@effect-agent/platform-cloudflare 0.1.0-beta.87 → 0.1.0-beta.89
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/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 -144
- package/dist/InteractiveBrowser.mjs +1 -1089
- package/dist/ProtectedBrowser.d.mts +70 -14
- package/dist/ProtectedBrowser.mjs +599 -328
- package/dist/ProtectedBrowser.mjs.map +1 -1
- package/package.json +1 -1
- package/src/ProtectedBrowser.ts +7 -0
- package/src/protected-browser/binding.ts +151 -25
- package/src/protected-browser/host.ts +328 -0
- package/src/protected-browser/native.ts +17 -1
- package/src/protected-browser/policy.ts +640 -536
- package/dist/InteractiveBrowser.mjs.map +0 -1
- package/dist/browser-session-lifecycle-CmbQABHC.mjs +0 -84
- package/dist/browser-session-lifecycle-CmbQABHC.mjs.map +0 -1
- package/dist/browser-session-lifecycle-DJpqLXy_.d.mts +0 -21
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
-
import puppeteer, { type Browser } from "@cloudflare/puppeteer";
|
|
2
|
+
import puppeteer, { type Browser, type Page, type CDPSession } from "@cloudflare/puppeteer";
|
|
3
3
|
import { Clock, 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";
|
|
@@ -12,22 +12,50 @@ import {
|
|
|
12
12
|
type ProtectedBrowserTransport,
|
|
13
13
|
} from "./policy.ts";
|
|
14
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
|
+
|
|
15
44
|
/**
|
|
16
45
|
* Acquires through BROWSER with recording=false explicitly on the wire. The trusted provider's
|
|
17
46
|
* documented opt-in semantics are the recording guarantee; no recording-status API exists.
|
|
18
47
|
* Account operators remain trusted, and must not attach external observers to private passes.
|
|
19
48
|
*/
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}) =>
|
|
23
|
-
Layer.effect(BrowserRunProtectedTransport)(
|
|
49
|
+
const protectedBindingLayer = (options: { readonly browser: Pick<BrowserRun, "fetch"> }) =>
|
|
50
|
+
Layer.effect(BrowserRunProtectedBinding)(
|
|
24
51
|
Effect.gen(function* () {
|
|
25
52
|
const lifecycle = yield* BrowserRunSessionLifecycle;
|
|
26
53
|
const crypto = yield* Crypto.Crypto;
|
|
27
54
|
|
|
28
55
|
const open = Effect.fn("BrowserRunProtectedTransport.open")(function* (
|
|
29
56
|
policy: InteractiveBrowserPolicy,
|
|
30
|
-
|
|
57
|
+
identity?: ProtectedProviderIdentity,
|
|
58
|
+
): Effect.fn.Return<ProtectedProviderSession, ProtectedBrowserError, Scope.Scope> {
|
|
31
59
|
const failure = () =>
|
|
32
60
|
new ProtectedBrowserError({
|
|
33
61
|
reason: "provider",
|
|
@@ -37,7 +65,10 @@ export const browserRunProtectedBindingLayer = (options: {
|
|
|
37
65
|
cleanup: "unconfirmed",
|
|
38
66
|
});
|
|
39
67
|
|
|
40
|
-
let sessionId: Redacted.Redacted<string> | undefined;
|
|
68
|
+
let sessionId: Redacted.Redacted<string> | undefined = identity?.sessionId;
|
|
69
|
+
let detached = false;
|
|
70
|
+
let control: CDPSession | undefined;
|
|
71
|
+
let providerIdentity: ProtectedProviderIdentity | undefined;
|
|
41
72
|
let browser: Browser | undefined;
|
|
42
73
|
let driver: ProtectedBrowserTransport | undefined;
|
|
43
74
|
let invalid = false;
|
|
@@ -76,13 +107,15 @@ export const browserRunProtectedBindingLayer = (options: {
|
|
|
76
107
|
const close = yield* Effect.cached(terminate);
|
|
77
108
|
|
|
78
109
|
yield* Effect.addFinalizer(() =>
|
|
79
|
-
|
|
80
|
-
Effect.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
+
),
|
|
86
119
|
);
|
|
87
120
|
|
|
88
121
|
const acquired = yield* Effect.tryPromise({
|
|
@@ -105,27 +138,64 @@ export const browserRunProtectedBindingLayer = (options: {
|
|
|
105
138
|
},
|
|
106
139
|
};
|
|
107
140
|
|
|
108
|
-
const acquired =
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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) };
|
|
113
149
|
|
|
114
150
|
sessionId = Redacted.make(
|
|
115
151
|
Schema.decodeSync(Schema.String.check(Schema.isUUID()))(acquired.sessionId),
|
|
116
152
|
);
|
|
117
|
-
if (!recordingDisabled || signal.aborted || invalid) {
|
|
153
|
+
if ((identity === undefined && !recordingDisabled) || signal.aborted || invalid) {
|
|
118
154
|
await runCleanup(terminate);
|
|
119
155
|
throw new ProtectedTransportError({ reason: "stale-reference" });
|
|
120
156
|
}
|
|
121
|
-
//
|
|
157
|
+
// Resume only a host-persisted exact page. Never open a replacement page or context.
|
|
122
158
|
browser = await puppeteer.connect(options.browser, acquired.sessionId);
|
|
123
159
|
if (signal.aborted || invalid) {
|
|
124
160
|
await runCleanup(terminate);
|
|
125
161
|
throw new ProtectedTransportError({ reason: "stale-reference" });
|
|
126
162
|
}
|
|
127
|
-
|
|
128
|
-
|
|
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
|
+
});
|
|
129
199
|
|
|
130
200
|
await page.setBypassServiceWorker(true);
|
|
131
201
|
await page.setRequestInterception(true);
|
|
@@ -156,7 +226,12 @@ export const browserRunProtectedBindingLayer = (options: {
|
|
|
156
226
|
throw new ProtectedTransportError({ reason: "stale-reference" });
|
|
157
227
|
}
|
|
158
228
|
|
|
159
|
-
return ProtectedNativeSession.of({
|
|
229
|
+
return ProtectedNativeSession.of({
|
|
230
|
+
browser,
|
|
231
|
+
page,
|
|
232
|
+
close,
|
|
233
|
+
release: Effect.suspend(() => (detached ? Effect.void : close.pipe(Effect.asVoid))),
|
|
234
|
+
});
|
|
160
235
|
},
|
|
161
236
|
catch: failure,
|
|
162
237
|
}).pipe(
|
|
@@ -179,9 +254,60 @@ export const browserRunProtectedBindingLayer = (options: {
|
|
|
179
254
|
Effect.provideService(Crypto.Crypto, crypto),
|
|
180
255
|
);
|
|
181
256
|
|
|
182
|
-
|
|
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
|
+
};
|
|
183
294
|
}, Effect.withTracerEnabled(false));
|
|
184
295
|
|
|
185
296
|
return { open };
|
|
186
297
|
}),
|
|
187
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,328 @@
|
|
|
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
|
+
ProtectedBrowserCheckpoint,
|
|
16
|
+
ProtectedBrowserError,
|
|
17
|
+
type ProtectedBrowserHandle,
|
|
18
|
+
type ProtectedCleanup,
|
|
19
|
+
} from "effect-agent/protected-browser";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
BrowserRunHandoffRequest,
|
|
23
|
+
BrowserRunHandoffState,
|
|
24
|
+
BrowserRunLiveViewRequest,
|
|
25
|
+
BrowserRunLiveViewResult,
|
|
26
|
+
} from "../InteractiveBrowser.ts";
|
|
27
|
+
import { BrowserRunSessionLifecycle } from "../internal/browser-session-lifecycle.ts";
|
|
28
|
+
import { BrowserRunProtectedBinding, ProtectedProviderIdentity } from "./binding.ts";
|
|
29
|
+
import { BrowserRunProtectedTransport, makeProtectedBrowserPolicy } from "./policy.ts";
|
|
30
|
+
|
|
31
|
+
/** Host-only receipt. Store atomically with the controller lease; never return it to a model. */
|
|
32
|
+
export class BrowserRunProtectedCheckpoint extends Schema.Class<BrowserRunProtectedCheckpoint>(
|
|
33
|
+
"BrowserRunProtectedCheckpoint",
|
|
34
|
+
)({
|
|
35
|
+
...ProtectedProviderIdentity.fields,
|
|
36
|
+
protected: ProtectedBrowserCheckpoint,
|
|
37
|
+
handoffId: Schema.optionalKey(
|
|
38
|
+
Schema.Redacted(Schema.NonEmptyString.check(Schema.isMaxLength(1024))),
|
|
39
|
+
),
|
|
40
|
+
}) {}
|
|
41
|
+
|
|
42
|
+
export interface BrowserRunProtectedSession {
|
|
43
|
+
readonly handle: ProtectedBrowserHandle;
|
|
44
|
+
readonly sessionId: Redacted.Redacted<string>;
|
|
45
|
+
/** Quiesce before publishing a durable human-control request. Persist before detaching. */
|
|
46
|
+
readonly suspend: Effect.Effect<BrowserRunProtectedCheckpoint, ProtectedBrowserError>;
|
|
47
|
+
/** Fences agent tools before dispatch. Persist the returned receipt before detaching. */
|
|
48
|
+
readonly handoff: (
|
|
49
|
+
request: BrowserRunHandoffRequest,
|
|
50
|
+
) => Effect.Effect<BrowserRunProtectedCheckpoint, ProtectedBrowserError>;
|
|
51
|
+
/** Available only while human control is held; the host must authorize the specific human. */
|
|
52
|
+
readonly getLiveView: (
|
|
53
|
+
request: BrowserRunLiveViewRequest,
|
|
54
|
+
) => Effect.Effect<BrowserRunLiveViewResult, ProtectedBrowserError>;
|
|
55
|
+
readonly getHandoffState: Effect.Effect<BrowserRunHandoffState, ProtectedBrowserError>;
|
|
56
|
+
/** Requires provider completion and a renewed observation grant. Tools must observe before acting. */
|
|
57
|
+
readonly returnControl: Effect.Effect<void, ProtectedBrowserError>;
|
|
58
|
+
/** Releases this attachment without terminating the remote browser. Only a committed handoff may detach. */
|
|
59
|
+
readonly detach: Effect.Effect<void, ProtectedBrowserError>;
|
|
60
|
+
readonly close: Effect.Effect<typeof ProtectedCleanup.Type>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Host authority for one protected page. The consumer owns durable generation fencing, authorized
|
|
65
|
+
* human recipients, checkpoint integrity, vault exposure metadata, and expiry cleanup. Resume only
|
|
66
|
+
* the latest committed suspended receipt; it never replays browser mutations or creates a page.
|
|
67
|
+
* Hosted passes require Unrestricted network policy: attachment-local request interception
|
|
68
|
+
* cannot enforce an ExactHosts policy during detachment. Current credential grants still apply.
|
|
69
|
+
* Provider inactivity/session deadlines still apply while the host is detached.
|
|
70
|
+
*/
|
|
71
|
+
export class BrowserRunProtectedHost extends Context.Service<
|
|
72
|
+
BrowserRunProtectedHost,
|
|
73
|
+
{
|
|
74
|
+
readonly open: (
|
|
75
|
+
policy: InteractiveBrowserPolicy,
|
|
76
|
+
) => Effect.Effect<
|
|
77
|
+
BrowserRunProtectedSession,
|
|
78
|
+
ProtectedBrowserError,
|
|
79
|
+
Scope.Scope | BrowserCredentialAccess
|
|
80
|
+
>;
|
|
81
|
+
readonly resume: (
|
|
82
|
+
checkpoint: BrowserRunProtectedCheckpoint,
|
|
83
|
+
) => Effect.Effect<
|
|
84
|
+
BrowserRunProtectedSession,
|
|
85
|
+
ProtectedBrowserError,
|
|
86
|
+
Scope.Scope | BrowserCredentialAccess
|
|
87
|
+
>;
|
|
88
|
+
readonly closeSession: (
|
|
89
|
+
sessionId: Redacted.Redacted<string>,
|
|
90
|
+
) => Effect.Effect<typeof ProtectedCleanup.Type>;
|
|
91
|
+
}
|
|
92
|
+
>()("@effect-agent/platform-cloudflare/BrowserRunProtectedHost") {}
|
|
93
|
+
|
|
94
|
+
const failure = (reason: ProtectedBrowserError["reason"]) =>
|
|
95
|
+
new ProtectedBrowserError({
|
|
96
|
+
reason,
|
|
97
|
+
dispatch: "not-dispatched",
|
|
98
|
+
milestone: "none",
|
|
99
|
+
observation: "protected",
|
|
100
|
+
cleanup: "not-requested",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const Handoff = Schema.Struct({ handoffId: Schema.NonEmptyString.check(Schema.isMaxLength(1024)) });
|
|
104
|
+
|
|
105
|
+
const HandoffState = Schema.Struct({
|
|
106
|
+
active: Schema.Boolean,
|
|
107
|
+
handoffId: Schema.optionalKey(Schema.NonEmptyString.check(Schema.isMaxLength(1024))),
|
|
108
|
+
durationMs: Schema.optionalKey(Schema.Natural),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const LiveView = Schema.Struct({ devtoolsFrontendUrl: Schema.String });
|
|
112
|
+
|
|
113
|
+
/** Compose with browserRunProtectedBindingLayer and BrowserRunSessionLifecycle. */
|
|
114
|
+
export const browserRunProtectedHostLayer = () =>
|
|
115
|
+
Layer.effect(BrowserRunProtectedHost)(
|
|
116
|
+
Effect.gen(function* () {
|
|
117
|
+
const binding = yield* BrowserRunProtectedBinding;
|
|
118
|
+
const lifecycle = yield* BrowserRunSessionLifecycle;
|
|
119
|
+
const crypto = yield* Crypto.Crypto;
|
|
120
|
+
|
|
121
|
+
const open = Effect.fn("BrowserRunProtectedHost.open")(function* (
|
|
122
|
+
input: InteractiveBrowserPolicy,
|
|
123
|
+
checkpoint?: BrowserRunProtectedCheckpoint,
|
|
124
|
+
): Effect.fn.Return<
|
|
125
|
+
BrowserRunProtectedSession,
|
|
126
|
+
ProtectedBrowserError,
|
|
127
|
+
Scope.Scope | BrowserCredentialAccess
|
|
128
|
+
> {
|
|
129
|
+
const policy = yield* Schema.decodeEffect(InteractiveBrowserPolicy)(input).pipe(
|
|
130
|
+
Effect.mapError(() => failure("denied")),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
if (policy.network._tag !== "Unrestricted") return yield* failure("unsupported");
|
|
134
|
+
|
|
135
|
+
const remaining =
|
|
136
|
+
policy.maxElapsedMillis -
|
|
137
|
+
((yield* Clock.currentTimeMillis) -
|
|
138
|
+
(checkpoint?.protected.startedAt ?? (yield* Clock.currentTimeMillis)));
|
|
139
|
+
|
|
140
|
+
if (
|
|
141
|
+
remaining <= 0 ||
|
|
142
|
+
(checkpoint !== undefined &&
|
|
143
|
+
checkpoint.protected.startedAt > (yield* Clock.currentTimeMillis))
|
|
144
|
+
)
|
|
145
|
+
return yield* failure("timeout");
|
|
146
|
+
if (checkpoint !== undefined && checkpoint.protected.actions > policy.maxActions)
|
|
147
|
+
return yield* failure("denied");
|
|
148
|
+
const provider = yield* binding.open(policy, checkpoint);
|
|
149
|
+
|
|
150
|
+
const state = yield* makeProtectedBrowserPolicy(policy, checkpoint?.protected).pipe(
|
|
151
|
+
Effect.provideService(BrowserRunProtectedTransport, {
|
|
152
|
+
open: () => Effect.succeed({ ...provider.driver, detach: provider.detach }),
|
|
153
|
+
}),
|
|
154
|
+
Effect.provideService(Crypto.Crypto, crypto),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const lock = yield* Semaphore.make(1);
|
|
158
|
+
let handoff = checkpoint;
|
|
159
|
+
let detached = false;
|
|
160
|
+
|
|
161
|
+
const control = <A>(effect: Effect.Effect<A, ProtectedBrowserError>) =>
|
|
162
|
+
lock
|
|
163
|
+
.withPermitsIfAvailable(1)(
|
|
164
|
+
Effect.suspend(() => (detached ? Effect.fail(failure("closed")) : effect)),
|
|
165
|
+
)
|
|
166
|
+
.pipe(Effect.flatMap(Effect.fromOption(() => failure("busy"))));
|
|
167
|
+
|
|
168
|
+
const fits = (millis: number) =>
|
|
169
|
+
Effect.gen(function* () {
|
|
170
|
+
const started = handoff?.protected.startedAt ?? startedAt;
|
|
171
|
+
|
|
172
|
+
if (millis > policy.maxElapsedMillis - ((yield* Clock.currentTimeMillis) - started))
|
|
173
|
+
return yield* failure("timeout");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const startedAt = checkpoint?.protected.startedAt ?? (yield* Clock.currentTimeMillis);
|
|
177
|
+
|
|
178
|
+
const getHandoffState = Effect.gen(function* () {
|
|
179
|
+
if (handoff?.handoffId === undefined) return yield* failure("denied");
|
|
180
|
+
yield* fits(0);
|
|
181
|
+
const raw = yield* provider.command("Cloudflare.getHandoffState", {});
|
|
182
|
+
|
|
183
|
+
const result = yield* Schema.decodeUnknownEffect(HandoffState)(raw).pipe(
|
|
184
|
+
Effect.mapError(() => failure("provider")),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
if (
|
|
188
|
+
(result.active && result.handoffId === undefined) ||
|
|
189
|
+
(result.handoffId !== undefined &&
|
|
190
|
+
result.handoffId !== Redacted.value(handoff.handoffId))
|
|
191
|
+
)
|
|
192
|
+
return yield* failure("denied");
|
|
193
|
+
|
|
194
|
+
return BrowserRunHandoffState.make({
|
|
195
|
+
active: result.active,
|
|
196
|
+
...(result.durationMs === undefined ? {} : { durationMs: result.durationMs }),
|
|
197
|
+
...(result.handoffId === undefined
|
|
198
|
+
? {}
|
|
199
|
+
: { handoffId: Redacted.make(result.handoffId) }),
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
handle: state.handle,
|
|
205
|
+
sessionId: provider.identity.sessionId,
|
|
206
|
+
suspend: control(
|
|
207
|
+
Effect.gen(function* () {
|
|
208
|
+
if (handoff !== undefined) {
|
|
209
|
+
yield* state.suspend();
|
|
210
|
+
|
|
211
|
+
return handoff;
|
|
212
|
+
}
|
|
213
|
+
const suspended = yield* state.suspend();
|
|
214
|
+
|
|
215
|
+
handoff = BrowserRunProtectedCheckpoint.make({
|
|
216
|
+
...provider.identity,
|
|
217
|
+
protected: suspended,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return handoff;
|
|
221
|
+
}),
|
|
222
|
+
),
|
|
223
|
+
handoff: (request) =>
|
|
224
|
+
control(
|
|
225
|
+
Effect.gen(function* () {
|
|
226
|
+
if (handoff?.handoffId !== undefined) return yield* failure("busy");
|
|
227
|
+
|
|
228
|
+
const decoded = yield* Schema.decodeEffect(BrowserRunHandoffRequest)(request).pipe(
|
|
229
|
+
Effect.mapError(() => failure("denied")),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
yield* fits(decoded.timeout);
|
|
233
|
+
const suspended = yield* state.suspend(true);
|
|
234
|
+
|
|
235
|
+
// An uncertain handoff is never retried; close exactly this provider session.
|
|
236
|
+
const result = yield* provider
|
|
237
|
+
.command("Cloudflare.handoff", {
|
|
238
|
+
instructions: decoded.instructions,
|
|
239
|
+
timeout: decoded.timeout,
|
|
240
|
+
})
|
|
241
|
+
.pipe(
|
|
242
|
+
Effect.flatMap(Schema.decodeUnknownEffect(Handoff)),
|
|
243
|
+
Effect.catch(() =>
|
|
244
|
+
state.handle.close.pipe(
|
|
245
|
+
Effect.flatMap((cleanup) =>
|
|
246
|
+
Effect.fail(
|
|
247
|
+
new ProtectedBrowserError({
|
|
248
|
+
reason: "outcome-unknown",
|
|
249
|
+
dispatch: "possibly-dispatched",
|
|
250
|
+
milestone: suspended.milestone,
|
|
251
|
+
observation: "closed",
|
|
252
|
+
cleanup,
|
|
253
|
+
}),
|
|
254
|
+
),
|
|
255
|
+
),
|
|
256
|
+
),
|
|
257
|
+
),
|
|
258
|
+
Effect.onInterrupt(() => state.handle.close),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
handoff = BrowserRunProtectedCheckpoint.make({
|
|
262
|
+
...provider.identity,
|
|
263
|
+
protected: suspended,
|
|
264
|
+
handoffId: Redacted.make(result.handoffId),
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
return handoff;
|
|
268
|
+
}),
|
|
269
|
+
),
|
|
270
|
+
getLiveView: (request) =>
|
|
271
|
+
control(
|
|
272
|
+
Effect.gen(function* () {
|
|
273
|
+
if (handoff?.handoffId === undefined) return yield* failure("denied");
|
|
274
|
+
|
|
275
|
+
const decoded = yield* Schema.decodeEffect(BrowserRunLiveViewRequest)(request).pipe(
|
|
276
|
+
Effect.mapError(() => failure("denied")),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
yield* fits(decoded.expiresInMs);
|
|
280
|
+
|
|
281
|
+
const raw = yield* provider.command("Cloudflare.getLiveView", {
|
|
282
|
+
mode: decoded.mode,
|
|
283
|
+
expiresInMs: decoded.expiresInMs,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const result = yield* Schema.decodeUnknownEffect(LiveView)(raw).pipe(
|
|
287
|
+
Effect.mapError(() => failure("provider")),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
return yield* Schema.decodeEffect(BrowserRunLiveViewResult)({
|
|
291
|
+
devtoolsFrontendUrl: Redacted.make(result.devtoolsFrontendUrl),
|
|
292
|
+
}).pipe(Effect.mapError(() => failure("provider")));
|
|
293
|
+
}),
|
|
294
|
+
),
|
|
295
|
+
getHandoffState: control(getHandoffState),
|
|
296
|
+
returnControl: control(
|
|
297
|
+
Effect.gen(function* () {
|
|
298
|
+
if ((yield* getHandoffState).active) return yield* failure("busy");
|
|
299
|
+
yield* state.returnControl;
|
|
300
|
+
handoff = undefined;
|
|
301
|
+
}),
|
|
302
|
+
),
|
|
303
|
+
detach: control(
|
|
304
|
+
Effect.gen(function* () {
|
|
305
|
+
if (handoff === undefined) return yield* failure("denied");
|
|
306
|
+
yield* state.detach;
|
|
307
|
+
detached = true;
|
|
308
|
+
}),
|
|
309
|
+
),
|
|
310
|
+
close: state.handle.close,
|
|
311
|
+
};
|
|
312
|
+
}, Effect.withTracerEnabled(false));
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
open: (policy: InteractiveBrowserPolicy) => open(policy),
|
|
316
|
+
resume: (checkpoint: BrowserRunProtectedCheckpoint) =>
|
|
317
|
+
Schema.decodeEffect(BrowserRunProtectedCheckpoint)(checkpoint).pipe(
|
|
318
|
+
Effect.mapError(() => failure("denied")),
|
|
319
|
+
Effect.flatMap((decoded) => open(decoded.protected.policy, decoded)),
|
|
320
|
+
),
|
|
321
|
+
closeSession: (id: Redacted.Redacted<string>) =>
|
|
322
|
+
lifecycle.close(id).pipe(
|
|
323
|
+
Effect.as("confirmed" as const),
|
|
324
|
+
Effect.catchCause(() => Effect.succeed("unconfirmed" as const)),
|
|
325
|
+
),
|
|
326
|
+
};
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
@@ -56,6 +56,8 @@ export class ProtectedNativeSession extends Context.Service<
|
|
|
56
56
|
readonly browser: Browser;
|
|
57
57
|
readonly page: Page;
|
|
58
58
|
readonly close: Effect.Effect<"confirmed" | "unconfirmed">;
|
|
59
|
+
/** Host-owned attachment release may detach a committed suspended session. */
|
|
60
|
+
readonly release?: Effect.Effect<void>;
|
|
59
61
|
}
|
|
60
62
|
>()("@effect-agent/platform-cloudflare/ProtectedNativeSession") {}
|
|
61
63
|
|
|
@@ -249,7 +251,20 @@ export const makeProtectedNativeTransport = Effect.fn("ProtectedNativeTransport.
|
|
|
249
251
|
page.on("framedetached", onNavigated);
|
|
250
252
|
browser.on("targetcreated", onTarget);
|
|
251
253
|
}),
|
|
252
|
-
() =>
|
|
254
|
+
() =>
|
|
255
|
+
session.release === undefined
|
|
256
|
+
? close
|
|
257
|
+
: session.release.pipe(
|
|
258
|
+
Effect.ensuring(
|
|
259
|
+
Effect.sync(() => {
|
|
260
|
+
invalidate();
|
|
261
|
+
page.off("framenavigated", onNavigated);
|
|
262
|
+
page.off("framedetached", onNavigated);
|
|
263
|
+
browser.off("targetcreated", onTarget);
|
|
264
|
+
frames.clear();
|
|
265
|
+
}),
|
|
266
|
+
),
|
|
267
|
+
),
|
|
253
268
|
);
|
|
254
269
|
|
|
255
270
|
return {
|
|
@@ -271,6 +286,7 @@ export const makeProtectedNativeTransport = Effect.fn("ProtectedNativeTransport.
|
|
|
271
286
|
),
|
|
272
287
|
context,
|
|
273
288
|
invalidate,
|
|
289
|
+
resetReferences: clear,
|
|
274
290
|
close,
|
|
275
291
|
navigate: Effect.fn("ProtectedNativeTransport.navigate")(function* (url) {
|
|
276
292
|
yield* check;
|