humanish 0.95.0 → 0.96.1

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 (88) hide show
  1. package/README.md +4 -2
  2. package/dist/comms-agentmail.d.ts +25 -0
  3. package/dist/comms-agentmail.js +418 -0
  4. package/dist/comms-agentmail.js.map +1 -0
  5. package/dist/comms-connections.d.ts +4 -2
  6. package/dist/comms-connections.js +3 -3
  7. package/dist/comms-connections.js.map +1 -1
  8. package/dist/comms-lease-store.d.ts +90 -0
  9. package/dist/comms-lease-store.js +489 -0
  10. package/dist/comms-lease-store.js.map +1 -0
  11. package/dist/comms-receiving-evidence.d.ts +46 -0
  12. package/dist/comms-receiving-evidence.js +35 -0
  13. package/dist/comms-receiving-evidence.js.map +1 -0
  14. package/dist/comms-receiving-inbox.d.ts +16 -0
  15. package/dist/comms-receiving-inbox.js +410 -0
  16. package/dist/comms-receiving-inbox.js.map +1 -0
  17. package/dist/comms-receiving-runtime.d.ts +25 -0
  18. package/dist/comms-receiving-runtime.js +45 -0
  19. package/dist/comms-receiving-runtime.js.map +1 -0
  20. package/dist/comms-receiving-types.d.ts +98 -0
  21. package/dist/comms-receiving-types.js +2 -0
  22. package/dist/comms-receiving-types.js.map +1 -0
  23. package/dist/comms-receiving.d.ts +58 -0
  24. package/dist/comms-receiving.js +554 -0
  25. package/dist/comms-receiving.js.map +1 -0
  26. package/dist/comms-setup.d.ts +40 -0
  27. package/dist/comms-setup.js +121 -0
  28. package/dist/comms-setup.js.map +1 -0
  29. package/dist/concurrent-shared-world-lab.js +662 -624
  30. package/dist/concurrent-shared-world-lab.js.map +1 -1
  31. package/dist/cua-actor-lab.d.ts +6 -1
  32. package/dist/cua-actor-lab.js +149 -52
  33. package/dist/cua-actor-lab.js.map +1 -1
  34. package/dist/doctor-lab.js +11 -0
  35. package/dist/doctor-lab.js.map +1 -1
  36. package/dist/e2b-terminal-lab.js +3 -0
  37. package/dist/e2b-terminal-lab.js.map +1 -1
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.js +1 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/lab-config.d.ts +20 -3
  42. package/dist/lab-config.js +59 -6
  43. package/dist/lab-config.js.map +1 -1
  44. package/dist/lab-engine.js +8 -3
  45. package/dist/lab-engine.js.map +1 -1
  46. package/dist/lab-summary.d.ts +1 -0
  47. package/dist/lab-summary.js +8 -2
  48. package/dist/lab-summary.js.map +1 -1
  49. package/dist/observer-app.html +1 -1
  50. package/dist/observer-data.js +14 -1
  51. package/dist/observer-data.js.map +1 -1
  52. package/dist/oss-lab.d.ts +1 -1
  53. package/dist/oss-lab.js.map +1 -1
  54. package/dist/oss-meta-lab.d.ts +1 -1
  55. package/dist/oss-meta-lab.js.map +1 -1
  56. package/dist/program.d.ts +2 -0
  57. package/dist/program.js +100 -4
  58. package/dist/program.js.map +1 -1
  59. package/dist/run-narration-secrets.d.ts +5 -0
  60. package/dist/run-narration-secrets.js +68 -0
  61. package/dist/run-narration-secrets.js.map +1 -0
  62. package/dist/run.d.ts +8 -3
  63. package/dist/run.js +26 -4
  64. package/dist/run.js.map +1 -1
  65. package/dist/scripted-browser-lab.js +7 -0
  66. package/dist/scripted-browser-lab.js.map +1 -1
  67. package/dist/shared-world-lab.js +4 -0
  68. package/dist/shared-world-lab.js.map +1 -1
  69. package/dist/study-analysis-engine.js +30 -1
  70. package/dist/study-analysis-engine.js.map +1 -1
  71. package/dist/study-analysis-evidence.js +9 -1
  72. package/dist/study-analysis-evidence.js.map +1 -1
  73. package/dist/tui-app.js +134 -134
  74. package/dist/tui-contract.d.ts +13 -0
  75. package/dist/tui-contract.js.map +1 -1
  76. package/dist/tui-launch.d.ts +2 -0
  77. package/dist/tui-launch.js +9 -1
  78. package/dist/tui-launch.js.map +1 -1
  79. package/docs/architecture/comms-inbox.md +4 -0
  80. package/docs/architecture/real-email-receiving.md +135 -0
  81. package/docs/contracts/run-bundle.md +16 -5
  82. package/docs/contracts/schemas.md +20 -3
  83. package/docs/goals/current.md +5 -5
  84. package/docs/ramp/README.md +13 -5
  85. package/docs/release/0.96.0-real-email-receiving.md +47 -0
  86. package/docs/release/0.96.1-browser-navigation.md +22 -0
  87. package/package.json +2 -1
  88. package/skills/humanish/SKILL.md +33 -13
