humanish 0.96.1 → 0.98.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 (193) hide show
  1. package/AGENTS.md +86 -79
  2. package/CONTRIBUTING.md +7 -2
  3. package/README.md +11 -2
  4. package/dist/actor-contract.d.ts +35 -1
  5. package/dist/actor-contract.js +38 -0
  6. package/dist/actor-contract.js.map +1 -1
  7. package/dist/adapter-extension.js +1 -0
  8. package/dist/adapter-extension.js.map +1 -1
  9. package/dist/automatic-analysis-config.d.ts +15 -5
  10. package/dist/automatic-analysis-config.js +25 -4
  11. package/dist/automatic-analysis-config.js.map +1 -1
  12. package/dist/automatic-study-analysis.js +4 -2
  13. package/dist/automatic-study-analysis.js.map +1 -1
  14. package/dist/browser-control-client.d.ts +14 -0
  15. package/dist/browser-control-client.js +134 -0
  16. package/dist/browser-control-client.js.map +1 -0
  17. package/dist/browser-control-dispatcher.d.ts +14 -0
  18. package/dist/browser-control-dispatcher.js +109 -0
  19. package/dist/browser-control-dispatcher.js.map +1 -0
  20. package/dist/browser-control-protocol.d.ts +371 -0
  21. package/dist/browser-control-protocol.js +155 -0
  22. package/dist/browser-control-protocol.js.map +1 -0
  23. package/dist/browser-control-transport.d.ts +24 -0
  24. package/dist/browser-control-transport.js +156 -0
  25. package/dist/browser-control-transport.js.map +1 -0
  26. package/dist/comms-lease-store.d.ts +1 -0
  27. package/dist/comms-lease-store.js +9 -3
  28. package/dist/comms-lease-store.js.map +1 -1
  29. package/dist/computer-use-actor.d.ts +2 -2
  30. package/dist/computer-use-actor.js +6 -1
  31. package/dist/computer-use-actor.js.map +1 -1
  32. package/dist/computer-use.d.ts +23 -1
  33. package/dist/computer-use.js +253 -70
  34. package/dist/computer-use.js.map +1 -1
  35. package/dist/cua-actor-lab.d.ts +24 -289
  36. package/dist/cua-actor-lab.js +203 -1983
  37. package/dist/cua-actor-lab.js.map +1 -1
  38. package/dist/cua-desktop-lane.d.ts +35 -0
  39. package/dist/cua-desktop-lane.js +13 -0
  40. package/dist/cua-desktop-lane.js.map +1 -0
  41. package/dist/cua-executor-error.d.ts +31 -0
  42. package/dist/cua-executor-error.js +48 -0
  43. package/dist/cua-executor-error.js.map +1 -0
  44. package/dist/cua-provider-error.d.ts +12 -0
  45. package/dist/cua-provider-error.js +28 -0
  46. package/dist/cua-provider-error.js.map +1 -0
  47. package/dist/desktop-session.d.ts +41 -0
  48. package/dist/desktop-session.js +46 -0
  49. package/dist/desktop-session.js.map +1 -0
  50. package/dist/doctor-lab.d.ts +8 -1
  51. package/dist/doctor-lab.js +40 -8
  52. package/dist/doctor-lab.js.map +1 -1
  53. package/dist/e2b-cua-desktop.d.ts +3 -0
  54. package/dist/e2b-cua-desktop.js +675 -0
  55. package/dist/e2b-cua-desktop.js.map +1 -0
  56. package/dist/e2b-cua-provisioning.d.ts +311 -0
  57. package/dist/e2b-cua-provisioning.js +1213 -0
  58. package/dist/e2b-cua-provisioning.js.map +1 -0
  59. package/dist/e2b-desktop-executor.d.ts +1 -24
  60. package/dist/e2b-desktop-executor.js +2 -127
  61. package/dist/e2b-desktop-executor.js.map +1 -1
  62. package/dist/e2b-desktop-session.d.ts +7 -0
  63. package/dist/e2b-desktop-session.js +29 -0
  64. package/dist/e2b-desktop-session.js.map +1 -0
  65. package/dist/e2b-terminal-lab.js +1 -0
  66. package/dist/e2b-terminal-lab.js.map +1 -1
  67. package/dist/frame-signature.d.ts +24 -0
  68. package/dist/frame-signature.js +128 -0
  69. package/dist/frame-signature.js.map +1 -0
  70. package/dist/guest-bootstrap.d.ts +43 -0
  71. package/dist/guest-bootstrap.js +240 -0
  72. package/dist/guest-bootstrap.js.map +1 -0
  73. package/dist/guest-browser-tools.d.ts +8 -0
  74. package/dist/guest-browser-tools.js +66 -0
  75. package/dist/guest-browser-tools.js.map +1 -0
  76. package/dist/guest-chromium-text.d.ts +27 -0
  77. package/dist/guest-chromium-text.js +281 -0
  78. package/dist/guest-chromium-text.js.map +1 -0
  79. package/dist/guest-desktop-executor.d.ts +22 -0
  80. package/dist/guest-desktop-executor.js +177 -0
  81. package/dist/guest-desktop-executor.js.map +1 -0
  82. package/dist/guest-desktop-native.d.ts +14 -0
  83. package/dist/guest-desktop-native.js +131 -0
  84. package/dist/guest-desktop-native.js.map +1 -0
  85. package/dist/guest-runtime-desktop.d.ts +35 -0
  86. package/dist/guest-runtime-desktop.js +231 -0
  87. package/dist/guest-runtime-desktop.js.map +1 -0
  88. package/dist/guest-runtime-main.d.ts +1 -0
  89. package/dist/guest-runtime-main.js +31 -0
  90. package/dist/guest-runtime-main.js.map +1 -0
  91. package/dist/guest-runtime-revision.d.ts +1 -0
  92. package/dist/guest-runtime-revision.js +3 -0
  93. package/dist/guest-runtime-revision.js.map +1 -0
  94. package/dist/guest-runtime.d.ts +25 -0
  95. package/dist/guest-runtime.js +96 -0
  96. package/dist/guest-runtime.js.map +1 -0
  97. package/dist/index.d.ts +1 -1
  98. package/dist/lab-config.js +10 -3
  99. package/dist/lab-config.js.map +1 -1
  100. package/dist/lab-engine.js +6 -0
  101. package/dist/lab-engine.js.map +1 -1
  102. package/dist/lab-summary.d.ts +5 -1
  103. package/dist/lab-summary.js +5 -0
  104. package/dist/lab-summary.js.map +1 -1
  105. package/dist/local-agent-cli.js +1 -1
  106. package/dist/local-agent-cli.js.map +1 -1
  107. package/dist/local-firecracker-desktop.d.ts +13 -0
  108. package/dist/local-firecracker-desktop.js +150 -0
  109. package/dist/local-firecracker-desktop.js.map +1 -0
  110. package/dist/local-firecracker-study.d.ts +9 -0
  111. package/dist/local-firecracker-study.js +93 -0
  112. package/dist/local-firecracker-study.js.map +1 -0
  113. package/dist/local-runtime-config.d.ts +6 -0
  114. package/dist/local-runtime-config.js +56 -0
  115. package/dist/local-runtime-config.js.map +1 -0
  116. package/dist/local-runtime-release.d.ts +3 -0
  117. package/dist/local-runtime-release.js +8 -0
  118. package/dist/local-runtime-release.js.map +1 -0
  119. package/dist/local-runtime.d.ts +25 -0
  120. package/dist/local-runtime.js +113 -0
  121. package/dist/local-runtime.js.map +1 -0
  122. package/dist/observer-app.html +4 -4
  123. package/dist/pricing.d.ts +22 -1
  124. package/dist/pricing.js +22 -0
  125. package/dist/pricing.js.map +1 -1
  126. package/dist/program.js +50 -9
  127. package/dist/program.js.map +1 -1
  128. package/dist/restricted-codex-analysis.d.ts +15 -0
  129. package/dist/restricted-codex-analysis.js +13 -0
  130. package/dist/restricted-codex-analysis.js.map +1 -0
  131. package/dist/restricted-codex-participant-policy.d.ts +39 -0
  132. package/dist/restricted-codex-participant-policy.js +69 -0
  133. package/dist/restricted-codex-participant-policy.js.map +1 -0
  134. package/dist/restricted-codex-participant-run.d.ts +20 -0
  135. package/dist/restricted-codex-participant-run.js +78 -0
  136. package/dist/restricted-codex-participant-run.js.map +1 -0
  137. package/dist/restricted-codex-participant.d.ts +14 -0
  138. package/dist/restricted-codex-participant.js +178 -0
  139. package/dist/restricted-codex-participant.js.map +1 -0
  140. package/dist/restricted-codex-policy.d.ts +56 -0
  141. package/dist/restricted-codex-policy.js +151 -0
  142. package/dist/restricted-codex-policy.js.map +1 -0
  143. package/dist/restricted-codex-session.d.ts +19 -0
  144. package/dist/restricted-codex-session.js +413 -0
  145. package/dist/restricted-codex-session.js.map +1 -0
  146. package/dist/restricted-codex-transport.d.ts +58 -0
  147. package/dist/restricted-codex-transport.js +233 -0
  148. package/dist/restricted-codex-transport.js.map +1 -0
  149. package/dist/run-detail.js +4 -2
  150. package/dist/run-detail.js.map +1 -1
  151. package/dist/run.d.ts +12 -5
  152. package/dist/run.js +17 -1
  153. package/dist/run.js.map +1 -1
  154. package/dist/shared-world-lab.js +2 -2
  155. package/dist/shared-world-lab.js.map +1 -1
  156. package/dist/study-analysis-codex-config.d.ts +11 -0
  157. package/dist/study-analysis-codex-config.js +34 -0
  158. package/dist/study-analysis-codex-config.js.map +1 -0
  159. package/dist/study-analysis-engine.d.ts +6 -3
  160. package/dist/study-analysis-engine.js +19 -10
  161. package/dist/study-analysis-engine.js.map +1 -1
  162. package/dist/study-analysis-job.d.ts +3 -2
  163. package/dist/study-analysis-job.js +1 -1
  164. package/dist/study-analysis-job.js.map +1 -1
  165. package/dist/study-analysis-provider.d.ts +4 -2
  166. package/dist/study-analysis-provider.js +1 -1
  167. package/dist/study-analysis-provider.js.map +1 -1
  168. package/dist/study-analysis-service.d.ts +3 -0
  169. package/dist/study-analysis-service.js +24 -6
  170. package/dist/study-analysis-service.js.map +1 -1
  171. package/dist/study-analysis-validation.d.ts +43 -19
  172. package/dist/study-analysis-validation.js +23 -10
  173. package/dist/study-analysis-validation.js.map +1 -1
  174. package/dist/study-analysis.d.ts +27 -2
  175. package/dist/study-costs.js +6 -0
  176. package/dist/study-costs.js.map +1 -1
  177. package/dist/tui-app.js +102 -102
  178. package/docs/architecture/browser-control.md +117 -0
  179. package/docs/architecture/desktop-sessions.md +80 -0
  180. package/docs/architecture/guest-desktop.md +87 -0
  181. package/docs/architecture/local-browser-runtime.md +100 -0
  182. package/docs/architecture/restricted-codex-analysis.md +91 -0
  183. package/docs/architecture/runtime-broker-core.md +30 -0
  184. package/docs/contracts/schemas.md +1 -1
  185. package/docs/contracts/study-analysis.md +44 -2
  186. package/docs/goals/current.md +25 -9
  187. package/docs/product/automatic-analysis.md +24 -3
  188. package/docs/product/open-source-install-experience.md +7 -0
  189. package/docs/ramp/README.md +30 -13
  190. package/docs/release/0.97.0-codex-account-analysis.md +45 -0
  191. package/docs/release/0.98.0-local-browser-studies.md +31 -0
  192. package/package.json +4 -2
  193. package/skills/humanish/SKILL.md +39 -0
