humanish 0.136.1 → 0.137.0

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/actors/computer-use/actor.d.ts +2 -0
  3. package/dist/actors/computer-use/actor.js +1 -0
  4. package/dist/actors/computer-use/loop/observation.js +2 -0
  5. package/dist/actors/computer-use/loop/session.d.ts +3 -2
  6. package/dist/actors/computer-use/loop/session.js +8 -12
  7. package/dist/actors/computer-use/loop/types.d.ts +14 -1
  8. package/dist/evidence/browser-network.d.ts +108 -0
  9. package/dist/evidence/browser-network.js +516 -0
  10. package/dist/evidence/trace-page.d.ts +31 -1
  11. package/dist/evidence/trace-page.js +68 -2
  12. package/dist/observer-app.html +2 -2
  13. package/dist/routes/computer-use/bundle.js +1 -0
  14. package/dist/routes/computer-use/e2b-desktop/desktop.js +6 -0
  15. package/dist/routes/computer-use/e2b-desktop/fidelity.d.ts +12 -1
  16. package/dist/routes/computer-use/e2b-desktop/fidelity.js +7 -2
  17. package/dist/routes/computer-use/e2b-desktop/network.d.ts +52 -0
  18. package/dist/routes/computer-use/e2b-desktop/network.js +153 -0
  19. package/dist/routes/computer-use/e2b-desktop/start.js +18 -1
  20. package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -0
  21. package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
  22. package/dist/routes/computer-use/e2b-desktop/teardown.js +6 -3
  23. package/dist/routes/computer-use/fanout-records.js +10 -0
  24. package/dist/routes/computer-use/participant-desktop.d.ts +7 -2
  25. package/dist/routes/computer-use/participant-model.js +2 -1
  26. package/dist/routes/computer-use/plan.js +2 -1
  27. package/dist/routes/computer-use/single-bundle.d.ts +3 -0
  28. package/dist/routes/computer-use/single-bundle.js +4 -0
  29. package/dist/routes/computer-use/types.d.ts +7 -0
  30. package/dist/routes/scripted/plan.js +2 -2
  31. package/dist/routes/shared-world/plan.js +3 -2
  32. package/dist/routes/terminal/plan.js +2 -2
  33. package/dist/run/live-items.d.ts +20 -2
  34. package/dist/run/live-items.js +23 -14
  35. package/dist/run/streams.d.ts +7 -0
  36. package/dist/study/config.js +4 -1
  37. package/dist/study/keys.d.ts +3 -0
  38. package/dist/study/keys.js +2 -0
  39. package/dist/study/parse/comms.js +1 -20
  40. package/dist/study/parse/execution.js +30 -1
  41. package/dist/study/parse/values.d.ts +5 -0
  42. package/dist/study/parse/values.js +22 -0
  43. package/dist/study/plan-base.js +6 -1
  44. package/dist/study/plan-types.d.ts +1 -1
  45. package/dist/study/study-fields.d.ts +7 -1
  46. package/dist/study/study-fields.js +10 -0
  47. package/dist/study/types.d.ts +11 -0
  48. package/dist/study/validation.d.ts +8 -0
  49. package/dist/study/validation.js +31 -2
  50. package/dist/substrates/detached.d.ts +5 -0
  51. package/dist/substrates/detached.js +9 -0
  52. package/dist/substrates/e2b/cdp-network-capture.d.ts +23 -0
  53. package/dist/substrates/e2b/cdp-network-capture.js +194 -0
  54. package/dist/substrates/e2b/cdp-probe.d.ts +20 -5
  55. package/dist/substrates/e2b/cdp-probe.js +98 -34
  56. package/dist/substrates/e2b/desktop-cdp.d.ts +32 -2
  57. package/dist/substrates/e2b/desktop-cdp.js +87 -5
  58. package/dist/verify/network.d.ts +10 -0
  59. package/dist/verify/network.js +162 -0
  60. package/dist/verify/pages.js +1 -16
  61. package/dist/verify/verify.js +8 -2
  62. package/docs/contracts/run-bundle.md +86 -1
  63. package/package.json +1 -1