@@ -1,3 +1,7 @@
1
+ import { withTransientCommsSecrets } from "./run-narration-secrets.js";
2
+ import { prepareReceivingRun, receivingPublication } from "./comms-receiving-runtime.js";
3
+ import { deployReceivingInbox } from "./comms-receiving-inbox.js";
4
+ import { receivingEmailValidationReason } from "./lab-config.js";
1
5
  // The computer-use lab backend: a subject (an app-url the caller provisioned, or a repo the
2
6
  // lab clones AND serves in-sandbox) driven by a REGISTRY-RESOLVED computer-use actor inside a
3
7
  // hosted E2B desktop. This is the path that makes `actors[].type` load-bearing — the
@@ -237,7 +241,7 @@ export function composeLaneInstructions(args) {
237
241
  * runtime loopback/getHost address (not secret), so — mirroring the lobby-code runtime injection — this
238
242
  * augments only the instructions the model receives; the authored prompt + its digest are unchanged.
239
243
  * Returns a new spec (never mutates). Shared by the CUA + concurrent shared-world routes. */
240
- export function withInboxMission(spec, inboxUrl, address) {
244
+ export function withInboxMission(spec, inboxUrl, address, receiving = false) {
241
245
  // No assigned identity means no participant inbox; never fall back to the shared operator view.
242
246
  if (!address?.trim())
243
247
  return spec;
@@ -248,6 +252,11 @@ export function withInboxMission(spec, inboxUrl, address) {
248
252
  // and ends its session — the exact give-up class a live run documented — unless told the wait
249
253
  // is expected and the inbox is the next step.
250
254
  const identity = ` Your email address is ${address} — when the app asks for an email address, enter exactly that.`;
255
+ if (receiving)
256
+ return {
257
+ ...spec,
258
+ instructions: `${spec.instructions}\n\nEmail inbox:${identity} This is a fresh test identity; it does not replace an existing account's email address. When the app says it sent email, open ${inboxUrl} to check your inbox. Read the original email and use its verification link or code. Delivery may take a little time; refresh if needed. If mail remains missing or unavailable, report what you observed rather than assuming the app failed to send. The inbox may block remote images or undeclared destinations; those are harness limitations.`
259
+ };
251
260
  return {
252
261
  ...spec,
253
262
  instructions: `${spec.instructions}\n\nEmail inbox:${identity} When the app tells you it has emailed you (a verification link, confirmation code, or magic link), open ${recipientInboxUrl(inboxUrl, address)} in the browser to read that email and follow its link or enter its code. All email the app sends you arrives there. Waiting for an email is normal, not a blocker — do not end your session while waiting; open the inbox and refresh it until the email appears.`
@@ -1155,36 +1164,48 @@ function isMeasuredViewport(value) {
1155
1164
  function isPositiveMeasurement(value) {
1156
1165
  return typeof value === "number" && Number.isFinite(value) && value > 0;
1157
1166
  }
1158
- async function measureBrowserWindowWithXdotool(desktop, windowId, requestTimeoutMs) {
1167
+ async function measureBrowserWindowWithXwininfo(desktop, windowId, requestTimeoutMs) {
1159
1168
  const result = await desktop.commands.run([
1160
1169
  "set -euo pipefail",
1161
1170
  `win=${shellSingleQuote(windowId)}`,
1162
- "xdotool getwindowgeometry --shell \"$win\" 2>/dev/null || true"
1171
+ // Older xdotool builds translate parent-relative offsets twice. With window
1172
+ // decorations that falsely reports a visible client as clipped, triggering
1173
+ // fullscreen and hiding the participant's address bar. Read root-relative
1174
+ // client coordinates directly; never substitute emulated CDP outer bounds.
1175
+ "LC_ALL=C xwininfo -id \"$win\" -stats 2>/dev/null"
1163
1176
  ].join("\n"), { requestTimeoutMs, timeoutMs: 5_000 });
1164
- const output = result.stdout ?? "";
1165
- const read = (name) => {
1166
- const raw = output.match(new RegExp(`^${name}=(-?\\d+)$`, "m"))?.[1];
1167
- if (raw === undefined)
1177
+ if (result.exitCode !== undefined && result.exitCode !== 0)
1178
+ return undefined;
1179
+ return parseXwininfoGeometry(result.stdout ?? "");
1180
+ }
1181
+ /** Root-relative physical client bounds from xwininfo's C-locale stats. */
1182
+ export function parseXwininfoGeometry(output) {
1183
+ const read = (label) => {
1184
+ const matches = [...output.matchAll(new RegExp(`^\\s*${label}:\\s*(-?\\d+)\\s*$`, "gm"))];
1185
+ if (matches.length !== 1)
1168
1186
  return undefined;
1169
- const value = Number(raw);
1170
- return Number.isFinite(value) ? value : undefined;
1187
+ const value = Number(matches[0][1]);
1188
+ return Number.isSafeInteger(value) ? value : undefined;
1171
1189
  };
1172
- const x = read("X");
1173
- const y = read("Y");
1174
- const width = read("WIDTH");
1175
- const height = read("HEIGHT");
1190
+ const x = read("Absolute upper-left X");
1191
+ const y = read("Absolute upper-left Y");
1192
+ const width = read("Width");
1193
+ const height = read("Height");
1194
+ const mapStates = [...output.matchAll(/^\s*Map State:\s*(\S+)\s*$/gm)];
1195
+ if (mapStates.length !== 1 || mapStates[0][1] !== "IsViewable")
1196
+ return undefined;
1176
1197
  if (x === undefined || y === undefined || width === undefined || height === undefined || width <= 0 || height <= 0) {
1177
1198
  return undefined;
1178
1199
  }
1179
- return { x, y, width, height, source: "xdotool" };
1200
+ return { x, y, width, height, source: "xwininfo" };
1180
1201
  }
1181
1202
  /** Physical X client bounds, never the page's emulated window.outerWidth/Height. */
1182
1203
  function isBrowserWindowContained(bounds, [width, height]) {
1183
1204
  return bounds.x >= 0 && bounds.y >= 0
1184
1205
  && bounds.x + bounds.width <= width && bounds.y + bounds.height <= height;
1185
1206
  }
1186
- /** One bounded repair. Window-manager decorations may keep the client origin below (0, 0),
1187
- * so a full-screen client height can clip the bottom even after windowmove succeeds. */
1207
+ /** Bounded repair. Resizing can clear a window-manager maximize state and move the
1208
+ * client origin as decorations return, so remeasure before a second adjustment. */
1188
1209
  async function fitBrowserWindowWithinDesktop(desktop, windowId, resolution, requestTimeoutMs) {
1189
1210
  const run = (command) => desktop.commands.run([
1190
1211
  "set -euo pipefail",
@@ -1193,18 +1214,22 @@ async function fitBrowserWindowWithinDesktop(desktop, windowId, resolution, requ
1193
1214
  ].join("\n"), { requestTimeoutMs, timeoutMs: 5_000 }).catch(() => undefined);
1194
1215
  await run('xdotool windowmove "$win" 0 0');
1195
1216
  await desktop.wait(250).catch(() => undefined);
1196
- const moved = await measureBrowserWindowWithXdotool(desktop, windowId, requestTimeoutMs).catch(() => undefined);
1217
+ const moved = await measureBrowserWindowWithXwininfo(desktop, windowId, requestTimeoutMs).catch(() => undefined);
1197
1218
  if (moved === undefined)
1198
1219
  return moved;
1199
1220
  let resized = moved;
1200
- const width = resolution[0] - moved.x;
1201
- const height = resolution[1] - moved.y;
1202
1221
  // Resizing alone cannot fix an offscreen client origin. The window manager
1203
1222
  // can also center a minimum-width client at a negative x on a narrow screen.
1204
- if (moved.x >= 0 && moved.y >= 0 && width > 0 && height > 0) {
1223
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1224
+ if (isBrowserWindowContained(resized, resolution))
1225
+ return resized;
1226
+ const width = resolution[0] - resized.x;
1227
+ const height = resolution[1] - resized.y;
1228
+ if (resized.x < 0 || resized.y < 0 || width <= 0 || height <= 0)
1229
+ break;
1205
1230
  await run(`xdotool windowsize "$win" ${width} ${height}`);
1206
1231
  await desktop.wait(250).catch(() => undefined);
1207
- const measured = await measureBrowserWindowWithXdotool(desktop, windowId, requestTimeoutMs).catch(() => undefined);
1232
+ const measured = await measureBrowserWindowWithXwininfo(desktop, windowId, requestTimeoutMs).catch(() => undefined);
1208
1233
  if (measured === undefined)
1209
1234
  return measured;
1210
1235
  resized = measured;
@@ -1227,7 +1252,7 @@ async function fitBrowserWindowWithinDesktop(desktop, windowId, resolution, requ
1227
1252
  // Give the window manager a bounded settling window, keeping missing reads unverified.
1228
1253
  for (let attempt = 0; attempt < 4; attempt += 1) {
1229
1254
  await desktop.wait(250).catch(() => undefined);
1230
- const measured = await measureBrowserWindowWithXdotool(desktop, windowId, requestTimeoutMs).catch(() => undefined);
1255
+ const measured = await measureBrowserWindowWithXwininfo(desktop, windowId, requestTimeoutMs).catch(() => undefined);
1231
1256
  if (measured === undefined || isBrowserWindowContained(measured, resolution))
1232
1257
  return measured;
1233
1258
  resized = measured;
@@ -1244,40 +1269,40 @@ export async function captureDesktopBrowserGeometry(args) {
1244
1269
  return undefined;
1245
1270
  });
1246
1271
  }
1247
- let xdotoolWindow;
1272
+ let physicalWindow;
1248
1273
  if (browserWindowId !== undefined) {
1249
1274
  if (args.resize !== false) {
1250
1275
  await fillDesktopBrowserWindow(args.desktop, browserWindowId, args.requestedScreen, args.requestTimeoutMs);
1251
1276
  // Let the window manager apply the resize before querying both X and page layout geometry.
1252
1277
  await args.desktop.wait(250).catch(() => undefined);
1253
1278
  }
1254
- xdotoolWindow = await measureBrowserWindowWithXdotool(args.desktop, browserWindowId, args.requestTimeoutMs)
1279
+ physicalWindow = await measureBrowserWindowWithXwininfo(args.desktop, browserWindowId, args.requestTimeoutMs)
1255
1280
  .catch(() => undefined);
1256
1281
  }
1257
1282
  else {
1258
1283
  warnings.push(`Browser window bounds could not be measured for lane ${args.laneId}; the live stream will use the full desktop.`);
1259
1284
  }
1260
1285
  let unusable;
1261
- if (xdotoolWindow !== undefined && !isBrowserWindowContained(xdotoolWindow, args.requestedScreen)) {
1262
- const before = xdotoolWindow;
1286
+ if (physicalWindow !== undefined && !isBrowserWindowContained(physicalWindow, args.requestedScreen)) {
1287
+ const before = physicalWindow;
1263
1288
  if (args.resize !== false && browserWindowId !== undefined) {
1264
- xdotoolWindow = await fitBrowserWindowWithinDesktop(args.desktop, browserWindowId, args.requestedScreen, args.requestTimeoutMs);
1265
- if (xdotoolWindow === undefined) {
1289
+ physicalWindow = await fitBrowserWindowWithinDesktop(args.desktop, browserWindowId, args.requestedScreen, args.requestTimeoutMs);
1290
+ if (physicalWindow === undefined) {
1266
1291
  // Keep the last measured bad state; a missing observation cannot prove a successful fix.
1267
- xdotoolWindow = before;
1292
+ physicalWindow = before;
1268
1293
  unusable = `Physical browser containment could not be verified after correction for lane ${args.laneId}; the last measured window was clipped.`;
1269
1294
  }
1270
- else if (isBrowserWindowContained(xdotoolWindow, args.requestedScreen)) {
1271
- warnings.push(`Browser window clipping corrected for lane ${args.laneId}; physical bounds are ${xdotoolWindow.width}x${xdotoolWindow.height} at (${xdotoolWindow.x}, ${xdotoolWindow.y}).`);
1295
+ else if (isBrowserWindowContained(physicalWindow, args.requestedScreen)) {
1296
+ warnings.push(`Browser window clipping corrected for lane ${args.laneId}; physical bounds are ${physicalWindow.width}x${physicalWindow.height} at (${physicalWindow.x}, ${physicalWindow.y}).`);
1272
1297
  }
1273
1298
  }
1274
- if (unusable === undefined && !isBrowserWindowContained(xdotoolWindow, args.requestedScreen)) {
1275
- unusable = `Browser window is outside the captured ${args.requestedScreen[0]}x${args.requestedScreen[1]} desktop for lane ${args.laneId}: physical bounds ${xdotoolWindow.width}x${xdotoolWindow.height} at (${xdotoolWindow.x}, ${xdotoolWindow.y}), right=${xdotoolWindow.x + xdotoolWindow.width}, bottom=${xdotoolWindow.y + xdotoolWindow.height}.`;
1299
+ if (unusable === undefined && !isBrowserWindowContained(physicalWindow, args.requestedScreen)) {
1300
+ unusable = `Browser window is outside the captured ${args.requestedScreen[0]}x${args.requestedScreen[1]} desktop for lane ${args.laneId}: physical bounds ${physicalWindow.width}x${physicalWindow.height} at (${physicalWindow.x}, ${physicalWindow.y}), right=${physicalWindow.x + physicalWindow.width}, bottom=${physicalWindow.y + physicalWindow.height}.`;
1276
1301
  }
1277
1302
  if (unusable !== undefined)
1278
1303
  warnings.push(unusable);
1279
1304
  }
1280
- if (xdotoolWindow === undefined) {
1305
+ if (physicalWindow === undefined) {
1281
1306
  warnings.push(`Physical browser containment is unverified for lane ${args.laneId}; X window bounds could not be measured. Page-reported outer dimensions can be emulated and do not prove physical visibility.`);
1282
1307
  }
1283
1308
  let cdpUnavailable;
@@ -1293,11 +1318,11 @@ export async function captureDesktopBrowserGeometry(args) {
1293
1318
  return undefined;
1294
1319
  })
1295
1320
  : undefined;
1296
- const browserWindow = xdotoolWindow ?? chromeGeometry?.browserWindow;
1321
+ const browserWindow = physicalWindow ?? chromeGeometry?.browserWindow;
1297
1322
  const viewport = chromeGeometry?.viewport;
1298
1323
  // The fill check reads the X window when it was measured: under mobile emulation (#221) the
1299
1324
  // page's window.outerWidth reports the EMULATED screen (414), which is not a fill failure.
1300
- const fillBounds = xdotoolWindow;
1325
+ const fillBounds = physicalWindow;
1301
1326
  if (!browserWindow) {
1302
1327
  warnings.push(`Browser outer bounds could not be measured for lane ${args.laneId}.`);
1303
1328
  }
@@ -1653,12 +1678,13 @@ export async function runCuaLane(spec, deps) {
1653
1678
  // in-sandbox catch (loopback) so its verification mail is CAPTURED, not sent to the internet. Gated
1654
1679
  // ENTIRELY on config.comms — no comms declared → zero change. The base-URL env is injected at
1655
1680
  // sandbox-create (below, so the app reads it at boot); the catch is started right after create.
1656
- const commsEmail = (cloneRoute || localTreeRoute) ? config.comms?.email : undefined;
1681
+ const commsEmail = (cloneRoute || localTreeRoute) && config.comms?.email?.kind === "fake" ? config.comms.email : undefined;
1657
1682
  const commsPort = commsEmail ? (commsEmail.port ?? DEFAULT_SANDBOX_CATCH_PORT) : undefined;
1658
1683
  // Hoisted so the finally can drain the catch before teardown; `commsArtifactPath` is the written
1659
1684
  // evidence path folded into the lane outcome.
1660
1685
  let deployedComms;
1661
1686
  let commsArtifactPath;
1687
+ let receivingInboxUrl;
1662
1688
  // injectEnv is absent on an adopter-hosted plane (#328): there is no subject env to inject
1663
1689
  // because the operator points their own app at their own catch.
1664
1690
  const commsEnv = commsEmail?.injectEnv !== undefined && commsPort !== undefined
@@ -1810,6 +1836,27 @@ export async function runCuaLane(spec, deps) {
1810
1836
  // Start the in-sandbox email catch BEFORE the subject serve, so the app's send-API base URL (injected
1811
1837
  // into its env at create) resolves the moment it boots. A comms-declared lab that can't stand the
1812
1838
  // catch up is a setup failure (fail closed) rather than silently sending real mail.
1839
+ if (deps.receiving) {
1840
+ const surface = await deployReceivingInbox(desktop, { leaseId: spec.streamId, requestTimeoutMs: Math.min(deps.requestTimeoutMs, 30_000) });
1841
+ receivingInboxUrl = surface.url;
1842
+ const email = config.comms?.email;
1843
+ try {
1844
+ await deps.receiving.attach(spec.laneId, {
1845
+ surface,
1846
+ allowedOrigins: [...new Set([new URL(targetUrl).origin, ...(email?.allowedOrigins ?? [])])],
1847
+ originMap: buildOriginMap({
1848
+ ...(config.subject.serve?.url === undefined ? {} : { internalServeUrl: config.subject.serve.url }),
1849
+ reachableBaseUrl: targetUrl,
1850
+ ...(email?.linkOrigin === undefined ? {} : { linkOrigin: email.linkOrigin })
1851
+ })
1852
+ });
1853
+ commsArtifactPath = "comms/receiving.json";
1854
+ }
1855
+ catch (error) {
1856
+ await surface.stop().catch(() => { });
1857
+ throw error;
1858
+ }
1859
+ }
1813
1860
  if (commsEmail && commsPort !== undefined) {
1814
1861
  deployedComms = await deployCommsCatch(desktop, {
1815
1862
  port: commsPort,
@@ -2098,11 +2145,13 @@ export async function runCuaLane(spec, deps) {
2098
2145
  // recipient it can actually receive mail into (else it would stall on an inbox that stays
2099
2146
  // empty). Two comms planes, mutually exclusive by parse: the in-sandbox catch humanish
2100
2147
  // deployed, or the adopter-hosted one (#380).
2101
- instructions: commsEmail && commsInboxUrl && deployedComms?.ready && laneHasInboxRecipient(commsEmail, spec.laneId)
2102
- ? withInboxMission(spec, commsInboxUrl, inboxRecipientFor(commsEmail, spec.laneId)?.address).instructions
2103
- : deps.externalComms && laneHasInboxRecipient(deps.externalComms.email, spec.laneId)
2104
- ? withInboxMission(spec, deps.externalComms.inboxUrl, inboxRecipientFor(deps.externalComms.email, spec.laneId)?.address).instructions
2105
- : spec.instructions,
2148
+ instructions: deps.receiving && receivingInboxUrl
2149
+ ? withInboxMission(spec, receivingInboxUrl, deps.receiving.address(spec.laneId), true).instructions
2150
+ : commsEmail && commsInboxUrl && deployedComms?.ready && laneHasInboxRecipient(commsEmail, spec.laneId)
2151
+ ? withInboxMission(spec, commsInboxUrl, inboxRecipientFor(commsEmail, spec.laneId)?.address).instructions
2152
+ : deps.externalComms && laneHasInboxRecipient(deps.externalComms.email, spec.laneId)
2153
+ ? withInboxMission(spec, deps.externalComms.inboxUrl, inboxRecipientFor(deps.externalComms.email, spec.laneId)?.address).instructions
2154
+ : spec.instructions,
2106
2155
  persona: spec.persona,
2107
2156
  timeoutMs: deps.timeoutMs,
2108
2157
  // The brain is either a keyed API client or a CLI the operator is already signed in to.
@@ -2257,6 +2306,14 @@ export async function runCuaLane(spec, deps) {
2257
2306
  : { warnings: [...(desktopGeometry.warnings ?? []), ...geometryWarnings] })
2258
2307
  };
2259
2308
  }
2309
+ if (deps.receiving) {
2310
+ try {
2311
+ await deps.receiving.finishParticipant(spec.laneId);
2312
+ }
2313
+ catch {
2314
+ warnings.push("Real email finalization is incomplete. Inspect communication cleanup with humanish comms recover.");
2315
+ }
2316
+ }
2260
2317
  // Off-app comms evidence (#297): before this lane's sandbox is torn down, drain everything the
2261
2318
  // in-sandbox catch captured, route it into a host fake inbox addressed to the declared
2262
2319
  // recipients, and write the digest-only thread artifact. Wrapped so a drain failure NEVER
@@ -2700,6 +2757,9 @@ function subjectProvenanceArg(subject, publicRepo, subjectEnvNames) {
2700
2757
  * ticking into a directory something else is deleting, which surfaces as an unrelated ENOTEMPTY.
2701
2758
  */
2702
2759
  export async function runCuaActorLab(options) {
2760
+ return withTransientCommsSecrets(() => runCuaActorLabWithSecrets(options));
2761
+ }
2762
+ async function runCuaActorLabWithSecrets(options) {
2703
2763
  const analysisReason = resolveAutomaticAnalysis(options.config.review?.analysis);
2704
2764
  const tasksReason = analysisReason.ok ? taskProtocolValidationReason(options.config, true) : analysisReason.message;
2705
2765
  if (tasksReason)
@@ -2792,7 +2852,12 @@ async function runCuaActorLabInScope(options) {
2792
2852
  if (actor?.maxOutputTokens !== undefined && (hooks.runSession || hooks.buildProvider || hooks.buildExecutor)) {
2793
2853
  return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", "maxOutputTokens cannot be enforced by a custom runSession/provider/executor route.", descriptor.id);
2794
2854
  }
2855
+ const receivingReason = receivingEmailValidationReason(config);
2856
+ if (receivingReason)
2857
+ return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", receivingReason, descriptor.id);
2795
2858
  const inProcessRoute = hooks.buildExecutor !== undefined;
2859
+ if (inProcessRoute && config.comms?.email?.kind === "real")
2860
+ return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", "Real email receiving requires hosted participant desktops.", descriptor.id);
2796
2861
  if (inProcessRoute && config.execution?.desktop?.media !== undefined) {
2797
2862
  return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", "execution.desktop.media is not provisioned by a caller-supplied executor. Remove the declaration or use a hosted computer-use browser lane.", descriptor.id);
2798
2863
  }
@@ -3267,20 +3332,47 @@ async function runCuaActorLabInScope(options) {
3267
3332
  await flushWriting;
3268
3333
  };
3269
3334
  }
3335
+ const receivingWarnings = [];
3336
+ let receiving;
3337
+ if (!dryRun && config.comms?.email?.kind === "real") {
3338
+ try {
3339
+ receiving = await prepareReceivingRun({ cwd, runId, config, env, participants: laneSpecs.map(spec => spec.laneId), runPaths,
3340
+ registerSecrets: values => { for (const value of values)
3341
+ if (value.length >= 4 && !knownSecretValues.includes(value))
3342
+ knownSecretValues.push(value); }
3343
+ });
3344
+ if (receiving)
3345
+ deps.receiving = receiving;
3346
+ }
3347
+ catch {
3348
+ await stopLiveFlush?.();
3349
+ return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", "Real email setup failed before desktop allocation. Run humanish comms check --online and humanish comms recover to inspect authentication and pending cleanup.", descriptor.id);
3350
+ }
3351
+ }
3270
3352
  // Run lanes (dry-run runs none). In-process is always one lane.
3271
3353
  let outcomes;
3272
3354
  let failFastReason;
3273
- if (!dryRun) {
3274
- if (inProcessRoute) {
3275
- outcomes = [await runInProcessLane(laneSpecs[0], deps)];
3355
+ try {
3356
+ if (!dryRun) {
3357
+ if (inProcessRoute) {
3358
+ outcomes = [await runInProcessLane(laneSpecs[0], deps)];
3359
+ }
3360
+ else if (laneCount === 1) {
3361
+ outcomes = [await runCuaLane(laneSpecs[0], deps)];
3362
+ }
3363
+ else {
3364
+ const ran = await runCuaLanes(laneSpecs, deps, plan.concurrency);
3365
+ outcomes = ran.outcomes;
3366
+ failFastReason = ran.failFastReason;
3367
+ }
3276
3368
  }
3277
- else if (laneCount === 1) {
3278
- outcomes = [await runCuaLane(laneSpecs[0], deps)];
3369
+ }
3370
+ finally {
3371
+ try {
3372
+ await receiving?.finish();
3279
3373
  }
3280
- else {
3281
- const ran = await runCuaLanes(laneSpecs, deps, plan.concurrency);
3282
- outcomes = ran.outcomes;
3283
- failFastReason = ran.failFastReason;
3374
+ catch {
3375
+ receivingWarnings.push("Email finalization could not complete. Inspect humanish comms recover; provider cleanup remains unresolved.");
3284
3376
  }
3285
3377
  }
3286
3378
  // Close the live flush BEFORE any final artifact work: no new flush may start, and an
@@ -3438,6 +3530,8 @@ async function runCuaActorLabInScope(options) {
3438
3530
  hookLabel: "cuaHooks",
3439
3531
  ...(options.scorerProvenance === undefined ? {} : { scorerProvenance: options.scorerProvenance })
3440
3532
  });
3533
+ if (receiving)
3534
+ bundle.commsReceiving = receiving.snapshot();
3441
3535
  await writeCuaRunArtifacts(bundle, createdAt, runPaths);
3442
3536
  // Finalize the status record from the bundle that was just written, so the index can never
3443
3537
  // claim an outcome the evidence does not carry. A run that throws before reaching here leaves
@@ -3467,7 +3561,7 @@ async function runCuaActorLabInScope(options) {
3467
3561
  const adapterFailure = adapterScoreFailureMessage(bundle);
3468
3562
  const ok = observer.ok && allLanesOk && adapterFailure === undefined && scorerResult.declaredVerdictFailure === undefined;
3469
3563
  const laneWarnings = (outcomes ?? []).flatMap((outcome) => outcome.warnings);
3470
- const warnings = [...laneWarnings, ...aggregateWarnings, ...adapterWarnings, ...observer.warnings];
3564
+ const warnings = [...receivingWarnings, ...laneWarnings, ...aggregateWarnings, ...adapterWarnings, ...observer.warnings];
3471
3565
  const laneResults = laneSpecs.map((spec, index) => toLaneResult(spec, outcomes?.[index], laneSubjects[index], dryRun));
3472
3566
  const laneSummary = buildLaneSummary(outcomes, laneCount, plan, dryRun);
3473
3567
  const firstOutcome = outcomes?.[0];
@@ -3625,6 +3719,7 @@ function observerResultForCuaArtifacts(cwd, runId, artifactRoot, warnings = [])
3625
3719
  function buildSingleLaneBundle(args) {
3626
3720
  const { spec, outcome, config } = args;
3627
3721
  return buildCuaBundle({
3722
+ realEmail: config.comms?.email?.kind === "real",
3628
3723
  ...(args.lab === undefined ? {} : { lab: args.lab }),
3629
3724
  actorId: args.descriptor.id,
3630
3725
  appUrl: args.appUrl,
@@ -4520,6 +4615,7 @@ export function buildCuaBundle(args) {
4520
4615
  }, [stream]);
4521
4616
  return {
4522
4617
  schema: RUN_BUNDLE_SCHEMA,
4618
+ ...(args.realEmail && !args.dryRun ? { publication: { restrictions: ["real-communications"] } } : {}),
4523
4619
  runId: args.runId,
4524
4620
  mode: args.dryRun ? "dry-run" : "live",
4525
4621
  simCount: 1,
@@ -5008,6 +5104,7 @@ export function buildCuaFanoutBundle(args) {
5008
5104
  const cost = buildCuaCostSummary({ lanes: costLanes, desktops });
5009
5105
  return {
5010
5106
  schema: RUN_BUNDLE_SCHEMA,
5107
+ ...receivingPublication(args.config, args.dryRun),
5011
5108
  runId: args.runId,
5012
5109
  mode: args.dryRun ? "dry-run" : "live",
5013
5110
  simCount: specs.length,