@effect-agent/platform-cloudflare 0.1.0-beta.42 → 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 +69 -56
- 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/interactive-browser.ts +176 -0
- package/src/layers.ts +10 -0
- 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 }),
|
|
@@ -328,6 +330,7 @@ export const layerFromBindings = (
|
|
|
328
330
|
const { ctx } = yield* DurableObjectContext;
|
|
329
331
|
const config = yield* CloudflareDurableRuntimeConfig;
|
|
330
332
|
const { threadId } = yield* ThreadObjectIdentity;
|
|
333
|
+
|
|
331
334
|
const storageOptions: DoStorageOptions = {
|
|
332
335
|
storage: ctx.storage,
|
|
333
336
|
observationPollInterval: config.observationPollInterval,
|
|
@@ -335,6 +338,7 @@ export const layerFromBindings = (
|
|
|
335
338
|
maxStoredValueBytes: config.maxStoredValueBytes,
|
|
336
339
|
verifyOnOpen: config.verifyOnOpen,
|
|
337
340
|
};
|
|
341
|
+
|
|
338
342
|
const infrastructure = Layer.mergeAll(
|
|
339
343
|
storageConfigLayer(storageOptions),
|
|
340
344
|
SqliteClient.layer({ storage: ctx.storage }),
|
|
@@ -345,24 +349,30 @@ export const layerFromBindings = (
|
|
|
345
349
|
const localPorts = Layer.mergeAll(threadStoreLayer, submissionLedgerLayer).pipe(
|
|
346
350
|
Layer.provide(infrastructure),
|
|
347
351
|
);
|
|
352
|
+
|
|
348
353
|
const portsEndpointLayer = Layer.effect(ThreadObjectPorts)(
|
|
349
354
|
Effect.gen(function* () {
|
|
350
355
|
const local = yield* Effect.context<SubmissionLedger | ThreadStore>();
|
|
356
|
+
|
|
351
357
|
return ThreadObjectPorts.of({
|
|
352
358
|
handle: (request) => executePortRequest(request).pipe(Effect.provide(local)),
|
|
353
359
|
});
|
|
354
360
|
}),
|
|
355
361
|
).pipe(Layer.provide(localPorts));
|
|
362
|
+
|
|
356
363
|
const routedPorts = Layer.mergeAll(
|
|
357
364
|
routedSubmissionLedgerLayer({ localThreadId: threadId }),
|
|
358
365
|
routedThreadStoreLayer({ localThreadId: threadId }),
|
|
359
366
|
).pipe(Layer.provide(localPorts), Layer.provide(threadPortTransportLayer));
|
|
367
|
+
|
|
360
368
|
const base = Layer.mergeAll(DurableAlarmService.layer, ProgressWaitRegistry.layer);
|
|
369
|
+
|
|
361
370
|
const runtimeStack = DurableAgentRuntime.layerWithServices.pipe(
|
|
362
371
|
Layer.provideMerge(routedPorts),
|
|
363
372
|
Layer.provideMerge(cloudflareWakeSchedulerLayer),
|
|
364
373
|
Layer.provideMerge(base),
|
|
365
374
|
);
|
|
375
|
+
|
|
366
376
|
return Layer.mergeAll(
|
|
367
377
|
runtimeStack,
|
|
368
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
|
+
})()`;
|