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.
- package/AGENTS.md +86 -79
- package/CONTRIBUTING.md +7 -2
- package/README.md +11 -2
- package/dist/actor-contract.d.ts +35 -1
- package/dist/actor-contract.js +38 -0
- package/dist/actor-contract.js.map +1 -1
- package/dist/adapter-extension.js +1 -0
- package/dist/adapter-extension.js.map +1 -1
- package/dist/automatic-analysis-config.d.ts +15 -5
- package/dist/automatic-analysis-config.js +25 -4
- package/dist/automatic-analysis-config.js.map +1 -1
- package/dist/automatic-study-analysis.js +4 -2
- package/dist/automatic-study-analysis.js.map +1 -1
- package/dist/browser-control-client.d.ts +14 -0
- package/dist/browser-control-client.js +134 -0
- package/dist/browser-control-client.js.map +1 -0
- package/dist/browser-control-dispatcher.d.ts +14 -0
- package/dist/browser-control-dispatcher.js +109 -0
- package/dist/browser-control-dispatcher.js.map +1 -0
- package/dist/browser-control-protocol.d.ts +371 -0
- package/dist/browser-control-protocol.js +155 -0
- package/dist/browser-control-protocol.js.map +1 -0
- package/dist/browser-control-transport.d.ts +24 -0
- package/dist/browser-control-transport.js +156 -0
- package/dist/browser-control-transport.js.map +1 -0
- package/dist/comms-lease-store.d.ts +1 -0
- package/dist/comms-lease-store.js +9 -3
- package/dist/comms-lease-store.js.map +1 -1
- package/dist/computer-use-actor.d.ts +2 -2
- package/dist/computer-use-actor.js +6 -1
- package/dist/computer-use-actor.js.map +1 -1
- package/dist/computer-use.d.ts +23 -1
- package/dist/computer-use.js +253 -70
- package/dist/computer-use.js.map +1 -1
- package/dist/cua-actor-lab.d.ts +24 -289
- package/dist/cua-actor-lab.js +203 -1983
- package/dist/cua-actor-lab.js.map +1 -1
- package/dist/cua-desktop-lane.d.ts +35 -0
- package/dist/cua-desktop-lane.js +13 -0
- package/dist/cua-desktop-lane.js.map +1 -0
- package/dist/cua-executor-error.d.ts +31 -0
- package/dist/cua-executor-error.js +48 -0
- package/dist/cua-executor-error.js.map +1 -0
- package/dist/cua-provider-error.d.ts +12 -0
- package/dist/cua-provider-error.js +28 -0
- package/dist/cua-provider-error.js.map +1 -0
- package/dist/desktop-session.d.ts +41 -0
- package/dist/desktop-session.js +46 -0
- package/dist/desktop-session.js.map +1 -0
- package/dist/doctor-lab.d.ts +8 -1
- package/dist/doctor-lab.js +40 -8
- package/dist/doctor-lab.js.map +1 -1
- package/dist/e2b-cua-desktop.d.ts +3 -0
- package/dist/e2b-cua-desktop.js +675 -0
- package/dist/e2b-cua-desktop.js.map +1 -0
- package/dist/e2b-cua-provisioning.d.ts +311 -0
- package/dist/e2b-cua-provisioning.js +1213 -0
- package/dist/e2b-cua-provisioning.js.map +1 -0
- package/dist/e2b-desktop-executor.d.ts +1 -24
- package/dist/e2b-desktop-executor.js +2 -127
- package/dist/e2b-desktop-executor.js.map +1 -1
- package/dist/e2b-desktop-session.d.ts +7 -0
- package/dist/e2b-desktop-session.js +29 -0
- package/dist/e2b-desktop-session.js.map +1 -0
- package/dist/e2b-terminal-lab.js +1 -0
- package/dist/e2b-terminal-lab.js.map +1 -1
- package/dist/frame-signature.d.ts +24 -0
- package/dist/frame-signature.js +128 -0
- package/dist/frame-signature.js.map +1 -0
- package/dist/guest-bootstrap.d.ts +43 -0
- package/dist/guest-bootstrap.js +240 -0
- package/dist/guest-bootstrap.js.map +1 -0
- package/dist/guest-browser-tools.d.ts +8 -0
- package/dist/guest-browser-tools.js +66 -0
- package/dist/guest-browser-tools.js.map +1 -0
- package/dist/guest-chromium-text.d.ts +27 -0
- package/dist/guest-chromium-text.js +281 -0
- package/dist/guest-chromium-text.js.map +1 -0
- package/dist/guest-desktop-executor.d.ts +22 -0
- package/dist/guest-desktop-executor.js +177 -0
- package/dist/guest-desktop-executor.js.map +1 -0
- package/dist/guest-desktop-native.d.ts +14 -0
- package/dist/guest-desktop-native.js +131 -0
- package/dist/guest-desktop-native.js.map +1 -0
- package/dist/guest-runtime-desktop.d.ts +35 -0
- package/dist/guest-runtime-desktop.js +231 -0
- package/dist/guest-runtime-desktop.js.map +1 -0
- package/dist/guest-runtime-main.d.ts +1 -0
- package/dist/guest-runtime-main.js +31 -0
- package/dist/guest-runtime-main.js.map +1 -0
- package/dist/guest-runtime-revision.d.ts +1 -0
- package/dist/guest-runtime-revision.js +3 -0
- package/dist/guest-runtime-revision.js.map +1 -0
- package/dist/guest-runtime.d.ts +25 -0
- package/dist/guest-runtime.js +96 -0
- package/dist/guest-runtime.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/lab-config.js +10 -3
- package/dist/lab-config.js.map +1 -1
- package/dist/lab-engine.js +6 -0
- package/dist/lab-engine.js.map +1 -1
- package/dist/lab-summary.d.ts +5 -1
- package/dist/lab-summary.js +5 -0
- package/dist/lab-summary.js.map +1 -1
- package/dist/local-agent-cli.js +1 -1
- package/dist/local-agent-cli.js.map +1 -1
- package/dist/local-firecracker-desktop.d.ts +13 -0
- package/dist/local-firecracker-desktop.js +150 -0
- package/dist/local-firecracker-desktop.js.map +1 -0
- package/dist/local-firecracker-study.d.ts +9 -0
- package/dist/local-firecracker-study.js +93 -0
- package/dist/local-firecracker-study.js.map +1 -0
- package/dist/local-runtime-config.d.ts +6 -0
- package/dist/local-runtime-config.js +56 -0
- package/dist/local-runtime-config.js.map +1 -0
- package/dist/local-runtime-release.d.ts +3 -0
- package/dist/local-runtime-release.js +8 -0
- package/dist/local-runtime-release.js.map +1 -0
- package/dist/local-runtime.d.ts +25 -0
- package/dist/local-runtime.js +113 -0
- package/dist/local-runtime.js.map +1 -0
- package/dist/observer-app.html +4 -4
- package/dist/pricing.d.ts +22 -1
- package/dist/pricing.js +22 -0
- package/dist/pricing.js.map +1 -1
- package/dist/program.js +50 -9
- package/dist/program.js.map +1 -1
- package/dist/restricted-codex-analysis.d.ts +15 -0
- package/dist/restricted-codex-analysis.js +13 -0
- package/dist/restricted-codex-analysis.js.map +1 -0
- package/dist/restricted-codex-participant-policy.d.ts +39 -0
- package/dist/restricted-codex-participant-policy.js +69 -0
- package/dist/restricted-codex-participant-policy.js.map +1 -0
- package/dist/restricted-codex-participant-run.d.ts +20 -0
- package/dist/restricted-codex-participant-run.js +78 -0
- package/dist/restricted-codex-participant-run.js.map +1 -0
- package/dist/restricted-codex-participant.d.ts +14 -0
- package/dist/restricted-codex-participant.js +178 -0
- package/dist/restricted-codex-participant.js.map +1 -0
- package/dist/restricted-codex-policy.d.ts +56 -0
- package/dist/restricted-codex-policy.js +151 -0
- package/dist/restricted-codex-policy.js.map +1 -0
- package/dist/restricted-codex-session.d.ts +19 -0
- package/dist/restricted-codex-session.js +413 -0
- package/dist/restricted-codex-session.js.map +1 -0
- package/dist/restricted-codex-transport.d.ts +58 -0
- package/dist/restricted-codex-transport.js +233 -0
- package/dist/restricted-codex-transport.js.map +1 -0
- package/dist/run-detail.js +4 -2
- package/dist/run-detail.js.map +1 -1
- package/dist/run.d.ts +12 -5
- package/dist/run.js +17 -1
- package/dist/run.js.map +1 -1
- package/dist/shared-world-lab.js +2 -2
- package/dist/shared-world-lab.js.map +1 -1
- package/dist/study-analysis-codex-config.d.ts +11 -0
- package/dist/study-analysis-codex-config.js +34 -0
- package/dist/study-analysis-codex-config.js.map +1 -0
- package/dist/study-analysis-engine.d.ts +6 -3
- package/dist/study-analysis-engine.js +19 -10
- package/dist/study-analysis-engine.js.map +1 -1
- package/dist/study-analysis-job.d.ts +3 -2
- package/dist/study-analysis-job.js +1 -1
- package/dist/study-analysis-job.js.map +1 -1
- package/dist/study-analysis-provider.d.ts +4 -2
- package/dist/study-analysis-provider.js +1 -1
- package/dist/study-analysis-provider.js.map +1 -1
- package/dist/study-analysis-service.d.ts +3 -0
- package/dist/study-analysis-service.js +24 -6
- package/dist/study-analysis-service.js.map +1 -1
- package/dist/study-analysis-validation.d.ts +43 -19
- package/dist/study-analysis-validation.js +23 -10
- package/dist/study-analysis-validation.js.map +1 -1
- package/dist/study-analysis.d.ts +27 -2
- package/dist/study-costs.js +6 -0
- package/dist/study-costs.js.map +1 -1
- package/dist/tui-app.js +102 -102
- package/docs/architecture/browser-control.md +117 -0
- package/docs/architecture/desktop-sessions.md +80 -0
- package/docs/architecture/guest-desktop.md +87 -0
- package/docs/architecture/local-browser-runtime.md +100 -0
- package/docs/architecture/restricted-codex-analysis.md +91 -0
- package/docs/architecture/runtime-broker-core.md +30 -0
- package/docs/contracts/schemas.md +1 -1
- package/docs/contracts/study-analysis.md +44 -2
- package/docs/goals/current.md +25 -9
- package/docs/product/automatic-analysis.md +24 -3
- package/docs/product/open-source-install-experience.md +7 -0
- package/docs/ramp/README.md +30 -13
- package/docs/release/0.97.0-codex-account-analysis.md +45 -0
- package/docs/release/0.98.0-local-browser-studies.md +31 -0
- package/package.json +4 -2
- 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
|