@@ -0,0 +1,675 @@
1
+ // E2B owns provisioning and final evidence; the participant runner only uses the ready port.
2
+ import { toErrorMessage } from "./command-failure.js";
3
+ import { FakeInbox } from "./comms-fake-inbox.js";
4
+ import { buildOriginMap } from "./comms-inbox.js";
5
+ import { deployReceivingInbox } from "./comms-receiving-inbox.js";
6
+ import { DEFAULT_SANDBOX_CATCH_PORT, collectCommsThread, deployCommsCatch, refreshInboxSurface, writeInboxSurface } from "./comms-sandbox-catch.js";
7
+ import { inboxRecipientFor, laneHasInboxRecipient } from "./cua-desktop-lane.js";
8
+ import { BROWSER_SETTLE_MS, CUA_ACTOR_LAB_PROVIDER_METADATA, DEFAULT_MOBILE_USER_AGENT, INBOX_SURFACE_CADENCE_MS, applyMobileEmulation, captureDesktopBrowserGeometry, declaredScreenForRender, defaultSubjectPhaseSink, inspectDesktopScreenGeometry, makeChromeBrowserStateObserver, openDesktopBrowserTarget, openDesktopTerminal, prepareDesktopMedia, provisionCloneSubject, provisionDesktopCli, provisionLocalTreeSubject, startDesktopStream } from "./e2b-cua-provisioning.js";
9
+ import { createE2BDesktopExecutor } from "./e2b-desktop-executor.js";
10
+ import { loadE2BDesktopModule } from "./e2b-desktop-launch.js";
11
+ import { observeDesktopResources } from "./e2b-desktop-resources.js";
12
+ import { allocateE2BDesktopSession } from "./e2b-desktop-session.js";
13
+ import { readDetachedLog } from "./e2b-detached.js";
14
+ import { redactText } from "./redaction.js";
15
+ import {} from "./run.js";
16
+ import { appendSandboxReceipt } from "./sandbox-receipts.js";
17
+ import { writeContainedOutputFile } from "./selected-output-paths.js";
18
+ function optionalAddress(address) {
19
+ return address === undefined ? {} : { address };
20
+ }
21
+ export function createE2BCuaDesktopLane(spec, deps, warnings) {
22
+ const { config, appUrl, cloneRoute, localTreeRoute, serve, subjectRepo, subjectEnvNames } = deps;
23
+ const desktopCliRoute = deps.desktopCliRoute === true;
24
+ const subjectEnvValues = config.subject.envValues ?? {};
25
+ const targetUrl = spec.targetUrl ?? appUrl;
26
+ const env = deps.env;
27
+ // Off-app comms (#297): on an in-sandbox subject route, redirect the app's email-API sends into an
28
+ // in-sandbox catch (loopback) so its verification mail is CAPTURED, not sent to the internet. Gated
29
+ // ENTIRELY on config.comms — no comms declared → zero change. The base-URL env is injected at
30
+ // sandbox-create (below, so the app reads it at boot); the catch is started right after create.
31
+ const commsEmail = (cloneRoute || localTreeRoute) && config.comms?.email?.kind === "fake" ? config.comms.email : undefined;
32
+ const commsPort = commsEmail ? (commsEmail.port ?? DEFAULT_SANDBOX_CATCH_PORT) : undefined;
33
+ // Hoisted so the finally can drain the catch before teardown; `commsArtifactPath` is the written
34
+ // evidence path folded into the lane outcome.
35
+ let deployedComms;
36
+ let commsArtifactPath;
37
+ let receivingInboxUrl;
38
+ // injectEnv is absent on an adopter-hosted plane (#328): there is no subject env to inject
39
+ // because the operator points their own app at their own catch.
40
+ const commsEnv = commsEmail?.injectEnv !== undefined && commsPort !== undefined
41
+ ? { [commsEmail.injectEnv]: `http://127.0.0.1:${commsPort}` }
42
+ : {};
43
+ // SMTP transport: the same idea as injectEnv, but an app that speaks SMTP needs a host and a port
44
+ // rather than a base URL. The catch accepts any credentials (loopback only), yet many apps refuse
45
+ // to boot unless the user/password vars exist at all, so those are injected when declared.
46
+ const commsSmtpPort = commsEmail?.smtp?.port;
47
+ if (commsEmail?.smtp && commsSmtpPort !== undefined) {
48
+ commsEnv[commsEmail.smtp.hostEnv] = "127.0.0.1";
49
+ commsEnv[commsEmail.smtp.portEnv] = String(commsSmtpPort);
50
+ if (commsEmail.smtp.userEnv)
51
+ commsEnv[commsEmail.smtp.userEnv] = commsEmail.smtp.user ?? "humanish";
52
+ if (commsEmail.smtp.passwordEnv)
53
+ commsEnv[commsEmail.smtp.passwordEnv] = commsEmail.smtp.password ?? "humanish";
54
+ }
55
+ // Persona inbox SURFACE (#297 slice B): the loopback URL the persona opens to read captured mail; the
56
+ // origin-rewrite map (identity on this same-sandbox route, but covers localhost/0.0.0.0 alias skew + an
57
+ // operator-declared linkOrigin); and a disposable background loop that renders the surface DURING the
58
+ // session so the inbox is live when the persona checks. The surface uses its OWN FakeInbox + cursor,
59
+ // independent of the teardown evidence drain (two readers of the append-only NDJSON — no double-count).
60
+ const commsInboxUrl = commsEmail && commsPort !== undefined ? `http://127.0.0.1:${commsPort}/inbox` : undefined;
61
+ const commsOriginMap = commsEmail
62
+ ? buildOriginMap({
63
+ ...(config.subject.serve?.url === undefined ? {} : { internalServeUrl: config.subject.serve.url }),
64
+ reachableBaseUrl: targetUrl,
65
+ ...(commsEmail.linkOrigin === undefined ? {} : { linkOrigin: commsEmail.linkOrigin })
66
+ })
67
+ : [];
68
+ const surfaceRecipients = (commsEmail?.recipients ?? [])
69
+ .filter((recipient) => recipient.address !== undefined)
70
+ .map((recipient) => ({ lane: recipient.lane, address: recipient.address }));
71
+ let surfaceRenderedCount = 0;
72
+ let surfaceDisposed = false;
73
+ let releaseSurface = () => { };
74
+ const surfaceDispose = new Promise((resolve) => { releaseSurface = resolve; });
75
+ let surfaceLoop;
76
+ const stateStepRecords = [];
77
+ // Completed-only trail (durationMs/ok are set on completed events, never on started ones):
78
+ // this is what survives into bundle.events. The default/injected sink below sees EVERY event,
79
+ // started and completed alike, so an operator watching stderr sees both halves of each phase.
80
+ const phaseRecords = [];
81
+ const onSubjectPhase = (event) => {
82
+ if (event.ok !== undefined) {
83
+ phaseRecords.push(event);
84
+ }
85
+ (deps.hooks.onPhase ?? defaultSubjectPhaseSink)(event, { laneId: spec.laneId, laneCount: deps.laneCount });
86
+ };
87
+ let failureCode;
88
+ let sandboxId;
89
+ // Host-side E2B desktop billed-span endpoints, measured via the injected clock. Captured right
90
+ // after create() succeeds and again in the finally after teardown resolves (both the killed and
91
+ // kept-for-debug paths). This measured span excludes allocation before the acquired handle;
92
+ // a kept/unconfirmed allocation gets an extra unknown lifetime cost line.
93
+ let sandboxCreatedAtMs;
94
+ let sandboxTornDownAtMs;
95
+ let desktopResources;
96
+ let killed = false;
97
+ let streamUrl;
98
+ let subjectCommit;
99
+ let desktopBrowser;
100
+ let launchedBrowserFamily = "unknown";
101
+ let browserLaunchIdentity;
102
+ let browserLaunched = false;
103
+ let initialBrowserGeometry;
104
+ let appliedFidelity;
105
+ let emulatedTargetId;
106
+ let emulationHolderName;
107
+ let browserWindowId;
108
+ let browserTargetId;
109
+ const declaredScreen = declaredScreenForRender(spec.devicePreset, spec.deviceName, spec.resolution);
110
+ let desktopGeometry = {
111
+ screen: {
112
+ requested: { width: spec.resolution[0], height: spec.resolution[1] },
113
+ ...(declaredScreen ? { declared: declaredScreen } : {})
114
+ }
115
+ };
116
+ let allocation;
117
+ let desktop;
118
+ let preparationStarted = false;
119
+ let prepared = false;
120
+ let opened = false;
121
+ let finalization;
122
+ async function prepare() {
123
+ if (preparationStarted || finalization)
124
+ throw new Error('Desktop lane preparation can only start once, before finalization.');
125
+ preparationStarted = true;
126
+ const desktopModule = await (deps.hooks.loadDesktopModule ?? loadE2BDesktopModule)();
127
+ // Optional custom desktop template (image): present → Sandbox.create(template, opts); absent →
128
+ // the byte-stable Sandbox.create(opts) default (stock `desktop` template).
129
+ const acquired = await allocateE2BDesktopSession(desktopModule, {
130
+ apiKey: deps.e2bApiKey,
131
+ requestTimeoutMs: deps.requestTimeoutMs,
132
+ timeoutMs: deps.perLaneSandboxMs,
133
+ metadata: {
134
+ ...CUA_ACTOR_LAB_PROVIDER_METADATA,
135
+ labId: config.id,
136
+ simId: spec.simId,
137
+ laneId: spec.laneId,
138
+ laneIndex: String(spec.laneIndex),
139
+ laneCount: String(deps.laneCount)
140
+ },
141
+ // Env placement per the doctrine: the ACTOR's key never enters the sandbox (the model drives
142
+ // from outside). The SUBJECT's declared env NAMES are provisioned here on the clone route.
143
+ // Three sources, in precedence order: committed non-secret config (subject.envValues), then
144
+ // secret values forwarded from the caller's environment (subject.env), then the harness's own
145
+ // comms wiring, which must win because only it knows the catch's address.
146
+ ...(subjectEnvNames.length > 0 || Object.keys(subjectEnvValues).length > 0 || Object.keys(commsEnv).length > 0
147
+ ? {
148
+ envs: {
149
+ ...subjectEnvValues,
150
+ ...Object.fromEntries(subjectEnvNames.map((name) => [name, env[name]])),
151
+ ...commsEnv
152
+ }
153
+ }
154
+ : {}),
155
+ resolution: spec.resolution,
156
+ dpi: 96,
157
+ lifecycle: { onTimeout: "kill" }
158
+ }, config.execution?.desktop?.template, {
159
+ // The default loader reclaims an acquired handle before retrying failed desktop startup.
160
+ // Its error names the cleanup outcome; pre-construction allocation failures remain unowned.
161
+ onRetry: (reason) => {
162
+ const named = redactText(deps.scrubKnownValues(reason));
163
+ warnings.push(`Sandbox create for lane ${spec.laneId} retried once after a transient provider error (${named}).`);
164
+ onSubjectPhase({ at: new Date(deps.now()).toISOString(), type: "cua-lab.sandbox.create.retry", message: `sandbox create retried once (${named})` });
165
+ }
166
+ });
167
+ desktop = acquired.desktop;
168
+ allocation = acquired.allocation;
169
+ sandboxId = allocation.resourceId;
170
+ // #358 salvage: journal the id to disk before any work — an interrupted run reclaims by
171
+ // exact recorded id (`humanish reclaim`), never by enumerating the account.
172
+ await appendSandboxReceipt(deps.artifactRoot, { at: new Date(deps.now()).toISOString(), laneId: spec.laneId, sandboxId, timeoutMs: deps.perLaneSandboxMs });
173
+ // The billed span starts the instant the sandbox exists.
174
+ sandboxCreatedAtMs = deps.now();
175
+ desktopResources = await observeDesktopResources(desktop);
176
+ if ("reason" in desktopResources) {
177
+ warnings.push(`Desktop resource size unavailable (${desktopResources.reason}); compute cost remains unpriced.`);
178
+ }
179
+ if (deps.hooks.prepareDesktop) {
180
+ await deps.hooks.prepareDesktop(desktop, { laneId: spec.laneId, laneIndex: spec.laneIndex, laneCount: deps.laneCount });
181
+ }
182
+ // Start the in-sandbox email catch BEFORE the subject serve, so the app's send-API base URL (injected
183
+ // into its env at create) resolves the moment it boots. A comms-declared lab that can't stand the
184
+ // catch up is a setup failure (fail closed) rather than silently sending real mail.
185
+ if (deps.receiving) {
186
+ const surface = await deployReceivingInbox(desktop, { leaseId: spec.streamId, requestTimeoutMs: Math.min(deps.requestTimeoutMs, 30_000) });
187
+ receivingInboxUrl = surface.url;
188
+ const email = config.comms?.email;
189
+ try {
190
+ await deps.receiving.attach(spec.laneId, {
191
+ surface,
192
+ allowedOrigins: [...new Set([new URL(targetUrl).origin, ...(email?.allowedOrigins ?? [])])],
193
+ originMap: buildOriginMap({
194
+ ...(config.subject.serve?.url === undefined ? {} : { internalServeUrl: config.subject.serve.url }),
195
+ reachableBaseUrl: targetUrl,
196
+ ...(email?.linkOrigin === undefined ? {} : { linkOrigin: email.linkOrigin })
197
+ })
198
+ });
199
+ commsArtifactPath = "comms/receiving.json";
200
+ }
201
+ catch (error) {
202
+ await surface.stop().catch(() => { });
203
+ throw error;
204
+ }
205
+ }
206
+ if (commsEmail && commsPort !== undefined) {
207
+ deployedComms = await deployCommsCatch(desktop, {
208
+ port: commsPort,
209
+ ...(commsSmtpPort === undefined ? {} : { smtpPort: commsSmtpPort }),
210
+ requestTimeoutMs: deps.requestTimeoutMs
211
+ });
212
+ if (!deployedComms.ready) {
213
+ throw new Error(`comms email catch did not become ready on 127.0.0.1:${commsPort} in the subject sandbox`);
214
+ }
215
+ // Write the EMPTY inbox once up front so the persona's /inbox always resolves to the "No messages
216
+ // yet." page — never a bare 404 — the instant it navigates there, even before any mail arrives OR if
217
+ // the app sends to an address no declared recipient matches (the loop only re-renders on new mail).
218
+ await writeInboxSurface(desktop, deployedComms.surfaceDir, [], { originMap: commsOriginMap, requestTimeoutMs: deps.requestTimeoutMs });
219
+ const deployedRef = deployedComms;
220
+ surfaceLoop = (async () => {
221
+ // Render-first (so even a short session gets a populated inbox), then refresh on a cadence. The
222
+ // cadence uses a REAL timer, NOT the injected instant clock: this loop is unbounded, so an instant
223
+ // sleep would busy-spin and starve the session's own timers. The wait is interruptible by
224
+ // surfaceDispose (and the timer cleared) so teardown never blocks for a full cadence. Each refresh
225
+ // is a full, idempotent rebuild; `surfaceRenderedCount` only advances on a SUCCESSFUL render so a
226
+ // transient failure retries cleanly (no duplicate emails).
227
+ for (;;) {
228
+ try {
229
+ const refreshed = await refreshInboxSurface({
230
+ desktop,
231
+ deployed: deployedRef,
232
+ recipients: surfaceRecipients,
233
+ sinceCount: surfaceRenderedCount,
234
+ originMap: commsOriginMap,
235
+ requestTimeoutMs: deps.requestTimeoutMs
236
+ });
237
+ if (refreshed.rendered)
238
+ surfaceRenderedCount = refreshed.count;
239
+ }
240
+ catch {
241
+ // Never throw into the render loop; the teardown drain + by-id teardown must still run.
242
+ }
243
+ if (surfaceDisposed)
244
+ break;
245
+ await new Promise((resolve) => {
246
+ const timer = setTimeout(resolve, INBOX_SURFACE_CADENCE_MS);
247
+ void surfaceDispose.then(() => { clearTimeout(timer); resolve(); });
248
+ });
249
+ if (surfaceDisposed)
250
+ break;
251
+ }
252
+ })();
253
+ }
254
+ // Per-lane geometry assertion (fail-closed) — the device claim is verified in-sandbox.
255
+ const screenGeometry = await inspectDesktopScreenGeometry({
256
+ desktop,
257
+ laneId: spec.laneId,
258
+ requestedScreen: spec.resolution,
259
+ requestTimeoutMs: deps.requestTimeoutMs
260
+ });
261
+ if (screenGeometry.verified) {
262
+ desktopGeometry = {
263
+ ...desktopGeometry,
264
+ screen: { ...desktopGeometry.screen, verified: screenGeometry.verified }
265
+ };
266
+ }
267
+ if (screenGeometry.warning) {
268
+ warnings.push(screenGeometry.warning);
269
+ desktopGeometry = { ...desktopGeometry, warnings: [screenGeometry.warning] };
270
+ }
271
+ if (screenGeometry.error && deps.screenMismatchPolicy !== "record-evidence") {
272
+ failureCode = "HUMANISH_CUA_LAB_DEVICE_GEOMETRY";
273
+ throw new Error(screenGeometry.error);
274
+ }
275
+ if (screenGeometry.error && screenGeometry.verified) {
276
+ // record-evidence policy: the bundle keeps requested vs verified as separate facts and
277
+ // discloses the divergence instead of failing this lane's world mid-flight.
278
+ const mismatchWarning = deps.scrubKnownValues(`Lane ${spec.laneId} requested a ${spec.resolution[0]}x${spec.resolution[1]} screen but xdpyinfo reports ${screenGeometry.verified.width}x${screenGeometry.verified.height}; recording requested vs verified separately instead of failing the lane closed.`);
279
+ warnings.push(mismatchWarning);
280
+ desktopGeometry = {
281
+ ...desktopGeometry,
282
+ warnings: [...(desktopGeometry.warnings ?? []), mismatchWarning]
283
+ };
284
+ }
285
+ if (desktopCliRoute) {
286
+ // Prepare the runtime and any declared product install, UNKEYED. With install omitted,
287
+ // the participant discovers and installs the product from its public surfaces.
288
+ await provisionDesktopCli(desktop, {
289
+ product: config.subject.product?.name ?? "",
290
+ ...(config.subject.product?.install === undefined ? {} : { install: config.subject.product.install }),
291
+ requestTimeoutMs: deps.requestTimeoutMs,
292
+ scrub: deps.scrubKnownValues,
293
+ onPhase: onSubjectPhase
294
+ });
295
+ }
296
+ if (cloneRoute && serve && subjectRepo) {
297
+ subjectCommit = await provisionCloneSubject(desktop, {
298
+ repo: subjectRepo,
299
+ depth: config.subject.clone?.depth ?? 1,
300
+ serve,
301
+ ...(config.subject.state === undefined ? {} : { state: config.subject.state }),
302
+ hasGithubToken: deps.hasGithubToken,
303
+ requestTimeoutMs: deps.requestTimeoutMs,
304
+ scrub: deps.scrubKnownValues,
305
+ onCommit: (commit) => {
306
+ subjectCommit = commit;
307
+ },
308
+ onStateStep: (record) => {
309
+ stateStepRecords.push(record);
310
+ },
311
+ onPhase: onSubjectPhase,
312
+ ...(deps.hooks.detachedTimers ?? {})
313
+ });
314
+ }
315
+ else if (localTreeRoute && serve && deps.localTreeArchiveBuffer) {
316
+ await provisionLocalTreeSubject(desktop, {
317
+ archiveBuffer: deps.localTreeArchiveBuffer,
318
+ serve,
319
+ ...(config.subject.state === undefined ? {} : { state: config.subject.state }),
320
+ requestTimeoutMs: deps.requestTimeoutMs,
321
+ scrub: deps.scrubKnownValues,
322
+ onStateStep: (record) => {
323
+ stateStepRecords.push(record);
324
+ },
325
+ onPhase: onSubjectPhase,
326
+ ...(deps.hooks.detachedTimers ?? {})
327
+ });
328
+ }
329
+ if (!desktopCliRoute) {
330
+ const requestedFidelity = config.execution?.desktop?.fidelity;
331
+ // A declared camera (#509) is in place before the browser starts: the feed is generated or
332
+ // uploaded first, and a feed that cannot be produced fails the lane closed here.
333
+ const requestedMedia = config.execution?.desktop?.media;
334
+ const mediaEvidence = requestedMedia === undefined
335
+ ? undefined
336
+ : await prepareDesktopMedia(desktop, requestedMedia, config.policies?.mediaPermission ?? "prompt", deps.labCwd, deps.requestTimeoutMs);
337
+ const browserLaunch = await openDesktopBrowserTarget(desktop, targetUrl, deps.requestTimeoutMs, config.execution?.desktop?.browser, [
338
+ ...(requestedFidelity?.mobileEmulation && spec.devicePreset.isMobile
339
+ ? [
340
+ `--user-agent=${requestedFidelity.userAgent ?? DEFAULT_MOBILE_USER_AGENT}`,
341
+ ...(requestedFidelity.touch === false ? [] : ["--touch-events=enabled"])
342
+ ]
343
+ : []),
344
+ ...(mediaEvidence?.flags ?? [])
345
+ ]);
346
+ desktopBrowser = mediaEvidence === undefined
347
+ ? browserLaunch.evidence
348
+ : { requested: config.execution?.desktop?.browser ?? "default", ...(browserLaunch.evidence ?? {}), media: mediaEvidence };
349
+ if (mediaEvidence !== undefined && browserLaunch.family !== "chromium") {
350
+ throw new Error(`execution.desktop.media needs Chrome or Chromium on lane ${spec.laneId} (the fake-device flags are Chromium's); the launched browser family is ${browserLaunch.family}. Set execution.desktop.browser: chrome.`);
351
+ }
352
+ launchedBrowserFamily = browserLaunch.family;
353
+ browserLaunchIdentity = browserLaunch.identity;
354
+ browserLaunched = true;
355
+ await desktop.wait(BROWSER_SETTLE_MS).catch(() => undefined);
356
+ // Mobile fidelity beyond viewport size (#221): applied to the launch page before the
357
+ // geometry capture and the participant's first observation, OUTSIDE the stream/geometry
358
+ // try below (whose catch degrades to a warning): a request that cannot be applied fails
359
+ // the lane closed with the reason.
360
+ // Only lanes on a mobile preset are emulated: a run-wide flag must not hand a desktop or
361
+ // tablet lane an iPhone user agent (the first live proof did exactly that to the desktop
362
+ // newcomer beside the phone lane). Those lanes carry no fidelity block, which is honest.
363
+ const fidelityRequest = config.execution?.desktop?.fidelity;
364
+ if (fidelityRequest?.mobileEmulation && spec.devicePreset.isMobile) {
365
+ if (launchedBrowserFamily !== "chromium") {
366
+ throw new Error(`execution.desktop.fidelity.mobileEmulation needs Chrome or Chromium on lane ${spec.laneId}; the launched browser family is ${launchedBrowserFamily}. Set execution.desktop.browser: chrome.`);
367
+ }
368
+ const applied = await applyMobileEmulation(desktop, deps.requestTimeoutMs, {
369
+ ...(browserLaunchIdentity?.cdpPort === undefined ? {} : { cdpPort: browserLaunchIdentity.cdpPort }),
370
+ ...(browserLaunchIdentity?.profileDir === undefined ? {} : { profileDir: browserLaunchIdentity.profileDir }),
371
+ targetUrl
372
+ }, browserTargetId, {
373
+ width: spec.devicePreset.width,
374
+ height: spec.devicePreset.height,
375
+ deviceScaleFactor: fidelityRequest.deviceScaleFactor ?? spec.devicePreset.deviceScaleFactor,
376
+ touch: fidelityRequest.touch ?? true,
377
+ userAgent: fidelityRequest.userAgent ?? DEFAULT_MOBILE_USER_AGENT
378
+ });
379
+ appliedFidelity = applied.fidelity;
380
+ emulatedTargetId = applied.targetId;
381
+ emulationHolderName = applied.holderName;
382
+ warnings.push(...applied.warnings);
383
+ }
384
+ }
385
+ else {
386
+ // A terminal window, opened the way the browser is opened on every other route: the
387
+ // participant arrives at a desktop with the thing they were asked to use already in front
388
+ // of them. They can still open another from the dock — that is the point of a desktop.
389
+ await openDesktopTerminal(desktop, deps.requestTimeoutMs, config.subject.product?.workdir);
390
+ await desktop.wait(BROWSER_SETTLE_MS).catch(() => undefined);
391
+ }
392
+ prepared = true;
393
+ }
394
+ async function openSession() {
395
+ if (!prepared || !desktop || !allocation || opened || finalization)
396
+ throw new Error('Desktop lane must be prepared and may only be opened once, before finalization.');
397
+ opened = true;
398
+ try {
399
+ // No browser means no browser geometry, and none is invented: the CSS-viewport facts a
400
+ // browser reports have no counterpart in a terminal window, and an empty record shaped like
401
+ // a measurement would read as one. The screen geometry above is still verified.
402
+ if (!desktopCliRoute) {
403
+ const browserGeometry = await captureDesktopBrowserGeometry({
404
+ desktop,
405
+ browserFamily: launchedBrowserFamily,
406
+ ...(browserLaunchIdentity === undefined ? {} : { launchIdentity: browserLaunchIdentity }),
407
+ laneId: spec.laneId,
408
+ targetUrl,
409
+ requestedScreen: spec.resolution,
410
+ requestTimeoutMs: deps.requestTimeoutMs
411
+ });
412
+ initialBrowserGeometry = browserGeometry;
413
+ browserWindowId = browserGeometry.browserWindowId;
414
+ browserTargetId = browserGeometry.browserTargetId;
415
+ }
416
+ // The WHOLE desktop, not one window: a person studying a terminal app opens other windows,
417
+ // and a stream bound to the first one would quietly stop being evidence.
418
+ await startDesktopStream(desktop, browserWindowId);
419
+ const candidateStreamUrl = desktop.stream.getUrl({
420
+ authKey: desktop.stream.getAuthKey(),
421
+ autoConnect: true,
422
+ viewOnly: true,
423
+ resize: "scale"
424
+ });
425
+ if (typeof candidateStreamUrl === "string" && candidateStreamUrl.trim().length > 0) {
426
+ streamUrl = candidateStreamUrl;
427
+ await deps.hooks.onRuntimeStreamReady?.({
428
+ laneId: spec.laneId,
429
+ sandboxId: desktop.sandboxId,
430
+ simId: spec.simId,
431
+ streamId: spec.streamId,
432
+ url: streamUrl
433
+ });
434
+ }
435
+ else {
436
+ warnings.push("Live desktop stream started but did not return a usable watch URL; Observer will fall back to screenshots.");
437
+ }
438
+ }
439
+ catch (error) {
440
+ warnings.push(`Live desktop stream unavailable (run continues; evidence still captured): ${redactText(deps.scrubKnownValues(toErrorMessage(error)))}`);
441
+ }
442
+ // This is outside the stream's best-effort catch: unusable geometry is a harness failure,
443
+ // never a participant finding about missing controls. Both per-lane and concurrent seats
444
+ // use this route; sequential seats enforce the same capture result in shared-world-lab.
445
+ if (initialBrowserGeometry?.unusable !== undefined) {
446
+ failureCode = "HUMANISH_CUA_LAB_DEVICE_GEOMETRY";
447
+ throw new Error(`${failureCode}: ${initialBrowserGeometry.unusable} Participant actions were not started.`);
448
+ }
449
+ const inbox = deps.receiving && receivingInboxUrl
450
+ ? { url: receivingInboxUrl, address: deps.receiving.address(spec.laneId), receiving: true }
451
+ : commsEmail && commsInboxUrl && deployedComms?.ready && laneHasInboxRecipient(commsEmail, spec.laneId)
452
+ ? { url: commsInboxUrl, ...optionalAddress(inboxRecipientFor(commsEmail, spec.laneId)?.address) }
453
+ : deps.externalComms && laneHasInboxRecipient(deps.externalComms.email, spec.laneId)
454
+ ? { url: deps.externalComms.inboxUrl, ...optionalAddress(inboxRecipientFor(deps.externalComms.email, spec.laneId)?.address) }
455
+ : undefined;
456
+ return {
457
+ executor: allocation.open(createE2BDesktopExecutor(desktop, {
458
+ ...(launchedBrowserFamily === "chromium"
459
+ ? {
460
+ observeBrowserState: makeChromeBrowserStateObserver(desktop, deps.requestTimeoutMs, {
461
+ ...(browserLaunchIdentity?.cdpPort === undefined ? {} : { cdpPort: browserLaunchIdentity.cdpPort }),
462
+ ...(browserLaunchIdentity?.profileDir === undefined ? {} : { profileDir: browserLaunchIdentity.profileDir }),
463
+ targetUrl
464
+ }, browserTargetId,
465
+ // Once per lane: a dark observation channel is a gap in the instrument, and the
466
+ // funnel's NEVER MEASURED count needs this line to explain itself (#514).
467
+ (reason) => {
468
+ warnings.push(`Browser-state observer unavailable for lane ${spec.laneId} (${redactText(deps.scrubKnownValues(reason))}); ` +
469
+ "urlIncludes/urlPathEquals/textIncludes stop conditions and task criteria are NOT being measured this session.");
470
+ }, emulatedTargetId === undefined
471
+ ? undefined
472
+ : {
473
+ emulatedTargetId,
474
+ expectedWidth: spec.devicePreset.width,
475
+ expectTouch: appliedFidelity?.requested.touch === true,
476
+ onDrift: (reason) => {
477
+ warnings.push(`Mobile emulation drift on lane ${spec.laneId}: ${reason} (#623).`);
478
+ },
479
+ onCovered: (coveredTargetId, read) => {
480
+ // A later tab the page itself reported at the phone width: evidence that
481
+ // the emulation followed the participant (#623), kept on the bundle.
482
+ if (appliedFidelity === undefined)
483
+ return;
484
+ appliedFidelity = {
485
+ ...appliedFidelity,
486
+ laterTargets: [...(appliedFidelity.laterTargets ?? []), { targetId: coveredTargetId, ...read }]
487
+ };
488
+ }
489
+ })
490
+ }
491
+ : {})
492
+ })).executor, ...(inbox === undefined ? {} : { inbox })
493
+ };
494
+ }
495
+ async function finish(failed) {
496
+ // Stop the mid-run inbox-surface loop FIRST — before the teardown evidence drain below — so the two
497
+ // `cat`s never overlap and the final surface state is deterministic. A surface failure can never
498
+ // block teardown (the loop body is fully try/caught and this await is on its already-caught promise).
499
+ surfaceDisposed = true;
500
+ releaseSurface();
501
+ if (surfaceLoop)
502
+ await surfaceLoop.catch(() => undefined);
503
+ if (desktop && allocation) {
504
+ try {
505
+ if (browserLaunched) {
506
+ const finalGeometry = await captureDesktopBrowserGeometry({
507
+ desktop,
508
+ browserFamily: launchedBrowserFamily,
509
+ ...(browserLaunchIdentity === undefined ? {} : { launchIdentity: browserLaunchIdentity }),
510
+ ...(browserWindowId === undefined ? {} : { browserWindowId }),
511
+ ...(browserTargetId === undefined ? {} : { browserTargetId }),
512
+ laneId: spec.laneId,
513
+ targetUrl,
514
+ requestedScreen: spec.resolution,
515
+ requestTimeoutMs: deps.requestTimeoutMs,
516
+ pagePreference: "active",
517
+ resize: false
518
+ }).catch((error) => ({
519
+ warnings: [`Final browser geometry measurement failed for lane ${spec.laneId}: ${redactText(deps.scrubKnownValues(toErrorMessage(error)))}`]
520
+ }));
521
+ // Chosen capture rule: final-if-it-measured-anything, else launch-time. A final capture
522
+ // that measured EITHER field wins whole, so a partial final capture omits fields the
523
+ // launch-time capture had (honest omission); only a final capture that measured NOTHING
524
+ // falls back to the launch-time capture.
525
+ const chosenGeometry = finalGeometry.browserWindow !== undefined || finalGeometry.viewport !== undefined
526
+ ? finalGeometry
527
+ : initialBrowserGeometry ?? finalGeometry;
528
+ const geometryWarnings = [...new Set([...(initialBrowserGeometry?.warnings ?? []), ...chosenGeometry.warnings].map((warning) => deps.scrubKnownValues(warning)))];
529
+ warnings.push(...geometryWarnings);
530
+ // The emulation holder's own log, after its announce line: which later targets it
531
+ // attached to, what it sent, and any reply that came back as an error (#623). Read while
532
+ // the sandbox is alive; the first live proof had no way to say what the holder did.
533
+ if (appliedFidelity !== undefined && emulationHolderName !== undefined) {
534
+ const holderLog = await readDetachedLog(desktop, emulationHolderName, deps.requestTimeoutMs).catch(() => "");
535
+ const lines = holderLog.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("{")).slice(1, 51);
536
+ if (lines.length > 0)
537
+ appliedFidelity = { ...appliedFidelity, holderLog: lines.map((line) => deps.scrubKnownValues(line)) };
538
+ }
539
+ desktopGeometry = {
540
+ screen: desktopGeometry.screen,
541
+ ...(chosenGeometry.browserWindow === undefined ? {} : { browserWindow: chosenGeometry.browserWindow }),
542
+ ...(chosenGeometry.viewport === undefined ? {} : { viewport: chosenGeometry.viewport }),
543
+ ...(appliedFidelity === undefined ? {} : { fidelity: appliedFidelity }),
544
+ ...((desktopGeometry.warnings?.length ?? 0) + geometryWarnings.length === 0
545
+ ? {}
546
+ : { warnings: [...(desktopGeometry.warnings ?? []), ...geometryWarnings] })
547
+ };
548
+ }
549
+ if (deps.receiving) {
550
+ try {
551
+ await deps.receiving.finishParticipant(spec.laneId);
552
+ }
553
+ catch {
554
+ warnings.push("Real email finalization is incomplete. Inspect communication cleanup with humanish comms recover.");
555
+ }
556
+ }
557
+ // Off-app comms evidence (#297): before this lane's sandbox is torn down, drain everything the
558
+ // in-sandbox catch captured, route it into a host fake inbox addressed to the declared
559
+ // recipients, and write the digest-only thread artifact. Wrapped so a drain failure NEVER
560
+ // breaks teardown — the sandbox must still be killed either way. Runs only for a ready catch.
561
+ if (commsEmail && deployedComms?.ready) {
562
+ try {
563
+ const commsChannel = new FakeInbox();
564
+ const commsInboxes = [];
565
+ for (const recipient of commsEmail.recipients ?? []) {
566
+ if (recipient.address !== undefined) {
567
+ commsInboxes.push(await commsChannel.provisionAddress(recipient.lane, recipient.address));
568
+ }
569
+ }
570
+ const collected = await collectCommsThread({
571
+ desktop,
572
+ deployed: deployedComms,
573
+ channel: commsChannel,
574
+ inboxes: commsInboxes,
575
+ requestTimeoutMs: deps.requestTimeoutMs
576
+ });
577
+ if (collected.artifact) {
578
+ const path = deps.laneCount === 1 ? "comms/thread.json" : `comms/${spec.streamId}.thread.json`;
579
+ await writeContainedOutputFile(deps.artifactRoot, path, `${JSON.stringify(collected.artifact, null, 2)}\n`, "utf8");
580
+ commsArtifactPath = path;
581
+ }
582
+ else if (collected.captured > 0) {
583
+ // Captured mail that matched no declared recipient must not vanish silently (invariant 6:
584
+ // honest signals): tell the operator to declare comms.email.recipients[].address to match
585
+ // the address the app actually sends to (e.g. the one the persona surface will sign up with).
586
+ warnings.push(`Comms catch captured ${collected.captured} email send(s) but none matched a declared recipient inbox — no comms evidence written. Declare comms.email.recipients[].address to match the address the app sends to.`);
587
+ }
588
+ else {
589
+ // Zero captures is the silent-broken shape (#351): the app never posted to the catch at
590
+ // all, so the personas stared at an empty inbox. Most common cause: the app does not
591
+ // actually read the declared injectEnv var for its email API base URL.
592
+ const transportHint = commsEmail.smtp
593
+ ? `Verify the app reads ${commsEmail.smtp.hostEnv}/${commsEmail.smtp.portEnv} for its SMTP host and port`
594
+ : `Verify the app reads ${commsEmail.injectEnv} for its email API base URL (an SDK that ignores it sends real mail or throws)`;
595
+ warnings.push(`Comms catch captured ZERO email sends — the app never delivered mail through the catch. ${transportHint} and that the flow reached an email step.`);
596
+ }
597
+ }
598
+ catch (error) {
599
+ warnings.push(`Comms evidence collection failed (run continues; sandbox still torn down): ${redactText(deps.scrubKnownValues(toErrorMessage(error)))}`);
600
+ }
601
+ }
602
+ }
603
+ catch (error) {
604
+ warnings.push(`Desktop final evidence collection failed: ${redactText(deps.scrubKnownValues(toErrorMessage(error)))}`);
605
+ }
606
+ finally {
607
+ // Each route's own keep flag gates its own lane only: a clone.keep can never leak into
608
+ // a local-tree lane's teardown decision, and vice versa.
609
+ const keepReason = cloneRoute && config.subject.clone?.keep === true
610
+ ? "subject.clone.keep"
611
+ : localTreeRoute && config.subject.localTree?.keep === true
612
+ ? "subject.localTree.keep"
613
+ : undefined;
614
+ const keepForDebug = keepReason !== undefined && failed;
615
+ const released = await allocation.close({ retainForDebug: keepForDebug });
616
+ killed = released.status === "released";
617
+ if (released.status === "released" && released.reason === "already_gone") {
618
+ warnings.push("Sandbox was already absent when cleanup ran; its exact termination time is unknown. Desktop cost uses the observed acquisition-to-cleanup span.");
619
+ }
620
+ else if (released.status === "retained") {
621
+ warnings.push(`Sandbox ${allocation.resourceId} kept for debugging (${keepReason} on failure); reclaim it via E2B or it will be killed on its server-side timeout.`);
622
+ }
623
+ else if (released.status === "unconfirmed") {
624
+ if (released.reason === "release_unavailable") {
625
+ warnings.push("Installed @e2b/desktop SDK does not expose Sandbox.kill; server-side kill-on-timeout will reclaim the sandbox.");
626
+ }
627
+ else if (released.reason === "release_failed") {
628
+ warnings.push(`Sandbox teardown failed (server-side kill-on-timeout will reclaim it): ${redactText(deps.scrubKnownValues(toErrorMessage(released.error)))}`);
629
+ }
630
+ else {
631
+ warnings.push("Sandbox teardown returned an unexpected result; release is unconfirmed and server-side kill-on-timeout remains the backstop.");
632
+ }
633
+ }
634
+ // Close the observed span. A kept or unconfirmed sandbox can still accrue compute cost;
635
+ // the summary records that remaining lifetime as unknown instead of calling this complete.
636
+ sandboxTornDownAtMs = deps.now();
637
+ // The lane's live stream is now a dead page whichever teardown path ran (killed, kept, or
638
+ // kill-failed-awaiting-TTL) — tell the watch overlay so the tile falls back to recorded
639
+ // evidence instead of "sandbox not found" (#357). Guarded: a viewer callback must never
640
+ // break teardown.
641
+ if (streamUrl !== undefined) {
642
+ try {
643
+ await deps.hooks.onRuntimeStreamEnded?.({ laneId: spec.laneId, simId: spec.simId, streamId: spec.streamId });
644
+ }
645
+ catch {
646
+ // viewer-side only; nothing to record
647
+ }
648
+ }
649
+ }
650
+ }
651
+ }
652
+ function snapshot() {
653
+ // Host-side approximation of the E2B desktop's billed lifetime; feeds the desktop-minute cost
654
+ // estimate. Never negative.
655
+ const desktopDurationMs = sandboxCreatedAtMs !== undefined && sandboxTornDownAtMs !== undefined
656
+ ? Math.max(0, sandboxTornDownAtMs - sandboxCreatedAtMs)
657
+ : undefined;
658
+ return {
659
+ ...(sandboxId === undefined ? {} : { sandboxId }),
660
+ ...(desktopDurationMs === undefined ? {} : { desktopDurationMs }),
661
+ ...(desktopResources === undefined ? {} : { desktopResources }),
662
+ killed,
663
+ streamUrlPresent: streamUrl !== undefined,
664
+ ...(subjectCommit === undefined ? {} : { subjectCommit }),
665
+ ...(desktopBrowser === undefined ? {} : { desktopBrowser }),
666
+ desktopGeometry,
667
+ stateStepRecords,
668
+ phaseRecords,
669
+ ...(failureCode === undefined ? {} : { failureCode }),
670
+ ...(commsArtifactPath === undefined ? {} : { commsArtifactPath })
671
+ };
672
+ }
673
+ return { prepare, openSession, snapshot, finalize({ failed }) { return finalization ??= finish(failed); } };
674
+ }
675
+ //# sourceMappingURL=e2b-cua-desktop.js.map