humanish 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -262,6 +262,18 @@ you own (a Vercel preview, staging), use an `app-url` subject with
262
262
  only declared subject env names do. `humanish init` scaffolds an example at
263
263
  `humanish/labs/cua-browser.yaml`.
264
264
 
265
+ **Off-app email/SMS verification (`comms`).** When a flow is gated behind an email
266
+ or SMS the app itself sends — a signup verification link, a one-time code, a magic
267
+ link — add a `comms:` block to the lab. Humanish redirects the app's email-API sends
268
+ (via one adopter-named env var, e.g. `RESEND_API_URL`) into a catch **inside** the
269
+ sandbox, so nothing leaves the machine; the persona then opens a synthetic inbox to
270
+ read and click through, and the run bundle gets a digest-only
271
+ `humanish.comms-thread.v1` artifact (from/to/subject/link digests + an OTP count —
272
+ no raw address, link, or code persists). It works on the single-lane clone/local-tree
273
+ route and the concurrent shared-world route, and is vendor-neutral (Resend/SendGrid
274
+ shaped, or a custom profile). See `docs/contracts/schemas.md` for the full `comms:`
275
+ shape.
276
+
265
277
  **Screenshots are full-fidelity by default.** Run bundles live in gitignored
266
278
  `.humanish/`, so the Observer shows exactly what the persona saw — the point of
267
279
  simming your own app. Set `policies.redactScreenshots: true` to persist blurred
@@ -17,13 +17,20 @@ export declare const DEFAULT_SANDBOX_CATCH_PORT = 8025;
17
17
  * and returns a plausible provider success — all normalization/profile parsing happens host-side on the
18
18
  * drained lines, so the typed, tested profiles stay in one place. It also serves the host-rendered inbox
19
19
  * surface statically at /inbox + /api/inbox (with a script-forbidding CSP). argv: <port> <deliveriesFile>
20
- * [servedDir]. Binds 127.0.0.1 only (loopback): the app under test reaches it in-sandbox; nothing is
21
- * exposed to the internet.
20
+ * <servedDir> [inboxPort]. The capture listener binds 127.0.0.1 (loopback) the app under test reaches
21
+ * it in-sandbox, nothing on the internet can inject a fake send. When an [inboxPort] is given (the
22
+ * shared-world route, where the persona lives in a DIFFERENT sandbox), it ALSO starts a READ-ONLY inbox
23
+ * listener on 0.0.0.0:<inboxPort> so getHost can proxy the persona's inbox reads to it; that listener
24
+ * serves GET only (POST → 405). The CUA same-sandbox route omits it and stays loopback-only.
22
25
  */
