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,6 @@
1
+ import { withTransientCommsSecrets } from "./run-narration-secrets.js";
2
+ import { prepareReceivingRun, receivingPublication } from "./comms-receiving-runtime.js";
3
+ import { receivingEmailValidationReason } from "./lab-config.js";
1
4
  // The CONCURRENT shared-world lab backend (#164 phase 2): N persona lanes drive ONE shared,
2
5
  // mutable service plane SIMULTANEOUSLY — the actual leverage of a sim. A recomposition of shipped
3
6
  // pieces + the getHost wrapper:
@@ -502,6 +505,9 @@ function observerResultForConcurrentArtifacts(cwd, runId, artifactRoot, warnings
502
505
  * ticking into a directory something else is deleting, which surfaces as an unrelated ENOTEMPTY.
503
506
  */
504
507
  export async function runConcurrentSharedWorld(options) {
508
+ return withTransientCommsSecrets(() => runConcurrentSharedWorldWithSecrets(options));
509
+ }
510
+ async function runConcurrentSharedWorldWithSecrets(options) {
505
511
  const analysis = resolveAutomaticAnalysis(options.config.review?.analysis);
506
512
  const result = await withRunStatusScope(() => runConcurrentSharedWorldInScope(options));
507
513
  return completeAutomaticAnalysis(result, analysis.ok ? analysis.config : undefined, options.automaticAnalysis, options.config.review?.analysis === undefined ? "default" : "explicit", analysis.ok && analysis.preferLargerOutput === true);
@@ -581,6 +587,9 @@ async function runConcurrentSharedWorldInScope(options) {
581
587
  // The per-seat vision lobby-code reader (default: the real single-frame OpenAI read). Injectable so the
582
588
  // barrier's handoff + convergence proof are testable without a live vision call.
583
589
  const readLobbyCode = hooks.readLobbyCodeFromFrame ?? readLobbyCodeFromFrame;
590
+ const receivingReason = receivingEmailValidationReason(config);
591
+ if (receivingReason)
592
+ return fail("HUMANISH_CONCURRENT_SHARED_WORLD_LAB_INVALID", receivingReason, descriptor.id);
584
593
  const openaiApiKey = env.OPENAI_API_KEY?.trim() ?? "";
585
594
  const e2bApiKey = env.E2B_API_KEY?.trim() ?? "";
586
595
  const knownSecretValues = [
@@ -659,7 +668,7 @@ async function runConcurrentSharedWorldInScope(options) {
659
668
  // sandbox at create (fixed port known up front); the catch is deployed before serve; the drain + digest
660
669
  // evidence run at subject teardown, then register run-level in the bundle. NOT available on the
661
670
  // external-public plane (the app is an operator-owned deployment the harness never provisions).
662
- const commsEmail = planeClass === "provisioned-getHost" ? config.comms?.email : undefined;
671
+ const commsEmail = planeClass === "provisioned-getHost" && config.comms?.email?.kind === "fake" ? config.comms.email : undefined;
663
672
  const commsPort = commsEmail ? (commsEmail.port ?? DEFAULT_SANDBOX_CATCH_PORT) : undefined;
664
673
  // injectEnv is absent on an adopter-hosted plane (#328): there is no subject env to inject
665
674
  // because the operator points their own app at their own catch.
@@ -674,7 +683,7 @@ async function runConcurrentSharedWorldInScope(options) {
674
683
  // the previously-inert block into a working one.
675
684
  const externalComms = planeClass === "external-public" ? config.comms?.email?.external : undefined;
676
685
  const externalCommsEmail = externalComms ? config.comms?.email : undefined;
677
- if (config.comms?.email && planeClass === "external-public" && externalComms === undefined) {
686
+ if (config.comms?.email?.kind === "fake" && planeClass === "external-public" && externalComms === undefined) {
678
687
  warnings.push("comms.email is declared but this is the external-public plane (the shared plane is an operator-owned public deployment the harness does not provision) — the in-sandbox email catch cannot be deployed and no comms evidence is collected. Declare `comms.email.external` to host the catch yourself (#328).");
679
688
  }
680
689
  if (externalComms) {
@@ -741,235 +750,393 @@ async function runConcurrentSharedWorldInScope(options) {
741
750
  return fail("HUMANISH_CONCURRENT_SHARED_WORLD_LAB_FAILED", `local-tree packing failed: ${redactText(scrubKnownValues(toErrorMessage(error)))}`, descriptor.id);
742
751
  }
743
752
  }
744
- if (!dryRun && planeClass === "provisioned-getHost") {
745
- if (!serve) {
746
- // Defense-in-depth: concurrentSharedWorldValidationReason already required serve above.
747
- return fail("HUMANISH_CONCURRENT_SHARED_WORLD_LAB_INVALID", "the provisioned-getHost concurrent shared-world route requires `subject.serve`.", descriptor.id);
748
- }
749
- let subjectModule;
750
- let subjectDesktop;
751
- // The in-sandbox email catch on the ONE subject sandbox (#297); drained at teardown. Undefined
752
- // unless a comms lab declared it. Hoisted so the finally can drain before the subject is killed.
753
- let deployedComms;
754
- // Background prober dispose signal (FIX-9: cleared in finally).
755
- let proberDisposed = false;
756
- let releaseDispose = () => { };
757
- const disposeSignal = new Promise((resolve) => { releaseDispose = resolve; });
758
- let proberLoop;
759
- const proberSnapshot = async () => {
760
- if (!subjectDesktop)
761
- return;
762
- const timestamp = now();
763
- const idx = snapshotIndex;
764
- snapshotIndex += 1;
765
- const snapshot = await runCheckpointSnapshot({
766
- desktop: subjectDesktop,
767
- snapshotIndex: idx,
768
- name: `state-${idx}`,
769
- checkpoints,
770
- prevDigest: undefined,
771
- scrub: scrubKnownValues,
772
- requestTimeoutMs,
773
- timers
774
- });
775
- stateSnapshots.push({ timestamp, digest: snapshot.digest });
776
- };
753
+ let receiving;
754
+ if (!dryRun && config.comms?.email?.kind === "real") {
777
755
  try {
778
- subjectModule = await (hooks.loadDesktopModule ?? loadE2BDesktopModule)();
779
- // The ONE subject sandbox: headless service host (no GUI seat). The SUBJECT env is provisioned
780
- // HERE; the actor sandboxes get NONE of it (FIX-10). A custom desktop template (image) is
781
- // honored on BOTH the subject sandbox (here) and every actor sandbox (via runCuaLane, which
782
- // reads the same config); absent keeps the byte-stable Sandbox.create(opts) default.
783
- subjectDesktop = await createDesktopSandbox(subjectModule, {
784
- apiKey: e2bApiKey,
785
- requestTimeoutMs,
786
- timeoutMs: timeoutMs + SUBJECT_PROVISION_BUDGET_MS
787
- + (config.subject.state?.seed ?? []).reduce((sum, step) => sum + (step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS), 0)
788
- + SANDBOX_TIMEOUT_BUFFER_MS,
789
- metadata: {
790
- ...CONCURRENT_SHARED_WORLD_PROVIDER_METADATA,
791
- labId: config.id,
792
- topology: "shared-world",
793
- topologyMode: "concurrent",
794
- role: "subject",
795
- roleCount: String(roles.length)
796
- },
797
- ...(subjectEnvNames.length > 0 || Object.keys(commsEnv).length > 0
798
- ? { envs: { ...Object.fromEntries(subjectEnvNames.map((name) => [name, env[name]])), ...commsEnv } }
799
- : {}),
800
- dpi: 96,
801
- lifecycle: { onTimeout: "kill" }
802
- }, config.execution?.desktop?.template);
803
- subjectSandboxId = subjectDesktop.sandboxId;
804
- // #358 salvage: durable id receipt the moment the subject sandbox exists.
805
- await appendSandboxReceipt(runPaths, { at: new Date().toISOString(), laneId: "subject", sandboxId: subjectSandboxId });
806
- if (hooks.prepareDesktop) {
807
- await hooks.prepareDesktop(subjectDesktop);
808
- }
809
- // Start the in-sandbox email catch BEFORE the subject serve, so the app's send-API base URL
810
- // (injected into its env at create) resolves the moment it boots. Fail closed if the catch can't
811
- // stand up rather than let a comms-declared app silently send real mail to the internet.
812
- if (commsEmail && commsPort !== undefined) {
813
- // A SECOND (0.0.0.0) read-only inbox listener on commsPort+1 so the persona — which lives in a
814
- // DIFFERENT sandbox here — can reach the inbox surface via getHost; capture stays loopback.
815
- deployedComms = await deployCommsCatch(subjectDesktop, { port: commsPort, inboxPort: commsPort + 1, requestTimeoutMs, timers });
816
- if (!deployedComms.ready) {
817
- throw new Error(`comms email catch did not become ready in the subject sandbox (loopback capture ${commsPort} / inbox ${commsPort + 1})`);
818
- }
819
- }
820
- // Provision the ONE shared plane: clone + install/build + seed + serve on 0.0.0.0 + probe
821
- // (clone route), or upload/extract the once-per-run packed archive + the SAME shared serve
822
- // pipeline (local-tree route).
823
- const onSubjectPhase = hooks.onPhase ?? ((event) => {
824
- process.stderr.write(`humanish shared-world (concurrent): ${event.message}${event.durationMs === undefined ? "" : ` (${event.durationMs}ms)`}\n`);
756
+ receiving = await prepareReceivingRun({ cwd, runId, config, env, participants: actorSpecs.map(spec => spec.laneId), runPaths,
757
+ registerSecrets: values => { for (const value of values)
758
+ if (value.length >= 4 && !knownSecretValues.includes(value))
759
+ knownSecretValues.push(value); }
825
760
  });
826
- if (localTreeRoute) {
827
- await provisionLocalTreeSubject(subjectDesktop, {
828
- archiveBuffer: localTreeArchiveBuffer,
829
- serve,
830
- ...(config.subject.state === undefined ? {} : { state: config.subject.state }),
831
- requestTimeoutMs,
832
- scrub: scrubKnownValues,
833
- onStateStep: (record) => { stateStepRecords.push(record); },
834
- onPhase: onSubjectPhase,
835
- ...timers
836
- });
761
+ commsArtifactPath = "comms/receiving.json";
762
+ }
763
+ catch {
764
+ return fail("HUMANISH_CONCURRENT_SHARED_WORLD_LAB_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);
765
+ }
766
+ }
767
+ try {
768
+ if (!dryRun && planeClass === "provisioned-getHost") {
769
+ if (!serve) {
770
+ // Defense-in-depth: concurrentSharedWorldValidationReason already required serve above.
771
+ return fail("HUMANISH_CONCURRENT_SHARED_WORLD_LAB_INVALID", "the provisioned-getHost concurrent shared-world route requires `subject.serve`.", descriptor.id);
837
772
  }
838
- else {
839
- subjectCommit = await provisionCloneSubject(subjectDesktop, {
840
- repo: subjectRepo,
841
- depth: config.subject.clone?.depth ?? 1,
842
- serve,
843
- ...(config.subject.state === undefined ? {} : { state: config.subject.state }),
844
- hasGithubToken,
845
- requestTimeoutMs,
773
+ let subjectModule;
774
+ let subjectDesktop;
775
+ // The in-sandbox email catch on the ONE subject sandbox (#297); drained at teardown. Undefined
776
+ // unless a comms lab declared it. Hoisted so the finally can drain before the subject is killed.
777
+ let deployedComms;
778
+ // Background prober dispose signal (FIX-9: cleared in finally).
779
+ let proberDisposed = false;
780
+ let releaseDispose = () => { };
781
+ const disposeSignal = new Promise((resolve) => { releaseDispose = resolve; });
782
+ let proberLoop;
783
+ const proberSnapshot = async () => {
784
+ if (!subjectDesktop)
785
+ return;
786
+ const timestamp = now();
787
+ const idx = snapshotIndex;
788
+ snapshotIndex += 1;
789
+ const snapshot = await runCheckpointSnapshot({
790
+ desktop: subjectDesktop,
791
+ snapshotIndex: idx,
792
+ name: `state-${idx}`,
793
+ checkpoints,
794
+ prevDigest: undefined,
846
795
  scrub: scrubKnownValues,
847
- onCommit: (commit) => { subjectCommit = commit; },
848
- onStateStep: (record) => { stateStepRecords.push(record); },
849
- onPhase: onSubjectPhase,
850
- ...timers
796
+ requestTimeoutMs,
797
+ timers
851
798
  });
852
- }
853
- // Expose the served port via getHost (FIX-2). Fail closed if the SDK lacks it.
854
- if (typeof subjectDesktop.getHost !== "function") {
855
- throw new Error("the installed @e2b/desktop SDK does not expose getHost(port); the concurrent shared-world route requires it to reach the subject plane");
856
- }
857
- // getHost returns a BARE host (e.g. "3000-<sandboxId>.e2b.app", no scheme); e2b exposes the
858
- // port over https. Normalize to a full URL before the tokenless check + before persisting.
859
- const rawHost = subjectDesktop.getHost(servePort(serve.url));
860
- const hostUrl = /^https?:\/\//i.test(rawHost) ? rawHost : `https://${rawHost}`;
861
- if (!isTokenlessHost(hostUrl)) {
862
- throw new Error("getHost returned a non-tokenless URL; refusing to persist a host URL that may carry a credential (invariant 1)");
863
- }
864
- getHostUrl = hostUrl;
865
- // Persona inbox SURFACE (#297 slice B, shared-world): getHost-expose the read-only inbox listener so
866
- // a persona in a DIFFERENT sandbox can open it; build the serve->getHost origin map (REQUIRED here —
867
- // the app's loopback verify links must be rewritten to a reachable host); provision the surface
868
- // channel; write the EMPTY inbox up front (so /inbox never 404s); and start a render loop that drains
869
- // + re-renders on a cadence. The loop shares the prober's dispose signal (disposed together, before
870
- // the teardown evidence drain), and uses a DEDICATED FakeInbox + cursor (independent of that drain).
871
- if (commsEmail && deployedComms?.inboxPort !== undefined) {
872
- const rawInboxHost = subjectDesktop.getHost(deployedComms.inboxPort);
873
- const inboxHostUrl = /^https?:\/\//i.test(rawInboxHost) ? rawInboxHost : `https://${rawInboxHost}`;
874
- if (!isTokenlessHost(inboxHostUrl)) {
875
- throw new Error("getHost returned a non-tokenless URL for the comms inbox; refusing to advertise it (invariant 1)");
799
+ stateSnapshots.push({ timestamp, digest: snapshot.digest });
800
+ };
801
+ try {
802
+ subjectModule = await (hooks.loadDesktopModule ?? loadE2BDesktopModule)();
803
+ // The ONE subject sandbox: headless service host (no GUI seat). The SUBJECT env is provisioned
804
+ // HERE; the actor sandboxes get NONE of it (FIX-10). A custom desktop template (image) is
805
+ // honored on BOTH the subject sandbox (here) and every actor sandbox (via runCuaLane, which
806
+ // reads the same config); absent keeps the byte-stable Sandbox.create(opts) default.
807
+ subjectDesktop = await createDesktopSandbox(subjectModule, {
808
+ apiKey: e2bApiKey,
809
+ requestTimeoutMs,
810
+ timeoutMs: timeoutMs + SUBJECT_PROVISION_BUDGET_MS
811
+ + (config.subject.state?.seed ?? []).reduce((sum, step) => sum + (step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS), 0)
812
+ + SANDBOX_TIMEOUT_BUFFER_MS,
813
+ metadata: {
814
+ ...CONCURRENT_SHARED_WORLD_PROVIDER_METADATA,
815
+ labId: config.id,
816
+ topology: "shared-world",
817
+ topologyMode: "concurrent",
818
+ role: "subject",
819
+ roleCount: String(roles.length)
820
+ },
821
+ ...(subjectEnvNames.length > 0 || Object.keys(commsEnv).length > 0
822
+ ? { envs: { ...Object.fromEntries(subjectEnvNames.map((name) => [name, env[name]])), ...commsEnv } }
823
+ : {}),
824
+ dpi: 96,
825
+ lifecycle: { onTimeout: "kill" }
826
+ }, config.execution?.desktop?.template);
827
+ subjectSandboxId = subjectDesktop.sandboxId;
828
+ // #358 salvage: durable id receipt the moment the subject sandbox exists.
829
+ await appendSandboxReceipt(runPaths, { at: new Date().toISOString(), laneId: "subject", sandboxId: subjectSandboxId });
830
+ if (hooks.prepareDesktop) {
831
+ await hooks.prepareDesktop(subjectDesktop);
876
832
  }
877
- commsInboxUrl = `${inboxHostUrl}/inbox`;
878
- commsOriginMap = buildOriginMap({
879
- internalServeUrl: serve.url,
880
- reachableBaseUrl: getHostUrl,
881
- ...(commsEmail.linkOrigin === undefined ? {} : { linkOrigin: commsEmail.linkOrigin })
833
+ // Start the in-sandbox email catch BEFORE the subject serve, so the app's send-API base URL
834
+ // (injected into its env at create) resolves the moment it boots. Fail closed if the catch can't
835
+ // stand up rather than let a comms-declared app silently send real mail to the internet.
836
+ if (commsEmail && commsPort !== undefined) {
837
+ // A SECOND (0.0.0.0) read-only inbox listener on commsPort+1 so the persona — which lives in a
838
+ // DIFFERENT sandbox here — can reach the inbox surface via getHost; capture stays loopback.
839
+ deployedComms = await deployCommsCatch(subjectDesktop, { port: commsPort, inboxPort: commsPort + 1, requestTimeoutMs, timers });
840
+ if (!deployedComms.ready) {
841
+ throw new Error(`comms email catch did not become ready in the subject sandbox (loopback capture ${commsPort} / inbox ${commsPort + 1})`);
842
+ }
843
+ }
844
+ // Provision the ONE shared plane: clone + install/build + seed + serve on 0.0.0.0 + probe
845
+ // (clone route), or upload/extract the once-per-run packed archive + the SAME shared serve
846
+ // pipeline (local-tree route).
847
+ const onSubjectPhase = hooks.onPhase ?? ((event) => {
848
+ process.stderr.write(`humanish shared-world (concurrent): ${event.message}${event.durationMs === undefined ? "" : ` (${event.durationMs}ms)`}\n`);
882
849
  });
883
- const surfaceRecipients = (commsEmail.recipients ?? [])
884
- .filter((recipient) => recipient.address !== undefined)
885
- .map((recipient) => ({ lane: recipient.lane, address: recipient.address }));
886
- await writeInboxSurface(subjectDesktop, deployedComms.surfaceDir, [], { originMap: commsOriginMap, requestTimeoutMs });
887
- const surfaceDeployed = deployedComms;
888
- const surfaceCadenceMs = 2500;
889
- surfaceLoop = (async () => {
890
- // Full, idempotent rebuild each tick; surfaceRenderedCount advances only on a successful render,
891
- // so a transient failure retries cleanly. Real timer (dispose-interruptible + cleared) — an
892
- // unbounded loop must not busy-spin on the injected instant clock.
893
- for (;;) {
894
- try {
895
- const refreshed = await refreshInboxSurface({
896
- desktop: subjectDesktop,
897
- deployed: surfaceDeployed,
898
- recipients: surfaceRecipients,
899
- sinceCount: surfaceRenderedCount,
900
- originMap: commsOriginMap,
901
- requestTimeoutMs
850
+ if (localTreeRoute) {
851
+ await provisionLocalTreeSubject(subjectDesktop, {
852
+ archiveBuffer: localTreeArchiveBuffer,
853
+ serve,
854
+ ...(config.subject.state === undefined ? {} : { state: config.subject.state }),
855
+ requestTimeoutMs,
856
+ scrub: scrubKnownValues,
857
+ onStateStep: (record) => { stateStepRecords.push(record); },
858
+ onPhase: onSubjectPhase,
859
+ ...timers
860
+ });
861
+ }
862
+ else {
863
+ subjectCommit = await provisionCloneSubject(subjectDesktop, {
864
+ repo: subjectRepo,
865
+ depth: config.subject.clone?.depth ?? 1,
866
+ serve,
867
+ ...(config.subject.state === undefined ? {} : { state: config.subject.state }),
868
+ hasGithubToken,
869
+ requestTimeoutMs,
870
+ scrub: scrubKnownValues,
871
+ onCommit: (commit) => { subjectCommit = commit; },
872
+ onStateStep: (record) => { stateStepRecords.push(record); },
873
+ onPhase: onSubjectPhase,
874
+ ...timers
875
+ });
876
+ }
877
+ // Expose the served port via getHost (FIX-2). Fail closed if the SDK lacks it.
878
+ if (typeof subjectDesktop.getHost !== "function") {
879
+ throw new Error("the installed @e2b/desktop SDK does not expose getHost(port); the concurrent shared-world route requires it to reach the subject plane");
880
+ }
881
+ // getHost returns a BARE host (e.g. "3000-<sandboxId>.e2b.app", no scheme); e2b exposes the
882
+ // port over https. Normalize to a full URL before the tokenless check + before persisting.
883
+ const rawHost = subjectDesktop.getHost(servePort(serve.url));
884
+ const hostUrl = /^https?:\/\//i.test(rawHost) ? rawHost : `https://${rawHost}`;
885
+ if (!isTokenlessHost(hostUrl)) {
886
+ throw new Error("getHost returned a non-tokenless URL; refusing to persist a host URL that may carry a credential (invariant 1)");
887
+ }
888
+ getHostUrl = hostUrl;
889
+ // Persona inbox SURFACE (#297 slice B, shared-world): getHost-expose the read-only inbox listener so
890
+ // a persona in a DIFFERENT sandbox can open it; build the serve->getHost origin map (REQUIRED here —
891
+ // the app's loopback verify links must be rewritten to a reachable host); provision the surface
892
+ // channel; write the EMPTY inbox up front (so /inbox never 404s); and start a render loop that drains
893
+ // + re-renders on a cadence. The loop shares the prober's dispose signal (disposed together, before
894
+ // the teardown evidence drain), and uses a DEDICATED FakeInbox + cursor (independent of that drain).
895
+ if (commsEmail && deployedComms?.inboxPort !== undefined) {
896
+ const rawInboxHost = subjectDesktop.getHost(deployedComms.inboxPort);
897
+ const inboxHostUrl = /^https?:\/\//i.test(rawInboxHost) ? rawInboxHost : `https://${rawInboxHost}`;
898
+ if (!isTokenlessHost(inboxHostUrl)) {
899
+ throw new Error("getHost returned a non-tokenless URL for the comms inbox; refusing to advertise it (invariant 1)");
900
+ }
901
+ commsInboxUrl = `${inboxHostUrl}/inbox`;
902
+ commsOriginMap = buildOriginMap({
903
+ internalServeUrl: serve.url,
904
+ reachableBaseUrl: getHostUrl,
905
+ ...(commsEmail.linkOrigin === undefined ? {} : { linkOrigin: commsEmail.linkOrigin })
906
+ });
907
+ const surfaceRecipients = (commsEmail.recipients ?? [])
908
+ .filter((recipient) => recipient.address !== undefined)
909
+ .map((recipient) => ({ lane: recipient.lane, address: recipient.address }));
910
+ await writeInboxSurface(subjectDesktop, deployedComms.surfaceDir, [], { originMap: commsOriginMap, requestTimeoutMs });
911
+ const surfaceDeployed = deployedComms;
912
+ const surfaceCadenceMs = 2500;
913
+ surfaceLoop = (async () => {
914
+ // Full, idempotent rebuild each tick; surfaceRenderedCount advances only on a successful render,
915
+ // so a transient failure retries cleanly. Real timer (dispose-interruptible + cleared) — an
916
+ // unbounded loop must not busy-spin on the injected instant clock.
917
+ for (;;) {
918
+ try {
919
+ const refreshed = await refreshInboxSurface({
920
+ desktop: subjectDesktop,
921
+ deployed: surfaceDeployed,
922
+ recipients: surfaceRecipients,
923
+ sinceCount: surfaceRenderedCount,
924
+ originMap: commsOriginMap,
925
+ requestTimeoutMs
926
+ });
927
+ if (refreshed.rendered)
928
+ surfaceRenderedCount = refreshed.count;
929
+ }
930
+ catch {
931
+ // Never throw into the render loop; the teardown drain + by-id teardown must still run.
932
+ }
933
+ if (proberDisposed)
934
+ break;
935
+ await new Promise((resolve) => {
936
+ const timer = setTimeout(resolve, surfaceCadenceMs);
937
+ void disposeSignal.then(() => { clearTimeout(timer); resolve(); });
902
938
  });
903
- if (refreshed.rendered)
904
- surfaceRenderedCount = refreshed.count;
905
- }
906
- catch {
907
- // Never throw into the render loop; the teardown drain + by-id teardown must still run.
939
+ if (proberDisposed)
940
+ break;
908
941
  }
942
+ })();
943
+ }
944
+ // Baseline state snapshot, then start the background cadence prober.
945
+ await proberSnapshot();
946
+ if (options.onObserverReady) {
947
+ const inProgressPlaneCommit = localTreeRoute ? localTreeArchive?.git?.commit : subjectCommit;
948
+ const inProgressSubject = buildSubjectProvenance({
949
+ localTreeRoute,
950
+ publicRepo,
951
+ subjectCommit: inProgressPlaneCommit,
952
+ localTreeArchive,
953
+ subjectEnvNames,
954
+ state: resolveSubjectState({ declared: config.subject.state, dryRun: false, executed: stateStepRecords })
955
+ });
956
+ const inProgressBundle = buildConcurrentSharedWorldBundle({
957
+ config,
958
+ descriptor,
959
+ createdAt,
960
+ dryRun: false,
961
+ inProgress: true,
962
+ runId,
963
+ source,
964
+ roles,
965
+ actorSpecs,
966
+ actorResults: [],
967
+ stateSnapshots,
968
+ subject: inProgressSubject,
969
+ seedDigest,
970
+ ...(inProgressPlaneCommit === undefined ? {} : { subjectCommit: inProgressPlaneCommit }),
971
+ hostDigest: hostOriginDigest(getHostUrl)
972
+ });
973
+ await writeConcurrentRunArtifacts(inProgressBundle, runPaths);
974
+ liveObserver = observerResultForConcurrentArtifacts(cwd, runId, artifactRoot, [
975
+ "Live concurrent shared-world Observer is attached before final verification; stream auth URLs are runtime-only and are not persisted."
976
+ ]);
977
+ await options.onObserverReady(liveObserver);
978
+ }
979
+ proberLoop = (async () => {
980
+ while (!proberDisposed) {
981
+ let timer;
982
+ await Promise.race([
983
+ new Promise((resolve) => { timer = setTimeout(resolve, proberCadenceMs); }),
984
+ disposeSignal
985
+ ]);
986
+ if (timer)
987
+ clearTimeout(timer); // FIX-9: no dangling prober timer.
909
988
  if (proberDisposed)
910
989
  break;
911
- await new Promise((resolve) => {
912
- const timer = setTimeout(resolve, surfaceCadenceMs);
913
- void disposeSignal.then(() => { clearTimeout(timer); resolve(); });
914
- });
915
- if (proberDisposed)
916
- break;
990
+ await proberSnapshot().catch(() => undefined);
917
991
  }
918
992
  })();
919
- }
920
- // Baseline state snapshot, then start the background cadence prober.
921
- await proberSnapshot();
922
- if (options.onObserverReady) {
923
- const inProgressPlaneCommit = localTreeRoute ? localTreeArchive?.git?.commit : subjectCommit;
924
- const inProgressSubject = buildSubjectProvenance({
925
- localTreeRoute,
926
- publicRepo,
927
- subjectCommit: inProgressPlaneCommit,
928
- localTreeArchive,
929
- subjectEnvNames,
930
- state: resolveSubjectState({ declared: config.subject.state, dryRun: false, executed: stateStepRecords })
931
- });
932
- const inProgressBundle = buildConcurrentSharedWorldBundle({
993
+ // Launch N actor sandboxes CONCURRENTLY, INDEPENDENT (FIX-11: runCuaLane + mapWithConcurrency,
994
+ // NOT runCuaLanes — no pipeline gate / fail-fast). Each actor's window is measured on the ONE
995
+ // orchestrator clock (FIX-1). cloneRoute=false + subjectEnvNames=[] keep subject creds out of
996
+ // every actor sandbox (FIX-10).
997
+ const cuaHooks = {
998
+ ...(hooks.loadDesktopModule ? { loadDesktopModule: hooks.loadDesktopModule } : {}),
999
+ ...(hooks.detachedTimers ? { detachedTimers: hooks.detachedTimers } : {}),
1000
+ ...(hooks.env ? { env: hooks.env } : {}),
1001
+ ...(hooks.prepareDesktop ? { prepareDesktop: (desktop) => hooks.prepareDesktop(desktop) } : {}),
1002
+ onRuntimeStreamReady: (stream) => {
1003
+ runtimeStreamUrls.push({ streamId: stream.streamId, url: stream.url });
1004
+ if (liveObserver) {
1005
+ attachObserverRuntimeStreamUrls(liveObserver, runtimeStreamUrls);
1006
+ }
1007
+ },
1008
+ onRuntimeStreamEnded: (stream) => {
1009
+ // Mark, never remove (#357): the tile falls back to recorded evidence and says why.
1010
+ for (const entry of runtimeStreamUrls) {
1011
+ if (entry.streamId === stream.streamId)
1012
+ entry.ended = true;
1013
+ }
1014
+ if (liveObserver) {
1015
+ attachObserverRuntimeStreamUrls(liveObserver, runtimeStreamUrls);
1016
+ }
1017
+ }
1018
+ };
1019
+ const baseActorDeps = {
933
1020
  config,
934
1021
  descriptor,
935
- createdAt,
936
- dryRun: false,
937
- inProgress: true,
938
- runId,
939
- source,
940
- roles,
941
- actorSpecs,
942
- actorResults: [],
943
- stateSnapshots,
944
- subject: inProgressSubject,
945
- seedDigest,
946
- ...(inProgressPlaneCommit === undefined ? {} : { subjectCommit: inProgressPlaneCommit }),
947
- hostDigest: hostOriginDigest(getHostUrl)
1022
+ cloneRoute: false,
1023
+ subjectEnvNames: [],
1024
+ hasGithubToken: false,
1025
+ env,
1026
+ openaiApiKey,
1027
+ e2bApiKey,
1028
+ requestTimeoutMs,
1029
+ perLaneSandboxMs: timeoutMs + SANDBOX_TIMEOUT_BUFFER_MS,
1030
+ timeoutMs,
1031
+ laneCount: roles.length,
1032
+ artifactRoot: runPaths,
1033
+ labCwd: cwd,
1034
+ redactScreenshots,
1035
+ scrubKnownValues,
1036
+ runSession,
1037
+ ...(receiving ? { receiving } : {}),
1038
+ now,
1039
+ hooks: cuaHooks,
1040
+ ...(runBudget === undefined ? {} : { runBudget }),
1041
+ // Concurrent lanes are independent evidence seats: a requested-vs-verified screen
1042
+ // mismatch is recorded as separate facts + a warning instead of failing the lane's
1043
+ // device claim closed, so one seat's window-manager drift cannot abort the whole
1044
+ // live multi-actor world (the single-lane/fan-out routes keep fail-closed).
1045
+ screenMismatchPolicy: "record-evidence"
1046
+ };
1047
+ actorResults = await mapWithConcurrency(actorSpecs, Math.max(1, concurrency), async (spec, i) => {
1048
+ const route = resolveActorSeatUrl(getHostUrl, roles[i]?.entry);
1049
+ // Tell this persona its (getHost-reachable) inbox URL — but only when comms is live AND this lane
1050
+ // has a declared recipient it can actually receive mail into (else it would stall on an empty
1051
+ // inbox). Only the in-sandbox catch exists on this plane; the adopter-hosted catch is the
1052
+ // external-public plane's, wired in ITS execution block below (#387).
1053
+ const laneSpec = commsEmail && commsInboxUrl && laneHasInboxRecipient(commsEmail, spec.laneId)
1054
+ ? withInboxMission(spec, commsInboxUrl, inboxRecipientFor(commsEmail, spec.laneId)?.address)
1055
+ : spec;
1056
+ const startedAt = now();
1057
+ const outcome = await runCuaLane(laneSpec, { ...baseActorDeps, appUrl: route });
1058
+ const endedAt = now();
1059
+ return { spec, outcome, startedAt, endedAt, route };
948
1060
  });
949
- await writeConcurrentRunArtifacts(inProgressBundle, runPaths);
950
- liveObserver = observerResultForConcurrentArtifacts(cwd, runId, artifactRoot, [
951
- "Live concurrent shared-world Observer is attached before final verification; stream auth URLs are runtime-only and are not persisted."
952
- ]);
953
- await options.onObserverReady(liveObserver);
954
1061
  }
955
- proberLoop = (async () => {
956
- while (!proberDisposed) {
957
- let timer;
958
- await Promise.race([
959
- new Promise((resolve) => { timer = setTimeout(resolve, proberCadenceMs); }),
960
- disposeSignal
961
- ]);
962
- if (timer)
963
- clearTimeout(timer); // FIX-9: no dangling prober timer.
964
- if (proberDisposed)
965
- break;
1062
+ catch (error) {
1063
+ runError = redactText(scrubKnownValues(toErrorMessage(error)));
1064
+ warnings.push(`Concurrent shared-world run failed before completion: ${runError}`);
1065
+ }
1066
+ finally {
1067
+ // FIX-9: stop the prober, take a final snapshot while the subject is still alive, then tear
1068
+ // down the ONE subject sandbox BY id (the actor sandboxes are torn down inside runCuaLane).
1069
+ proberDisposed = true;
1070
+ releaseDispose();
1071
+ if (proberLoop) {
1072
+ await proberLoop.catch(() => undefined);
1073
+ }
1074
+ // Stop the inbox-surface render loop too (shares the prober's dispose signal), before the teardown
1075
+ // evidence drain below — so the two in-sandbox reads never overlap and the surface state is final.
1076
+ if (surfaceLoop) {
1077
+ await surfaceLoop.catch(() => undefined);
1078
+ }
1079
+ if (subjectDesktop && getHostUrl) {
966
1080
  await proberSnapshot().catch(() => undefined);
967
1081
  }
968
- })();
969
- // Launch N actor sandboxes CONCURRENTLY, INDEPENDENT (FIX-11: runCuaLane + mapWithConcurrency,
970
- // NOT runCuaLanes — no pipeline gate / fail-fast). Each actor's window is measured on the ONE
971
- // orchestrator clock (FIX-1). cloneRoute=false + subjectEnvNames=[] keep subject creds out of
972
- // every actor sandbox (FIX-10).
1082
+ // Off-app comms evidence (#297): drain everything the in-sandbox catch captured, route it into a
1083
+ // host fake inbox addressed to the declared recipients, and write the run-level digest-only thread
1084
+ // artifact — while the subject is STILL alive, before it is killed below. Wrapped so a drain error
1085
+ // never blocks teardown (invariant: all sandboxes torn down by id in this finally).
1086
+ if (commsEmail && deployedComms?.ready && subjectDesktop) {
1087
+ try {
1088
+ const commsChannel = new FakeInbox();
1089
+ const commsInboxes = [];
1090
+ for (const recipient of commsEmail.recipients ?? []) {
1091
+ if (recipient.address !== undefined) {
1092
+ commsInboxes.push(await commsChannel.provisionAddress(recipient.lane, recipient.address));
1093
+ }
1094
+ }
1095
+ const collected = await collectCommsThread({
1096
+ desktop: subjectDesktop,
1097
+ deployed: deployedComms,
1098
+ channel: commsChannel,
1099
+ inboxes: commsInboxes,
1100
+ requestTimeoutMs
1101
+ });
1102
+ if (collected.artifact) {
1103
+ await writeContainedOutputFile(runPaths, "comms/thread.json", `${JSON.stringify(collected.artifact, null, 2)}\n`, "utf8");
1104
+ commsArtifactPath = "comms/thread.json";
1105
+ }
1106
+ else if (collected.captured > 0) {
1107
+ 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.`);
1108
+ }
1109
+ else {
1110
+ // Zero captures is the silent-broken shape (#351): the app never posted to the catch.
1111
+ warnings.push(`Comms catch captured ZERO email sends — the app never delivered mail through the catch. Verify the app reads ${commsEmail.injectEnv} for its email API base URL (an SDK that ignores it sends real mail or throws) and that the flow reached an email step.`);
1112
+ }
1113
+ }
1114
+ catch (error) {
1115
+ warnings.push(`Comms evidence collection failed (run continues; subject still torn down): ${redactText(scrubKnownValues(toErrorMessage(error)))}`);
1116
+ }
1117
+ }
1118
+ if (subjectDesktop && subjectModule) {
1119
+ if (typeof subjectModule.Sandbox.kill === "function") {
1120
+ try {
1121
+ await subjectModule.Sandbox.kill(subjectDesktop.sandboxId, { requestTimeoutMs: 60_000 });
1122
+ subjectKilled = true;
1123
+ }
1124
+ catch (error) {
1125
+ warnings.push(`Subject sandbox teardown failed (server-side kill-on-timeout will reclaim it): ${redactText(scrubKnownValues(toErrorMessage(error)))}`);
1126
+ }
1127
+ }
1128
+ else {
1129
+ warnings.push("Installed @e2b/desktop SDK does not expose Sandbox.kill; server-side kill-on-timeout will reclaim the subject sandbox.");
1130
+ }
1131
+ }
1132
+ }
1133
+ }
1134
+ // EXTERNAL-PUBLIC plane (#164 phase 2): NO subject sandbox, NO getHost, NO prober. The shared plane
1135
+ // is the operator-declared public deployment (publicAppUrl); each seat opens it directly and reaches
1136
+ // the shared session through the real UI. A host-first barrier extracts the /lobby/CODE from the host
1137
+ // seat's CDP-observed URL (onObservedUrl) and threads it into the follower missions; a follower fails
1138
+ // closed WITHOUT opening if the host never yields a code within the handoff deadline.
1139
+ if (!dryRun && planeClass === "external-public") {
973
1140
  const cuaHooks = {
974
1141
  ...(hooks.loadDesktopModule ? { loadDesktopModule: hooks.loadDesktopModule } : {}),
975
1142
  ...(hooks.detachedTimers ? { detachedTimers: hooks.detachedTimers } : {}),
@@ -1008,445 +1175,313 @@ async function runConcurrentSharedWorldInScope(options) {
1008
1175
  artifactRoot: runPaths,
1009
1176
  labCwd: cwd,
1010
1177
  redactScreenshots,
1011
- scrubKnownValues,
1178
+ // Scrub the latched lobby CODE (known once the host resolves it) from ALL narration.
1179
+ scrubKnownValues: scrubKnownValuesWithLobbyCode,
1012
1180
  runSession,
1181
+ ...(receiving ? { receiving } : {}),
1013
1182
  now,
1014
1183
  hooks: cuaHooks,
1015
1184
  ...(runBudget === undefined ? {} : { runBudget }),
1016
- // Concurrent lanes are independent evidence seats: a requested-vs-verified screen
1017
- // mismatch is recorded as separate facts + a warning instead of failing the lane's
1018
- // device claim closed, so one seat's window-manager drift cannot abort the whole
1019
- // live multi-actor world (the single-lane/fan-out routes keep fail-closed).
1020
1185
  screenMismatchPolicy: "record-evidence"
1021
1186
  };
1022
- actorResults = await mapWithConcurrency(actorSpecs, Math.max(1, concurrency), async (spec, i) => {
1023
- const route = resolveActorSeatUrl(getHostUrl, roles[i]?.entry);
1024
- // Tell this persona its (getHost-reachable) inbox URL — but only when comms is live AND this lane
1025
- // has a declared recipient it can actually receive mail into (else it would stall on an empty
1026
- // inbox). Only the in-sandbox catch exists on this plane; the adopter-hosted catch is the
1027
- // external-public plane's, wired in ITS execution block below (#387).
1028
- const laneSpec = commsEmail && commsInboxUrl && laneHasInboxRecipient(commsEmail, spec.laneId)
1029
- ? withInboxMission(spec, commsInboxUrl, inboxRecipientFor(commsEmail, spec.laneId)?.address)
1030
- : spec;
1031
- const startedAt = now();
1032
- const outcome = await runCuaLane(laneSpec, { ...baseActorDeps, appUrl: route });
1033
- const endedAt = now();
1034
- return { spec, outcome, startedAt, endedAt, route };
1035
- });
1036
- }
1037
- catch (error) {
1038
- runError = redactText(scrubKnownValues(toErrorMessage(error)));
1039
- warnings.push(`Concurrent shared-world run failed before completion: ${runError}`);
1040
- }
1041
- finally {
1042
- // FIX-9: stop the prober, take a final snapshot while the subject is still alive, then tear
1043
- // down the ONE subject sandbox BY id (the actor sandboxes are torn down inside runCuaLane).
1044
- proberDisposed = true;
1045
- releaseDispose();
1046
- if (proberLoop) {
1047
- await proberLoop.catch(() => undefined);
1048
- }
1049
- // Stop the inbox-surface render loop too (shares the prober's dispose signal), before the teardown
1050
- // evidence drain below — so the two in-sandbox reads never overlap and the surface state is final.
1051
- if (surfaceLoop) {
1052
- await surfaceLoop.catch(() => undefined);
1053
- }
1054
- if (subjectDesktop && getHostUrl) {
1055
- await proberSnapshot().catch(() => undefined);
1187
+ // Publish an attached live Observer BEFORE fan-out (mirrors the provisioned path).
1188
+ if (options.onObserverReady) {
1189
+ const inProgressBundle = buildConcurrentSharedWorldBundle({
1190
+ config,
1191
+ descriptor,
1192
+ createdAt,
1193
+ dryRun: false,
1194
+ inProgress: true,
1195
+ runId,
1196
+ source,
1197
+ roles,
1198
+ actorSpecs,
1199
+ actorResults: [],
1200
+ stateSnapshots: [],
1201
+ subject: { source: "app-url", envNames: [], state: { provenance: "external-public" } },
1202
+ seedDigest,
1203
+ planeClass: "external-public",
1204
+ // Pre-fan-out snapshot: no seat has observed an origin yet, so the OBSERVED publicOriginDigest
1205
+ // is not available; surface the DECLARED origin for the live Observer's reference.
1206
+ ...(declaredOriginDigest === undefined ? {} : { declaredOriginDigest })
1207
+ });
1208
+ await writeConcurrentRunArtifacts(inProgressBundle, runPaths);
1209
+ liveObserver = observerResultForConcurrentArtifacts(cwd, runId, artifactRoot, [
1210
+ "Live external-public concurrent shared-world Observer is attached before final verification; stream auth URLs are runtime-only and are not persisted."
1211
+ ]);
1212
+ await options.onObserverReady(liveObserver);
1056
1213
  }
1057
- // Off-app comms evidence (#297): drain everything the in-sandbox catch captured, route it into a
1058
- // host fake inbox addressed to the declared recipients, and write the run-level digest-only thread
1059
- // artifact — while the subject is STILL alive, before it is killed below. Wrapped so a drain error
1060
- // never blocks teardown (invariant: all sandboxes torn down by id in this finally).
1061
- if (commsEmail && deployedComms?.ready && subjectDesktop) {
1062
- try {
1063
- const commsChannel = new FakeInbox();
1064
- const commsInboxes = [];
1065
- for (const recipient of commsEmail.recipients ?? []) {
1066
- if (recipient.address !== undefined) {
1067
- commsInboxes.push(await commsChannel.provisionAddress(recipient.lane, recipient.address));
1068
- }
1069
- }
1070
- const collected = await collectCommsThread({
1071
- desktop: subjectDesktop,
1072
- deployed: deployedComms,
1073
- channel: commsChannel,
1074
- inboxes: commsInboxes,
1075
- requestTimeoutMs
1214
+ // The host-first handoff barrier.
1215
+ //
1216
+ // TEMPORARY SHIM (tracked by #296): this CDP URL-relay handoff — reading the host's /lobby/CODE off
1217
+ // its own browser and threading it into the follower missions — is a temporary coordination shim.
1218
+ // It is to be augmented/replaced by the actor message bus (fake SMS/email invite) in #297: the
1219
+ // human-realistic version is the HOST SENDING the invite link and followers RECEIVING and tapping
1220
+ // it, rather than the orchestrator relaying the code out-of-band.
1221
+ const lobbyCodeLatch = deferred();
1222
+ const handoffDeadlineMs = hooks.handoffDeadlineMs
1223
+ ?? Math.min(timeoutMs, Math.max(DEFAULT_HANDOFF_DEADLINE_MS, Math.floor(timeoutMs * HANDOFF_DEADLINE_BUDGET_FRACTION)));
1224
+ let deadlineTimer;
1225
+ const deadline = new Promise((_resolve, reject) => {
1226
+ deadlineTimer = setTimeout(() => reject(new HandoffTimeoutError(handoffDeadlineMs)), handoffDeadlineMs);
1227
+ });
1228
+ deadline.catch(() => undefined); // never an unhandled rejection
1229
+ // Resolve the host->follower handoff latch from WHICHEVER path sees the code first (CDP url-read,
1230
+ // host narration, or vision-off-frame). Idempotent: only the first code wins, and it is also stashed
1231
+ // as latchedLobbyCode so it gets scrubbed from any later narration. The latched code and observed URLs
1232
+ // are runtime-only and land in persisted METADATA only as digests (origin + convergence). (The code
1233
+ // is a shareable game code, not a secret, and it still renders in the host's screenshots, which are
1234
+ // full-fidelity unless redactScreenshots is set — the digesting is about narration/URL metadata.)
1235
+ const latchLobbyCode = (code, laneIndex) => {
1236
+ if (latchedLobbyCode !== undefined)
1237
+ return;
1238
+ observedLobbyCodes[laneIndex] = code;
1239
+ latchedLobbyCode = code;
1240
+ if (deadlineTimer) {
1241
+ clearTimeout(deadlineTimer);
1242
+ deadlineTimer = undefined;
1243
+ }
1244
+ lobbyCodeLatch.resolve(code);
1245
+ };
1246
+ // Build an onScreenshot handler that vision-reads the lobby code off THIS seat's own frame (the
1247
+ // CDP-independent observation). `done()` short-circuits once this seat has what it needs (the host
1248
+ // once latched; a follower once it has recorded its own observed code), `onCode` records/latches the
1249
+ // result. One read in flight at a time, bounded by MAX_LOBBY_CODE_VISION_READS so a seat that never
1250
+ // reaches a lobby can't rack up unbounded calls (fire-and-forget; the loop never awaits it).
1251
+ const makeLobbyCodeVisionReader = (done, onCode) => {
1252
+ let inFlight = false;
1253
+ let reads = 0;
1254
+ return (frame) => {
1255
+ if (done() || inFlight || reads >= MAX_LOBBY_CODE_VISION_READS)
1256
+ return;
1257
+ inFlight = true;
1258
+ reads += 1;
1259
+ void readLobbyCode(frame, openaiApiKey)
1260
+ .then((code) => {
1261
+ if (code !== undefined && !done())
1262
+ onCode(code);
1263
+ })
1264
+ .catch(() => undefined)
1265
+ .finally(() => {
1266
+ inFlight = false;
1076
1267
  });
1077
- if (collected.artifact) {
1078
- await writeContainedOutputFile(runPaths, "comms/thread.json", `${JSON.stringify(collected.artifact, null, 2)}\n`, "utf8");
1079
- commsArtifactPath = "comms/thread.json";
1080
- }
1081
- else if (collected.captured > 0) {
1082
- 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.`);
1083
- }
1084
- else {
1085
- // Zero captures is the silent-broken shape (#351): the app never posted to the catch.
1086
- warnings.push(`Comms catch captured ZERO email sends — the app never delivered mail through the catch. Verify the app reads ${commsEmail.injectEnv} for its email API base URL (an SDK that ignores it sends real mail or throws) and that the flow reached an email step.`);
1087
- }
1268
+ };
1269
+ };
1270
+ const makeLaneObservedUrl = (laneIndex, isHost) => (url) => {
1271
+ if (typeof url !== "string" || url.length === 0)
1272
+ return;
1273
+ observedFinalUrls[laneIndex] = url; // runtime-only; digested to origin, never persisted raw
1274
+ const code = extractLobbyCode(url);
1275
+ if (code !== undefined) {
1276
+ observedLobbyCodes[laneIndex] = code;
1277
+ if (isHost)
1278
+ latchLobbyCode(code, laneIndex);
1088
1279
  }
1089
- catch (error) {
1090
- warnings.push(`Comms evidence collection failed (run continues; subject still torn down): ${redactText(scrubKnownValues(toErrorMessage(error)))}`);
1280
+ };
1281
+ // The HOST lane (which yields the /lobby/CODE the followers wait on) runs on its OWN dedicated
1282
+ // slot, and the FOLLOWERS run through a bounded pool of size concurrency-1 (blockers 1 & 4):
1283
+ // followers block on `Promise.race([lobbyCodeLatch.promise, deadline])` while holding a worker
1284
+ // slot, so if the host lane were scheduled INSIDE the same bounded pool it could be starved (never
1285
+ // scheduled among the first `concurrency` workers) and the run would die with a spurious
1286
+ // HANDOFF_TIMEOUT (e.g. lanes [p2,p3,host] with concurrency 2). Giving the host its own slot,
1287
+ // started IMMEDIATELY and OUTSIDE the follower pool, guarantees it is ALWAYS schedulable regardless
1288
+ // of its roster position or of concurrency vs lane count — while total in-flight paid desktops stay
1289
+ // ≤ the declared concurrency (host + up to concurrency-1 followers), preserving the spend cap.
1290
+ const runHostLane = async (spec, laneIndex) => {
1291
+ const onObservedUrl = makeLaneObservedUrl(laneIndex, true);
1292
+ // CDP-INDEPENDENT handoff paths (the E2B-desktop CDP url-read the onObservedUrl path relies on is
1293
+ // unreliable in practice). Two backups, both resolving the SAME latch; whichever sees the code first
1294
+ // wins, all digest-only:
1295
+ // (1) onMessage — scan the host's own narration IF it happens to state the lobby URL; and
1296
+ // (2) onScreenshot — vision-read the code straight off the host's waiting-room frame. This is the
1297
+ // robust one: the code is rendered on screen even when CDP fails AND when the host never
1298
+ // narrates it, and — crucially — the host is NOT asked to announce anything, so it keeps
1299
+ // running (create -> wait for players -> Start -> play) instead of ending on a stray message.
1300
+ const onMessage = (text) => {
1301
+ if (latchedLobbyCode !== undefined)
1302
+ return;
1303
+ const code = extractLobbyCodeFromNarration(text);
1304
+ if (code !== undefined)
1305
+ latchLobbyCode(code, laneIndex);
1306
+ };
1307
+ // Vision-read the host's waiting-room frame and LATCH the code for the followers (stops once latched).
1308
+ const onScreenshot = makeLobbyCodeVisionReader(() => latchedLobbyCode !== undefined, (code) => latchLobbyCode(code, laneIndex));
1309
+ // The host's job includes a long LEGITIMATE idle wait — sitting in the waiting room while the
1310
+ // followers provision their own desktops and walk the Join flow (easily 15-30 turns of an
1311
+ // unchanging "waiting for players" screen). At the default idle backstop (6) the host would give up
1312
+ // before anyone arrives, orphaning the lobby (exactly the earlier failure). Raise the host's idle /
1313
+ // no-progress tolerance so it waits patiently; the per-seat timeout still bounds a truly stuck host.
1314
+ // Adopter-hosted inbox (#387): the persona is told its address and inbox URL on THIS plane —
1315
+ // previously only the provisioned plane's seats ever got the instruction, so external comms
1316
+ // ran on no route at all.
1317
+ const hostInboxSpec = externalCommsEmail && commsInboxUrl && laneHasInboxRecipient(externalCommsEmail, spec.laneId)
1318
+ ? withInboxMission(spec, commsInboxUrl, inboxRecipientFor(externalCommsEmail, spec.laneId)?.address)
1319
+ : spec;
1320
+ const hostSpec = {
1321
+ ...hostInboxSpec,
1322
+ idleSteps: spec.idleSteps ?? HOST_WAIT_IDLE_STEPS,
1323
+ noProgressSteps: spec.noProgressSteps ?? HOST_WAIT_IDLE_STEPS
1324
+ };
1325
+ const startedAt = now();
1326
+ let outcome;
1327
+ try {
1328
+ outcome = await runCuaLane(hostSpec, { ...baseActorDeps, appUrl: publicAppUrl, onObservedUrl, onMessage, onScreenshot });
1091
1329
  }
1092
- }
1093
- if (subjectDesktop && subjectModule) {
1094
- if (typeof subjectModule.Sandbox.kill === "function") {
1095
- try {
1096
- await subjectModule.Sandbox.kill(subjectDesktop.sandboxId, { requestTimeoutMs: 60_000 });
1097
- subjectKilled = true;
1330
+ finally {
1331
+ // If the host finished without ever surfacing a code, release followers to fail closed
1332
+ // immediately rather than wait the full deadline (a no-op if it already resolved).
1333
+ if (!lobbyCodeLatch.settled()) {
1334
+ const reason = outcome?.sessionError ?? outcome?.session?.reason ?? "no terminal host outcome was recorded";
1335
+ hostHandoffFailure = scrubKnownValuesWithLobbyCode(`Host seat ended before producing a lobby URL: ${reason}`);
1336
+ lobbyCodeLatch.reject(new Error(hostHandoffFailure));
1098
1337
  }
1099
- catch (error) {
1100
- warnings.push(`Subject sandbox teardown failed (server-side kill-on-timeout will reclaim it): ${redactText(scrubKnownValues(toErrorMessage(error)))}`);
1101
- }
1102
- }
1103
- else {
1104
- warnings.push("Installed @e2b/desktop SDK does not expose Sandbox.kill; server-side kill-on-timeout will reclaim the subject sandbox.");
1105
1338
  }
1106
- }
1107
- }
1108
- }
1109
- // EXTERNAL-PUBLIC plane (#164 phase 2): NO subject sandbox, NO getHost, NO prober. The shared plane
1110
- // is the operator-declared public deployment (publicAppUrl); each seat opens it directly and reaches
1111
- // the shared session through the real UI. A host-first barrier extracts the /lobby/CODE from the host
1112
- // seat's CDP-observed URL (onObservedUrl) and threads it into the follower missions; a follower fails
1113
- // closed WITHOUT opening if the host never yields a code within the handoff deadline.
1114
- if (!dryRun && planeClass === "external-public") {
1115
- const cuaHooks = {
1116
- ...(hooks.loadDesktopModule ? { loadDesktopModule: hooks.loadDesktopModule } : {}),
1117
- ...(hooks.detachedTimers ? { detachedTimers: hooks.detachedTimers } : {}),
1118
- ...(hooks.env ? { env: hooks.env } : {}),
1119
- ...(hooks.prepareDesktop ? { prepareDesktop: (desktop) => hooks.prepareDesktop(desktop) } : {}),
1120
- onRuntimeStreamReady: (stream) => {
1121
- runtimeStreamUrls.push({ streamId: stream.streamId, url: stream.url });
1122
- if (liveObserver) {
1123
- attachObserverRuntimeStreamUrls(liveObserver, runtimeStreamUrls);
1124
- }
1125
- },
1126
- onRuntimeStreamEnded: (stream) => {
1127
- // Mark, never remove (#357): the tile falls back to recorded evidence and says why.
1128
- for (const entry of runtimeStreamUrls) {
1129
- if (entry.streamId === stream.streamId)
1130
- entry.ended = true;
1339
+ const endedAt = now();
1340
+ return { spec, outcome, startedAt, endedAt, route: observedFinalUrls[laneIndex] ?? publicAppUrl };
1341
+ };
1342
+ const runFollowerLane = async (spec, laneIndex) => {
1343
+ const onObservedUrl = makeLaneObservedUrl(laneIndex, false);
1344
+ // FOLLOWER: do NOT compose a mission or open the target until the host yields a lobby code.
1345
+ let code;
1346
+ try {
1347
+ code = await Promise.race([lobbyCodeLatch.promise, deadline]);
1131
1348
  }
1132
- if (liveObserver) {
1133
- attachObserverRuntimeStreamUrls(liveObserver, runtimeStreamUrls);
1349
+ catch (error) {
1350
+ // An ended host is not a deadline expiry. Preserve its actual failure.
1351
+ const timedOut = error instanceof HandoffTimeoutError;
1352
+ handoffTimedOut ||= timedOut;
1353
+ const reason = scrubKnownValuesWithLobbyCode(toErrorMessage(error));
1354
+ const at = now();
1355
+ return { spec, outcome: makeBlockedFollowerOutcome(spec, reason, timedOut), startedAt: at, endedAt: at, route: publicAppUrl };
1134
1356
  }
1135
- }
1136
- };
1137
- const baseActorDeps = {
1138
- config,
1139
- descriptor,
1140
- cloneRoute: false,
1141
- subjectEnvNames: [],
1142
- hasGithubToken: false,
1143
- env,
1144
- openaiApiKey,
1145
- e2bApiKey,
1146
- requestTimeoutMs,
1147
- perLaneSandboxMs: timeoutMs + SANDBOX_TIMEOUT_BUFFER_MS,
1148
- timeoutMs,
1149
- laneCount: roles.length,
1150
- artifactRoot: runPaths,
1151
- labCwd: cwd,
1152
- redactScreenshots,
1153
- // Scrub the latched lobby CODE (known once the host resolves it) from ALL narration.
1154
- scrubKnownValues: scrubKnownValuesWithLobbyCode,
1155
- runSession,
1156
- now,
1157
- hooks: cuaHooks,
1158
- ...(runBudget === undefined ? {} : { runBudget }),
1159
- screenMismatchPolicy: "record-evidence"
1160
- };
1161
- // Publish an attached live Observer BEFORE fan-out (mirrors the provisioned path).
1162
- if (options.onObserverReady) {
1163
- const inProgressBundle = buildConcurrentSharedWorldBundle({
1164
- config,
1165
- descriptor,
1166
- createdAt,
1167
- dryRun: false,
1168
- inProgress: true,
1169
- runId,
1170
- source,
1171
- roles,
1172
- actorSpecs,
1173
- actorResults: [],
1174
- stateSnapshots: [],
1175
- subject: { source: "app-url", envNames: [], state: { provenance: "external-public" } },
1176
- seedDigest,
1177
- planeClass: "external-public",
1178
- // Pre-fan-out snapshot: no seat has observed an origin yet, so the OBSERVED publicOriginDigest
1179
- // is not available; surface the DECLARED origin for the live Observer's reference.
1180
- ...(declaredOriginDigest === undefined ? {} : { declaredOriginDigest })
1181
- });
1182
- await writeConcurrentRunArtifacts(inProgressBundle, runPaths);
1183
- liveObserver = observerResultForConcurrentArtifacts(cwd, runId, artifactRoot, [
1184
- "Live external-public concurrent shared-world Observer is attached before final verification; stream auth URLs are runtime-only and are not persisted."
1185
- ]);
1186
- await options.onObserverReady(liveObserver);
1187
- }
1188
- // The host-first handoff barrier.
1189
- //
1190
- // TEMPORARY SHIM (tracked by #296): this CDP URL-relay handoff — reading the host's /lobby/CODE off
1191
- // its own browser and threading it into the follower missions — is a temporary coordination shim.
1192
- // It is to be augmented/replaced by the actor message bus (fake SMS/email invite) in #297: the
1193
- // human-realistic version is the HOST SENDING the invite link and followers RECEIVING and tapping
1194
- // it, rather than the orchestrator relaying the code out-of-band.
1195
- const lobbyCodeLatch = deferred();
1196
- const handoffDeadlineMs = hooks.handoffDeadlineMs
1197
- ?? Math.min(timeoutMs, Math.max(DEFAULT_HANDOFF_DEADLINE_MS, Math.floor(timeoutMs * HANDOFF_DEADLINE_BUDGET_FRACTION)));
1198
- let deadlineTimer;
1199
- const deadline = new Promise((_resolve, reject) => {
1200
- deadlineTimer = setTimeout(() => reject(new HandoffTimeoutError(handoffDeadlineMs)), handoffDeadlineMs);
1201
- });
1202
- deadline.catch(() => undefined); // never an unhandled rejection
1203
- // Resolve the host->follower handoff latch from WHICHEVER path sees the code first (CDP url-read,
1204
- // host narration, or vision-off-frame). Idempotent: only the first code wins, and it is also stashed
1205
- // as latchedLobbyCode so it gets scrubbed from any later narration. The latched code and observed URLs
1206
- // are runtime-only and land in persisted METADATA only as digests (origin + convergence). (The code
1207
- // is a shareable game code, not a secret, and it still renders in the host's screenshots, which are
1208
- // full-fidelity unless redactScreenshots is set — the digesting is about narration/URL metadata.)
1209
- const latchLobbyCode = (code, laneIndex) => {
1210
- if (latchedLobbyCode !== undefined)
1211
- return;
1212
- observedLobbyCodes[laneIndex] = code;
1213
- latchedLobbyCode = code;
1214
- if (deadlineTimer) {
1215
- clearTimeout(deadlineTimer);
1216
- deadlineTimer = undefined;
1217
- }
1218
- lobbyCodeLatch.resolve(code);
1219
- };
1220
- // Build an onScreenshot handler that vision-reads the lobby code off THIS seat's own frame (the
1221
- // CDP-independent observation). `done()` short-circuits once this seat has what it needs (the host
1222
- // once latched; a follower once it has recorded its own observed code), `onCode` records/latches the
1223
- // result. One read in flight at a time, bounded by MAX_LOBBY_CODE_VISION_READS so a seat that never
1224
- // reaches a lobby can't rack up unbounded calls (fire-and-forget; the loop never awaits it).
1225
- const makeLobbyCodeVisionReader = (done, onCode) => {
1226
- let inFlight = false;
1227
- let reads = 0;
1228
- return (frame) => {
1229
- if (done() || inFlight || reads >= MAX_LOBBY_CODE_VISION_READS)
1230
- return;
1231
- inFlight = true;
1232
- reads += 1;
1233
- void readLobbyCode(frame, openaiApiKey)
1234
- .then((code) => {
1235
- if (code !== undefined && !done())
1236
- onCode(code);
1237
- })
1238
- .catch(() => undefined)
1239
- .finally(() => {
1240
- inFlight = false;
1241
- });
1242
- };
1243
- };
1244
- const makeLaneObservedUrl = (laneIndex, isHost) => (url) => {
1245
- if (typeof url !== "string" || url.length === 0)
1246
- return;
1247
- observedFinalUrls[laneIndex] = url; // runtime-only; digested to origin, never persisted raw
1248
- const code = extractLobbyCode(url);
1249
- if (code !== undefined) {
1250
- observedLobbyCodes[laneIndex] = code;
1251
- if (isHost)
1252
- latchLobbyCode(code, laneIndex);
1253
- }
1254
- };
1255
- // The HOST lane (which yields the /lobby/CODE the followers wait on) runs on its OWN dedicated
1256
- // slot, and the FOLLOWERS run through a bounded pool of size concurrency-1 (blockers 1 & 4):
1257
- // followers block on `Promise.race([lobbyCodeLatch.promise, deadline])` while holding a worker
1258
- // slot, so if the host lane were scheduled INSIDE the same bounded pool it could be starved (never
1259
- // scheduled among the first `concurrency` workers) and the run would die with a spurious
1260
- // HANDOFF_TIMEOUT (e.g. lanes [p2,p3,host] with concurrency 2). Giving the host its own slot,
1261
- // started IMMEDIATELY and OUTSIDE the follower pool, guarantees it is ALWAYS schedulable regardless
1262
- // of its roster position or of concurrency vs lane count — while total in-flight paid desktops stay
1263
- // ≤ the declared concurrency (host + up to concurrency-1 followers), preserving the spend cap.
1264
- const runHostLane = async (spec, laneIndex) => {
1265
- const onObservedUrl = makeLaneObservedUrl(laneIndex, true);
1266
- // CDP-INDEPENDENT handoff paths (the E2B-desktop CDP url-read the onObservedUrl path relies on is
1267
- // unreliable in practice). Two backups, both resolving the SAME latch; whichever sees the code first
1268
- // wins, all digest-only:
1269
- // (1) onMessage — scan the host's own narration IF it happens to state the lobby URL; and
1270
- // (2) onScreenshot — vision-read the code straight off the host's waiting-room frame. This is the
1271
- // robust one: the code is rendered on screen even when CDP fails AND when the host never
1272
- // narrates it, and — crucially — the host is NOT asked to announce anything, so it keeps
1273
- // running (create -> wait for players -> Start -> play) instead of ending on a stray message.
1274
- const onMessage = (text) => {
1275
- if (latchedLobbyCode !== undefined)
1276
- return;
1277
- const code = extractLobbyCodeFromNarration(text);
1278
- if (code !== undefined)
1279
- latchLobbyCode(code, laneIndex);
1280
- };
1281
- // Vision-read the host's waiting-room frame and LATCH the code for the followers (stops once latched).
1282
- const onScreenshot = makeLobbyCodeVisionReader(() => latchedLobbyCode !== undefined, (code) => latchLobbyCode(code, laneIndex));
1283
- // The host's job includes a long LEGITIMATE idle wait — sitting in the waiting room while the
1284
- // followers provision their own desktops and walk the Join flow (easily 15-30 turns of an
1285
- // unchanging "waiting for players" screen). At the default idle backstop (6) the host would give up
1286
- // before anyone arrives, orphaning the lobby (exactly the earlier failure). Raise the host's idle /
1287
- // no-progress tolerance so it waits patiently; the per-seat timeout still bounds a truly stuck host.
1288
- // Adopter-hosted inbox (#387): the persona is told its address and inbox URL on THIS plane —
1289
- // previously only the provisioned plane's seats ever got the instruction, so external comms
1290
- // ran on no route at all.
1291
- const hostInboxSpec = externalCommsEmail && commsInboxUrl && laneHasInboxRecipient(externalCommsEmail, spec.laneId)
1292
- ? withInboxMission(spec, commsInboxUrl, inboxRecipientFor(externalCommsEmail, spec.laneId)?.address)
1293
- : spec;
1294
- const hostSpec = {
1295
- ...hostInboxSpec,
1296
- idleSteps: spec.idleSteps ?? HOST_WAIT_IDLE_STEPS,
1297
- noProgressSteps: spec.noProgressSteps ?? HOST_WAIT_IDLE_STEPS
1357
+ // Followers also idle-wait — in the waiting room until the host starts, and between rounds. Raise
1358
+ // their idle backstop too (less than the host's: they wait less), so a follower that joins ahead of
1359
+ // the other does not give up before the game begins. Per-seat timeout still bounds a stuck follower.
1360
+ const followerInboxSpec = externalCommsEmail && commsInboxUrl && laneHasInboxRecipient(externalCommsEmail, spec.laneId)
1361
+ ? withInboxMission(spec, commsInboxUrl, inboxRecipientFor(externalCommsEmail, spec.laneId)?.address)
1362
+ : spec;
1363
+ const followerSpec = {
1364
+ ...withLobbyCodeMission(followerInboxSpec, code),
1365
+ idleSteps: spec.idleSteps ?? FOLLOWER_WAIT_IDLE_STEPS,
1366
+ noProgressSteps: spec.noProgressSteps ?? FOLLOWER_WAIT_IDLE_STEPS
1367
+ };
1368
+ // Independently OBSERVE this follower's own lobby code by vision-reading its waiting-room frame,
1369
+ // and record it for the cross-seat convergence proof. This does NOT latch anything (followers gate
1370
+ // on the HOST's code, not their own) — it just fills this seat's observedLobbyCodes slot from a
1371
+ // reliable signal instead of the flaky CDP url-read, so lobbyConvergenceDigest can prove all seats
1372
+ // reached the SAME /lobby/CODE. If a follower somehow joined a DIFFERENT lobby, it reads a different
1373
+ // code and convergence correctly fails (no false proof); if it never reads one, the seat stays a
1374
+ // hole and convergence is honestly "not observed" for that seat.
1375
+ const onScreenshot = makeLobbyCodeVisionReader(() => observedLobbyCodes[laneIndex] !== undefined, (observed) => { observedLobbyCodes[laneIndex] = observed; });
1376
+ const startedAt = now();
1377
+ const outcome = await runCuaLane(followerSpec, { ...baseActorDeps, appUrl: publicAppUrl, onObservedUrl, onScreenshot });
1378
+ const endedAt = now();
1379
+ return { spec, outcome, startedAt, endedAt, route: observedFinalUrls[laneIndex] ?? publicAppUrl };
1298
1380
  };
1299
- const startedAt = now();
1300
- let outcome;
1381
+ // Split the roster into the designated host lane and the followers, preserving each follower's
1382
+ // ORIGINAL lane index so results land back in lane order (validation guarantees EXACTLY ONE host).
1383
+ const hostLaneIndex = roles.findIndex((role) => role.host === true);
1384
+ const followerEntries = actorSpecs
1385
+ .map((spec, index) => ({ spec, index }))
1386
+ .filter(({ index }) => index !== hostLaneIndex);
1387
+ const laneResults = new Array(actorSpecs.length);
1301
1388
  try {
1302
- outcome = await runCuaLane(hostSpec, { ...baseActorDeps, appUrl: publicAppUrl, onObservedUrl, onMessage, onScreenshot });
1303
- }
1304
- finally {
1305
- // If the host finished without ever surfacing a code, release followers to fail closed
1306
- // immediately rather than wait the full deadline (a no-op if it already resolved).
1307
- if (!lobbyCodeLatch.settled()) {
1308
- const reason = outcome?.sessionError ?? outcome?.session?.reason ?? "no terminal host outcome was recorded";
1309
- hostHandoffFailure = scrubKnownValuesWithLobbyCode(`Host seat ended before producing a lobby URL: ${reason}`);
1310
- lobbyCodeLatch.reject(new Error(hostHandoffFailure));
1389
+ const hostPromise = hostLaneIndex >= 0 && actorSpecs[hostLaneIndex] !== undefined
1390
+ ? runHostLane(actorSpecs[hostLaneIndex], hostLaneIndex)
1391
+ : undefined;
1392
+ const followerResultsPromise = mapWithConcurrency(followerEntries, Math.max(1, concurrency - 1), ({ spec, index }) => runFollowerLane(spec, index));
1393
+ const [hostResult, followerResults] = await Promise.all([hostPromise, followerResultsPromise]);
1394
+ if (hostResult !== undefined && hostLaneIndex >= 0) {
1395
+ laneResults[hostLaneIndex] = hostResult;
1311
1396
  }
1312
- }
1313
- const endedAt = now();
1314
- return { spec, outcome, startedAt, endedAt, route: observedFinalUrls[laneIndex] ?? publicAppUrl };
1315
- };
1316
- const runFollowerLane = async (spec, laneIndex) => {
1317
- const onObservedUrl = makeLaneObservedUrl(laneIndex, false);
1318
- // FOLLOWER: do NOT compose a mission or open the target until the host yields a lobby code.
1319
- let code;
1320
- try {
1321
- code = await Promise.race([lobbyCodeLatch.promise, deadline]);
1397
+ followerEntries.forEach((entry, i) => { laneResults[entry.index] = followerResults[i]; });
1398
+ actorResults = laneResults;
1322
1399
  }
1323
1400
  catch (error) {
1324
- // An ended host is not a deadline expiry. Preserve its actual failure.
1325
- const timedOut = error instanceof HandoffTimeoutError;
1326
- handoffTimedOut ||= timedOut;
1327
- const reason = scrubKnownValuesWithLobbyCode(toErrorMessage(error));
1328
- const at = now();
1329
- return { spec, outcome: makeBlockedFollowerOutcome(spec, reason, timedOut), startedAt: at, endedAt: at, route: publicAppUrl };
1401
+ runError = redactText(scrubKnownValuesWithLobbyCode(toErrorMessage(error)));
1402
+ warnings.push(`External-public concurrent shared-world run failed before completion: ${runError}`);
1330
1403
  }
1331
- // Followers also idle-wait — in the waiting room until the host starts, and between rounds. Raise
1332
- // their idle backstop too (less than the host's: they wait less), so a follower that joins ahead of
1333
- // the other does not give up before the game begins. Per-seat timeout still bounds a stuck follower.
1334
- const followerInboxSpec = externalCommsEmail && commsInboxUrl && laneHasInboxRecipient(externalCommsEmail, spec.laneId)
1335
- ? withInboxMission(spec, commsInboxUrl, inboxRecipientFor(externalCommsEmail, spec.laneId)?.address)
1336
- : spec;
1337
- const followerSpec = {
1338
- ...withLobbyCodeMission(followerInboxSpec, code),
1339
- idleSteps: spec.idleSteps ?? FOLLOWER_WAIT_IDLE_STEPS,
1340
- noProgressSteps: spec.noProgressSteps ?? FOLLOWER_WAIT_IDLE_STEPS
1341
- };
1342
- // Independently OBSERVE this follower's own lobby code by vision-reading its waiting-room frame,
1343
- // and record it for the cross-seat convergence proof. This does NOT latch anything (followers gate
1344
- // on the HOST's code, not their own) — it just fills this seat's observedLobbyCodes slot from a
1345
- // reliable signal instead of the flaky CDP url-read, so lobbyConvergenceDigest can prove all seats
1346
- // reached the SAME /lobby/CODE. If a follower somehow joined a DIFFERENT lobby, it reads a different
1347
- // code and convergence correctly fails (no false proof); if it never reads one, the seat stays a
1348
- // hole and convergence is honestly "not observed" for that seat.
1349
- const onScreenshot = makeLobbyCodeVisionReader(() => observedLobbyCodes[laneIndex] !== undefined, (observed) => { observedLobbyCodes[laneIndex] = observed; });
1350
- const startedAt = now();
1351
- const outcome = await runCuaLane(followerSpec, { ...baseActorDeps, appUrl: publicAppUrl, onObservedUrl, onScreenshot });
1352
- const endedAt = now();
1353
- return { spec, outcome, startedAt, endedAt, route: observedFinalUrls[laneIndex] ?? publicAppUrl };
1354
- };
1355
- // Split the roster into the designated host lane and the followers, preserving each follower's
1356
- // ORIGINAL lane index so results land back in lane order (validation guarantees EXACTLY ONE host).
1357
- const hostLaneIndex = roles.findIndex((role) => role.host === true);
1358
- const followerEntries = actorSpecs
1359
- .map((spec, index) => ({ spec, index }))
1360
- .filter(({ index }) => index !== hostLaneIndex);
1361
- const laneResults = new Array(actorSpecs.length);
1362
- try {
1363
- const hostPromise = hostLaneIndex >= 0 && actorSpecs[hostLaneIndex] !== undefined
1364
- ? runHostLane(actorSpecs[hostLaneIndex], hostLaneIndex)
1365
- : undefined;
1366
- const followerResultsPromise = mapWithConcurrency(followerEntries, Math.max(1, concurrency - 1), ({ spec, index }) => runFollowerLane(spec, index));
1367
- const [hostResult, followerResults] = await Promise.all([hostPromise, followerResultsPromise]);
1368
- if (hostResult !== undefined && hostLaneIndex >= 0) {
1369
- laneResults[hostLaneIndex] = hostResult;
1370
- }
1371
- followerEntries.forEach((entry, i) => { laneResults[entry.index] = followerResults[i]; });
1372
- actorResults = laneResults;
1373
- }
1374
- catch (error) {
1375
- runError = redactText(scrubKnownValuesWithLobbyCode(toErrorMessage(error)));
1376
- warnings.push(`External-public concurrent shared-world run failed before completion: ${runError}`);
1377
- }
1378
- finally {
1379
- if (deadlineTimer) {
1380
- clearTimeout(deadlineTimer);
1381
- deadlineTimer = undefined;
1382
- }
1383
- // Adopter-hosted drain (#328/#387): same routing and digest-only artifact as the in-sandbox
1384
- // catch — only the transport differs (HTTP GET /deliveries against the catch the operator
1385
- // runs). In the finally so the evidence survives a failed run; a drain error never masks
1386
- // the run's own outcome.
1387
- if (externalComms && externalCommsEmail) {
1388
- try {
1389
- const commsChannel = new FakeInbox();
1390
- const commsInboxes = [];
1391
- for (const recipient of externalCommsEmail.recipients ?? []) {
1392
- if (recipient.address !== undefined) {
1393
- commsInboxes.push(await commsChannel.provisionAddress(recipient.lane, recipient.address));
1404
+ finally {
1405
+ if (deadlineTimer) {
1406
+ clearTimeout(deadlineTimer);
1407
+ deadlineTimer = undefined;
1408
+ }
1409
+ // Adopter-hosted drain (#328/#387): same routing and digest-only artifact as the in-sandbox
1410
+ // catch — only the transport differs (HTTP GET /deliveries against the catch the operator
1411
+ // runs). In the finally so the evidence survives a failed run; a drain error never masks
1412
+ // the run's own outcome.
1413
+ if (externalComms && externalCommsEmail) {
1414
+ try {
1415
+ const commsChannel = new FakeInbox();
1416
+ const commsInboxes = [];
1417
+ for (const recipient of externalCommsEmail.recipients ?? []) {
1418
+ if (recipient.address !== undefined) {
1419
+ commsInboxes.push(await commsChannel.provisionAddress(recipient.lane, recipient.address));
1420
+ }
1421
+ }
1422
+ const authToken = externalComms.authTokenEnv === undefined ? undefined : env[externalComms.authTokenEnv];
1423
+ const collected = await collectExternalCommsThread({
1424
+ external: { ...externalComms, ...(authToken === undefined ? {} : { authToken }) },
1425
+ channel: commsChannel,
1426
+ inboxes: commsInboxes
1427
+ });
1428
+ if (collected.artifact) {
1429
+ const path = "comms/thread.json";
1430
+ await writeContainedOutputFile(runPaths, path, `${JSON.stringify(collected.artifact, null, 2)}\n`, "utf8");
1431
+ commsArtifactPath = path;
1432
+ }
1433
+ else if (collected.captured > 0) {
1434
+ 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.`);
1435
+ }
1436
+ else {
1437
+ warnings.push(`Comms catch captured ZERO email sends — your app never delivered mail through the catch at ${externalComms.catchBaseUrl}. Verify the app's email-API base URL points at it and that the flow reached an email step.`);
1394
1438
  }
1395
1439
  }
1396
- const authToken = externalComms.authTokenEnv === undefined ? undefined : env[externalComms.authTokenEnv];
1397
- const collected = await collectExternalCommsThread({
1398
- external: { ...externalComms, ...(authToken === undefined ? {} : { authToken }) },
1399
- channel: commsChannel,
1400
- inboxes: commsInboxes
1401
- });
1402
- if (collected.artifact) {
1403
- const path = "comms/thread.json";
1404
- await writeContainedOutputFile(runPaths, path, `${JSON.stringify(collected.artifact, null, 2)}\n`, "utf8");
1405
- commsArtifactPath = path;
1406
- }
1407
- else if (collected.captured > 0) {
1408
- 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.`);
1409
- }
1410
- else {
1411
- warnings.push(`Comms catch captured ZERO email sends — your app never delivered mail through the catch at ${externalComms.catchBaseUrl}. Verify the app's email-API base URL points at it and that the flow reached an email step.`);
1440
+ catch (error) {
1441
+ warnings.push(`Comms evidence collection failed against the adopter-hosted catch (run continues): ${redactText(toErrorMessage(error))}`);
1412
1442
  }
1413
1443
  }
1414
- catch (error) {
1415
- warnings.push(`Comms evidence collection failed against the adopter-hosted catch (run continues): ${redactText(toErrorMessage(error))}`);
1416
- }
1444
+ }
1445
+ // Observed-origin convergence proof (blocker 2): the convergence claim is about what the seats
1446
+ // OBSERVED, not what was DECLARED. Digest each observing seat's origin and require they AGREE on
1447
+ // ONE — that agreement IS the convergence proof and becomes plane.publicOriginDigest. A normal
1448
+ // cross-origin redirect (declared apex -> observed www) is therefore tolerated: the seats still
1449
+ // converge on ONE observed origin. Leave it undefined (verify fails closed) only if the seats did
1450
+ // not converge on a single observed origin (or none observed one).
1451
+ const observedOriginDigests = observedFinalUrls
1452
+ .filter((url) => typeof url === "string" && url.length > 0)
1453
+ .map((url) => hostOriginDigest(url));
1454
+ const distinctObservedOrigins = new Set(observedOriginDigests);
1455
+ publicOriginDigest = distinctObservedOrigins.size === 1
1456
+ ? [...distinctObservedOrigins][0]
1457
+ // NOTHING observed (e.g. a handoff-timeout run where no seat ever navigated): fall back to the
1458
+ // DECLARED origin so a FAILED run's bundle stays structurally valid (every seat's route then
1459
+ // digests to the declared origin too). The run still fails closed for its own reason (HANDOFF_
1460
+ // TIMEOUT / no lobby convergence / no overlap-on-pass). GENUINE divergence (≥2 distinct observed
1461
+ // origins) leaves it undefined so verify fails closed on the non-convergence.
1462
+ : distinctObservedOrigins.size === 0
1463
+ ? declaredOriginDigest
1464
+ : undefined;
1465
+ // Lobby-convergence proof: a digest of the shared /lobby/CODE path iff EVERY seat converged on the
1466
+ // SAME code (a follower stuck on "/" yields no code → no false convergence). Digest-only. NOTE:
1467
+ // observedLobbyCodes may be a SPARSE array (a seat that never observed a code leaves a hole), and
1468
+ // Array.prototype.every SKIPS holes — so count the DEFINED codes explicitly, never rely on every().
1469
+ const definedCodes = observedLobbyCodes.filter((code) => code !== undefined);
1470
+ const distinctCodes = new Set(definedCodes);
1471
+ if (distinctCodes.size === 1 && definedCodes.length === roles.length) {
1472
+ lobbyConvergenceDigest = commandDigestOf(`/lobby/${[...distinctCodes][0]}`);
1473
+ }
1474
+ if (handoffTimedOut && runError === undefined) {
1475
+ runError = `The host seat never produced a /lobby/CODE URL within the ${handoffDeadlineMs}ms handoff deadline; follower seats failed closed without opening.`;
1417
1476
  }
1418
1477
  }
1419
- // Observed-origin convergence proof (blocker 2): the convergence claim is about what the seats
1420
- // OBSERVED, not what was DECLARED. Digest each observing seat's origin and require they AGREE on
1421
- // ONE — that agreement IS the convergence proof and becomes plane.publicOriginDigest. A normal
1422
- // cross-origin redirect (declared apex -> observed www) is therefore tolerated: the seats still
1423
- // converge on ONE observed origin. Leave it undefined (verify fails closed) only if the seats did
1424
- // not converge on a single observed origin (or none observed one).
1425
- const observedOriginDigests = observedFinalUrls
1426
- .filter((url) => typeof url === "string" && url.length > 0)
1427
- .map((url) => hostOriginDigest(url));
1428
- const distinctObservedOrigins = new Set(observedOriginDigests);
1429
- publicOriginDigest = distinctObservedOrigins.size === 1
1430
- ? [...distinctObservedOrigins][0]
1431
- // NOTHING observed (e.g. a handoff-timeout run where no seat ever navigated): fall back to the
1432
- // DECLARED origin so a FAILED run's bundle stays structurally valid (every seat's route then
1433
- // digests to the declared origin too). The run still fails closed for its own reason (HANDOFF_
1434
- // TIMEOUT / no lobby convergence / no overlap-on-pass). GENUINE divergence (≥2 distinct observed
1435
- // origins) leaves it undefined so verify fails closed on the non-convergence.
1436
- : distinctObservedOrigins.size === 0
1437
- ? declaredOriginDigest
1438
- : undefined;
1439
- // Lobby-convergence proof: a digest of the shared /lobby/CODE path iff EVERY seat converged on the
1440
- // SAME code (a follower stuck on "/" yields no code → no false convergence). Digest-only. NOTE:
1441
- // observedLobbyCodes may be a SPARSE array (a seat that never observed a code leaves a hole), and
1442
- // Array.prototype.every SKIPS holes — so count the DEFINED codes explicitly, never rely on every().
1443
- const definedCodes = observedLobbyCodes.filter((code) => code !== undefined);
1444
- const distinctCodes = new Set(definedCodes);
1445
- if (distinctCodes.size === 1 && definedCodes.length === roles.length) {
1446
- lobbyConvergenceDigest = commandDigestOf(`/lobby/${[...distinctCodes][0]}`);
1478
+ }
1479
+ finally {
1480
+ try {
1481
+ await receiving?.finish();
1447
1482
  }
1448
- if (handoffTimedOut && runError === undefined) {
1449
- runError = `The host seat never produced a /lobby/CODE URL within the ${handoffDeadlineMs}ms handoff deadline; follower seats failed closed without opening.`;
1483
+ catch {
1484
+ warnings.push("Email finalization could not complete. Inspect humanish comms recover; provider cleanup remains unresolved.");
1450
1485
  }
1451
1486
  }
1452
1487
  // Subject provenance: external-public is the operator-declared, operator-owned public deployment
@@ -1508,6 +1543,8 @@ async function runConcurrentSharedWorldInScope(options) {
1508
1543
  hookLabel: "sharedWorldHooks",
1509
1544
  ...(options.scorerProvenance === undefined ? {} : { scorerProvenance: options.scorerProvenance })
1510
1545
  });
1546
+ if (receiving)
1547
+ bundle.commsReceiving = receiving.snapshot();
1511
1548
  await writeConcurrentRunArtifacts(bundle, runPaths);
1512
1549
  // Finalize identity+liveness from the bundle just written. Deliberately here and not inside
1513
1550
  // writeConcurrentRunArtifacts — that writer is shared with the mid-run in-progress flushes, and
@@ -2055,6 +2092,7 @@ export function buildConcurrentSharedWorldBundle(args) {
2055
2092
  const ranLive = actorResults.some((result) => result.outcome.session !== undefined || result.outcome.sessionError !== undefined);
2056
2093
  return {
2057
2094
  schema: RUN_BUNDLE_SCHEMA,
2095
+ ...receivingPublication(args.config, args.dryRun),
2058
2096
  runId: args.runId,
2059
2097
  mode: dryRun ? "dry-run" : "live",
2060
2098
  simCount: actorSpecs.length,