@effect-agent/testing 0.0.1-beta.5 → 0.1.0-beta.6

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.
@@ -0,0 +1,488 @@
1
+ import {
2
+ CodeExecutionHost,
3
+ CodeExecutionLimits,
4
+ CodeExecutionNamespace,
5
+ CodeExecutionProtocolError,
6
+ CodeExecutionRequest,
7
+ CodeExecutionResourceUse,
8
+ CodeExecutionResult,
9
+ CodeExecutionTimeoutError,
10
+ CodeExecutor,
11
+ type CodeExecutorExecute,
12
+ CodeExecutorUnsupportedError,
13
+ CodeHostCall,
14
+ CodeHostCallLimitError,
15
+ CodeHostCallResult,
16
+ CodeOutputLimitError,
17
+ CodeProgramFailedError,
18
+ CodeSourceError,
19
+ SandboxImplementation,
20
+ } from "@effect-agent/sandbox";
21
+ import { Clock, Duration, Effect, Fiber, Layer, Option, Queue, Schema } from "effect";
22
+
23
+ /**
24
+ * The deterministic in-process executor substitute (C1 of ADR-0017). It runs
25
+ * the generated program on the host JavaScript engine with best-effort global
26
+ * shadowing only, so it self-identifies as `unisolated` and is never a
27
+ * security boundary (CAP-010, CAP-015). It exists to prove the public
28
+ * `CodeExecutor` contract and to drive deterministic capability tests.
29
+ */
30
+ export const inProcessCodeExecutorImplementation = SandboxImplementation.make({
31
+ isolation: "unisolated",
32
+ identity: "in-process-javascript",
33
+ });
34
+
35
+ // These two caps mirror the wire schema bounds (`BoundedLogs` is at most
36
+ // 4096 lines of at most 16 KiB each): capture must stay inside what
37
+ // `CodeExecutionResult` can carry. A line over the per-line cap is truncated
38
+ // with an explicit `…` marker; exceeding either the byte budget or the line
39
+ // cap fails the pass typed.
40
+ const MAX_LOG_LINES = 4_096;
41
+ const MAX_LOG_LINE_CHARACTERS = 16_000;
42
+ const MAX_THROWN_CHARACTERS = 4_000;
43
+
44
+ const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength;
45
+
46
+ /**
47
+ * Ambient globals shadowed inside the harness. Shadowing blocks the obvious
48
+ * identifier paths only; a determined program can still escape, which is
49
+ * exactly why this executor reports `unisolated` and the isolated network and
50
+ * CPU enforcement conformance cases run only against isolated adapters.
51
+ */
52
+ const shadowedGlobals = [
53
+ "fetch",
54
+ "process",
55
+ "require",
56
+ "module",
57
+ "exports",
58
+ "global",
59
+ "globalThis",
60
+ "XMLHttpRequest",
61
+ "WebSocket",
62
+ "Deno",
63
+ "Bun",
64
+ ] as const;
65
+
66
+ class LogLimitSignal {
67
+ constructor(readonly observed: number) {}
68
+ }
69
+
70
+ class EvaluationThrew {
71
+ constructor(readonly inner: unknown) {}
72
+ }
73
+
74
+ class NotAFunction {
75
+ constructor(readonly actual: string) {}
76
+ }
77
+
78
+ interface LogCapture {
79
+ readonly lines: Array<string>;
80
+ bytes: number;
81
+ }
82
+
83
+ /**
84
+ * Total, defect-free rendering of untrusted values: a hostile Proxy can throw
85
+ * from property access, `toString`, and `Symbol.toPrimitive`, and an expected
86
+ * program failure must never escape the typed channel as a defect while its
87
+ * diagnostics are being serialized.
88
+ */
89
+ const formatLogValue = (value: unknown): string => {
90
+ try {
91
+ if (typeof value === "string") {
92
+ return value;
93
+ }
94
+ return JSON.stringify(value) ?? String(value);
95
+ } catch {
96
+ try {
97
+ return String(value);
98
+ } catch {
99
+ return "[unprintable value]";
100
+ }
101
+ }
102
+ };
103
+
104
+ const makeConsole = (capture: LogCapture, limits: CodeExecutionLimits) => {
105
+ const write = (...values: ReadonlyArray<unknown>): void => {
106
+ const joined = values.map(formatLogValue).join(" ");
107
+ const line =
108
+ joined.length > MAX_LOG_LINE_CHARACTERS
109
+ ? `${joined.slice(0, MAX_LOG_LINE_CHARACTERS - 1)}…`
110
+ : joined;
111
+ const bytes = utf8ByteLength(line);
112
+ if (capture.lines.length >= MAX_LOG_LINES || capture.bytes + bytes > limits.maxLogBytes) {
113
+ throw new LogLimitSignal(capture.bytes + bytes);
114
+ }
115
+ capture.lines.push(line);
116
+ capture.bytes += bytes;
117
+ };
118
+ return { debug: write, error: write, info: write, log: write, warn: write };
119
+ };
120
+
121
+ interface PendingHostCall {
122
+ readonly namespace: string;
123
+ readonly method: string;
124
+ readonly argument: unknown;
125
+ readonly resolve: (value: unknown) => void;
126
+ readonly reject: (reason: unknown) => void;
127
+ }
128
+
129
+ const buildNamespaceObject = (
130
+ namespace: CodeExecutionNamespace,
131
+ offer: (pending: PendingHostCall) => void,
132
+ ): Record<string, unknown> => {
133
+ const methods: Record<string, unknown> = {};
134
+ for (const method of namespace.methods) {
135
+ methods[method] = (argument: unknown) =>
136
+ new Promise((resolve, reject) => {
137
+ offer({ namespace: namespace.name, method, argument, resolve, reject });
138
+ });
139
+ }
140
+ return methods;
141
+ };
142
+
143
+ const boundedText = (value: unknown): string => {
144
+ try {
145
+ const text = value instanceof Error ? `${value.name}: ${value.message}` : formatLogValue(value);
146
+ return text.slice(0, MAX_THROWN_CHARACTERS);
147
+ } catch {
148
+ return "[unserializable thrown value]";
149
+ }
150
+ };
151
+
152
+ /** Schema decoding of hostile values may itself throw through trap getters. */
153
+ const safeDecodeJson = (value: unknown): Option.Option<Schema.Json> => {
154
+ try {
155
+ return Schema.decodeUnknownOption(Schema.Json)(value);
156
+ } catch {
157
+ return Option.none();
158
+ }
159
+ };
160
+
161
+ const boundedThrown = (value: unknown): Schema.Json => {
162
+ const decoded = safeDecodeJson(value);
163
+ if (Option.isSome(decoded)) {
164
+ try {
165
+ const encoded = JSON.stringify(decoded.value);
166
+ if (encoded !== undefined && encoded.length <= MAX_THROWN_CHARACTERS) {
167
+ return decoded.value;
168
+ }
169
+ } catch {
170
+ // fall through to the bounded string form
171
+ }
172
+ }
173
+ return boundedText(value);
174
+ };
175
+
176
+ const encodedJsonByteLength = (value: Schema.Json): number | undefined => {
177
+ try {
178
+ const encoded = JSON.stringify(value);
179
+ return encoded === undefined ? undefined : utf8ByteLength(encoded);
180
+ } catch {
181
+ return undefined;
182
+ }
183
+ };
184
+
185
+ /** Host outcomes are protocol input; a hostile value must not defect mid-decode. */
186
+ const decodeHostOutcome = (value: unknown): Option.Option<CodeHostCallResult> => {
187
+ try {
188
+ return Schema.decodeUnknownOption(CodeHostCallResult)(value);
189
+ } catch {
190
+ return Option.none();
191
+ }
192
+ };
193
+
194
+ const validateRequest = (
195
+ request: CodeExecutionRequest,
196
+ ): Effect.Effect<void, CodeExecutorUnsupportedError | CodeSourceError> =>
197
+ Effect.gen(function* () {
198
+ if (request.network._tag !== "NetworkDisabled") {
199
+ return yield* CodeExecutorUnsupportedError.make({
200
+ implementation: inProcessCodeExecutorImplementation,
201
+ feature: "network",
202
+ message:
203
+ "The unisolated in-process executor cannot enforce an egress allowlist; only NetworkDisabled is accepted, and even that is shadowed rather than enforced",
204
+ });
205
+ }
206
+ if (request.limits.cpuMillis !== undefined) {
207
+ return yield* CodeExecutorUnsupportedError.make({
208
+ implementation: inProcessCodeExecutorImplementation,
209
+ feature: "cpu-limit",
210
+ message:
211
+ "The unisolated in-process executor shares the host engine and cannot enforce a CPU limit",
212
+ });
213
+ }
214
+ const reservedNames = new Set<string>([...shadowedGlobals, "console"]);
215
+ const seen = new Set<string>();
216
+ for (const namespace of request.namespaces) {
217
+ if (reservedNames.has(namespace.name) || seen.has(namespace.name)) {
218
+ return yield* CodeExecutorUnsupportedError.make({
219
+ implementation: inProcessCodeExecutorImplementation,
220
+ feature: "namespaces",
221
+ message: `Namespace ${namespace.name} collides with a harness binding or another namespace`,
222
+ });
223
+ }
224
+ seen.add(namespace.name);
225
+ }
226
+ const sourceBytes = utf8ByteLength(request.source);
227
+ if (sourceBytes > request.limits.maxSourceBytes) {
228
+ return yield* CodeSourceError.make({
229
+ implementation: inProcessCodeExecutorImplementation,
230
+ reason: "oversized",
231
+ message: `Source is ${sourceBytes} bytes; the request allows ${request.limits.maxSourceBytes}`,
232
+ });
233
+ }
234
+ });
235
+
236
+ const serveHostCalls = (
237
+ host: CodeExecutionHost["Service"],
238
+ queue: Queue.Queue<PendingHostCall>,
239
+ limits: CodeExecutionLimits,
240
+ capture: LogCapture,
241
+ counter: { calls: number },
242
+ ): Effect.Effect<
243
+ never,
244
+ CodeHostCallLimitError | CodeOutputLimitError | CodeExecutionProtocolError
245
+ > =>
246
+ Effect.gen(function* () {
247
+ while (true) {
248
+ const pending = yield* Queue.take(queue);
249
+ counter.calls += 1;
250
+ if (counter.calls > limits.maxHostCalls) {
251
+ return yield* CodeHostCallLimitError.make({
252
+ implementation: inProcessCodeExecutorImplementation,
253
+ limit: limits.maxHostCalls,
254
+ logs: [...capture.lines],
255
+ });
256
+ }
257
+ const argument = safeDecodeJson(pending.argument);
258
+ if (Option.isNone(argument)) {
259
+ pending.reject(new TypeError("host call arguments must be JSON values"));
260
+ continue;
261
+ }
262
+ const argumentBytes = encodedJsonByteLength(argument.value);
263
+ if (argumentBytes === undefined || argumentBytes > limits.maxHostCallArgumentBytes) {
264
+ return yield* CodeOutputLimitError.make({
265
+ implementation: inProcessCodeExecutorImplementation,
266
+ surface: "host-call-argument",
267
+ limit: limits.maxHostCallArgumentBytes,
268
+ observed: argumentBytes ?? 0,
269
+ logs: [...capture.lines],
270
+ });
271
+ }
272
+ const rawOutcome = yield* host.call(
273
+ CodeHostCall.make({
274
+ namespace: pending.namespace,
275
+ method: pending.method,
276
+ argument: argument.value,
277
+ }),
278
+ );
279
+ const outcome = decodeHostOutcome(rawOutcome);
280
+ if (Option.isNone(outcome)) {
281
+ return yield* CodeExecutionProtocolError.make({
282
+ implementation: inProcessCodeExecutorImplementation,
283
+ message: "The execution host returned a value outside the CodeHostCallResult schema",
284
+ });
285
+ }
286
+ if (outcome.value._tag === "CodeHostCallFailure") {
287
+ pending.reject(outcome.value.error);
288
+ continue;
289
+ }
290
+ const resultBytes = encodedJsonByteLength(outcome.value.value);
291
+ if (resultBytes === undefined || resultBytes > limits.maxHostCallResultBytes) {
292
+ return yield* CodeOutputLimitError.make({
293
+ implementation: inProcessCodeExecutorImplementation,
294
+ surface: "host-call-result",
295
+ limit: limits.maxHostCallResultBytes,
296
+ observed: resultBytes ?? 0,
297
+ logs: [...capture.lines],
298
+ });
299
+ }
300
+ pending.resolve(outcome.value.value);
301
+ }
302
+ });
303
+
304
+ const classifyProgramFailure = (
305
+ thrown: unknown,
306
+ limits: CodeExecutionLimits,
307
+ capture: LogCapture,
308
+ ): CodeOutputLimitError | CodeSourceError | CodeProgramFailedError => {
309
+ const inner = thrown instanceof EvaluationThrew ? thrown.inner : thrown;
310
+ if (inner instanceof LogLimitSignal) {
311
+ return CodeOutputLimitError.make({
312
+ implementation: inProcessCodeExecutorImplementation,
313
+ surface: "logs",
314
+ limit: limits.maxLogBytes,
315
+ observed: inner.observed,
316
+ logs: [...capture.lines],
317
+ });
318
+ }
319
+ if (inner instanceof NotAFunction) {
320
+ return CodeSourceError.make({
321
+ implementation: inProcessCodeExecutorImplementation,
322
+ reason: "not-a-function",
323
+ message: `The source expression evaluated to ${inner.actual}; it must evaluate to one async function`,
324
+ });
325
+ }
326
+ // An async function converts a body-level `throw` into a rejection, so the
327
+ // split is by value shape: exception-like values read as `threw`, plain
328
+ // rejection values (an uncaught host failure envelope) read as `rejected`.
329
+ const reason = thrown instanceof EvaluationThrew || inner instanceof Error ? "threw" : "rejected";
330
+ return CodeProgramFailedError.make({
331
+ implementation: inProcessCodeExecutorImplementation,
332
+ reason,
333
+ thrown: boundedThrown(inner),
334
+ message: boundedText(inner),
335
+ logs: [...capture.lines],
336
+ });
337
+ };
338
+
339
+ const executeInProcess: CodeExecutorExecute = Effect.fn("InProcessCodeExecutor.execute")(
340
+ function* (request) {
341
+ yield* validateRequest(request);
342
+ const host = yield* CodeExecutionHost;
343
+ const capture: LogCapture = { lines: [], bytes: 0 };
344
+ const counter = { calls: 0 };
345
+ const queue = yield* Queue.unbounded<PendingHostCall>();
346
+
347
+ const factory = yield* Effect.try({
348
+ try: () =>
349
+ new Function(
350
+ ...shadowedGlobals,
351
+ "console",
352
+ ...request.namespaces.map((namespace) => namespace.name),
353
+ `"use strict";\nreturn (\n${request.source}\n);`,
354
+ ),
355
+ catch: (cause) =>
356
+ CodeSourceError.make({
357
+ implementation: inProcessCodeExecutorImplementation,
358
+ reason: "invalid",
359
+ message: boundedText(cause),
360
+ }),
361
+ });
362
+
363
+ const harnessConsole = makeConsole(capture, request.limits);
364
+ // Admission is enforced at call creation, not only at the single-consumer
365
+ // dequeue: a burst of unawaited calls can enqueue at most one entry past
366
+ // the cap (the entry the server fails the pass on); everything beyond is
367
+ // rejected synchronously, so the queue stays bounded against hostile
368
+ // programs.
369
+ let issuedHostCalls = 0;
370
+ const namespaceObjects = request.namespaces.map((namespace) =>
371
+ buildNamespaceObject(namespace, (pending) => {
372
+ issuedHostCalls += 1;
373
+ if (issuedHostCalls > request.limits.maxHostCalls + 1) {
374
+ pending.reject(new Error(`host-call limit of ${request.limits.maxHostCalls} exceeded`));
375
+ return;
376
+ }
377
+ Queue.offerUnsafe(queue, pending);
378
+ }),
379
+ );
380
+
381
+ const server = yield* serveHostCalls(host, queue, request.limits, capture, counter).pipe(
382
+ Effect.forkScoped,
383
+ );
384
+
385
+ const program = Effect.tryPromise({
386
+ try: async () => {
387
+ let candidate: unknown;
388
+ try {
389
+ candidate = factory(
390
+ ...shadowedGlobals.map(() => undefined),
391
+ harnessConsole,
392
+ ...namespaceObjects,
393
+ );
394
+ } catch (cause) {
395
+ throw new EvaluationThrew(cause);
396
+ }
397
+ if (typeof candidate !== "function") {
398
+ throw new EvaluationThrew(new NotAFunction(typeof candidate));
399
+ }
400
+ let outcome: unknown;
401
+ try {
402
+ outcome = (candidate as () => unknown)();
403
+ } catch (cause) {
404
+ throw new EvaluationThrew(cause);
405
+ }
406
+ return await Promise.resolve(outcome);
407
+ },
408
+ catch: (thrown) => classifyProgramFailure(thrown, request.limits, capture),
409
+ });
410
+
411
+ const startedAt = yield* Clock.currentTimeMillis;
412
+ // The wall-clock deadline interrupts only at asynchronous suspension
413
+ // points: a synchronous runaway shares the host thread and cannot be
414
+ // stopped in-process — exactly why the platform CPU enforcement cases
415
+ // belong to isolated adapters only (testing spec §8.1). The server fiber
416
+ // is interrupted when the pass settles so no host call outlives the
417
+ // program that issued it.
418
+ const returned = yield* Effect.raceFirst(program, Fiber.join(server)).pipe(
419
+ Effect.timeoutOrElse({
420
+ duration: request.limits.maxWallTime,
421
+ orElse: () =>
422
+ CodeExecutionTimeoutError.make({
423
+ implementation: inProcessCodeExecutorImplementation,
424
+ kind: "wall-clock",
425
+ maxWallTime: request.limits.maxWallTime,
426
+ logs: [...capture.lines],
427
+ }),
428
+ }),
429
+ Effect.ensuring(Fiber.interrupt(server)),
430
+ );
431
+ const finishedAt = yield* Clock.currentTimeMillis;
432
+
433
+ // An unawaited burst can outrun the server: the program may return before
434
+ // the over-limit entry is dequeued, so the admission counter is the
435
+ // authority — a pass that ISSUED more calls than the cap fails even when
436
+ // its promise settled first.
437
+ if (issuedHostCalls > request.limits.maxHostCalls) {
438
+ return yield* CodeHostCallLimitError.make({
439
+ implementation: inProcessCodeExecutorImplementation,
440
+ limit: request.limits.maxHostCalls,
441
+ logs: [...capture.lines],
442
+ });
443
+ }
444
+
445
+ const value = yield* Schema.decodeUnknownEffect(Schema.Json)(returned).pipe(
446
+ Effect.mapError(() =>
447
+ CodeProgramFailedError.make({
448
+ implementation: inProcessCodeExecutorImplementation,
449
+ reason: "non-json-result",
450
+ thrown: null,
451
+ message: "The program must return a JSON value",
452
+ logs: [...capture.lines],
453
+ }),
454
+ ),
455
+ );
456
+ const resultBytes = encodedJsonByteLength(value);
457
+ if (resultBytes === undefined || resultBytes > request.limits.maxResultBytes) {
458
+ return yield* CodeOutputLimitError.make({
459
+ implementation: inProcessCodeExecutorImplementation,
460
+ surface: "result",
461
+ limit: request.limits.maxResultBytes,
462
+ observed: resultBytes ?? 0,
463
+ logs: [...capture.lines],
464
+ });
465
+ }
466
+
467
+ return CodeExecutionResult.make({
468
+ implementation: inProcessCodeExecutorImplementation,
469
+ value,
470
+ logs: [...capture.lines],
471
+ resourceUse: CodeExecutionResourceUse.make({
472
+ wallTime: Duration.millis(Math.max(0, finishedAt - startedAt)),
473
+ hostCalls: counter.calls,
474
+ logBytes: capture.bytes,
475
+ resultBytes,
476
+ }),
477
+ });
478
+ },
479
+ );
480
+
481
+ /**
482
+ * Layer providing the unisolated in-process `CodeExecutor` substitute. The
483
+ * per-pass `CodeExecutionHost` stays in the caller's requirement channel, the
484
+ * same as every real adapter.
485
+ */
486
+ export const inProcessCodeExecutorLayer: Layer.Layer<CodeExecutor> = Layer.succeed(CodeExecutor)(
487
+ CodeExecutor.of({ execute: executeInProcess }),
488
+ );