@effect-agent/platform-cloudflare 0.1.0-beta.41 → 0.1.0-beta.44
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/browser-quick-action.mjs.map +1 -1
- package/dist/browser-rest-capture.mjs.map +1 -1
- package/dist/browser-rest-crawl.mjs.map +1 -1
- package/dist/browser-session-lifecycle-DqntvG-Y.d.mts +21 -0
- package/dist/browser-session-lifecycle-ZGgb3pnK.mjs +84 -0
- package/dist/browser-session-lifecycle-ZGgb3pnK.mjs.map +1 -0
- package/dist/index.d.mts +85 -65
- package/dist/index.mjs +7 -2
- package/dist/index.mjs.map +1 -1
- package/dist/interactive-browser.d.mts +1 -18
- package/dist/interactive-browser.mjs +2 -81
- package/dist/interactive-browser.mjs.map +1 -1
- package/dist/prepared-admission-BKp_Upw2.mjs.map +1 -1
- package/dist/protected-browser.d.mts +62 -0
- package/dist/protected-browser.mjs +714 -0
- package/dist/protected-browser.mjs.map +1 -0
- package/dist/scheduling.mjs.map +1 -1
- package/dist/subscriptions.mjs.map +1 -1
- package/package.json +1 -81
- package/src/alarm.ts +41 -0
- package/src/bindings.ts +5 -0
- package/src/boundary.ts +4 -0
- package/src/browser-quick-action.ts +52 -0
- package/src/browser-rest-capture.ts +30 -0
- package/src/browser-rest-crawl.ts +43 -0
- package/src/browser-session-lifecycle.ts +18 -0
- package/src/client.ts +40 -0
- package/src/code-mode-executor.ts +50 -0
- package/src/index.ts +1 -1
- package/src/interactive-browser.ts +176 -0
- package/src/layers.ts +16 -1
- package/src/memory.ts +42 -3
- package/src/prepared-admission.ts +1 -0
- package/src/progress-wait.ts +14 -0
- package/src/protected-browser/binding.ts +185 -0
- package/src/protected-browser/inspect-frame.ts +82 -0
- package/src/protected-browser/native.ts +384 -0
- package/src/protected-browser/policy.ts +594 -0
- package/src/protected-browser.ts +2 -0
- package/src/scheduling.ts +34 -0
- package/src/subscriptions.ts +50 -0
- package/src/thread-object.ts +55 -1
- package/src/transport.ts +1 -0
- package/src/wake-scheduler.ts +1 -0
package/src/layers.ts
CHANGED
|
@@ -256,6 +256,7 @@ export const layerConfig = (
|
|
|
256
256
|
const { ctx } = yield* DurableObjectContext;
|
|
257
257
|
const config = yield* configFromOptions(options);
|
|
258
258
|
const threadId = yield* threadIdFromState(ctx);
|
|
259
|
+
|
|
259
260
|
const producerId = yield* decodeProducerId(`${config.producerPrefix}:${threadId}`).pipe(
|
|
260
261
|
Effect.mapError((error) =>
|
|
261
262
|
CloudflarePlatformConfigError.make({
|
|
@@ -264,6 +265,7 @@ export const layerConfig = (
|
|
|
264
265
|
}),
|
|
265
266
|
),
|
|
266
267
|
);
|
|
268
|
+
|
|
267
269
|
return Layer.mergeAll(
|
|
268
270
|
Layer.succeed(CloudflareDurableRuntimeConfig, config),
|
|
269
271
|
Layer.succeed(ThreadObjectIdentity, { threadId, producerId }),
|
|
@@ -310,7 +312,12 @@ export const layer = <const Entries extends ReadonlyArray<AgentRegistration>>(
|
|
|
310
312
|
registrations: Entries,
|
|
311
313
|
) => Layer.unwrap(Effect.map(compileRegistrations(registrations), layerFromBindings));
|
|
312
314
|
|
|
313
|
-
/**
|
|
315
|
+
/**
|
|
316
|
+
* Assemble the durable runtime from already-resolved Agent Bindings.
|
|
317
|
+
* Use `ThreadObject.layer` to compile typed Agent registrations instead.
|
|
318
|
+
* Supply host services through `ThreadObject.make` or `ThreadObject.layerConfig` and
|
|
319
|
+
* the Durable Object context and namespace Layers when composing a custom host.
|
|
320
|
+
*/
|
|
314
321
|
export const layerFromBindings = (
|
|
315
322
|
bindings: ReadonlyArray<ResolvedBinding>,
|
|
316
323
|
): Layer.Layer<
|
|
@@ -323,6 +330,7 @@ export const layerFromBindings = (
|
|
|
323
330
|
const { ctx } = yield* DurableObjectContext;
|
|
324
331
|
const config = yield* CloudflareDurableRuntimeConfig;
|
|
325
332
|
const { threadId } = yield* ThreadObjectIdentity;
|
|
333
|
+
|
|
326
334
|
const storageOptions: DoStorageOptions = {
|
|
327
335
|
storage: ctx.storage,
|
|
328
336
|
observationPollInterval: config.observationPollInterval,
|
|
@@ -330,6 +338,7 @@ export const layerFromBindings = (
|
|
|
330
338
|
maxStoredValueBytes: config.maxStoredValueBytes,
|
|
331
339
|
verifyOnOpen: config.verifyOnOpen,
|
|
332
340
|
};
|
|
341
|
+
|
|
333
342
|
const infrastructure = Layer.mergeAll(
|
|
334
343
|
storageConfigLayer(storageOptions),
|
|
335
344
|
SqliteClient.layer({ storage: ctx.storage }),
|
|
@@ -340,24 +349,30 @@ export const layerFromBindings = (
|
|
|
340
349
|
const localPorts = Layer.mergeAll(threadStoreLayer, submissionLedgerLayer).pipe(
|
|
341
350
|
Layer.provide(infrastructure),
|
|
342
351
|
);
|
|
352
|
+
|
|
343
353
|
const portsEndpointLayer = Layer.effect(ThreadObjectPorts)(
|
|
344
354
|
Effect.gen(function* () {
|
|
345
355
|
const local = yield* Effect.context<SubmissionLedger | ThreadStore>();
|
|
356
|
+
|
|
346
357
|
return ThreadObjectPorts.of({
|
|
347
358
|
handle: (request) => executePortRequest(request).pipe(Effect.provide(local)),
|
|
348
359
|
});
|
|
349
360
|
}),
|
|
350
361
|
).pipe(Layer.provide(localPorts));
|
|
362
|
+
|
|
351
363
|
const routedPorts = Layer.mergeAll(
|
|
352
364
|
routedSubmissionLedgerLayer({ localThreadId: threadId }),
|
|
353
365
|
routedThreadStoreLayer({ localThreadId: threadId }),
|
|
354
366
|
).pipe(Layer.provide(localPorts), Layer.provide(threadPortTransportLayer));
|
|
367
|
+
|
|
355
368
|
const base = Layer.mergeAll(DurableAlarmService.layer, ProgressWaitRegistry.layer);
|
|
369
|
+
|
|
356
370
|
const runtimeStack = DurableAgentRuntime.layerWithServices.pipe(
|
|
357
371
|
Layer.provideMerge(routedPorts),
|
|
358
372
|
Layer.provideMerge(cloudflareWakeSchedulerLayer),
|
|
359
373
|
Layer.provideMerge(base),
|
|
360
374
|
);
|
|
375
|
+
|
|
361
376
|
return Layer.mergeAll(
|
|
362
377
|
runtimeStack,
|
|
363
378
|
ThreadMaintenance.layer(bindings).pipe(Layer.provide(runtimeStack)),
|
package/src/memory.ts
CHANGED
|
@@ -69,32 +69,41 @@ const makeMemoryClient = Effect.fn("CloudflareMemoryClient.make")(function* <
|
|
|
69
69
|
const validated = yield* Schema.decodeUnknownEffect(MemoryRpcLimits)(rpcLimits).pipe(
|
|
70
70
|
Effect.mapError(() => MemoryRpcError.make({ reason: "protocol" })),
|
|
71
71
|
);
|
|
72
|
+
|
|
72
73
|
const bound = yield* Schema.decodeUnknownEffect(MemoryAccess.Wire)(access).pipe(
|
|
73
74
|
Effect.mapError(() => MemoryRpcError.make({ reason: "protocol" })),
|
|
74
75
|
);
|
|
76
|
+
|
|
75
77
|
principal = yield* Schema.decodeUnknownEffect(Principal)(principal).pipe(
|
|
76
78
|
Effect.mapError(() => MemoryRpcError.make({ reason: "protocol" })),
|
|
77
79
|
);
|
|
78
80
|
const { namespace } = yield* MemoryObjectNamespace;
|
|
81
|
+
|
|
79
82
|
const call = Effect.fn("CloudflareMemoryClient.call")(function* (request: MemoryOwnerRequest) {
|
|
80
83
|
const decoded = yield* Schema.decodeUnknownEffect(MemoryOwnerRequest)(request).pipe(
|
|
81
84
|
Effect.mapError(() => MemoryRpcError.make({ reason: "protocol" })),
|
|
82
85
|
);
|
|
86
|
+
|
|
83
87
|
const encoded = yield* encodeMemoryWire(MemoryOwnerRequest, decoded, validated.maxRequestBytes);
|
|
88
|
+
|
|
84
89
|
const raw = yield* Effect.tryPromise({
|
|
85
90
|
try: () =>
|
|
86
91
|
namespace.get(namespace.idFromName(memoryObjectName(bound.namespace))).memory(encoded),
|
|
87
92
|
catch: () => MemoryRpcError.make({ reason: "unavailable" }),
|
|
88
93
|
});
|
|
94
|
+
|
|
89
95
|
const response = yield* decodeMemoryWire(MemoryOwnerResponse, raw, validated.maxResponseBytes);
|
|
96
|
+
|
|
90
97
|
if (response._tag === "Failed") return yield* response.failure;
|
|
91
98
|
if (
|
|
92
99
|
!MemoryNamespace.equals(response.access.namespace, bound.namespace) ||
|
|
93
100
|
response.access.scope !== bound.scope
|
|
94
101
|
)
|
|
95
102
|
return yield* MemoryRpcError.make({ reason: "protocol" });
|
|
103
|
+
|
|
96
104
|
return response;
|
|
97
105
|
});
|
|
106
|
+
|
|
98
107
|
const withinDeadline = <A, E, R>(effect: Effect.Effect<A, E, R>, timeoutMillis: number) =>
|
|
99
108
|
effect.pipe(
|
|
100
109
|
Effect.timeoutOrElse({
|
|
@@ -102,6 +111,7 @@ const makeMemoryClient = Effect.fn("CloudflareMemoryClient.make")(function* <
|
|
|
102
111
|
orElse: () => Effect.fail(MemoryRpcError.make({ reason: "timeout" })),
|
|
103
112
|
}),
|
|
104
113
|
);
|
|
114
|
+
|
|
105
115
|
const revalidate = Effect.fn("CloudflareMemoryClient.revalidate")(function* (
|
|
106
116
|
lookup: MemoryLookup,
|
|
107
117
|
limits: MemoryRecallLimits,
|
|
@@ -110,6 +120,7 @@ const makeMemoryClient = Effect.fn("CloudflareMemoryClient.make")(function* <
|
|
|
110
120
|
Effect.mapError(() => MemoryRpcError.make({ reason: "protocol" })),
|
|
111
121
|
);
|
|
112
122
|
const timeoutMillis = Math.min(validated.timeoutMillis, limits.timeoutMillis);
|
|
123
|
+
|
|
113
124
|
return yield* Effect.gen(function* () {
|
|
114
125
|
const response = yield* call({
|
|
115
126
|
_tag: "Revalidate",
|
|
@@ -120,15 +131,19 @@ const makeMemoryClient = Effect.fn("CloudflareMemoryClient.make")(function* <
|
|
|
120
131
|
limits,
|
|
121
132
|
deadlineMillis: (yield* Clock.currentTimeMillis) + timeoutMillis,
|
|
122
133
|
});
|
|
134
|
+
|
|
123
135
|
if (response._tag !== "Lookup") return yield* MemoryRpcError.make({ reason: "protocol" });
|
|
136
|
+
|
|
124
137
|
return response.lookup;
|
|
125
138
|
}).pipe((effect) => withinDeadline(effect, timeoutMillis));
|
|
126
139
|
});
|
|
140
|
+
|
|
127
141
|
const change = Effect.fn("CloudflareMemoryClient.change")(function* (
|
|
128
142
|
write: MemoryWrite<Namespace>,
|
|
129
143
|
) {
|
|
130
144
|
if (!MemoryNamespace.equals(write.key.namespace, bound.namespace))
|
|
131
145
|
return yield* MemoryRpcError.make({ reason: "denied" });
|
|
146
|
+
|
|
132
147
|
return yield* Effect.gen(function* () {
|
|
133
148
|
const response = yield* call({
|
|
134
149
|
_tag: "Change",
|
|
@@ -138,11 +153,14 @@ const makeMemoryClient = Effect.fn("CloudflareMemoryClient.make")(function* <
|
|
|
138
153
|
write,
|
|
139
154
|
deadlineMillis: (yield* Clock.currentTimeMillis) + validated.timeoutMillis,
|
|
140
155
|
});
|
|
156
|
+
|
|
141
157
|
if (response._tag !== "Changed" || response.document.key.id !== write.key.id)
|
|
142
158
|
return yield* MemoryRpcError.make({ reason: "protocol" });
|
|
159
|
+
|
|
143
160
|
return yield* MemoryDocument.restore(access.namespace, response.document);
|
|
144
161
|
}).pipe((effect) => withinDeadline(effect, validated.timeoutMillis));
|
|
145
162
|
});
|
|
163
|
+
|
|
146
164
|
const revalidateSemantic = Effect.fn("CloudflareMemoryClient.revalidateSemantic")(function* (
|
|
147
165
|
found: MemoryIndexSearch<Namespace>,
|
|
148
166
|
profile: SemanticMemoryProfile,
|
|
@@ -159,10 +177,13 @@ const makeMemoryClient = Effect.fn("CloudflareMemoryClient.make")(function* <
|
|
|
159
177
|
limits,
|
|
160
178
|
deadlineMillis: (yield* Clock.currentTimeMillis) + validated.timeoutMillis,
|
|
161
179
|
});
|
|
180
|
+
|
|
162
181
|
if (response._tag !== "Semantic") return yield* MemoryRpcError.make({ reason: "protocol" });
|
|
182
|
+
|
|
163
183
|
return response.result;
|
|
164
184
|
}).pipe((effect) => withinDeadline(effect, validated.timeoutMillis));
|
|
165
185
|
});
|
|
186
|
+
|
|
166
187
|
/**
|
|
167
188
|
* Revalidate in one owner RPC, then render whole passages within the caller's budget.
|
|
168
189
|
* The bound source is essential: unavailable/stale results and matches that cannot fit
|
|
@@ -181,6 +202,7 @@ const makeMemoryClient = Effect.fn("CloudflareMemoryClient.make")(function* <
|
|
|
181
202
|
estimateTokens,
|
|
182
203
|
);
|
|
183
204
|
});
|
|
205
|
+
|
|
184
206
|
return { recall, revalidate, revalidateSemantic, change };
|
|
185
207
|
});
|
|
186
208
|
|
|
@@ -218,6 +240,7 @@ export const cloudflareMemoryWriterLayer = (
|
|
|
218
240
|
MemoryWriter,
|
|
219
241
|
Effect.gen(function* () {
|
|
220
242
|
const client = yield* CloudflareMemoryClient.make(access, principal, limits);
|
|
243
|
+
|
|
221
244
|
return MemoryWriter.fromAdapter({
|
|
222
245
|
change: (write) =>
|
|
223
246
|
client.change(write).pipe(
|
|
@@ -243,11 +266,13 @@ export const cloudflareMemoryWriterLayer = (
|
|
|
243
266
|
);
|
|
244
267
|
|
|
245
268
|
type OwnerServices = MemoryReader | MemoryWriter | MemoryOwnerAuthorizer | MemoryOwnerIdentity;
|
|
269
|
+
|
|
246
270
|
export interface MemoryObjectInstance extends InstanceType<
|
|
247
271
|
EffectCfDurableObject.DurableObjectClass<Record<never, never>, OwnerServices>
|
|
248
272
|
> {
|
|
249
273
|
memory(encoded: string): Promise<string>;
|
|
250
274
|
}
|
|
275
|
+
|
|
251
276
|
export interface MemoryObjectClass {
|
|
252
277
|
new (ctx: globalThis.DurableObjectState, env: Cloudflare.Env): MemoryObjectInstance;
|
|
253
278
|
}
|
|
@@ -277,12 +302,15 @@ const makeMemoryObject = <E>(
|
|
|
277
302
|
MemoryOwnerIdentity,
|
|
278
303
|
Effect.gen(function* () {
|
|
279
304
|
const state = yield* DurableObjectState.DurableObjectState;
|
|
305
|
+
|
|
280
306
|
const address = yield* Schema.decodeUnknownEffect(MemoryNamespaceAddress)(
|
|
281
307
|
state.raw.id.name,
|
|
282
308
|
).pipe(Effect.mapError(() => MemoryRpcError.make({ reason: "denied" })));
|
|
309
|
+
|
|
283
310
|
return { namespace: MemoryNamespace.Any.make({ address }) };
|
|
284
311
|
}),
|
|
285
312
|
);
|
|
313
|
+
|
|
286
314
|
const store = Layer.unwrap(
|
|
287
315
|
Effect.map(DurableObjectState.DurableObjectState, (state) =>
|
|
288
316
|
doMemoryStoreLayerWithFailpoints(
|
|
@@ -291,7 +319,9 @@ const makeMemoryObject = <E>(
|
|
|
291
319
|
),
|
|
292
320
|
),
|
|
293
321
|
).pipe(Layer.provide(options.failpoints ?? MemoryMutationFailpoint.layer));
|
|
322
|
+
|
|
294
323
|
const application = Layer.merge(store, host).pipe(Layer.provideMerge(identity));
|
|
324
|
+
|
|
295
325
|
const runtime: Layer.Layer<
|
|
296
326
|
OwnerServices,
|
|
297
327
|
E | MemoryOwnerFailure,
|
|
@@ -300,15 +330,24 @@ const makeMemoryObject = <E>(
|
|
|
300
330
|
Effect.gen(function* () {
|
|
301
331
|
const state = yield* DurableObjectState.DurableObjectState;
|
|
302
332
|
const scope = yield* Effect.scope;
|
|
333
|
+
|
|
303
334
|
yield* Schema.decodeUnknownEffect(MemoryRpcLimits)(
|
|
304
335
|
options.rpcLimits ?? defaultMemoryRpcLimits,
|
|
305
336
|
).pipe(Effect.mapError(() => MemoryRpcError.make({ reason: "protocol" })));
|
|
337
|
+
|
|
306
338
|
return yield* state.blockConcurrencyWhile(Layer.buildWithScope(application, scope));
|
|
307
339
|
}),
|
|
308
340
|
);
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
341
|
+
|
|
342
|
+
const rpc = { memory: (encoded: string) => handleMemoryOwnerRequest(encoded, options.rpcLimits) };
|
|
343
|
+
|
|
344
|
+
return EffectCfDurableObject.make<
|
|
345
|
+
OwnerServices,
|
|
346
|
+
E | MemoryOwnerFailure,
|
|
347
|
+
never,
|
|
348
|
+
never,
|
|
349
|
+
typeof rpc
|
|
350
|
+
>(runtime, { rpc });
|
|
312
351
|
};
|
|
313
352
|
|
|
314
353
|
export const MemoryObject = {
|
package/src/progress-wait.ts
CHANGED
|
@@ -31,17 +31,20 @@ export class ProgressWaitRegistry extends Context.Service<
|
|
|
31
31
|
const remove = (waiterId: string, deferred: Deferred.Deferred<void>) =>
|
|
32
32
|
Ref.update(registrations, (current) => {
|
|
33
33
|
const existing = current.get(waiterId);
|
|
34
|
+
|
|
34
35
|
if (existing === undefined || existing === "cancelled" || !existing.has(deferred)) {
|
|
35
36
|
return current;
|
|
36
37
|
}
|
|
37
38
|
const next = new Map(current);
|
|
38
39
|
const active = new Set(existing);
|
|
40
|
+
|
|
39
41
|
active.delete(deferred);
|
|
40
42
|
if (active.size === 0) {
|
|
41
43
|
next.delete(waiterId);
|
|
42
44
|
} else {
|
|
43
45
|
next.set(waiterId, active);
|
|
44
46
|
}
|
|
47
|
+
|
|
45
48
|
return next;
|
|
46
49
|
});
|
|
47
50
|
|
|
@@ -49,18 +52,24 @@ export class ProgressWaitRegistry extends Context.Service<
|
|
|
49
52
|
(waiterId: string): Effect.Effect<Effect.Effect<void>, never, Scope.Scope> =>
|
|
50
53
|
Effect.gen(function* () {
|
|
51
54
|
const deferred = yield* Deferred.make<void>();
|
|
55
|
+
|
|
52
56
|
yield* Effect.addFinalizer(() => remove(waiterId, deferred));
|
|
57
|
+
|
|
53
58
|
const cancelled = yield* Ref.modify(registrations, (current) => {
|
|
54
59
|
const existing = current.get(waiterId);
|
|
55
60
|
const next = new Map(current);
|
|
61
|
+
|
|
56
62
|
if (existing === "cancelled") {
|
|
57
63
|
return [true, current] as const;
|
|
58
64
|
}
|
|
59
65
|
const active = new Set(existing ?? []);
|
|
66
|
+
|
|
60
67
|
active.add(deferred);
|
|
61
68
|
next.set(waiterId, active);
|
|
69
|
+
|
|
62
70
|
return [false, next] as const;
|
|
63
71
|
});
|
|
72
|
+
|
|
64
73
|
return { cancelled, deferred };
|
|
65
74
|
}).pipe(
|
|
66
75
|
Effect.map(({ cancelled, deferred }) =>
|
|
@@ -73,9 +82,11 @@ export class ProgressWaitRegistry extends Context.Service<
|
|
|
73
82
|
const waiters = yield* Ref.modify(registrations, (current) => {
|
|
74
83
|
const existing = current.get(waiterId);
|
|
75
84
|
const next = new Map(current);
|
|
85
|
+
|
|
76
86
|
if (existing === undefined) {
|
|
77
87
|
next.set(waiterId, "cancelled");
|
|
78
88
|
let tombstones = 0;
|
|
89
|
+
|
|
79
90
|
for (const registration of next.values()) {
|
|
80
91
|
if (registration === "cancelled") tombstones += 1;
|
|
81
92
|
}
|
|
@@ -86,12 +97,15 @@ export class ProgressWaitRegistry extends Context.Service<
|
|
|
86
97
|
break;
|
|
87
98
|
}
|
|
88
99
|
}
|
|
100
|
+
|
|
89
101
|
return [[], next] as const;
|
|
90
102
|
}
|
|
91
103
|
if (existing === "cancelled") return [[], current] as const;
|
|
92
104
|
next.delete(waiterId);
|
|
105
|
+
|
|
93
106
|
return [[...existing], next] as const;
|
|
94
107
|
});
|
|
108
|
+
|
|
95
109
|
yield* Effect.forEach(waiters, (waiter) => Deferred.succeed(waiter, undefined), {
|
|
96
110
|
discard: true,
|
|
97
111
|
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import puppeteer, { type Browser } from "@cloudflare/puppeteer";
|
|
3
|
+
import { ProtectedBrowserError, type InteractiveBrowserPolicy } from "@effect-agent/sandbox";
|
|
4
|
+
import { Clock, Context, Crypto, Effect, Layer, Redacted, Schema, type Scope } from "effect";
|
|
5
|
+
|
|
6
|
+
import { BrowserRunSessionLifecycle } from "../browser-session-lifecycle.ts";
|
|
7
|
+
import { makeProtectedNativeTransport, ProtectedNativeSession } from "./native.ts";
|
|
8
|
+
import {
|
|
9
|
+
BrowserRunProtectedTransport,
|
|
10
|
+
ProtectedTransportError,
|
|
11
|
+
type ProtectedBrowserTransport,
|
|
12
|
+
} from "./policy.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Acquires through BROWSER with recording=false explicitly on the wire. The trusted provider's
|
|
16
|
+
* documented opt-in semantics are the recording guarantee; no recording-status API exists.
|
|
17
|
+
* Account operators remain trusted, and must not attach external observers to private passes.
|
|
18
|
+
*/
|
|
19
|
+
export const browserRunProtectedBindingLayer = (options: {
|
|
20
|
+
readonly browser: Pick<BrowserRun, "fetch">;
|
|
21
|
+
}) =>
|
|
22
|
+
Layer.effect(BrowserRunProtectedTransport)(
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
const lifecycle = yield* BrowserRunSessionLifecycle;
|
|
25
|
+
const crypto = yield* Crypto.Crypto;
|
|
26
|
+
|
|
27
|
+
const open = Effect.fn("BrowserRunProtectedTransport.open")(function* (
|
|
28
|
+
policy: InteractiveBrowserPolicy,
|
|
29
|
+
): Effect.fn.Return<ProtectedBrowserTransport, ProtectedBrowserError, Scope.Scope> {
|
|
30
|
+
const failure = () =>
|
|
31
|
+
new ProtectedBrowserError({
|
|
32
|
+
reason: "provider",
|
|
33
|
+
dispatch: "not-dispatched",
|
|
34
|
+
milestone: "none",
|
|
35
|
+
observation: "closed",
|
|
36
|
+
cleanup: "unconfirmed",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
let sessionId: Redacted.Redacted<string> | undefined;
|
|
40
|
+
let browser: Browser | undefined;
|
|
41
|
+
let driver: ProtectedBrowserTransport | undefined;
|
|
42
|
+
let invalid = false;
|
|
43
|
+
// SDK acquisition may finish after interruption. Its late-reply callback must await cleanup,
|
|
44
|
+
// using this pass's clock rather than starting an Effect runtime with default services.
|
|
45
|
+
const runCleanup = Effect.runPromiseWith(Context.make(Clock.Clock, yield* Clock.Clock));
|
|
46
|
+
|
|
47
|
+
const terminate = Effect.gen(function* () {
|
|
48
|
+
invalid = true;
|
|
49
|
+
driver?.invalidate();
|
|
50
|
+
if (sessionId === undefined) return "unconfirmed" as const;
|
|
51
|
+
|
|
52
|
+
const cleanup = yield* lifecycle.close(sessionId).pipe(
|
|
53
|
+
Effect.as("confirmed" as const),
|
|
54
|
+
Effect.catchCause(() => Effect.succeed("unconfirmed" as const)),
|
|
55
|
+
Effect.interruptible,
|
|
56
|
+
Effect.timeoutOrElse({
|
|
57
|
+
duration: "10 seconds",
|
|
58
|
+
orElse: () => Effect.succeed("unconfirmed" as const),
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Local disconnect is not remote-closure evidence. Do it even when confirmation fails.
|
|
63
|
+
const connected = browser;
|
|
64
|
+
|
|
65
|
+
if (connected !== undefined)
|
|
66
|
+
yield* Effect.promise(() => connected.disconnect()).pipe(
|
|
67
|
+
Effect.catchCause(() => Effect.void),
|
|
68
|
+
Effect.interruptible,
|
|
69
|
+
Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.void }),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return cleanup;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const close = yield* Effect.cached(terminate);
|
|
76
|
+
|
|
77
|
+
yield* Effect.addFinalizer(() =>
|
|
78
|
+
close.pipe(
|
|
79
|
+
Effect.flatMap((state) =>
|
|
80
|
+
state === "confirmed"
|
|
81
|
+
? Effect.void
|
|
82
|
+
: Effect.logWarning("Protected browser exact-session closure unconfirmed"),
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const acquired = yield* Effect.tryPromise({
|
|
88
|
+
try: async (signal) => {
|
|
89
|
+
let recordingDisabled = false;
|
|
90
|
+
|
|
91
|
+
const binding = {
|
|
92
|
+
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
93
|
+
const request = new Request(input, init);
|
|
94
|
+
const url = new URL(request.url);
|
|
95
|
+
|
|
96
|
+
if (url.pathname === "/v1/devtools/browser" && request.method === "POST") {
|
|
97
|
+
url.searchParams.set("recording", "false");
|
|
98
|
+
recordingDisabled = url.searchParams.get("recording") === "false";
|
|
99
|
+
|
|
100
|
+
return options.browser.fetch(new Request(url, request));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return options.browser.fetch(request);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const acquired = await puppeteer.acquire(binding, {
|
|
108
|
+
recording: false,
|
|
109
|
+
keep_alive: Math.max(10_000, policy.maxElapsedMillis),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
sessionId = Redacted.make(
|
|
113
|
+
Schema.decodeUnknownSync(Schema.String.check(Schema.isUUID()))(acquired.sessionId),
|
|
114
|
+
);
|
|
115
|
+
if (!recordingDisabled || signal.aborted || invalid) {
|
|
116
|
+
await runCleanup(terminate);
|
|
117
|
+
throw new ProtectedTransportError({ reason: "stale-reference" });
|
|
118
|
+
}
|
|
119
|
+
// Initial attachment only. No recovery/reconnection path exists.
|
|
120
|
+
browser = await puppeteer.connect(options.browser, acquired.sessionId);
|
|
121
|
+
if (signal.aborted || invalid) {
|
|
122
|
+
await runCleanup(terminate);
|
|
123
|
+
throw new ProtectedTransportError({ reason: "stale-reference" });
|
|
124
|
+
}
|
|
125
|
+
const context = await browser.createBrowserContext();
|
|
126
|
+
const page = await context.newPage();
|
|
127
|
+
|
|
128
|
+
await page.setBypassServiceWorker(true);
|
|
129
|
+
await page.setRequestInterception(true);
|
|
130
|
+
page.on("request", (request) => {
|
|
131
|
+
let allowed = policy.network._tag === "Unrestricted";
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const url = new URL(request.url());
|
|
135
|
+
|
|
136
|
+
allowed ||=
|
|
137
|
+
policy.network._tag === "ExactHosts" &&
|
|
138
|
+
url.protocol === "https:" &&
|
|
139
|
+
!url.username &&
|
|
140
|
+
!url.password &&
|
|
141
|
+
policy.network.allowedHosts.includes(url.host);
|
|
142
|
+
} catch {
|
|
143
|
+
/* Refuse malformed destinations. */
|
|
144
|
+
}
|
|
145
|
+
void (
|
|
146
|
+
allowed && !invalid ? request.continue() : request.abort("blockedbyclient")
|
|
147
|
+
).catch(() => {
|
|
148
|
+
invalid = true;
|
|
149
|
+
driver?.invalidate();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
if (signal.aborted || invalid) {
|
|
153
|
+
await runCleanup(terminate);
|
|
154
|
+
throw new ProtectedTransportError({ reason: "stale-reference" });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return ProtectedNativeSession.of({ browser, page, close });
|
|
158
|
+
},
|
|
159
|
+
catch: failure,
|
|
160
|
+
}).pipe(
|
|
161
|
+
Effect.timeoutOrElse({
|
|
162
|
+
duration: Math.min(policy.maxElapsedMillis, 30_000),
|
|
163
|
+
orElse: () =>
|
|
164
|
+
Effect.fail(new ProtectedBrowserError({ ...failure(), reason: "timeout" })),
|
|
165
|
+
}),
|
|
166
|
+
Effect.catch((error) =>
|
|
167
|
+
close.pipe(
|
|
168
|
+
Effect.flatMap((cleanup) =>
|
|
169
|
+
Effect.fail(new ProtectedBrowserError({ ...error, cleanup })),
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
driver = yield* makeProtectedNativeTransport(policy).pipe(
|
|
176
|
+
Effect.provideService(ProtectedNativeSession, acquired),
|
|
177
|
+
Effect.provideService(Crypto.Crypto, crypto),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return driver;
|
|
181
|
+
}, Effect.withTracerEnabled(false));
|
|
182
|
+
|
|
183
|
+
return { open };
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// This fixed program runs in an isolated world. Pages cannot replace its native DOM methods.
|
|
2
|
+
// Handles retain actual nodes; a selector is never reconstructed from an opaque reference.
|
|
3
|
+
export const maxAttributeLength = 2048;
|
|
4
|
+
|
|
5
|
+
export const inspectFrame = `(() => {
|
|
6
|
+
const doc = document;
|
|
7
|
+
const forms = [];
|
|
8
|
+
const elements = [...doc.querySelectorAll('input,select,button,a[href]')].slice(0, 65);
|
|
9
|
+
const describe = (el) => {
|
|
10
|
+
if (doc !== document || !el.isConnected || el.ownerDocument !== doc) return null;
|
|
11
|
+
const form = el.form ?? null;
|
|
12
|
+
let formIndex = forms.indexOf(form);
|
|
13
|
+
if (formIndex < 0) { formIndex = forms.length; forms.push(form); }
|
|
14
|
+
const action = form ? (el.hasAttribute('formaction') ? el.formAction : form.action || doc.URL) : doc.URL;
|
|
15
|
+
const method = form ? (el.hasAttribute('formmethod') ? el.formMethod : form.method) : '';
|
|
16
|
+
const enctype = form?.enctype ?? '';
|
|
17
|
+
const name = el.name ?? '';
|
|
18
|
+
const completion = el.getAttribute('autocomplete') ?? '';
|
|
19
|
+
const inputType = el.type ?? '';
|
|
20
|
+
// Reject before parsing, fingerprinting or CDP transfer. Truncation could hide a target change.
|
|
21
|
+
if ([action, method, enctype, name, completion, inputType].some(value => value.length > ${maxAttributeLength})) return null;
|
|
22
|
+
const type = inputType.toLowerCase();
|
|
23
|
+
const autocomplete = completion.trim().toLowerCase().split(/\\s+/).at(-1);
|
|
24
|
+
const cardRoles = { 'cc-name':'card-name', 'cc-number':'card-number', 'cc-exp':'card-expiry',
|
|
25
|
+
'cc-exp-month':'card-expiry-month', 'cc-exp-year':'card-expiry-year', 'cc-csc':'card-security-code' };
|
|
26
|
+
let role = 'unsupported';
|
|
27
|
+
const nativeField = el instanceof HTMLInputElement || el instanceof HTMLSelectElement;
|
|
28
|
+
if (nativeField && form && !['submit','button'].includes(type) && !el.disabled && !el.readOnly && el.getClientRects().length > 0) {
|
|
29
|
+
if (el instanceof HTMLInputElement && type === 'password') role = 'password';
|
|
30
|
+
else if (['text','email','tel','number','month',''].includes(type) || el instanceof HTMLSelectElement) {
|
|
31
|
+
if (cardRoles[autocomplete]) role = cardRoles[autocomplete];
|
|
32
|
+
else if (autocomplete === 'username' || autocomplete === 'email') role = 'username';
|
|
33
|
+
else if (['text','email'].includes(type) && form && form.querySelector('input[type="password"]')) role = 'username';
|
|
34
|
+
}
|
|
35
|
+
} else if (el instanceof HTMLButtonElement || (el instanceof HTMLInputElement && ['submit','button'].includes(type))) {
|
|
36
|
+
if (!el.disabled) role = type === 'submit' && form ? 'submit' : type === 'button' ? 'button' : 'unsupported';
|
|
37
|
+
} else if (el instanceof HTMLAnchorElement) role = 'link';
|
|
38
|
+
const fingerprint = JSON.stringify([role, action, method, enctype, name, completion, type]);
|
|
39
|
+
return { role, formIndex, action, fingerprint,
|
|
40
|
+
label: (el.labels?.[0]?.textContent ?? el.getAttribute('aria-label') ?? el.textContent ?? '').slice(0,200) };
|
|
41
|
+
};
|
|
42
|
+
const expose = ({role, formIndex, action, label}) => ({role, formIndex, action, label});
|
|
43
|
+
const original = elements.map(describe);
|
|
44
|
+
const validate = (index) => {
|
|
45
|
+
const current = describe(elements[index]);
|
|
46
|
+
if (!current || !original[index] || current.fingerprint !== original[index].fingerprint ||
|
|
47
|
+
current.formIndex !== original[index].formIndex) return null;
|
|
48
|
+
return expose(current);
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
doc, elements, original: original.map(current => current && expose(current)), validate,
|
|
52
|
+
text: () => {
|
|
53
|
+
if (doc !== document) return null;
|
|
54
|
+
const clone = doc.body?.cloneNode(true);
|
|
55
|
+
clone?.querySelectorAll('input,textarea,select,script,style,noscript,iframe,object,embed').forEach(el => el.remove());
|
|
56
|
+
return (clone?.textContent ?? '').slice(0,65536);
|
|
57
|
+
},
|
|
58
|
+
fill: (index, role, value) => {
|
|
59
|
+
const current = validate(index);
|
|
60
|
+
if (!current || current.role !== role) return false;
|
|
61
|
+
const el = elements[index];
|
|
62
|
+
let prototype = Object.getPrototypeOf(el);
|
|
63
|
+
let setter;
|
|
64
|
+
while (prototype && !setter) { setter = Object.getOwnPropertyDescriptor(prototype,'value')?.set; prototype = Object.getPrototypeOf(prototype); }
|
|
65
|
+
if (!setter) return false;
|
|
66
|
+
// Validate and mutate in one isolated-world task, without focusing first and running
|
|
67
|
+
// page handlers between validation and assignment. Native setters support controlled inputs.
|
|
68
|
+
setter.call(el, value);
|
|
69
|
+
if (el.value !== value) return 'unsupported';
|
|
70
|
+
el.dispatchEvent(new Event('input', {bubbles:true}));
|
|
71
|
+
el.dispatchEvent(new Event('change', {bubbles:true}));
|
|
72
|
+
return true;
|
|
73
|
+
},
|
|
74
|
+
click: (index) => {
|
|
75
|
+
const current = validate(index);
|
|
76
|
+
if (!current || !['button','submit','link'].includes(current.role)) return false;
|
|
77
|
+
if (current.role === 'submit' && elements[index].form && !elements[index].form.matches(':valid')) return 'needs-attention';
|
|
78
|
+
elements[index].click();
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
})()`;
|