humanish 0.21.0 → 0.23.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.
@@ -0,0 +1,27 @@
1
+ import type { CommsMessage } from "./comms-types.js";
2
+ export declare const COMMS_THREAD_SCHEMA = "humanish.comms-thread.v1";
3
+ export interface CommsThreadEntry {
4
+ id: string;
5
+ channel: CommsMessage["channel"];
6
+ /** sha256-16 of the raw sender address. */
7
+ fromDigest: string;
8
+ /** sha256-16 of each recipient inbox address. */
9
+ toDigests: string[];
10
+ /** sha256-16 of the subject (non-reversible for a PII subject; correlates identical subjects). */
11
+ subjectDigest?: string;
12
+ /** sha256-16 of each actionable link (high-entropy → non-reversible). */
13
+ linkDigests: string[];
14
+ /** COUNT ONLY — a short OTP's digest is reversible, so the code itself never lands here. */
15
+ codeCount: number;
16
+ sentAt: number;
17
+ deliveredAt: number;
18
+ }
19
+ export interface CommsThreadArtifact {
20
+ schema: typeof COMMS_THREAD_SCHEMA;
21
+ channel: CommsMessage["channel"];
22
+ count: number;
23
+ thread: CommsThreadEntry[];
24
+ }
25
+ /** Project polled inbox messages into the digest-only thread artifact. Pure; the caller writes it into
26
+ * the run dir and registers it as an adapter-artifact. */
27
+ export declare function buildCommsThreadArtifact(messages: CommsMessage[]): CommsThreadArtifact;
@@ -0,0 +1,31 @@
1
+ // Digest-only evidence for a captured comms thread (#297). Proves "the verification mail arrived and
2
+ // the persona could act on it" WITHOUT persisting raw PHI. Written as an adapter-artifact
3
+ // (humanish.comms-thread.v1), so it inherits the bundle's existence-verify + public-safety scan.
4
+ //
5
+ // Digest discipline (deliberate, see below): addresses + links are digested (high entropy → the digest
6
+ // is not reversible). The subject is DIGESTED too, not stored as text — redactText only scrubs
7
+ // secret-SHAPED tokens/paths, not free-form PII (the #108 gap), so a subject like "results for <name>"
8
+ // would pass through verbatim; a sha256-16 keeps a PII subject non-reversible while still letting you
9
+ // correlate identical subjects. OTP CODES are a COUNT ONLY, never digested — a sha256 of a 6-digit code
10
+ // has ~10^6 preimages and is trivially brute-forced back to the code, so a "code digest" would leak it.
11
+ // Net: NO raw address/subject/link/OTP text ever lands in the artifact. Same caution as the lobby code.
12
+ import { digestText } from "./redaction.js";
13
+ export const COMMS_THREAD_SCHEMA = "humanish.comms-thread.v1";
14
+ /** Project polled inbox messages into the digest-only thread artifact. Pure; the caller writes it into
15
+ * the run dir and registers it as an adapter-artifact. */
16
+ export function buildCommsThreadArtifact(messages) {
17
+ const channel = messages[0]?.channel ?? "email";
18
+ const thread = messages.map((message) => ({
19
+ id: message.id,
20
+ channel: message.channel,
21
+ fromDigest: digestText(message.from, 16),
22
+ toDigests: message.to.map((address) => address.digest),
23
+ ...(message.subject === undefined ? {} : { subjectDigest: digestText(message.subject, 16) }),
24
+ linkDigests: message.links.map((link) => digestText(link, 16)),
25
+ codeCount: message.codes.length,
26
+ sentAt: message.sentAt,
27
+ deliveredAt: message.deliveredAt
28
+ }));
29
+ return { schema: COMMS_THREAD_SCHEMA, channel, count: thread.length, thread };
30
+ }
31
+ //# sourceMappingURL=comms-evidence.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"comms-evidence.js","sourceRoot":"","sources":["../src/comms-evidence.ts"],"names":[],"mappings":"AAAA,qGAAqG;AACrG,0FAA0F;AAC1F,iGAAiG;AACjG,EAAE;AACF,uGAAuG;AACvG,+FAA+F;AAC/F,uGAAuG;AACvG,sGAAsG;AACtG,wGAAwG;AACxG,wGAAwG;AACxG,wGAAwG;AAGxG,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAE5C,MAAM,CAAC,MAAM,mBAAmB,GAAG,0BAA0B,CAAC;AA0B9D;2DAC2D;AAC3D,MAAM,UAAU,wBAAwB,CAAC,QAAwB;IAC/D,MAAM,OAAO,GAA4B,QAAQ,CAAC,CAAC,CAAC,EAAE,OAAO,IAAI,OAAO,CAAC;IACzE,MAAM,MAAM,GAAuB,QAAQ,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;QAC5D,EAAE,EAAE,OAAO,CAAC,EAAE;QACd,OAAO,EAAE,OAAO,CAAC,OAAO;QACxB,UAAU,EAAE,UAAU,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC;QACxC,SAAS,EAAE,OAAO,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC;QACtD,GAAG,CAAC,OAAO,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,UAAU,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,EAAE,CAAC;QAC5F,WAAW,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,UAAU,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QAC9D,SAAS,EAAE,OAAO,CAAC,KAAK,CAAC,MAAM;QAC/B,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,WAAW,EAAE,OAAO,CAAC,WAAW;KACjC,CAAC,CAAC,CAAC;IACJ,OAAO,EAAE,MAAM,EAAE,mBAAmB,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC;AAChF,CAAC"}
@@ -0,0 +1,63 @@
1
+ import type { CommsChannel } from "./comms-types.js";
2
+ import { type EmailSendProfile } from "./comms-email-catch.js";
3
+ import { type DetachedTimers } from "./e2b-detached.js";
4
+ import type { E2BDesktopSandbox } from "./e2b-desktop-launch.js";
5
+ /** The default in-sandbox loopback port for the catch. Fixed (not ephemeral) so the injected base-URL
6
+ * env is known before `createDesktopSandbox`. 8025 is the conventional local-mail-UI port and is
7
+ * unlikely to collide with a subject app; override via config if it does. */
8
+ export declare const DEFAULT_SANDBOX_CATCH_PORT = 8025;
9
+ /**
10
+ * The self-contained in-sandbox capture server (a plain node ESM string — runs on the sandbox's own
11
+ * node, imports nothing from humanish). It is DELIBERATELY dumb: it records each POST verbatim as an
12
+ * NDJSON line `{t, path, body}` and returns a plausible provider success — all normalization/profile
13
+ * parsing happens host-side on the drained lines, so the typed, tested profiles stay in one place.
14
+ * argv: <port> <deliveriesFile>.
15
+ */
16
+ export declare const SANDBOX_CATCH_SCRIPT: string;
17
+ export interface DeployCommsCatchOptions {
18
+ /** Fixed loopback port the catch listens on (default 8025). Must be free inside the sandbox. */
19
+ port?: number;
20
+ /** In-sandbox working dir for the script + NDJSON (default /tmp/humanish-comms). */
21
+ dir?: string;
22
+ /** Detached-process name ([a-z0-9-]); default "comms-catch". */
23
+ name?: string;
24
+ /** Readiness-probe budget (ms) for the catch's /health (default 15000). */
25
+ readyTimeoutMs?: number;
26
+ requestTimeoutMs?: number;
27
+ timers?: DetachedTimers;
28
+ }
29
+ export interface DeployedCommsCatch {
30
+ port: number;
31
+ /** Inject THIS as the app's email-API base URL (e.g. RESEND_API_URL) — the sandbox's own loopback. */
32
+ baseUrl: string;
33
+ deliveriesPath: string;
34
+ /** Whether the catch's /health returned OUR service marker within the readiness budget. Callers MUST
35
+ * treat `ready === false` as fatal (do not inject baseUrl into a dead catch — the app's sends would
36
+ * silently fail with nothing captured). */
37
+ ready: boolean;
38
+ }
39
+ /** A raw send the in-sandbox catch captured (host-side parsing happens in routeCapturedSends). */
40
+ export interface RawCapturedSend {
41
+ path: string;
42
+ body: string;
43
+ t: number;
44
+ }
45
+ /**
46
+ * Write + launch the in-sandbox catch (detached), then probe it ready. Call AFTER the subject sandbox
47
+ * is created and BEFORE the subject app's serve.start, so the base URL resolves at the app's boot.
48
+ */
49
+ export declare function deployCommsCatch(desktop: E2BDesktopSandbox, options?: DeployCommsCatchOptions): Promise<DeployedCommsCatch>;
50
+ /**
51
+ * Drain new captured sends from the in-sandbox NDJSON since `cursor` (a line count). Returns the fresh
52
+ * sends and the new cursor. Cheap `cat` over commands.run; NDJSON is small for a run.
53
+ */
54
+ export declare function drainCommsCatch(desktop: E2BDesktopSandbox, deployed: Pick<DeployedCommsCatch, "deliveriesPath">, cursor?: number, requestTimeoutMs?: number): Promise<{
55
+ sends: RawCapturedSend[];
56
+ cursor: number;
57
+ }>;
58
+ /**
59
+ * Parse drained raw sends with the host profiles and route them into the CommsChannel (the host-side
60
+ * FauxInbox). Returns the number of inbox deliveries made. Same profiles as the host catch, so the
61
+ * in-sandbox and in-process routes normalize identically.
62
+ */
63
+ export declare function routeCapturedSends(sends: RawCapturedSend[], channel: CommsChannel, profiles?: EmailSendProfile[]): Promise<number>;
@@ -0,0 +1,157 @@
1
+ // Deploy the vendor-neutral email catch INSIDE the subject E2B sandbox and bridge captured sends back
2
+ // to the host bus (#297 config-block core). The host `startEmailCatchServer` binds the HOST's loopback,
3
+ // which a sandboxed app cannot reach — `127.0.0.1:PORT` from the app is the SANDBOX's loopback. So the
4
+ // listener must live in the sandbox: we write a tiny self-contained capture server (no deps, no host
5
+ // import) into the sandbox, launch it detached (the same substrate that serves the subject app), and
6
+ // each poll `cat` its append-only NDJSON of captured sends back to the host, where the real profiles
7
+ // parse them and route into the CommsChannel. A FIXED loopback port is chosen up front so the app's
8
+ // injected base-URL env (`http://127.0.0.1:<port>`) is known before the sandbox is created.
9
+ import { DEFAULT_EMAIL_PROFILES } from "./comms-email-catch.js";
10
+ import { startDetachedProcess } from "./e2b-detached.js";
11
+ /** The default in-sandbox loopback port for the catch. Fixed (not ephemeral) so the injected base-URL
12
+ * env is known before `createDesktopSandbox`. 8025 is the conventional local-mail-UI port and is
13
+ * unlikely to collide with a subject app; override via config if it does. */
14
+ export const DEFAULT_SANDBOX_CATCH_PORT = 8025;
15
+ const DEFAULT_CATCH_DIR = "/tmp/humanish-comms";
16
+ /** Single-quote for safe shell interpolation. */
17
+ function shq(value) {
18
+ return `'${value.replaceAll("'", "'\\''")}'`;
19
+ }
20
+ /**
21
+ * The self-contained in-sandbox capture server (a plain node ESM string — runs on the sandbox's own
22
+ * node, imports nothing from humanish). It is DELIBERATELY dumb: it records each POST verbatim as an
23
+ * NDJSON line `{t, path, body}` and returns a plausible provider success — all normalization/profile
24
+ * parsing happens host-side on the drained lines, so the typed, tested profiles stay in one place.
25
+ * argv: <port> <deliveriesFile>.
26
+ */
27
+ export const SANDBOX_CATCH_SCRIPT = [
28
+ 'import { createServer } from "node:http";',
29
+ 'import { appendFileSync, mkdirSync } from "node:fs";',
30
+ 'import { dirname } from "node:path";',
31
+ 'const port = Number(process.argv[2] || 8025);',
32
+ 'const outFile = process.argv[3] || "/tmp/humanish-comms/deliveries.ndjson";',
33
+ 'try { mkdirSync(dirname(outFile), { recursive: true }); } catch {}',
34
+ 'const MAX = 5 * 1024 * 1024;',
35
+ 'createServer((req, res) => {',
36
+ ' const path = (req.url || "/").split("?")[0];',
37
+ ' if (req.method === "GET" && (path === "/" || path === "/health")) { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true, service: "humanish-comms-catch" })); return; }',
38
+ ' if (req.method !== "POST") { res.writeHead(404, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "not found" })); return; }',
39
+ ' let size = 0; const chunks = [];',
40
+ ' req.on("data", (c) => { size += c.length; if (size > MAX) { req.destroy(); } else { chunks.push(c); } });',
41
+ ' req.on("end", () => {',
42
+ ' const body = Buffer.concat(chunks).toString("utf8");',
43
+ ' try { appendFileSync(outFile, JSON.stringify({ t: Date.now(), path, body }) + "\\n"); } catch {}',
44
+ ' const id = "humanish-catch-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);',
45
+ ' if (path === "/v3/mail/send") { res.writeHead(202, { "x-message-id": id }); res.end(); }',
46
+ ' else if (path.endsWith("/batch")) { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ data: [{ id }] })); }',
47
+ ' else { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ id })); }',
48
+ ' });',
49
+ ' req.on("error", () => { try { res.writeHead(400); res.end(); } catch {} });',
50
+ '}).listen(port, "127.0.0.1");'
51
+ ].join("\n");
52
+ /** Readiness probe that asserts OUR service marker in the /health body (not merely any 2xx) — so a
53
+ * process squatting on the fixed port cannot produce a false "ready" while the app's sends bypass us. */
54
+ async function catchHealthy(desktop, port, options) {
55
+ const now = options.now ?? Date.now;
56
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
57
+ const deadline = now() + options.timeoutMs;
58
+ for (;;) {
59
+ const result = await desktop.commands
60
+ .run(`curl -s --max-time 5 http://127.0.0.1:${port}/health 2>/dev/null || true`, { requestTimeoutMs: options.requestTimeoutMs })
61
+ .catch(() => ({ stdout: "" }));
62
+ if ((result.stdout ?? "").includes("humanish-comms-catch"))
63
+ return true;
64
+ if (now() >= deadline)
65
+ return false;
66
+ await sleep(1000);
67
+ }
68
+ }
69
+ /**
70
+ * Write + launch the in-sandbox catch (detached), then probe it ready. Call AFTER the subject sandbox
71
+ * is created and BEFORE the subject app's serve.start, so the base URL resolves at the app's boot.
72
+ */
73
+ export async function deployCommsCatch(desktop, options = {}) {
74
+ // Validate the port to an integer before it reaches the shell command (defense-in-depth: a future
75
+ // caller might cast a config value; the value is typed `number` but this makes injection impossible).
76
+ const port = Math.trunc(Number(options.port ?? DEFAULT_SANDBOX_CATCH_PORT));
77
+ if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
78
+ throw new Error(`deployCommsCatch: invalid port ${JSON.stringify(options.port)}`);
79
+ }
80
+ const dir = options.dir ?? DEFAULT_CATCH_DIR;
81
+ const name = options.name ?? "comms-catch";
82
+ const requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
83
+ const scriptPath = `${dir}/catch.mjs`;
84
+ const deliveriesPath = `${dir}/deliveries.ndjson`;
85
+ await desktop.commands.run(`mkdir -p ${shq(dir)}`, { requestTimeoutMs });
86
+ await desktop.files.write(scriptPath, SANDBOX_CATCH_SCRIPT);
87
+ await startDetachedProcess(desktop, {
88
+ name,
89
+ command: `node ${shq(scriptPath)} ${port} ${shq(deliveriesPath)}`,
90
+ requestTimeoutMs
91
+ });
92
+ const ready = await catchHealthy(desktop, port, {
93
+ timeoutMs: options.readyTimeoutMs ?? 15_000,
94
+ requestTimeoutMs,
95
+ ...(options.timers ?? {})
96
+ });
97
+ return { port, baseUrl: `http://127.0.0.1:${port}`, deliveriesPath, ready };
98
+ }
99
+ /**
100
+ * Drain new captured sends from the in-sandbox NDJSON since `cursor` (a line count). Returns the fresh
101
+ * sends and the new cursor. Cheap `cat` over commands.run; NDJSON is small for a run.
102
+ */
103
+ export async function drainCommsCatch(desktop, deployed, cursor = 0, requestTimeoutMs = 30_000) {
104
+ const result = await desktop.commands.run(`cat ${shq(deployed.deliveriesPath)} 2>/dev/null || true`, { requestTimeoutMs });
105
+ const stdout = result.stdout ?? "";
106
+ let lines = stdout.split("\n").filter((line) => line.trim().length > 0);
107
+ // If the file doesn't end in a newline, the last line may be a PARTIAL append (the host `cat` raced
108
+ // an in-sandbox append of a large body). Drop it and don't advance the cursor past it — it re-reads
109
+ // complete on the next poll, so a captured send is never lost to the race (the script only ever emits
110
+ // valid JSON, so an incomplete line is the only cause of a parse miss).
111
+ if (!stdout.endsWith("\n") && lines.length > 0)
112
+ lines = lines.slice(0, -1);
113
+ const sends = [];
114
+ for (const line of lines.slice(cursor)) {
115
+ try {
116
+ const parsed = JSON.parse(line);
117
+ if (typeof parsed.path === "string" && typeof parsed.body === "string") {
118
+ sends.push({ path: parsed.path, body: parsed.body, t: typeof parsed.t === "number" ? parsed.t : 0 });
119
+ }
120
+ }
121
+ catch {
122
+ // skip a malformed line
123
+ }
124
+ }
125
+ return { sends, cursor: lines.length };
126
+ }
127
+ /**
128
+ * Parse drained raw sends with the host profiles and route them into the CommsChannel (the host-side
129
+ * FauxInbox). Returns the number of inbox deliveries made. Same profiles as the host catch, so the
130
+ * in-sandbox and in-process routes normalize identically.
131
+ */
132
+ export async function routeCapturedSends(sends, channel, profiles = DEFAULT_EMAIL_PROFILES) {
133
+ let delivered = 0;
134
+ for (const send of sends) {
135
+ let parsed;
136
+ try {
137
+ parsed = JSON.parse(send.body.length > 0 ? send.body : "{}");
138
+ }
139
+ catch {
140
+ continue;
141
+ }
142
+ const profile = profiles.find((candidate) => candidate.sendPaths.includes(send.path)) ?? profiles[0];
143
+ if (profile === undefined)
144
+ continue;
145
+ for (const normalized of profile.parse(send.path, parsed)) {
146
+ const messages = await channel.deliverRaw({
147
+ from: normalized.from,
148
+ to: normalized.to,
149
+ ...(normalized.subject === undefined ? {} : { subject: normalized.subject }),
150
+ body: normalized.body
151
+ });
152
+ delivered += messages.length;
153
+ }
154
+ }
155
+ return delivered;
156
+ }
157
+ //# sourceMappingURL=comms-sandbox-catch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"comms-sandbox-catch.js","sourceRoot":"","sources":["../src/comms-sandbox-catch.ts"],"names":[],"mappings":"AAAA,sGAAsG;AACtG,wGAAwG;AACxG,uGAAuG;AACvG,qGAAqG;AACrG,qGAAqG;AACrG,qGAAqG;AACrG,oGAAoG;AACpG,4FAA4F;AAG5F,OAAO,EAAE,sBAAsB,EAAyB,MAAM,wBAAwB,CAAC;AACvF,OAAO,EAAE,oBAAoB,EAAuB,MAAM,mBAAmB,CAAC;AAG9E;;8EAE8E;AAC9E,MAAM,CAAC,MAAM,0BAA0B,GAAG,IAAI,CAAC;AAC/C,MAAM,iBAAiB,GAAG,qBAAqB,CAAC;AAEhD,iDAAiD;AACjD,SAAS,GAAG,CAAC,KAAa;IACxB,OAAO,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC;AAC/C,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG;IAClC,2CAA2C;IAC3C,sDAAsD;IACtD,sCAAsC;IACtC,+CAA+C;IAC/C,6EAA6E;IAC7E,oEAAoE;IACpE,8BAA8B;IAC9B,8BAA8B;IAC9B,gDAAgD;IAChD,qNAAqN;IACrN,uJAAuJ;IACvJ,oCAAoC;IACpC,6GAA6G;IAC7G,yBAAyB;IACzB,0DAA0D;IAC1D,sGAAsG;IACtG,sGAAsG;IACtG,8FAA8F;IAC9F,oJAAoJ;IACpJ,2GAA2G;IAC3G,OAAO;IACP,+EAA+E;IAC/E,+BAA+B;CAChC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AA0Bb;0GAC0G;AAC1G,KAAK,UAAU,YAAY,CACzB,OAA0B,EAC1B,IAAY,EACZ,OAAyE;IAEzE,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC;IACpC,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,CAAC,CAAC,EAAU,EAAE,EAAE,CAAC,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;IACzG,MAAM,QAAQ,GAAG,GAAG,EAAE,GAAG,OAAO,CAAC,SAAS,CAAC;IAC3C,SAAS,CAAC;QACR,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,QAAQ;aAClC,GAAG,CAAC,yCAAyC,IAAI,6BAA6B,EAAE,EAAE,gBAAgB,EAAE,OAAO,CAAC,gBAAgB,EAAE,CAAC;aAC/H,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC;QACjC,IAAI,CAAC,MAAM,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,QAAQ,CAAC,sBAAsB,CAAC;YAAE,OAAO,IAAI,CAAC;QACxE,IAAI,GAAG,EAAE,IAAI,QAAQ;YAAE,OAAO,KAAK,CAAC;QACpC,MAAM,KAAK,CAAC,IAAI,CAAC,CAAC;IACpB,CAAC;AACH,CAAC;AASD;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,OAA0B,EAC1B,UAAmC,EAAE;IAErC,kGAAkG;IAClG,sGAAsG;IACtG,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI,0BAA0B,CAAC,CAAC,CAAC;IAC5E,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,IAAI,IAAI,GAAG,MAAM,EAAE,CAAC;QAC1D,MAAM,IAAI,KAAK,CAAC,kCAAkC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACpF,CAAC;IACD,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,iBAAiB,CAAC;IAC7C,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,aAAa,CAAC;IAC3C,MAAM,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,IAAI,MAAM,CAAC;IAC5D,MAAM,UAAU,GAAG,GAAG,GAAG,YAAY,CAAC;IACtC,MAAM,cAAc,GAAG,GAAG,GAAG,oBAAoB,CAAC;IAElD,MAAM,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,YAAY,GAAG,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,gBAAgB,EAAE,CAAC,CAAC;IACzE,MAAM,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,EAAE,oBAAoB,CAAC,CAAC;IAC5D,MAAM,oBAAoB,CAAC,OAAO,EAAE;QAClC,IAAI;QACJ,OAAO,EAAE,QAAQ,GAAG,CAAC,UAAU,CAAC,IAAI,IAAI,IAAI,GAAG,CAAC,cAAc,CAAC,EAAE;QACjE,gBAAgB;KACjB,CAAC,CAAC;IACH,MAAM,KAAK,GAAG,MAAM,YAAY,CAAC,OAAO,EAAE,IAAI,EAAE;QAC9C,SAAS,EAAE,OAAO,CAAC,cAAc,IAAI,MAAM;QAC3C,gBAAgB;QAChB,GAAG,CAAC,OAAO,CAAC,MAAM,IAAI,EAAE,CAAC;KAC1B,CAAC,CAAC;IACH,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,oBAAoB,IAAI,EAAE,EAAE,cAAc,EAAE,KAAK,EAAE,CAAC;AAC9E,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,OAA0B,EAC1B,QAAoD,EACpD,MAAM,GAAG,CAAC,EACV,gBAAgB,GAAG,MAAM;IAEzB,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,GAAG,CAAC,QAAQ,CAAC,cAAc,CAAC,sBAAsB,EAAE,EAAE,gBAAgB,EAAE,CAAC,CAAC;IAC3H,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,IAAI,EAAE,CAAC;IACnC,IAAI,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IACxE,oGAAoG;IACpG,oGAAoG;IACpG,sGAAsG;IACtG,wEAAwE;IACxE,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;QAAE,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAC3E,MAAM,KAAK,GAAsB,EAAE,CAAC;IACpC,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC;QACvC,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAA4B,CAAC;YAC3D,IAAI,OAAO,MAAM,CAAC,IAAI,KAAK,QAAQ,IAAI,OAAO,MAAM,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACvE,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC,EAAE,OAAO,MAAM,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;YACvG,CAAC;QACH,CAAC;QAAC,MAAM,CAAC;YACP,wBAAwB;QAC1B,CAAC;IACH,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC;AACzC,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,KAAwB,EACxB,OAAqB,EACrB,WAA+B,sBAAsB;IAErD,IAAI,SAAS,GAAG,CAAC,CAAC;IAClB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,MAAe,CAAC;QACpB,IAAI,CAAC;YACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QAC/D,CAAC;QAAC,MAAM,CAAC;YACP,SAAS;QACX,CAAC;QACD,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,QAAQ,CAAC,CAAC,CAAC,CAAC;QACrG,IAAI,OAAO,KAAK,SAAS;YAAE,SAAS;QACpC,KAAK,MAAM,UAAU,IAAI,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,CAAC;YAC1D,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,UAAU,CAAC;gBACxC,IAAI,EAAE,UAAU,CAAC,IAAI;gBACrB,EAAE,EAAE,UAAU,CAAC,EAAE;gBACjB,GAAG,CAAC,UAAU,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,UAAU,CAAC,OAAO,EAAE,CAAC;gBAC5E,IAAI,EAAE,UAAU,CAAC,IAAI;aACtB,CAAC,CAAC;YACH,SAAS,IAAI,QAAQ,CAAC,MAAM,CAAC;QAC/B,CAAC;IACH,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC"}
@@ -31,6 +31,7 @@ import { CHROMIUM_EVIDENCE_HYGIENE_FLAGS, chromiumEvidenceProfilePreferencesJson
31
31
  import { DEFAULT_OPENAI_CU_MODEL } from "./openai-responses-cu.js";
32
32
  import { createDesktopSandbox, loadE2BDesktopModule } from "./e2b-desktop-launch.js";
33
33
  import { probeUrl, readDetachedLog, runDetachedStep, startDetachedProcess } from "./e2b-detached.js";
34
+ import { DEFAULT_SANDBOX_CATCH_PORT, deployCommsCatch } from "./comms-sandbox-catch.js";
34
35
  import { DEFAULT_DEVICE_PRESET, isDevicePresetName, resolveDevicePreset } from "./device-presets.js";
35
36
  import { cuaLaneValidationReason, isHttpUrl, isLoopbackUrl, MAX_CUA_LANES, subjectStateInvalidReason } from "./lab-config.js";
36
37
  import { mapWithConcurrency } from "./concurrency.js";
@@ -987,6 +988,15 @@ export async function runCuaLane(spec, deps) {
987
988
  const { config, appUrl, cloneRoute, localTreeRoute, serve, subjectRepo, subjectEnvNames } = deps;
988
989
  const targetUrl = spec.targetUrl ?? appUrl;
989
990
  const env = deps.env;
991
+ // Off-app comms (#297): on an in-sandbox subject route, redirect the app's email-API sends into an
992
+ // in-sandbox catch (loopback) so its verification mail is CAPTURED, not sent to the internet. Gated
993
+ // ENTIRELY on config.comms — no comms declared → zero change. The base-URL env is injected at
994
+ // sandbox-create (below, so the app reads it at boot); the catch is started right after create.
995
+ const commsEmail = (cloneRoute || localTreeRoute) ? config.comms?.email : undefined;
996
+ const commsPort = commsEmail ? (commsEmail.port ?? DEFAULT_SANDBOX_CATCH_PORT) : undefined;
997
+ const commsEnv = commsEmail && commsPort !== undefined
998
+ ? { [commsEmail.injectEnv]: `http://127.0.0.1:${commsPort}` }
999
+ : {};
990
1000
  const warnings = [];
991
1001
  const screenshots = [];
992
1002
  const writeScreenshot = makeLaneWriteScreenshot(deps.artifactRoot, spec, screenshots);
@@ -1055,8 +1065,8 @@ export async function runCuaLane(spec, deps) {
1055
1065
  },
1056
1066
  // Env placement per the doctrine: the ACTOR's key never enters the sandbox (the model drives
1057
1067
  // from outside). The SUBJECT's declared env NAMES are provisioned here on the clone route.
1058
- ...(subjectEnvNames.length > 0
1059
- ? { envs: Object.fromEntries(subjectEnvNames.map((name) => [name, env[name]])) }
1068
+ ...(subjectEnvNames.length > 0 || Object.keys(commsEnv).length > 0
1069
+ ? { envs: { ...Object.fromEntries(subjectEnvNames.map((name) => [name, env[name]])), ...commsEnv } }
1060
1070
  : {}),
1061
1071
  resolution: spec.resolution,
1062
1072
  dpi: 96,
@@ -1068,6 +1078,15 @@ export async function runCuaLane(spec, deps) {
1068
1078
  if (deps.hooks.prepareDesktop) {
1069
1079
  await deps.hooks.prepareDesktop(desktop, { laneId: spec.laneId, laneIndex: spec.laneIndex, laneCount: deps.laneCount });
1070
1080
  }
1081
+ // Start the in-sandbox email catch BEFORE the subject serve, so the app's send-API base URL (injected
1082
+ // into its env at create) resolves the moment it boots. A comms-declared lab that can't stand the
1083
+ // catch up is a setup failure (fail closed) rather than silently sending real mail.
1084
+ if (commsEmail && commsPort !== undefined) {
1085
+ const deployedComms = await deployCommsCatch(desktop, { port: commsPort, requestTimeoutMs: deps.requestTimeoutMs });
1086
+ if (!deployedComms.ready) {
1087
+ throw new Error(`comms email catch did not become ready on 127.0.0.1:${commsPort} in the subject sandbox`);
1088
+ }
1089
+ }
1071
1090
  // Per-lane geometry assertion (fail-closed) — the device claim is verified in-sandbox.
1072
1091
  const screenGeometry = await inspectDesktopScreenGeometry({
1073
1092
  desktop,