@@ -101,6 +101,7 @@ function singleParticipantArgs(base, state) {
101
101
  : "e2b-desktop",
102
102
  ...(outcome?.desktopGeometry === undefined ? {} : { desktopGeometry: outcome.desktopGeometry }),
103
103
  ...(outcome?.recording === undefined ? {} : { recording: outcome.recording }),
104
+ ...(outcome?.networkLog === undefined ? {} : { network: outcome.networkLog }),
104
105
  ...(outcome?.desktopFiles === undefined ? {} : { desktopFiles: outcome.desktopFiles }),
105
106
  ...(spec.planned.limits.timeZone === undefined
106
107
  ? {}
@@ -1,6 +1,7 @@
1
1
  import { createE2BDesktopExecutor } from "../../../substrates/e2b/desktop-executor.js";
2
2
  import { participantInbox, planParticipantComms } from "./comms.js";
3
3
  import { participantBrowserStateObserver } from "./fidelity.js";
4
+ import { networkHooks, planParticipantNetwork } from "./network.js";
4
5
  import { acquireParticipantDesktop, placeParticipantFiles, provisionParticipantSubject, verifyParticipantScreen, } from "./prepare.js";
5
6
  import { keepParticipantScreenOn, openParticipantSurface, startParticipantMedia, startParticipantStream, } from "./start.js";
6
7
  import { desktopEvidenceOf, newParticipantState } from "./state.js";
@@ -9,6 +10,7 @@ import { participantServeUrl } from "../types.js";
9
10
  export function createE2BParticipantDesktop(spec, deps, warnings) {
10
11
  const targetUrl = spec.planned.targetUrl ?? deps.appUrl;
11
12
  const state = newParticipantState(spec);
13
+ state.network = planParticipantNetwork(deps, targetUrl);
12
14
  const ctx = {
13
15
  spec,
14
16
  deps,
@@ -60,6 +62,8 @@ export function createE2BParticipantDesktop(spec, deps, warnings) {
60
62
  comms: ctx.comms,
61
63
  catchReady: state.commsCatch?.deployed.ready === true,
62
64
  });
65
+ // Capture that started pairs each checkpoint with the time of the read before it.
66
+ const network = networkHooks(state.network);
63
67
  const executor = createE2BDesktopExecutor(desktop, state.launchedBrowserFamily === "chromium"
64
68
  ? {
65
69
  observeBrowserState: participantBrowserStateObserver({
@@ -71,12 +75,14 @@ export function createE2BParticipantDesktop(spec, deps, warnings) {
71
75
  targetId: state.browserTargetId,
72
76
  fidelity: state.fidelity,
73
77
  warnings,
78
+ ...(network === undefined ? {} : { onClock: network.onClock }),
74
79
  }),
75
80
  }
76
81
  : {});
77
82
  return {
78
83
  executor: allocation.open(state.speech?.wrap(executor) ?? executor).executor,
79
84
  ...(inbox === undefined ? {} : { inbox }),
85
+ ...(network === undefined ? {} : { onCheckpoint: network.onCheckpoint }),
80
86
  };
81
87
  }
82
88
  return {
@@ -1,7 +1,9 @@
1
1
  import type { ResidualConfig } from "../../../study/plan-types.js";
2
2
  import type { RunDesktopGeometry } from "../../../run/streams.js";
3
3
  import type { DesktopBrowserFamily, DesktopBrowserLaunchIdentity } from "../../../substrates/e2b/desktop-browser.js";
4
- import { makeChromeBrowserStateObserver } from "../../../substrates/e2b/desktop-cdp.js";
4
+ import { makeChromeBrowserStateObserver, type ChromeCdpEndpoint } from "../../../substrates/e2b/desktop-cdp.js";
5
+ import type { ChromeNetworkCaptureRequest } from "../../../substrates/e2b/cdp-network-capture.js";
6
+ import type { ChromeCdpProbeResult } from "../../../substrates/e2b/cdp-probe.js";
5
7
  import { captureDesktopBrowserGeometry } from "../../../substrates/e2b/desktop-geometry.js";
6
8
  import type { E2BDesktopSandbox } from "../../../substrates/e2b/sdk.js";
7
9
  import type { E2BDesktopDeps, DesktopParticipantRun } from "../types.js";
@@ -14,6 +16,8 @@ export interface ParticipantFidelity {
14
16
  }
15
17
  /** Only participants on a mobile preset are emulated; the flags go on the browser command line. */
16
18
  export declare function mobileLaunchFlags(residual: Readonly<ResidualConfig>, spec: DesktopParticipantRun): string[];
19
+ /** The launched browser's DevTools address, as far as the launch recorded it. */
20
+ export declare function cdpEndpoint(identity: DesktopBrowserLaunchIdentity | undefined, targetUrl: string): ChromeCdpEndpoint;
17
21
  /**
18
22
  * Apply mobile fidelity to the launch page before the geometry capture and the participant's first
19
23
  * observation. A request that cannot be applied fails the participant closed with the reason. Only
@@ -31,6 +35,11 @@ export declare function applyParticipantMobileFidelity(args: {
31
35
  launchIdentity: DesktopBrowserLaunchIdentity | undefined;
32
36
  targetId: string | undefined;
33
37
  warnings: string[];
38
+ /** Request capture for the holder to start beside emulation, and where its status goes. */
39
+ network?: {
40
+ request: ChromeNetworkCaptureRequest;
41
+ onStart: (status: ChromeCdpProbeResult["network"]) => void;
42
+ };
34
43
  }): Promise<ParticipantFidelity>;
35
44
  /** The Chrome observer behind URL/text stop conditions and task criteria, watching for emulation drift. */
36
45
  export declare function participantBrowserStateObserver(args: {
@@ -42,6 +51,8 @@ export declare function participantBrowserStateObserver(args: {
42
51
  targetId: string | undefined;
43
52
  fidelity: ParticipantFidelity;
44
53
  warnings: string[];
54
+ /** Receives the sandbox clock of each read; see makeChromeBrowserStateObserver's onClock. */
55
+ onClock?: (now: number | undefined) => void;
45
56
  }): ReturnType<typeof makeChromeBrowserStateObserver>;
46
57
  /**
47
58
  * The participant's final browser geometry, measured while the sandbox is alive. A final capture that
@@ -16,7 +16,7 @@ export function mobileLaunchFlags(residual, spec) {
16
16
  ];
17
17
  }
18
18
  /** The launched browser's DevTools address, as far as the launch recorded it. */
19
- function cdpEndpoint(identity, targetUrl) {
19
+ export function cdpEndpoint(identity, targetUrl) {
20
20
  return {
21
21
  ...(identity?.cdpPort === undefined ? {} : { cdpPort: identity.cdpPort }),
22
22
  ...(identity?.profileDir === undefined ? {} : { profileDir: identity.profileDir }),
@@ -50,8 +50,12 @@ export async function applyParticipantMobileFidelity(args) {
50
50
  deviceScaleFactor: request.deviceScaleFactor ?? spec.planned.device.preset.deviceScaleFactor,
51
51
  touch: request.touch ?? true,
52
52
  userAgent: request.userAgent ?? DEFAULT_MOBILE_USER_AGENT,
53
- }, { targetId: args.targetId });
53
+ }, {
54
+ targetId: args.targetId,
55
+ ...(args.network === undefined ? {} : { network: args.network.request }),
56
+ });
54
57
  args.warnings.push(...applied.warnings);
58
+ args.network?.onStart(applied.network);
55
59
  return {
56
60
  applied: applied.fidelity,
57
61
  emulatedTargetId: applied.targetId,
@@ -63,6 +67,7 @@ export function participantBrowserStateObserver(args) {
63
67
  const { spec, deps, fidelity, warnings } = args;
64
68
  return makeChromeBrowserStateObserver(args.desktop, deps.requestTimeoutMs, cdpEndpoint(args.launchIdentity, args.targetUrl), {
65
69
  targetId: args.targetId,
70
+ ...(args.onClock === undefined ? {} : { onClock: args.onClock }),
66
71
  // Once per participant: a dark observation channel is a gap in the instrument, and the
67
72
  // funnel's "never measured" count needs this line to explain itself.
68
73
  onUnavailable: (reason) => {
@@ -0,0 +1,52 @@
1
+ import { type BrowserNetworkIndex, type TurnMark } from "../../../evidence/browser-network.js";
2
+ import type { ChromeNetworkCaptureRequest } from "../../../substrates/e2b/cdp-network-capture.js";
3
+ import type { ChromeCdpProbeResult } from "../../../substrates/e2b/cdp-probe.js";
4
+ import type { E2BDesktopSandbox } from "../../../substrates/e2b/sdk.js";
5
+ import type { E2BDesktopDeps } from "../types.js";
6
+ import type { E2BParticipantContext, E2BParticipantState } from "./state.js";
7
+ /** One participant's capture, from the request to the collected log's index. */
8
+ export interface ParticipantNetwork {
9
+ readonly request: ChromeNetworkCaptureRequest;
10
+ /** The capture-only holder's process name, when this participant started one. */
11
+ holderName: string | undefined;
12
+ /** Capture answered on the launch page, so the events file is worth reading at teardown. */
13
+ started: boolean;
14
+ /** The sandbox clock of the latest browser-state read, cleared when a read starts. */
15
+ latestRead: number | undefined;
16
+ readonly marks: TurnMark[];
17
+ /** What the stream records: no capture until the file is collected. */
18
+ index: BrowserNetworkIndex;
19
+ }
20
+ /**
21
+ * The participant's capture, or undefined when the study did not set execution.network. The
22
+ * holder keeps the participant's target origin and `execution.network.origins`.
23
+ */
24
+ export declare function planParticipantNetwork(deps: E2BDesktopDeps, targetUrl: string): ParticipantNetwork | undefined;
25
+ /**
26
+ * Start the capture-only holder for a participant without mobile emulation, right after the
27
+ * browser launched. A browser other than Chrome or Chromium has no DevTools port to record from,
28
+ * which is one warning.
29
+ */
30
+ export declare function startParticipantNetwork(ctx: E2BParticipantContext, state: E2BParticipantState, desktop: E2BDesktopSandbox): Promise<void>;
31
+ /** Read the capture-only holder's announce, after the desktop settled. */
32
+ export declare function readParticipantNetworkStart(ctx: E2BParticipantContext, state: E2BParticipantState, desktop: E2BDesktopSandbox): Promise<void>;
33
+ /**
34
+ * Record what a holder said about capture: started, or one warning with why not. A mobile holder
35
+ * that said nothing about it did not start it.
36
+ */
37
+ export declare function noteNetworkStart(ctx: E2BParticipantContext, network: ParticipantNetwork, status: ChromeCdpProbeResult["network"]): void;
38
+ /**
39
+ * The callbacks a participant whose capture started runs with, or undefined when capture did not
40
+ * start: the browser-state observer reports each read's sandbox clock, and each checkpoint
41
+ * screenshot is paired with the read before it. A read that failed leaves no mark.
42
+ */
43
+ export declare function networkHooks(network: ParticipantNetwork | undefined): {
44
+ onClock: (now: number | undefined) => void;
45
+ onCheckpoint: (turnNumber: number) => void;
46
+ } | undefined;
47
+ /**
48
+ * Read the holder's events at teardown, reduce them and write the participant's network log. Runs
49
+ * before any other teardown evidence and never throws: a file that cannot be read or written is
50
+ * one warning, and the index says it was not collected.
51
+ */
52
+ export declare function collectParticipantNetwork(ctx: E2BParticipantContext, state: E2BParticipantState, desktop: E2BDesktopSandbox): Promise<void>;
@@ -0,0 +1,153 @@
1
+ // One E2B desktop participant's browser network log, when its study sets execution.network. The
2
+ // in-sandbox DevTools holder (src/substrates/e2b/cdp-probe.ts, mode "hold") appends a line per
3
+ // request event of the participant's Chrome to a sandbox file. A participant with mobile emulation
4
+ // hands the capture request to the holder it already starts; any other starts a capture-only holder
5
+ // once the browser's DevTools answer. Each checkpoint screenshot is paired with the sandbox time of
6
+ // the browser-state read before it, and at teardown the file is read and reduced to
7
+ // network/<participant id>.ndjson (src/evidence/browser-network.ts). Capture never changes how the
8
+ // participant runs: each failure is one warning, and the index says why records are missing.
9
+ import { networkIndexWithoutEvents, reduceBrowserNetwork, } from "../../../evidence/browser-network.js";
10
+ import { redactText, toErrorMessage } from "../../../evidence/redaction.js";
11
+ import { traceRedaction } from "../../../evidence/trace-page.js";
12
+ import { writeContainedOutputFile } from "../../../run/contained-output.js";
13
+ import { readRunSandboxIds } from "../../../run/sandbox-ids.js";
14
+ import { networkCaptureOrigins } from "../../../study/study-fields.js";
15
+ import { readNetworkCaptureStart, readNetworkEvents, startNetworkCapture, } from "../../../substrates/e2b/desktop-cdp.js";
16
+ import { e2bShell } from "../../../substrates/e2b/shell.js";
17
+ import { detachedProcessExited } from "../../../substrates/detached.js";
18
+ import { cdpEndpoint } from "./fidelity.js";
19
+ /** The sandbox file the holder appends to. Each participant has its own sandbox. */
20
+ const NETWORK_EVENTS_PATH = "/tmp/humanish-network/events.ndjson";
21
+ /** The trace redaction a participant's pages go through, which its network log shares. */
22
+ function networkRedaction(deps) {
23
+ return traceRedaction(deps.scrubKnownValues, redactText);
24
+ }
25
+ /**
26
+ * The participant's capture, or undefined when the study did not set execution.network. The
27
+ * holder keeps the participant's target origin and `execution.network.origins`.
28
+ */
29
+ export function planParticipantNetwork(deps, targetUrl) {
30
+ const extra = networkCaptureOrigins(deps.residual.execution?.network);
31
+ if (extra === undefined)
32
+ return undefined;
33
+ const target = URL.parse(targetUrl)?.origin;
34
+ const origins = [
35
+ ...new Set([...(target === undefined || target === "null" ? [] : [target]), ...extra]),
36
+ ];
37
+ return {
38
+ request: { eventsPath: NETWORK_EVENTS_PATH, origins },
39
+ holderName: undefined,
40
+ started: false,
41
+ latestRead: undefined,
42
+ marks: [],
43
+ index: networkIndexWithoutEvents({ origins, redact: networkRedaction(deps) }, "no-capture"),
44
+ };
45
+ }
46
+ function warnNotRecorded(ctx, reason) {
47
+ ctx.warnings.push(`Participant ${ctx.spec.planned.id}'s network log was not recorded: ${redactText(ctx.deps.scrubKnownValues(reason))}. The participant ran without request capture.`);
48
+ }
49
+ /**
50
+ * Start the capture-only holder for a participant without mobile emulation, right after the
51
+ * browser launched. A browser other than Chrome or Chromium has no DevTools port to record from,
52
+ * which is one warning.
53
+ */
54
+ export async function startParticipantNetwork(ctx, state, desktop) {
55
+ const network = state.network;
56
+ if (network === undefined)
57
+ return;
58
+ if (state.launchedBrowserFamily !== "chromium") {
59
+ warnNotRecorded(ctx, `the launched browser family is ${state.launchedBrowserFamily}, and requests are recorded only from Chrome or Chromium. Set execution.desktop.browser: chrome`);
60
+ return;
61
+ }
62
+ try {
63
+ network.holderName = await startNetworkCapture(desktop, ctx.deps.requestTimeoutMs, cdpEndpoint(state.browserLaunchIdentity, ctx.targetUrl), network.request);
64
+ }
65
+ catch (error) {
66
+ warnNotRecorded(ctx, `the capture holder could not start (${toErrorMessage(error)})`);
67
+ }
68
+ }
69
+ /** Read the capture-only holder's announce, after the desktop settled. */
70
+ export async function readParticipantNetworkStart(ctx, state, desktop) {
71
+ const network = state.network;
72
+ if (network?.holderName === undefined)
73
+ return;
74
+ noteNetworkStart(ctx, network, await readNetworkCaptureStart(desktop, ctx.deps.requestTimeoutMs, network.holderName));
75
+ }
76
+ /**
77
+ * Record what a holder said about capture: started, or one warning with why not. A mobile holder
78
+ * that said nothing about it did not start it.
79
+ */
80
+ export function noteNetworkStart(ctx, network, status) {
81
+ if (status !== undefined && "startedAt" in status) {
82
+ network.started = true;
83
+ return;
84
+ }
85
+ warnNotRecorded(ctx, status?.unavailable ?? "the emulation holder did not report capture");
86
+ }
87
+ /**
88
+ * The callbacks a participant whose capture started runs with, or undefined when capture did not
89
+ * start: the browser-state observer reports each read's sandbox clock, and each checkpoint
90
+ * screenshot is paired with the read before it. A read that failed leaves no mark.
91
+ */
92
+ export function networkHooks(network) {
93
+ if (network?.started !== true)
94
+ return undefined;
95
+ return {
96
+ onClock: (now) => {
97
+ network.latestRead = now;
98
+ },
99
+ onCheckpoint: (turnNumber) => {
100
+ if (network.latestRead !== undefined)
101
+ network.marks.push({ wallTime: network.latestRead, turn: turnNumber });
102
+ },
103
+ };
104
+ }
105
+ /**
106
+ * Read the holder's events at teardown, reduce them and write the participant's network log. Runs
107
+ * before any other teardown evidence and never throws: a file that cannot be read or written is
108
+ * one warning, and the index says it was not collected.
109
+ */
110
+ export async function collectParticipantNetwork(ctx, state, desktop) {
111
+ const network = state.network;
112
+ if (network === undefined || !network.started)
113
+ return;
114
+ const { spec, deps } = ctx;
115
+ // Another participant's id the run journaled is swept from every run file at finish, so a log
116
+ // that named one would no longer match its index. An unreadable journal leaves the own id.
117
+ const journaled = await readRunSandboxIds(deps.artifactRoot).catch(() => []);
118
+ const capture = {
119
+ participantId: spec.planned.id,
120
+ origins: network.request.origins,
121
+ marks: network.marks,
122
+ redact: networkRedaction(deps),
123
+ sandboxIds: [desktop.sandboxId, ...journaled],
124
+ };
125
+ const notCollected = (error) => {
126
+ ctx.warnings.push(`Participant ${spec.planned.id}'s network log could not be collected: ${redactText(deps.scrubKnownValues(toErrorMessage(error)))}. Its index records it as not collected.`);
127
+ network.index = networkIndexWithoutEvents(capture, "not-collected");
128
+ };
129
+ // Asked before the read, so an exited holder's file is already whole. A failed check leaves the
130
+ // log as complete as its own lines say.
131
+ const holderName = network.holderName ?? state.fidelity.holderName;
132
+ const holderExited = holderName !== undefined &&
133
+ (await detachedProcessExited(e2bShell(desktop), holderName, deps.requestTimeoutMs).catch(() => false));
134
+ let events;
135
+ try {
136
+ events = await readNetworkEvents(desktop, NETWORK_EVENTS_PATH, deps.requestTimeoutMs);
137
+ }
138
+ catch (error) {
139
+ notCollected(error);
140
+ return;
141
+ }
142
+ const log = await reduceBrowserNetwork({ ...capture, events, holderExited });
143
+ try {
144
+ if (log.text !== undefined && log.index.path !== undefined)
145
+ await writeContainedOutputFile(deps.artifactRoot, log.index.path, log.text, "utf8");
146
+ }
147
+ catch (error) {
148
+ notCollected(error);
149
+ return;
150
+ }
151
+ network.index = log.index;
152
+ ctx.warnings.push(...log.warnings);
153
+ }
@@ -9,6 +9,7 @@ import { prepareDesktopMedia, startE2BDesktopMedia, } from "../../../substrates/
9
9
  import { startE2BDesktopRecording } from "../../../substrates/e2b/desktop-recording.js";
10
10
  import { turnOffScreenSaver } from "../../../substrates/e2b/desktop-screen.js";
11
11
  import { applyParticipantMobileFidelity, mobileLaunchFlags } from "./fidelity.js";
12
+ import { noteNetworkStart, readParticipantNetworkStart, startParticipantNetwork, } from "./network.js";
12
13
  /** Start the declared speech worker and screen recording. A recording that cannot start is a warning. */
13
14
  export async function startParticipantMedia(ctx, state, desktop) {
14
15
  const { spec, deps, warnings } = ctx;
@@ -87,8 +88,16 @@ export async function openParticipantSurface(ctx, state, desktop) {
87
88
  state.launchedBrowserFamily = browserLaunch.family;
88
89
  state.browserLaunchIdentity = browserLaunch.identity;
89
90
  state.browserLaunched = true;
90
- noteDevToolsReadiness(ctx, browserLaunch.devTools, emulationFlags.length > 0);
91
+ const emulated = emulationFlags.length > 0;
92
+ noteDevToolsReadiness(ctx, browserLaunch.devTools, emulated);
93
+ // A mobile-emulated participant's holder captures too; any other gets a capture-only holder,
94
+ // started here so the settle wait covers its start.
95
+ if (!emulated)
96
+ await startParticipantNetwork(ctx, state, desktop);
91
97
  await desktop.wait(DESKTOP_SETTLE_MS).catch(() => undefined);
98
+ if (!emulated)
99
+ await readParticipantNetworkStart(ctx, state, desktop);
100
+ const network = state.network;
92
101
  // Mobile fidelity is applied outside startParticipantStream's best-effort catch, so a request
93
102
  // that cannot be applied fails the participant closed.
94
103
  state.fidelity = await applyParticipantMobileFidelity({
@@ -100,6 +109,14 @@ export async function openParticipantSurface(ctx, state, desktop) {
100
109
  launchIdentity: state.browserLaunchIdentity,
101
110
  targetId: state.browserTargetId,
102
111
  warnings,
112
+ ...(network === undefined || !emulated
113
+ ? {}
114
+ : {
115
+ network: {
116
+ request: network.request,
117
+ onStart: (status) => noteNetworkStart(ctx, network, status),
118
+ },
119
+ }),
103
120
  });
104
121
  }
105
122
  /**
@@ -12,6 +12,7 @@ import type { E2BDesktopSandbox } from "../../../substrates/e2b/sdk.js";
12
12
  import type { ParticipantDesktopEvidence } from "../participant-desktop.js";
13
13
  import type { ParticipantComms, RunningCommsCatch } from "./comms.js";
14
14
  import type { ParticipantFidelity } from "./fidelity.js";
15
+ import type { ParticipantNetwork } from "./network.js";
15
16
  import type { CuaActorStudyErrorCode, E2BDesktopDeps, DesktopParticipantRun, SandboxReleaseFact } from "../types.js";
16
17
  /** What every step of one participant reads: its spec, the run's dependencies and where it points. */
17
18
  export interface E2BParticipantContext {
@@ -62,6 +63,8 @@ export interface E2BParticipantState {
62
63
  browserTargetId: string | undefined;
63
64
  desktopGeometry: RunDesktopGeometry;
64
65
  streamUrl: string | undefined;
66
+ /** The browser network log, when the study set execution.network. */
67
+ network: ParticipantNetwork | undefined;
65
68
  }
66
69
  export declare function newParticipantState(spec: DesktopParticipantRun): E2BParticipantState;
67
70
  /** The participant's outcome evidence, read from its state. */
@@ -42,6 +42,7 @@ export function newParticipantState(spec) {
42
42
  },
43
43
  },
44
44
  streamUrl: undefined,
45
+ network: undefined,
45
46
  };
46
47
  }
47
48
  /** The participant's outcome evidence, read from its state. */
@@ -69,5 +70,6 @@ export function desktopEvidenceOf(state) {
69
70
  ...(state.commsArtifactPath === undefined
70
71
  ? {}
71
72
  : { commsArtifactPath: state.commsArtifactPath }),
73
+ ...(state.network === undefined ? {} : { networkLog: state.network.index }),
72
74
  };
73
75
  }
@@ -1,12 +1,13 @@
1
- // Teardown for one E2B desktop participant: its final geometry and comms evidence are collected, then the
2
- // recording and speech worker, then the sandbox is released by id (or kept for debugging), with a
3
- // warning for every outcome that is not a confirmed release.
1
+ // Teardown for one E2B desktop participant: its network log, final geometry and comms evidence are
2
+ // collected, then the recording and speech worker, then the sandbox is released by id (or kept for
3
+ // debugging), with a warning for every outcome that is not a confirmed release.
4
4
  import { collectDesktopRecording } from "../../../evidence/desktop-recording-artifact.js";
5
5
  import { redactText, toErrorMessage } from "../../../evidence/redaction.js";
6
6
  import { readE2BRelease } from "../../../substrates/e2b/sandbox.js";
7
7
  import { e2bShell } from "../../../substrates/e2b/shell.js";
8
8
  import { drainCommsEvidence } from "./comms.js";
9
9
  import { finalParticipantGeometry } from "./fidelity.js";
10
+ import { collectParticipantNetwork } from "./network.js";
10
11
  /**
11
12
  * Each route's own keep flag gates its own participants only: a clone.keep can never leak into a
12
13
  * local-tree participant's teardown decision, and vice versa.
@@ -81,6 +82,8 @@ export async function finishE2BDesktop(ctx, state, failed) {
81
82
  const { desktop, allocation } = state;
82
83
  if (!desktop || !allocation)
83
84
  return;
85
+ // First and on its own: a throw from a later step cannot skip it.
86
+ await collectParticipantNetwork(ctx, state, desktop);
84
87
  try {
85
88
  if (state.browserLaunched) {
86
89
  state.desktopGeometry = await finalParticipantGeometry({
@@ -125,6 +125,7 @@ function fanoutParticipantStream(args, spec, view) {
125
125
  ? {}
126
126
  : { timeZone: spec.planned.limits.timeZone }),
127
127
  ...(outcome?.recording === undefined ? {} : { recording: outcome.recording }),
128
+ ...(outcome?.networkLog === undefined ? {} : { network: outcome.networkLog }),
128
129
  ui: {
129
130
  route: publicTargetUrl,
130
131
  intent: `Watch participant ${spec.planned.id} (${spec.persona.id}/${spec.planned.device.name}) drive the subject app in its own hosted desktop.`,
@@ -164,6 +165,15 @@ function fanoutParticipantStream(args, spec, view) {
164
165
  },
165
166
  ]
166
167
  : []),
168
+ ...(outcome?.networkLog?.path === undefined
169
+ ? []
170
+ : [
171
+ {
172
+ label: `participant ${spec.planned.id} network log`,
173
+ path: outcome.networkLog.path,
174
+ kind: "log",
175
+ },
176
+ ]),
167
177
  ...screenshots.map((screenshot, screenshotIndex) => ({
168
178
  label: `participant ${spec.planned.id} screenshot ${String(screenshotIndex + 1).padStart(2, "0")} (${screenshotMode})`,
169
179
  path: screenshot,
@@ -2,7 +2,10 @@ import type { CuaExecutor } from "../../actors/computer-use/loop.js";
2
2
  import type { DesktopParticipantRun, ParticipantRunOutcome } from "./types.js";
3
3
  import type { StudyCommsEmail, StudyCommsRecipient } from "../../study/types.js";
4
4
  import type { PreparedOutputRoot } from "../../run/contained-output.js";
5
- /** A prepared desktop supplies only participant input/observation and its inbox location. */
5
+ /**
6
+ * A prepared desktop supplies participant input and observation, its inbox location, and a
7
+ * checkpoint callback when it keeps something per turn (the hosted desktop's network log marks).
8
+ */
6
9
  export interface ReadyParticipantDesktop {
7
10
  executor: CuaExecutor;
8
11
  inbox?: {
@@ -10,6 +13,8 @@ export interface ReadyParticipantDesktop {
10
13
  address?: string;
11
14
  receiving?: boolean;
12
15
  };
16
+ /** Called with the turn's number after each checkpoint screenshot; see LoopRunOptions. */
17
+ onCheckpoint?: (turnNumber: number) => void;
13
18
  }
14
19
  /** Existing bundle fields. Provider-specific facts remain optional and must be measured. */
15
20
  export type ParticipantDesktopEvidence = {
@@ -22,7 +27,7 @@ export type ParticipantDesktopEvidence = {
22
27
  * who did not pass.
23
28
  */
24
29
  desktopFailure?: string;
25
- } & Pick<ParticipantRunOutcome, "sandboxId" | "sandboxRelease" | "desktopDurationMs" | "desktopResources" | "streamUrlPresent" | "subjectCommit" | "desktopBrowser" | "desktopGeometry" | "stateStepRecords" | "phaseRecords" | "failureCode" | "commsArtifactPath" | "recording" | "desktopFiles">;
30
+ } & Pick<ParticipantRunOutcome, "sandboxId" | "sandboxRelease" | "desktopDurationMs" | "desktopResources" | "streamUrlPresent" | "subjectCommit" | "desktopBrowser" | "desktopGeometry" | "stateStepRecords" | "phaseRecords" | "failureCode" | "commsArtifactPath" | "recording" | "desktopFiles" | "networkLog">;
26
31
  /**
27
32
  * The desktop one participant runs on, owned from preparation through final evidence collection.
28
33
  * Call methods sequentially: prepare, openSession, then finalize in a finally block.
@@ -89,7 +89,7 @@ export async function startParticipantModel(spec, deps, executor) {
89
89
  }
90
90
  /** The options the participant's session runs with: prompt, model settings, spend caps and callbacks. */
91
91
  export function participantSessionOptions(spec, deps, ready, provider, writeScreenshot) {
92
- const { executor, inbox } = ready;
92
+ const { executor, inbox, onCheckpoint } = ready;
93
93
  // The fail-closed spend cap (caps.maxUsd) is wired into the loop as maxUsd + an
94
94
  // injected pure per-turn estimator keyed on the resolved model. Preflight already refused a
95
95
  // cap on an unpriced model, so the estimate is measurable whenever a cap is in force. The
@@ -159,6 +159,7 @@ export function participantSessionOptions(spec, deps, ready, provider, writeScre
159
159
  : `study budget reached: ${crossed}; the study's participants stop before their next model request`;
160
160
  },
161
161
  }),
162
+ ...(onCheckpoint === undefined ? {} : { onCheckpoint }),
162
163
  ...(deps.onObservedUrl === undefined ? {} : { onObservedUrl: deps.onObservedUrl }),
163
164
  ...(deps.onMessage === undefined ? {} : { onMessage: deps.onMessage }),
164
165
  ...(deps.onScreenshot === undefined ? {} : { onScreenshot: deps.onScreenshot }),
@@ -12,7 +12,7 @@ import { brainOf, callerBrainOf, planCaps, desktopRequirements, planBase, provis
12
12
  import { computerUseParticipants, declaredTargets, } from "../../study/plan-participants.js";
13
13
  import { planStarts, windowDeadlineRefusal } from "../../study/arrivals.js";
14
14
  import { computerUseParticipantCount, participantCountReason } from "../../study/routing.js";
15
- import { cloneTargetValidationReason, computerUseValidationReason, desktopMediaValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, taskProtocolValidationReason, waitLimitValidationReason, } from "../../study/validation.js";
15
+ import { cloneTargetValidationReason, computerUseValidationReason, desktopMediaValidationReason, networkCaptureValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, taskProtocolValidationReason, waitLimitValidationReason, } from "../../study/validation.js";
16
16
  import { desktopCliProductReason } from "../../study/composition-rules.js";
17
17
  import { isLocalBrowserStudy } from "../../substrates/local/runtime-config.js";
18
18
  import { boundedConcurrency, defaultSessionTimeoutMs, resolveParticipantSandboxMs, } from "./participant-runs.js";
@@ -86,6 +86,7 @@ function cuaStudyRejection(config, hasRunSession, driving, subjectRoute, ceiling
86
86
  /** A declaration this route cannot honor, for any subject or with the caller's own driver. */
87
87
  function unsupportedDeclarationReason(config, hasRunSession, driving, { inProcessRoute }) {
88
88
  const reason = desktopMediaValidationReason(config) ||
89
+ networkCaptureValidationReason(config, "computer-use") ||
89
90
  outputTokenLimitValidationReason(config) ||
90
91
  waitLimitValidationReason(config);
91
92
  if (reason)
@@ -1,3 +1,4 @@
1
+ import type { BrowserNetworkIndex } from "../../evidence/browser-network.js";
1
2
  import type { RunDesktopRecording } from "../../evidence/desktop-recording-types.js";
2
3
  import type { SubjectPhaseEvent } from "../../subject/steps.js";
3
4
  import type { DesktopBrowserEvidence } from "../../substrates/e2b/desktop-browser.js";
@@ -41,6 +42,8 @@ export declare function buildSingleParticipantBundle(args: {
41
42
  /** Runtime screen/window/viewport evidence. `viewport` inside this object must be measured. */
42
43
  desktopGeometry?: RunDesktopGeometry;
43
44
  recording?: RunDesktopRecording;
45
+ /** The participant's browser network log index, when its study set execution.network. */
46
+ network?: BrowserNetworkIndex;
44
47
  /** participants[].files confirmed on the participant's desktop. */
45
48
  desktopFiles?: RunDesktopFile[];
46
49
  /** The participant's time zone, which its turns and its hosted browser used. */
@@ -157,6 +157,7 @@ function singleStream(args, view) {
157
157
  ...(args.desktopFiles === undefined ? {} : { desktopFiles: args.desktopFiles }),
158
158
  ...(args.timeZone === undefined ? {} : { timeZone: args.timeZone }),
159
159
  ...(args.recording === undefined ? {} : { recording: args.recording }),
160
+ ...(args.network === undefined ? {} : { network: args.network }),
160
161
  ui: {
161
162
  route: publicAppUrl,
162
163
  intent: `Watch the computer-use actor drive the subject app ${browserPlace(args)}.`,
@@ -179,6 +180,9 @@ function singleStream(args, view) {
179
180
  ...(args.recording
180
181
  ? [{ label: "desktop recording", path: args.recording.path, kind: "recording" }]
181
182
  : []),
183
+ ...(args.network?.path === undefined
184
+ ? []
185
+ : [{ label: "network log", path: args.network.path, kind: "log" }]),
182
186
  ...args.screenshots.map((screenshot, index) => ({
183
187
  label: `screenshot ${String(index + 1).padStart(2, "0")} (${screenshotMode})`,
184
188
  path: screenshot,
@@ -1,6 +1,7 @@
1
1
  import type { Verdict } from "../../run/judge.js";
2
2
  import type { HostSuspensionReading } from "../../run/host-suspension.js";
3
3
  import type { LocalCapacity } from "../../substrates/local/capacity.js";
4
+ import type { BrowserNetworkIndex } from "../../evidence/browser-network.js";
4
5
  import type { RunDesktopRecording } from "../../evidence/desktop-recording-types.js";
5
6
  import type { CuaExecutor, CuaLiveMetadata, CuaLoopResult, CuaProvider } from "../../actors/computer-use/loop.js";
6
7
  import type { CommsReceivingRun } from "../../comms/receiving.js";
@@ -481,6 +482,12 @@ export interface ParticipantRunOutcome {
481
482
  * (humanish.comms-thread.v1), when a comms study captured mail into its in-sandbox catch. Registered
482
483
  * in the participant's stream artifacts. Absent when no comms study ran or nothing was captured. */
483
484
  commsArtifactPath?: string;
485
+ /**
486
+ * The participant's browser network log index (humanish.browser-network.v1), when its study set
487
+ * execution.network: the stream's `network` field. Its `path`, when present, is registered as a
488
+ * log artifact.
489
+ */
490
+ networkLog?: BrowserNetworkIndex;
484
491
  }
485
492
  /** What buildCuaFanoutBundle projects into a fan-out run bundle. */
486
493
  export interface CuaFanoutBundleArgs {
@@ -6,7 +6,7 @@ import { normalizeLocalAppUrl } from "../../actors/scripted-browser/steps.js";
6
6
  import { browserSurfaces } from "../../actors/scripted-browser/types.js";
7
7
  import { resolveAutomaticAnalysis } from "../../analysis/automatic-config.js";
8
8
  import { isNonEmpty, planBase, provisionedSubject } from "../../study/plan-base.js";
9
- import { cloneTargetValidationReason, desktopMediaValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
9
+ import { cloneTargetValidationReason, desktopMediaValidationReason, networkCaptureValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
10
10
  import { sandboxDeadlineRefusal } from "../../substrates/e2b/lifetime.js";
11
11
  // Default surface roster is 1 (desktop only): the defaults-table single-participant row governs;
12
12
  // `count: 2` is the declared override that adds the mobile surface.
@@ -40,7 +40,7 @@ export function planScriptedStudy(config, input) {
40
40
  const tasksReason = taskProtocolValidationReason(config, false);
41
41
  if (tasksReason)
42
42
  return refuse("HUMANISH_STUDY_TASKS_UNSUPPORTED", tasksReason, beforeScope);
43
- const mediaReason = desktopMediaValidationReason(config);
43
+ const mediaReason = desktopMediaValidationReason(config) ?? networkCaptureValidationReason(config, "scripted");
44
44
  if (mediaReason)
45
45
  return refuse("HUMANISH_SCRIPTED_SCENARIO_INVALID", mediaReason);
46
46
  const cloneTargetReason = cloneTargetValidationReason(config);
@@ -12,7 +12,7 @@ import { DESKTOP_FILES_ROUTE_REASON } from "../../study/desktop-files.js";
12
12
  import { participantList } from "../../study/study-fields.js";
13
13
  import { PUBLIC_TARGET_OWNER_PATTERN } from "../../study/parse/subject.js";
14
14
  import { REPO_SLUG_PATTERN } from "../../study/parse/values.js";
15
- import { concurrentSharedWorldValidationReason, desktopMediaValidationReason, externalPublicSharedWorldValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, taskProtocolValidationReason, waitLimitValidationReason, } from "../../study/validation.js";
15
+ import { concurrentSharedWorldValidationReason, desktopMediaValidationReason, networkCaptureValidationReason, externalPublicSharedWorldValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, taskProtocolValidationReason, waitLimitValidationReason, } from "../../study/validation.js";
16
16
  import { unpricedCapCheck } from "../../study/requirements.js";
17
17
  import { participantsAtOnce, sandboxDeadlineRefusal, sandboxHeadroomMs, } from "../../substrates/e2b/lifetime.js";
18
18
  import { boundedConcurrency } from "../computer-use/participant-runs.js";
@@ -34,7 +34,8 @@ export function planSharedWorldStudy(config, input) {
34
34
  refusal: { route: "shared-world", code, message, ...(actor === undefined ? {} : { actor }) },
35
35
  });
36
36
  const invalid = "HUMANISH_SHARED_WORLD_INVALID";
37
- const mediaReason = desktopMediaValidationReason(config, false);
37
+ const mediaReason = desktopMediaValidationReason(config, false) ??
38
+ networkCaptureValidationReason(config, "shared-world");
38
39
  if (mediaReason)
39
40
  return refuse(invalid, mediaReason);
40
41
  const analysis = resolveAutomaticAnalysis(config.review?.analysis);
@@ -7,7 +7,7 @@ import { isReasoningEffort } from "../../actors/reasoning-effort.js";
7
7
  import { actorRegistry, isTerminalActorDescriptor } from "../../actors/registry.js";
8
8
  import { isNonEmpty, planBase } from "../../study/plan-base.js";
9
9
  import { studyUrlCredentialReason } from "../../study/url-credentials.js";
10
- import { desktopMediaValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
10
+ import { desktopMediaValidationReason, networkCaptureValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
11
11
  import { ceilingAdvice } from "../../substrates/e2b/lifetime.js";
12
12
  import { NODE_BOOTSTRAP_TIMEOUT_MS } from "../../subject/node-bootstrap.js";
13
13
  import { terminalSandboxTimeoutMs } from "./lifetime.js";
@@ -24,7 +24,7 @@ export function planTerminalStudy(config, input) {
24
24
  });
25
25
  if (String(config.comms?.email?.kind) === "real")
26
26
  return refuse("HUMANISH_TERMINAL_SUBJECT_INVALID", "Real email receiving is unsupported on the terminal route. Use a supported hosted computer-use browser study.");
27
- const mediaReason = desktopMediaValidationReason(config);
27
+ const mediaReason = desktopMediaValidationReason(config) ?? networkCaptureValidationReason(config, "terminal");
28
28
  if (mediaReason)
29
29
  return refuse("HUMANISH_TERMINAL_SUBJECT_INVALID", mediaReason);
30
30
  // A library caller's config skips the parser, which refuses these first.