23
- export declare const SANDBOX_CATCH_SCRIPT = "import json\nimport os\nimport random\nimport sys\nimport time\nfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer\nfrom urllib.parse import unquote\n\nPORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8025\nOUT_FILE = sys.argv[2] if len(sys.argv) > 2 else \"/tmp/humanish-comms/deliveries.ndjson\"\nSERVED_DIR = sys.argv[3] if len(sys.argv) > 3 else (os.path.dirname(OUT_FILE) + \"/surface\")\ntry:\n os.makedirs(os.path.dirname(OUT_FILE), exist_ok=True)\nexcept Exception:\n pass\nMAX_BODY = 5 * 1024 * 1024\nCSP = \"default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-src 'none'; img-src * data:; style-src 'unsafe-inline'; font-src * data:\"\n\n\ndef message_id():\n return \"humanish-catch-\" + format(int(time.time() * 1000), \"x\") + format(random.randrange(16 ** 8), \"08x\")\n\n\nclass Handler(BaseHTTPRequestHandler):\n def log_message(self, *args):\n return\n\n def _json(self, status, obj, extra_headers=None):\n payload = json.dumps(obj).encode(\"utf-8\")\n self.send_response(status)\n self.send_header(\"content-type\", \"application/json; charset=utf-8\")\n for key, value in (extra_headers or {}).items():\n self.send_header(key, value)\n self.end_headers()\n self.wfile.write(payload)\n\n def do_GET(self):\n path = self.path.split(\"?\")[0]\n if path == \"/\" or path == \"/health\":\n self._json(200, {\"ok\": True, \"service\": \"humanish-comms-catch\"})\n return\n if path == \"/inbox\" or path.startswith(\"/inbox/\") or path == \"/api/inbox\" or path.startswith(\"/api/inbox/\"):\n rel = unquote(path)\n if \"..\" in rel or chr(0) in rel:\n self.send_response(400)\n self.end_headers()\n return\n data = None\n for candidate in (SERVED_DIR + rel, SERVED_DIR + rel + \"/index\"):\n try:\n with open(candidate, \"rb\") as handle:\n data = handle.read()\n break\n except Exception:\n data = None\n if data is None:\n self.send_response(404)\n self.send_header(\"content-type\", \"text/html; charset=utf-8\")\n self.end_headers()\n self.wfile.write(b\"<p>message not found</p>\")\n return\n is_api = rel.startswith(\"/api/\")\n self.send_response(200)\n self.send_header(\"content-type\", \"application/json; charset=utf-8\" if is_api else \"text/html; charset=utf-8\")\n if not is_api:\n self.send_header(\"content-security-policy\", CSP)\n self.end_headers()\n self.wfile.write(data)\n return\n self._json(404, {\"error\": \"not found\"})\n\n def do_POST(self):\n path = self.path.split(\"?\")[0]\n try:\n length = int(self.headers.get(\"content-length\") or 0)\n except Exception:\n length = 0\n if length > MAX_BODY:\n self.send_response(413)\n self.end_headers()\n return\n body = self.rfile.read(length).decode(\"utf-8\", \"replace\") if length > 0 else \"\"\n try:\n with open(OUT_FILE, \"a\", encoding=\"utf-8\") as handle:\n print(json.dumps({\"t\": int(time.time() * 1000), \"path\": path, \"body\": body}), file=handle)\n except Exception:\n pass\n mid = message_id()\n if path == \"/v3/mail/send\":\n self.send_response(202)\n self.send_header(\"x-message-id\", mid)\n self.end_headers()\n elif path.endswith(\"/batch\"):\n self._json(200, {\"data\": [{\"id\": mid}]})\n else:\n self._json(200, {\"id\": mid})\n\n\nThreadingHTTPServer((\"127.0.0.1\", PORT), Handler).serve_forever()\n";
26
+ export declare const SANDBOX_CATCH_SCRIPT = "import json\nimport os\nimport random\nimport sys\nimport threading\nimport time\nfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer\nfrom urllib.parse import unquote\n\nPORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8025\nOUT_FILE = sys.argv[2] if len(sys.argv) > 2 else \"/tmp/humanish-comms/deliveries.ndjson\"\nSERVED_DIR = sys.argv[3] if len(sys.argv) > 3 else (os.path.dirname(OUT_FILE) + \"/surface\")\nINBOX_PORT = int(sys.argv[4]) if len(sys.argv) > 4 else 0\ntry:\n os.makedirs(os.path.dirname(OUT_FILE), exist_ok=True)\nexcept Exception:\n pass\nMAX_BODY = 5 * 1024 * 1024\nCSP = \"default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-src 'none'; img-src * data:; style-src 'unsafe-inline'; font-src * data:\"\n\n\ndef message_id():\n return \"humanish-catch-\" + format(int(time.time() * 1000), \"x\") + format(random.randrange(16 ** 8), \"08x\")\n\n\nclass BaseHandler(BaseHTTPRequestHandler):\n def log_message(self, *args):\n return\n\n def _json(self, status, obj, extra_headers=None):\n payload = json.dumps(obj).encode(\"utf-8\")\n self.send_response(status)\n self.send_header(\"content-type\", \"application/json; charset=utf-8\")\n for key, value in (extra_headers or {}).items():\n self.send_header(key, value)\n self.end_headers()\n self.wfile.write(payload)\n\n def do_GET(self):\n path = self.path.split(\"?\")[0]\n if path == \"/\" or path == \"/health\":\n self._json(200, {\"ok\": True, \"service\": \"humanish-comms-catch\"})\n return\n if path == \"/inbox\" or path.startswith(\"/inbox/\") or path == \"/api/inbox\" or path.startswith(\"/api/inbox/\"):\n rel = unquote(path)\n if \"..\" in rel or chr(0) in rel:\n self.send_response(400)\n self.end_headers()\n return\n data = None\n for candidate in (SERVED_DIR + rel, SERVED_DIR + rel + \"/index\"):\n try:\n with open(candidate, \"rb\") as handle:\n data = handle.read()\n break\n except Exception:\n data = None\n if data is None:\n self.send_response(404)\n self.send_header(\"content-type\", \"text/html; charset=utf-8\")\n self.end_headers()\n self.wfile.write(b\"<p>message not found</p>\")\n return\n is_api = rel.startswith(\"/api/\")\n self.send_response(200)\n self.send_header(\"content-type\", \"application/json; charset=utf-8\" if is_api else \"text/html; charset=utf-8\")\n if not is_api:\n self.send_header(\"content-security-policy\", CSP)\n self.end_headers()\n self.wfile.write(data)\n return\n self._json(404, {\"error\": \"not found\"})\n\n\nclass CaptureHandler(BaseHandler):\n def do_POST(self):\n path = self.path.split(\"?\")[0]\n try:\n length = int(self.headers.get(\"content-length\") or 0)\n except Exception:\n length = 0\n if length > MAX_BODY:\n self.send_response(413)\n self.end_headers()\n return\n body = self.rfile.read(length).decode(\"utf-8\", \"replace\") if length > 0 else \"\"\n try:\n with open(OUT_FILE, \"a\", encoding=\"utf-8\") as handle:\n print(json.dumps({\"t\": int(time.time() * 1000), \"path\": path, \"body\": body}), file=handle)\n except Exception:\n pass\n mid = message_id()\n if path == \"/v3/mail/send\":\n self.send_response(202)\n self.send_header(\"x-message-id\", mid)\n self.end_headers()\n elif path.endswith(\"/batch\"):\n self._json(200, {\"data\": [{\"id\": mid}]})\n else:\n self._json(200, {\"id\": mid})\n\n\nclass ReadOnlyHandler(BaseHandler):\n def do_POST(self):\n self.send_response(405)\n self.end_headers()\n\n\n# Optional read-only inbox listener on 0.0.0.0 (getHost-reachable from a DIFFERENT sandbox on the\n# shared-world route). Serves GET /inbox + /api/inbox + /health only; POST capture stays on the\n# 127.0.0.1 listener so nothing on the internet can inject a fake captured send. Started only when a\n# distinct inbox port is provided (the CUA same-sandbox route omits it and stays loopback-only).\nif INBOX_PORT and INBOX_PORT != PORT:\n threading.Thread(target=lambda: ThreadingHTTPServer((\"0.0.0.0\", INBOX_PORT), ReadOnlyHandler).serve_forever(), daemon=True).start()\n\nThreadingHTTPServer((\"127.0.0.1\", PORT), CaptureHandler).serve_forever()\n";
24
27
  export interface DeployCommsCatchOptions {
25
28
  /** Fixed loopback port the catch listens on (default 8025). Must be free inside the sandbox. */
26
29
  port?: number;
30
+ /** Optional SECOND fixed port for a READ-ONLY inbox listener bound to 0.0.0.0, so a persona in a
31
+ * DIFFERENT sandbox can reach the inbox surface via getHost (the shared-world route). Omit on the
32
+ * CUA same-sandbox route (loopback is enough). Must differ from `port` and be free in the sandbox. */
33
+ inboxPort?: number;
27
34
  /** In-sandbox working dir for the script + NDJSON (default /tmp/humanish-comms). */
28
35
  dir?: string;
29
36
  /** Detached-process name ([a-z0-9-]); default "comms-catch". */
@@ -41,6 +48,9 @@ export interface DeployedCommsCatch {
41
48
  /** In-sandbox dir the HOST renders the persona-facing inbox-surface files into (via writeInboxSurface);
42
49
  * the catch serves them at /inbox and /api/inbox. */
43
50
  surfaceDir: string;
51
+ /** The 0.0.0.0 read-only inbox port, when one was requested — getHost-expose THIS to give a
52
+ * different-sandbox persona a reachable inbox URL. Absent on the loopback-only (CUA) route. */
53
+ inboxPort?: number;
44
54
  /** Whether the catch's /health returned OUR service marker within the readiness budget. Callers MUST
45
55
  * treat `ready === false` as fatal (do not inject baseUrl into a dead catch — the app's sends would
46
56
  * silently fail with nothing captured). */
@@ -111,27 +121,32 @@ export declare function collectCommsThread(args: {
111
121
  export declare function writeInboxSurface(desktop: E2BDesktopSandbox, surfaceDir: string, messages: CommsMessage[], options?: InboxRenderOptions & {
112
122
  requestTimeoutMs?: number;
113
123
  }): Promise<number>;
124
+ /** A declared inbox recipient the surface renders for (lane + the literal address the app sends to). */
125
+ export interface InboxSurfaceRecipient {
126
+ lane: string;
127
+ address: string;
128
+ }
114
129
  /**
115
- * One mid-run inbox-surface refresh cycle: drain the catch INCREMENTALLY from `cursor` into the
116
- * persistent surface `channel`, poll the provisioned `inboxes`, and (re)render the surface so the
117
- * persona sees new mail while the session is live. Returns the advanced cursor + whether it rendered.
130
+ * One mid-run inbox-surface refresh cycle: FULL rebuild from the append-only NDJSON (drain from cursor 0)
131
+ * into a FRESH FakeInbox each call, provisioning the declared `recipients`, then (re)render the surface
132
+ * so the persona sees new mail while the session is live. Returns the total captured-send `count` + whether
133
+ * it rendered.
118
134
  *
119
- * MUST use a channel dedicated to the surface NOT the teardown evidence channel: the teardown
120
- * collectCommsThread drains the same append-only NDJSON from cursor 0 into its OWN fresh channel, so
121
- * feeding both an incremental and a cursor-0 drain into one channel would route every send twice
122
- * (FakeInbox mints fresh ids each time, so dedup-by-id can't catch it) and double-count the evidence.
123
- * Two channels + two cursors are independent readers of the same file no evidence is lost or altered.
124
- * Skips the (N-file) render when no new sends arrived this tick, so idle ticks are cheap.
135
+ * The full rebuild is deliberate it is IDEMPOTENT and RETRY-SAFE: a transient writeInboxSurface failure
136
+ * PROPAGATES (the caller retries next tick without advancing its `sinceCount`), and because each rebuild
137
+ * starts from a clean channel, a send is never routed twice, so the persona never sees duplicate emails.
138
+ * Pass `sinceCount` (the last SUCCESSFULLY-rendered send count) to skip the (N-file) render when nothing
139
+ * new has arrived. This is independent of the teardown collectCommsThread drain (its own fresh channel,
140
+ * also cursor 0) no evidence is lost or altered. The NDJSON is small for a run, so re-reading it is cheap.
125
141
  */
126
142
  export declare function refreshInboxSurface(args: {
127
143
  desktop: E2BDesktopSandbox;
128
144
  deployed: Pick<DeployedCommsCatch, "deliveriesPath" | "surfaceDir">;
129
- channel: CommsChannel;
130
- inboxes: CommsAddress[];
131
- cursor: number;
145
+ recipients: InboxSurfaceRecipient[];
146
+ sinceCount?: number;
132
147
  originMap?: InboxRenderOptions["originMap"];
133
148
  requestTimeoutMs?: number;
134
149
  }): Promise<{
135
- cursor: number;
150
+ count: number;
136
151
  rendered: boolean;
137
152
  }>;
@@ -6,6 +6,7 @@
6
6
  // each poll `cat` its append-only NDJSON of captured sends back to the host, where the real profiles
7
7
  // parse them and route into the CommsChannel. A FIXED loopback port is chosen up front so the app's
8
8
  // injected base-URL env (`http://127.0.0.1:<port>`) is known before the sandbox is created.
9
+ import { FakeInbox } from "./comms-fake-inbox.js";
9
10
  import { buildCommsThreadArtifact } from "./comms-evidence.js";
10
11
  import { buildInboxSurface } from "./comms-inbox.js";
11
12
  import { DEFAULT_EMAIL_PROFILES } from "./comms-email-catch.js";
@@ -28,13 +29,17 @@ function shq(value) {
28
29
  * and returns a plausible provider success — all normalization/profile parsing happens host-side on the
29
30
  * drained lines, so the typed, tested profiles stay in one place. It also serves the host-rendered inbox
30
31
  * surface statically at /inbox + /api/inbox (with a script-forbidding CSP). argv: <port> <deliveriesFile>
31
- * [servedDir]. Binds 127.0.0.1 only (loopback): the app under test reaches it in-sandbox; nothing is
32
- * exposed to the internet.
32
+ * <servedDir> [inboxPort]. The capture listener binds 127.0.0.1 (loopback) the app under test reaches
33
+ * it in-sandbox, nothing on the internet can inject a fake send. When an [inboxPort] is given (the
34
+ * shared-world route, where the persona lives in a DIFFERENT sandbox), it ALSO starts a READ-ONLY inbox
35
+ * listener on 0.0.0.0:<inboxPort> so getHost can proxy the persona's inbox reads to it; that listener
36
+ * serves GET only (POST → 405). The CUA same-sandbox route omits it and stays loopback-only.
33
37
  */
34
38
  export const SANDBOX_CATCH_SCRIPT = `import json
35
39
  import os
36
40
  import random
37
41
  import sys
42
+ import threading
38
43
  import time
39
44
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
40
45
  from urllib.parse import unquote
@@ -42,6 +47,7 @@ from urllib.parse import unquote
42
47
  PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8025
43
48
  OUT_FILE = sys.argv[2] if len(sys.argv) > 2 else "/tmp/humanish-comms/deliveries.ndjson"
44
49
  SERVED_DIR = sys.argv[3] if len(sys.argv) > 3 else (os.path.dirname(OUT_FILE) + "/surface")
50
+ INBOX_PORT = int(sys.argv[4]) if len(sys.argv) > 4 else 0
45
51
  try:
46
52
  os.makedirs(os.path.dirname(OUT_FILE), exist_ok=True)
47
53
  except Exception:
@@ -54,7 +60,7 @@ def message_id():
54
60
  return "humanish-catch-" + format(int(time.time() * 1000), "x") + format(random.randrange(16 ** 8), "08x")
55
61
 
56
62
 
57
- class Handler(BaseHTTPRequestHandler):
63
+ class BaseHandler(BaseHTTPRequestHandler):
58
64
  def log_message(self, *args):
59
65
  return
60
66
 
@@ -102,6 +108,8 @@ class Handler(BaseHTTPRequestHandler):
102
108
  return
103
109
  self._json(404, {"error": "not found"})
104
110
 
111
+
112
+ class CaptureHandler(BaseHandler):
105
113
  def do_POST(self):
106
114
  path = self.path.split("?")[0]
107
115
  try:
@@ -129,7 +137,20 @@ class Handler(BaseHTTPRequestHandler):
129
137
  self._json(200, {"id": mid})
130
138
 
131
139
 
132
- ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
140
+ class ReadOnlyHandler(BaseHandler):
141
+ def do_POST(self):
142
+ self.send_response(405)
143
+ self.end_headers()
144
+
145
+
146
+ # Optional read-only inbox listener on 0.0.0.0 (getHost-reachable from a DIFFERENT sandbox on the
147
+ # shared-world route). Serves GET /inbox + /api/inbox + /health only; POST capture stays on the
148
+ # 127.0.0.1 listener so nothing on the internet can inject a fake captured send. Started only when a
149
+ # distinct inbox port is provided (the CUA same-sandbox route omits it and stays loopback-only).
150
+ if INBOX_PORT and INBOX_PORT != PORT:
151
+ threading.Thread(target=lambda: ThreadingHTTPServer(("0.0.0.0", INBOX_PORT), ReadOnlyHandler).serve_forever(), daemon=True).start()
152
+
153
+ ThreadingHTTPServer(("127.0.0.1", PORT), CaptureHandler).serve_forever()
133
154
  `;
134
155
  /** Readiness probe that asserts OUR service marker in the /health body (not merely any 2xx) — so a
135
156
  * process squatting on the fixed port cannot produce a false "ready" while the app's sends bypass us. */
@@ -159,6 +180,10 @@ export async function deployCommsCatch(desktop, options = {}) {
159
180
  if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
160
181
  throw new Error(`deployCommsCatch: invalid port ${JSON.stringify(options.port)}`);
161
182
  }
183
+ const inboxPort = options.inboxPort === undefined ? undefined : Math.trunc(Number(options.inboxPort));
184
+ if (inboxPort !== undefined && (!Number.isInteger(inboxPort) || inboxPort <= 0 || inboxPort > 65_535 || inboxPort === port)) {
185
+ throw new Error(`deployCommsCatch: invalid inboxPort ${JSON.stringify(options.inboxPort)}`);
186
+ }
162
187
  const dir = options.dir ?? DEFAULT_CATCH_DIR;
163
188
  const name = options.name ?? "comms-catch";
164
189
  const requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
@@ -169,15 +194,22 @@ export async function deployCommsCatch(desktop, options = {}) {
169
194
  await desktop.files.write(scriptPath, SANDBOX_CATCH_SCRIPT);
170
195
  await startDetachedProcess(desktop, {
171
196
  name,
172
- command: `python3 ${shq(scriptPath)} ${port} ${shq(deliveriesPath)} ${shq(surfaceDir)}`,
197
+ command: `python3 ${shq(scriptPath)} ${port} ${shq(deliveriesPath)} ${shq(surfaceDir)}${inboxPort === undefined ? "" : ` ${inboxPort}`}`,
173
198
  requestTimeoutMs
174
199
  });
175
- const ready = await catchHealthy(desktop, port, {
176
- timeoutMs: options.readyTimeoutMs ?? 15_000,
177
- requestTimeoutMs,
178
- ...(options.timers ?? {})
179
- });
180
- return { port, baseUrl: `http://127.0.0.1:${port}`, deliveriesPath, surfaceDir, ready };
200
+ const probe = { timeoutMs: options.readyTimeoutMs ?? 15_000, requestTimeoutMs, ...(options.timers ?? {}) };
201
+ const ready = await catchHealthy(desktop, port, probe);
202
+ // Confirm the read-only inbox listener bound too (loopback-reachable at its own port), when requested —
203
+ // else a getHost-exposed inbox would 502. Fail closed by folding it into `ready`.
204
+ const inboxReady = inboxPort === undefined ? true : await catchHealthy(desktop, inboxPort, probe);
205
+ return {
206
+ port,
207
+ baseUrl: `http://127.0.0.1:${port}`,
208
+ deliveriesPath,
209
+ surfaceDir,
210
+ ...(inboxPort === undefined ? {} : { inboxPort }),
211
+ ready: ready && inboxReady
212
+ };
181
213
  }
182
214
  /**
183
215
  * Drain new captured sends from the in-sandbox NDJSON since `cursor` (a line count). Returns the fresh
@@ -291,26 +323,33 @@ export async function writeInboxSurface(desktop, surfaceDir, messages, options =
291
323
  return files.length;
292
324
  }
293
325
  /**
294
- * One mid-run inbox-surface refresh cycle: drain the catch INCREMENTALLY from `cursor` into the
295
- * persistent surface `channel`, poll the provisioned `inboxes`, and (re)render the surface so the
296
- * persona sees new mail while the session is live. Returns the advanced cursor + whether it rendered.
326
+ * One mid-run inbox-surface refresh cycle: FULL rebuild from the append-only NDJSON (drain from cursor 0)
327
+ * into a FRESH FakeInbox each call, provisioning the declared `recipients`, then (re)render the surface
328
+ * so the persona sees new mail while the session is live. Returns the total captured-send `count` + whether
329
+ * it rendered.
297
330
  *
298
- * MUST use a channel dedicated to the surface NOT the teardown evidence channel: the teardown
299
- * collectCommsThread drains the same append-only NDJSON from cursor 0 into its OWN fresh channel, so
300
- * feeding both an incremental and a cursor-0 drain into one channel would route every send twice
301
- * (FakeInbox mints fresh ids each time, so dedup-by-id can't catch it) and double-count the evidence.
302
- * Two channels + two cursors are independent readers of the same file no evidence is lost or altered.
303
- * Skips the (N-file) render when no new sends arrived this tick, so idle ticks are cheap.
331
+ * The full rebuild is deliberate it is IDEMPOTENT and RETRY-SAFE: a transient writeInboxSurface failure
332
+ * PROPAGATES (the caller retries next tick without advancing its `sinceCount`), and because each rebuild
333
+ * starts from a clean channel, a send is never routed twice, so the persona never sees duplicate emails.
334
+ * Pass `sinceCount` (the last SUCCESSFULLY-rendered send count) to skip the (N-file) render when nothing
335
+ * new has arrived. This is independent of the teardown collectCommsThread drain (its own fresh channel,
336
+ * also cursor 0) no evidence is lost or altered. The NDJSON is small for a run, so re-reading it is cheap.
304
337
  */
305
338
  export async function refreshInboxSurface(args) {
306
- const { sends, cursor } = await drainCommsCatch(args.desktop, args.deployed, args.cursor, args.requestTimeoutMs);
339
+ const { sends } = await drainCommsCatch(args.desktop, args.deployed, 0, args.requestTimeoutMs);
307
340
  if (sends.length === 0)
308
- return { cursor, rendered: false };
309
- await routeCapturedSends(sends, args.channel);
341
+ return { count: 0, rendered: false };
342
+ if (args.sinceCount !== undefined && sends.length <= args.sinceCount)
343
+ return { count: sends.length, rendered: false };
344
+ const channel = new FakeInbox();
345
+ const inboxes = [];
346
+ for (const recipient of args.recipients)
347
+ inboxes.push(await channel.provisionAddress(recipient.lane, recipient.address));
348
+ await routeCapturedSends(sends, channel);
310
349
  const seen = new Set();
311
350
  const messages = [];
312
- for (const inbox of args.inboxes) {
313
- for (const message of await args.channel.poll(inbox, 0)) {
351
+ for (const inbox of inboxes) {
352
+ for (const message of await channel.poll(inbox, 0)) {
314
353
  if (seen.has(message.id))
315
354
  continue;
316
355
  seen.add(message.id);
@@ -318,12 +357,12 @@ export async function refreshInboxSurface(args) {
318
357
  }
319
358
  }
320
359
  if (messages.length === 0)
321
- return { cursor, rendered: false };
360
+ return { count: sends.length, rendered: false };
322
361
  messages.sort((a, b) => a.deliveredAt - b.deliveredAt || a.id.localeCompare(b.id));
323
362
  await writeInboxSurface(args.desktop, args.deployed.surfaceDir, messages, {
324
363
  ...(args.originMap === undefined ? {} : { originMap: args.originMap }),
325
364
  ...(args.requestTimeoutMs === undefined ? {} : { requestTimeoutMs: args.requestTimeoutMs })
326
365
  });
327
- return { cursor, rendered: true };
366
+ return { count: sends.length, rendered: true };
328
367
  }
329
368
  //# sourceMappingURL=comms-sandbox-catch.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"comms-sandbox-catch.js","sourceRoot":"","sources":["../src/comms-sandbox-catch.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"comms-sandbox-catch.js","sourceRoot":"","sources":["../src/comms-sandbox-catch.ts"],"names":[],"mappings":"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"}
@@ -4,7 +4,7 @@ export interface CommsAddress {
4
4
  channel: CommsChannelKind;
5
5
  /** Which lane owns this inbox. */
6
6
  actorId: string;
7
- /** Runtime-only raw address, e.g. patient-07@example.test | +15550137. */
7
+ /** Runtime-only raw address, e.g. user-07@example.test | +15550137. */
8
8
  value: string;
9
9
  /** sha256-short(value) — the only form persisted (redaction.digestText). */
10
10
  digest: string;
@@ -39,9 +39,10 @@ import { adapterScoreFailureMessage, applyBrowserAdapterHooks } from "./adapter-
39
39
  import { actorRegistry, isCuaActorDescriptor } from "./actor-registry.js";
40
40
  import { toErrorMessage } from "./command-failure.js";
41
41
  import { mapWithConcurrency } from "./concurrency.js";
42
- import { commandDigestOf, composeLaneInstructions, defaultPackLocalTree, provisionCloneSubject, provisionLocalTreeSubject, declaredScreenForRender, resolveLaneDevice, resolveSubjectState, runCuaLane } from "./cua-actor-lab.js";
43
- import { DEFAULT_SANDBOX_CATCH_PORT, collectCommsThread, deployCommsCatch } from "./comms-sandbox-catch.js";
42
+ import { commandDigestOf, composeLaneInstructions, defaultPackLocalTree, provisionCloneSubject, provisionLocalTreeSubject, declaredScreenForRender, laneHasInboxRecipient, resolveLaneDevice, resolveSubjectState, runCuaLane, withInboxMission } from "./cua-actor-lab.js";
43
+ import { DEFAULT_SANDBOX_CATCH_PORT, collectCommsThread, deployCommsCatch, refreshInboxSurface, writeInboxSurface } from "./comms-sandbox-catch.js";
44
44
  import { FakeInbox } from "./comms-fake-inbox.js";
45
+ import { buildOriginMap } from "./comms-inbox.js";
45
46
  import { createDesktopSandbox, loadE2BDesktopModule } from "./e2b-desktop-launch.js";
46
47
  import { concurrentSharedWorldValidationReason, externalPublicSharedWorldValidationReason } from "./lab-config.js";
47
48
  import { buildObserverData } from "./observer-data.js";
@@ -558,6 +559,13 @@ export async function runConcurrentSharedWorld(options) {
558
559
  let subjectSandboxId;
559
560
  let subjectKilled = false;
560
561
  let getHostUrl;
562
+ // Persona inbox SURFACE (#297 slice B, shared-world): the getHost-exposed inbox URL a persona (in a
563
+ // DIFFERENT sandbox) opens, the serve->getHost origin-rewrite map (REQUIRED here so the app's loopback
564
+ // verify links resolve to a reachable host), and the dedicated surface channel + render loop.
565
+ let commsInboxUrl;
566
+ let commsOriginMap = [];
567
+ let surfaceRenderedCount = 0;
568
+ let surfaceLoop;
561
569
  let runError;
562
570
  let snapshotIndex = 0;
563
571
  let liveObserver;
@@ -698,9 +706,11 @@ export async function runConcurrentSharedWorld(options) {
698
706
  // (injected into its env at create) resolves the moment it boots. Fail closed if the catch can't
699
707
  // stand up rather than let a comms-declared app silently send real mail to the internet.
700
708
  if (commsEmail && commsPort !== undefined) {
701
- deployedComms = await deployCommsCatch(subjectDesktop, { port: commsPort, requestTimeoutMs, timers });
709
+ // A SECOND (0.0.0.0) read-only inbox listener on commsPort+1 so the persona — which lives in a
710
+ // DIFFERENT sandbox here — can reach the inbox surface via getHost; capture stays loopback.
711
+ deployedComms = await deployCommsCatch(subjectDesktop, { port: commsPort, inboxPort: commsPort + 1, requestTimeoutMs, timers });
702
712
  if (!deployedComms.ready) {
703
- throw new Error(`comms email catch did not become ready on 127.0.0.1:${commsPort} in the subject sandbox`);
713
+ throw new Error(`comms email catch did not become ready in the subject sandbox (loopback capture ${commsPort} / inbox ${commsPort + 1})`);
704
714
  }
705
715
  }
706
716
  // Provision the ONE shared plane: clone + install/build + seed + serve on 0.0.0.0 + probe
@@ -748,6 +758,61 @@ export async function runConcurrentSharedWorld(options) {
748
758
  throw new Error("getHost returned a non-tokenless URL; refusing to persist a host URL that may carry a credential (invariant 1)");
749
759
  }
750
760
  getHostUrl = hostUrl;
761
+ // Persona inbox SURFACE (#297 slice B, shared-world): getHost-expose the read-only inbox listener so
762
+ // a persona in a DIFFERENT sandbox can open it; build the serve->getHost origin map (REQUIRED here —
763
+ // the app's loopback verify links must be rewritten to a reachable host); provision the surface
764
+ // channel; write the EMPTY inbox up front (so /inbox never 404s); and start a render loop that drains
765
+ // + re-renders on a cadence. The loop shares the prober's dispose signal (disposed together, before
766
+ // the teardown evidence drain), and uses a DEDICATED FakeInbox + cursor (independent of that drain).
767
+ if (commsEmail && deployedComms?.inboxPort !== undefined) {
768
+ const rawInboxHost = subjectDesktop.getHost(deployedComms.inboxPort);
769
+ const inboxHostUrl = /^https?:\/\//i.test(rawInboxHost) ? rawInboxHost : `https://${rawInboxHost}`;
770
+ if (!isTokenlessHost(inboxHostUrl)) {
771
+ throw new Error("getHost returned a non-tokenless URL for the comms inbox; refusing to advertise it (invariant 1)");
772
+ }
773
+ commsInboxUrl = `${inboxHostUrl}/inbox`;
774
+ commsOriginMap = buildOriginMap({
775
+ internalServeUrl: serve.url,
776
+ reachableBaseUrl: getHostUrl,
777
+ ...(commsEmail.linkOrigin === undefined ? {} : { linkOrigin: commsEmail.linkOrigin })
778
+ });
779
+ const surfaceRecipients = (commsEmail.recipients ?? [])
780
+ .filter((recipient) => recipient.address !== undefined)
781
+ .map((recipient) => ({ lane: recipient.lane, address: recipient.address }));
782
+ await writeInboxSurface(subjectDesktop, deployedComms.surfaceDir, [], { originMap: commsOriginMap, requestTimeoutMs });
783
+ const surfaceDeployed = deployedComms;
784
+ const surfaceCadenceMs = 2500;
785
+ surfaceLoop = (async () => {
786
+ // Full, idempotent rebuild each tick; surfaceRenderedCount advances only on a successful render,
787
+ // so a transient failure retries cleanly. Real timer (dispose-interruptible + cleared) — an
788
+ // unbounded loop must not busy-spin on the injected instant clock.
789
+ for (;;) {
790
+ try {
791
+ const refreshed = await refreshInboxSurface({
792
+ desktop: subjectDesktop,
793
+ deployed: surfaceDeployed,
794
+ recipients: surfaceRecipients,
795
+ sinceCount: surfaceRenderedCount,
796
+ originMap: commsOriginMap,
797
+ requestTimeoutMs
798
+ });
799
+ if (refreshed.rendered)
800
+ surfaceRenderedCount = refreshed.count;
801
+ }
802
+ catch {
803
+ // Never throw into the render loop; the teardown drain + by-id teardown must still run.
804
+ }
805
+ if (proberDisposed)
806
+ break;
807
+ await new Promise((resolve) => {
808
+ const timer = setTimeout(resolve, surfaceCadenceMs);
809
+ void disposeSignal.then(() => { clearTimeout(timer); resolve(); });
810
+ });
811
+ if (proberDisposed)
812
+ break;
813
+ }
814
+ })();
815
+ }
751
816
  // Baseline state snapshot, then start the background cadence prober.
752
817
  await proberSnapshot();
753
818
  if (options.onObserverReady) {
@@ -840,8 +905,13 @@ export async function runConcurrentSharedWorld(options) {
840
905
  };
841
906
  actorResults = await mapWithConcurrency(actorSpecs, Math.max(1, concurrency), async (spec, i) => {
842
907
  const route = resolveActorSeatUrl(getHostUrl, roles[i]?.entry);
908
+ // Tell this persona its (getHost-reachable) inbox URL — but only when comms is live AND this lane
909
+ // has a declared recipient it can actually receive mail into (else it would stall on an empty inbox).
910
+ const laneSpec = commsEmail && commsInboxUrl && laneHasInboxRecipient(commsEmail, spec.laneId)
911
+ ? withInboxMission(spec, commsInboxUrl)
912
+ : spec;
843
913
  const startedAt = now();
844
- const outcome = await runCuaLane(spec, { ...baseActorDeps, appUrl: route });
914
+ const outcome = await runCuaLane(laneSpec, { ...baseActorDeps, appUrl: route });
845
915
  const endedAt = now();
846
916
  return { spec, outcome, startedAt, endedAt, route };
847
917
  });
@@ -858,6 +928,11 @@ export async function runConcurrentSharedWorld(options) {
858
928
  if (proberLoop) {
859
929
  await proberLoop.catch(() => undefined);
860
930
  }
931
+ // Stop the inbox-surface render loop too (shares the prober's dispose signal), before the teardown
932
+ // evidence drain below — so the two in-sandbox reads never overlap and the surface state is final.
933
+ if (surfaceLoop) {
934
+ await surfaceLoop.catch(() => undefined);
935
+ }
861
936
  if (subjectDesktop && getHostUrl) {
862
937
  await proberSnapshot().catch(() => undefined);
863
938
  }