humanish 0.37.0 → 0.39.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,50 @@
1
+ /** The CLI writer surface this command needs (structurally compatible with program.ts CliIo). */
2
+ interface CatchHostIo {
3
+ writeOut(text: string): void;
4
+ writeErr(text: string): void;
5
+ setExitCode(code: number): void;
6
+ }
7
+ export interface CommsCatchHostOptions {
8
+ port: number;
9
+ dir: string;
10
+ /** Bearer token required on GET /deliveries. Strongly recommended when the host is reachable. */
11
+ token?: string;
12
+ /** SECOND port for the READ-ONLY inbox listener bound to 0.0.0.0, so a persona on another machine
13
+ * (or in a per-lane desktop) can reach the inbox. Omit to stay loopback-only. */
14
+ inboxPort?: number;
15
+ /** Restrict the rendered inbox to these addresses. Omit to render whatever the app actually mailed
16
+ * — a standalone catch has no lab roster to read recipients from, and an operator who forgets to
17
+ * name one should not get a healthy catch that renders an empty inbox forever (#380). */
18
+ recipients?: string[];
19
+ /** Inbox re-render cadence in ms (test seam). */
20
+ renderIntervalMs?: number;
21
+ }
22
+ /**
23
+ * Render the persona-facing inbox surface from the catch's own deliveries file into `surfaceDir`.
24
+ *
25
+ * This is the half that was missing on an adopter-hosted plane (#380). The catch serves /inbox and
26
+ * /api/inbox as STATIC FILES; in-sandbox, humanish-as-host renders those files on a cadence. On a
27
+ * plane humanish does not provision there is no such host, so nothing wrote them and every persona
28
+ * that reached /inbox got `message not found` against a catch whose /health was green — the funnel
29
+ * dead-ended at a technically-healthy service.
30
+ *
31
+ * Rebuilding from scratch each pass is deliberate and matches refreshInboxSurface: a fresh channel per
32
+ * pass means a send is never routed twice, so the persona never sees duplicates, and a failed write
33
+ * simply retries next tick.
34
+ */
35
+ export declare function renderInboxSurfaceLocally(args: {
36
+ deliveriesPath: string;
37
+ surfaceDir: string;
38
+ recipients?: string[] | undefined;
39
+ }): Promise<{
40
+ sends: number;
41
+ messages: number;
42
+ files: number;
43
+ }>;
44
+ /**
45
+ * Write the catch script and run it in the foreground. Resolves when the child exits; the caller's
46
+ * Ctrl-C reaches the child through the shared process group, so the normal way to stop it is the
47
+ * normal way to stop any foreground server.
48
+ */
49
+ export declare function runCommsCatchHost(options: CommsCatchHostOptions, io: CatchHostIo): Promise<void>;
50
+ export {};
@@ -0,0 +1,147 @@
1
+ // `humanish comms catch` (#328): run the email catch on the OPERATOR's own host.
2
+ //
3
+ // Why this exists. The in-sandbox catch only works when humanish provisions the subject itself —
4
+ // it clones the app into a sandbox, injects the email-API base URL at boot, and hosts the catch
5
+ // alongside it. An adopter whose study runs against their OWN deployed environment (an app-url or
6
+ // operator-provisioned plane) hands humanish a URL instead, so humanish never boots the app and
7
+ // has nowhere to put a catch. Before this, a `comms:` block on that route was warned inert and the
8
+ // personas simply stalled at the verification screen.
9
+ //
10
+ // The fix is to let the adopter host the same catch and declare it (`comms.email.external`).
11
+ // Shipping it as a COMMAND rather than a spec matters: the adopter runs the identical
12
+ // implementation humanish deploys in-sandbox, so the capture shape, the inbox surface, and the
13
+ // drain contract cannot drift between the two planes. Point the app's email-API base URL at this
14
+ // server and point the lab's `catchBaseUrl` at it too.
15
+ //
16
+ // Runtime: the catch is a python3 stdlib server with no dependencies (that was the 0.29.0 lesson —
17
+ // a co-located catcher must use a runtime the environment guarantees). This command writes the
18
+ // same script and runs it in the foreground until interrupted.
19
+ import { spawn } from "node:child_process";
20
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
21
+ import path from "node:path";
22
+ import { SANDBOX_CATCH_SCRIPT, capturedRecipientAddresses, inboxMessagesFrom, parseDeliveriesNdjson } from "./comms-sandbox-catch.js";
23
+ import { buildInboxSurface } from "./comms-inbox.js";
24
+ /** How often the host re-renders the inbox surface from the deliveries file. */
25
+ const DEFAULT_RENDER_INTERVAL_MS = 3_000;
26
+ /**
27
+ * Render the persona-facing inbox surface from the catch's own deliveries file into `surfaceDir`.
28
+ *
29
+ * This is the half that was missing on an adopter-hosted plane (#380). The catch serves /inbox and
30
+ * /api/inbox as STATIC FILES; in-sandbox, humanish-as-host renders those files on a cadence. On a
31
+ * plane humanish does not provision there is no such host, so nothing wrote them and every persona
32
+ * that reached /inbox got `message not found` against a catch whose /health was green — the funnel
33
+ * dead-ended at a technically-healthy service.
34
+ *
35
+ * Rebuilding from scratch each pass is deliberate and matches refreshInboxSurface: a fresh channel per
36
+ * pass means a send is never routed twice, so the persona never sees duplicates, and a failed write
37
+ * simply retries next tick.
38
+ */
39
+ export async function renderInboxSurfaceLocally(args) {
40
+ let text = "";
41
+ try {
42
+ text = await readFile(args.deliveriesPath, "utf8");
43
+ }
44
+ catch {
45
+ text = ""; // no mail captured yet — still render, so /inbox answers "No messages yet."
46
+ }
47
+ const sends = parseDeliveriesNdjson(text);
48
+ const addresses = args.recipients && args.recipients.length > 0 ? args.recipients : capturedRecipientAddresses(sends);
49
+ const recipients = addresses.map((address, index) => ({
50
+ lane: `catch-${String(index + 1).padStart(2, "0")}`,
51
+ address
52
+ }));
53
+ const messages = await inboxMessagesFrom(sends, recipients);
54
+ const files = buildInboxSurface(messages);
55
+ const dirs = new Set([args.surfaceDir]);
56
+ for (const file of files) {
57
+ const slash = file.path.lastIndexOf("/");
58
+ if (slash > 0)
59
+ dirs.add(path.join(args.surfaceDir, file.path.slice(0, slash)));
60
+ }
61
+ for (const dir of dirs)
62
+ await mkdir(dir, { recursive: true });
63
+ for (const file of files)
64
+ await writeFile(path.join(args.surfaceDir, file.path), file.body, "utf8");
65
+ return { sends: sends.length, messages: messages.length, files: files.length };
66
+ }
67
+ /**
68
+ * Write the catch script and run it in the foreground. Resolves when the child exits; the caller's
69
+ * Ctrl-C reaches the child through the shared process group, so the normal way to stop it is the
70
+ * normal way to stop any foreground server.
71
+ */
72
+ export async function runCommsCatchHost(options, io) {
73
+ const dir = path.resolve(options.dir);
74
+ const scriptPath = path.join(dir, "catch.py");
75
+ const deliveriesPath = path.join(dir, "deliveries.ndjson");
76
+ const surfaceDir = path.join(dir, "surface");
77
+ await mkdir(surfaceDir, { recursive: true });
78
+ await writeFile(scriptPath, SANDBOX_CATCH_SCRIPT, "utf8");
79
+ // Render the EMPTY inbox before the server is announced, so /inbox resolves to the "No messages
80
+ // yet." page from the first request instead of a bare `message not found` (#380) — a persona reads
81
+ // that 404 as a broken product and reports a blocker that is really just an empty mailbox.
82
+ await renderInboxSurfaceLocally({
83
+ deliveriesPath,
84
+ surfaceDir,
85
+ ...(options.recipients === undefined ? {} : { recipients: options.recipients })
86
+ });
87
+ const inboxPort = options.inboxPort;
88
+ const args = [
89
+ scriptPath,
90
+ String(options.port),
91
+ deliveriesPath,
92
+ surfaceDir,
93
+ String(inboxPort ?? 0),
94
+ options.token ?? ""
95
+ ];
96
+ io.writeOut([
97
+ `humanish comms catch listening on http://127.0.0.1:${options.port}`,
98
+ ...(inboxPort === undefined
99
+ ? []
100
+ : [` read-only inbox listener on http://0.0.0.0:${inboxPort} (GET only; expose THIS to personas)`]),
101
+ ` POST /emails <- point your app's email-API base URL here`,
102
+ ` GET /inbox <- the persona opens this${inboxPort === undefined ? " (loopback only without --inbox-port)" : ""}`,
103
+ ` GET /deliveries <- humanish drains this${options.token ? " (bearer token required)" : ""}`,
104
+ ` GET /health <- readiness marker humanish probes before a run`,
105
+ ``,
106
+ `Declare it in the lab:`,
107
+ ` comms:`,
108
+ ` email:`,
109
+ ` external:`,
110
+ ` catchBaseUrl: http://<this-host>:${options.port}`,
111
+ ...(inboxPort === undefined ? [] : [` inboxBaseUrl: http://<this-host>:${inboxPort}`]),
112
+ ...(options.token ? [` authTokenEnv: HUMANISH_COMMS_TOKEN # value read at runtime, never persisted`] : []),
113
+ ``,
114
+ `Captured mail is written to ${deliveriesPath}. Raw bodies stay on THIS host: the run bundle`,
115
+ `only ever receives digests (from/to/subject/link) and an OTP count.`,
116
+ ``
117
+ ].join("\n") + "\n");
118
+ await new Promise((resolve) => {
119
+ const child = spawn("python3", args, { stdio: ["ignore", "inherit", "inherit"] });
120
+ // Keep the inbox current while the catch runs. Failures are swallowed on purpose: a transient
121
+ // render error must never take down a server that is still capturing mail correctly, and the
122
+ // next tick rebuilds from scratch anyway.
123
+ const renderTimer = setInterval(() => {
124
+ void renderInboxSurfaceLocally({
125
+ deliveriesPath,
126
+ surfaceDir,
127
+ ...(options.recipients === undefined ? {} : { recipients: options.recipients })
128
+ }).catch(() => { });
129
+ }, options.renderIntervalMs ?? DEFAULT_RENDER_INTERVAL_MS);
130
+ renderTimer.unref?.();
131
+ const stopRendering = () => clearInterval(renderTimer);
132
+ child.on("error", (error) => {
133
+ stopRendering();
134
+ io.writeErr(`comms catch failed to start (python3 is required): ${error.message}\n`);
135
+ io.setExitCode(2);
136
+ resolve();
137
+ });
138
+ child.on("exit", (code) => {
139
+ stopRendering();
140
+ if (code !== 0 && code !== null) {
141
+ io.setExitCode(2);
142
+ }
143
+ resolve();
144
+ });
145
+ });
146
+ }
147
+ //# sourceMappingURL=comms-catch-host.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"comms-catch-host.js","sourceRoot":"","sources":["../src/comms-catch-host.ts"],"names":[],"mappings":"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"}
@@ -23,7 +23,7 @@ export declare const DEFAULT_SANDBOX_CATCH_PORT = 8025;
23
23
  * listener on 0.0.0.0:<inboxPort> so getHost can proxy the persona's inbox reads to it; that listener
24
24
  * serves GET only (POST → 405). The CUA same-sandbox route omits it and stays loopback-only.
25
25
  */
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";
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\n# Optional shared token guarding GET /deliveries (the drain read). Empty = unguarded, which is the\n# in-sandbox default: the capture listener binds loopback there, so nothing external can reach it.\n# An ADOPTER-HOSTED catch is reachable over the network, so it should pass one.\nDELIVERIES_TOKEN = sys.argv[5] if len(sys.argv) > 5 else \"\"\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 == \"/health\":\n self._json(200, {\"ok\": True, \"service\": \"humanish-comms-catch\"})\n return\n if path == \"/\":\n # A persona that trims the /inbox path lands here. It used to get the health JSON and read\n # it as \"wrong place / broken\", so send it where it meant to go. /health keeps the machine\n # marker: both readiness probes assert on /health specifically, never on /.\n self.send_response(200)\n self.send_header(\"content-type\", \"text/html; charset=utf-8\")\n self.send_header(\"content-security-policy\", CSP)\n self.end_headers()\n self.wfile.write(b\"<!doctype html><title>Mailbox</title><p><a href='/inbox'>Open the inbox</a></p>\")\n return\n if path == \"/deliveries\":\n # The drain read. In-sandbox humanish reads the NDJSON file directly; an adopter-hosted\n # catch is on another machine, so the same bytes are served over HTTP. Capture bodies\n # can contain a verification link, so this is the one route worth guarding.\n if DELIVERIES_TOKEN:\n supplied = self.headers.get(\"authorization\", \"\")\n if supplied != (\"Bearer \" + DELIVERIES_TOKEN):\n self._json(401, {\"error\": \"unauthorized\"})\n return\n try:\n with open(OUT_FILE, \"rb\") as handle:\n body = handle.read()\n except Exception:\n body = b\"\"\n self.send_response(200)\n self.send_header(\"content-type\", \"application/x-ndjson; charset=utf-8\")\n self.send_header(\"cache-control\", \"no-store\")\n self.end_headers()\n self.wfile.write(body)\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 # A JSON route answers in JSON; only the HTML route answers in HTML.\n if rel.startswith(\"/api/\"):\n self._json(404, {\"error\": \"message not found\"})\n return\n self.send_response(404)\n self.send_header(\"content-type\", \"text/html; charset=utf-8\")\n self.send_header(\"content-security-policy\", CSP)\n self.end_headers()\n self.wfile.write(b\"<!doctype html><title>Mailbox</title><p>message not found</p><p><a href='/inbox'>Back to the inbox</a></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";
27
27
  export interface DeployCommsCatchOptions {
28
28
  /** Fixed loopback port the catch listens on (default 8025). Must be free inside the sandbox. */
29
29
  port?: number;
@@ -75,6 +75,30 @@ export declare function drainCommsCatch(desktop: E2BDesktopSandbox, deployed: Pi
75
75
  sends: RawCapturedSend[];
76
76
  cursor: number;
77
77
  }>;
78
+ /**
79
+ * Parse an append-only deliveries NDJSON blob into raw sends. Split out of drainCommsCatch (#380) so
80
+ * the SAME parsing serves a sandbox we own (read over the E2B command channel) and a catch running on
81
+ * a plane we do not own (read from the local filesystem by `humanish comms catch`).
82
+ *
83
+ * A file that does not end in a newline may have a PARTIAL last line — a reader racing an append of a
84
+ * large body. Dropping it is never lossy: the script only ever emits valid JSON lines, so an incomplete
85
+ * line re-reads complete on the next pass.
86
+ */
87
+ export declare function parseDeliveriesNdjson(text: string): RawCapturedSend[];
88
+ /**
89
+ * The distinct `to` addresses the captured mail was actually sent to, parsed with the SAME profiles
90
+ * that route it. A lab run knows its recipients from the declared roster; a standalone catch does not,
91
+ * so it discovers them from the mail itself — otherwise an operator who forgot to name an address gets
92
+ * a technically-healthy catch rendering an empty inbox forever, which is the false-green class #380 is
93
+ * about.
94
+ */
95
+ export declare function capturedRecipientAddresses(sends: readonly RawCapturedSend[], profiles?: EmailSendProfile[]): string[];
96
+ /**
97
+ * Route raw sends into a FRESH FakeInbox and return the deduped, delivery-ordered messages. Split out
98
+ * of refreshInboxSurface (#380) so the rendering pipeline is shared by every transport; the freshness
99
+ * is what makes a full rebuild idempotent (a send is never routed twice, so no duplicate emails).
100
+ */
101
+ export declare function inboxMessagesFrom(sends: readonly RawCapturedSend[], recipients: readonly InboxSurfaceRecipient[]): Promise<CommsMessage[]>;
78
102
  /**
79
103
  * Parse drained raw sends with the host profiles and route them into the CommsChannel (the host-side
80
104
  * FakeInbox). Returns the number of inbox deliveries made. Same profiles as the host catch, so the
@@ -111,6 +135,52 @@ export declare function collectCommsThread(args: {
111
135
  profiles?: EmailSendProfile[];
112
136
  requestTimeoutMs?: number;
113
137
  }): Promise<CommsThreadCollection>;
138
+ /** An adopter-hosted catch: humanish never provisioned it, so it is addressed over HTTP (#328). */
139
+ export interface ExternalCommsCatch {
140
+ /** Base URL of the catch the ADOPTER runs (its POST capture endpoint and GET /deliveries). */
141
+ catchBaseUrl: string;
142
+ /** Base URL the persona opens to read mail. Defaults to catchBaseUrl (same server serves /inbox). */
143
+ inboxBaseUrl?: string;
144
+ /** Bearer token for the drain read, when the adopter guarded it. Value is used, never persisted. */
145
+ authToken?: string;
146
+ }
147
+ /** The URL a persona is told to open to read its mail on an adopter-hosted plane. */
148
+ export declare function externalInboxUrl(external: ExternalCommsCatch): string;
149
+ /**
150
+ * Probe an adopter-hosted catch the way the in-sandbox one is probed: assert OUR service marker in
151
+ * /health, not merely any 2xx — an adopter's reverse proxy or a captive portal will happily return
152
+ * 200 for anything, and a comms lab whose catch is not actually there collects nothing while
153
+ * looking fine. Fail-closed callers treat `false` as a hard stop before spending on a run.
154
+ */
155
+ export declare function externalCatchHealthy(external: ExternalCommsCatch, options?: {
156
+ timeoutMs?: number;
157
+ fetchFn?: typeof fetch;
158
+ }): Promise<boolean>;
159
+ /**
160
+ * Drain an adopter-hosted catch over HTTP. Same NDJSON contract and same partial-line discipline as
161
+ * the in-sandbox `cat` drain: a body that does not end in a newline may have a torn final append, so
162
+ * that line is dropped rather than parsed into a half-message.
163
+ */
164
+ export declare function drainExternalCommsCatch(external: ExternalCommsCatch, cursor?: number, options?: {
165
+ timeoutMs?: number;
166
+ fetchFn?: typeof fetch;
167
+ }): Promise<{
168
+ sends: RawCapturedSend[];
169
+ cursor: number;
170
+ }>;
171
+ /**
172
+ * The adopter-hosted analogue of collectCommsThread: drain over HTTP, route into the host inbox bus,
173
+ * and build the SAME digest-only humanish.comms-thread.v1 artifact. Evidence shape does not depend
174
+ * on who hosted the catch — only the transport does.
175
+ */
176
+ export declare function collectExternalCommsThread(args: {
177
+ external: ExternalCommsCatch;
178
+ channel: CommsChannel;
179
+ inboxes: CommsAddress[];
180
+ profiles?: EmailSendProfile[];
181
+ timeoutMs?: number;
182
+ fetchFn?: typeof fetch;
183
+ }): Promise<CommsThreadCollection>;
114
184
  /**
115
185
  * Render the persona-facing inbox surface (host-side, typed — see comms-inbox.ts) and write the files
116
186
  * into the sandbox's served dir, so the catch serves a LIVE inbox the persona opens and clicks. Creates
@@ -48,6 +48,10 @@ PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8025
48
48
  OUT_FILE = sys.argv[2] if len(sys.argv) > 2 else "/tmp/humanish-comms/deliveries.ndjson"
49
49
  SERVED_DIR = sys.argv[3] if len(sys.argv) > 3 else (os.path.dirname(OUT_FILE) + "/surface")
50
50
  INBOX_PORT = int(sys.argv[4]) if len(sys.argv) > 4 else 0
51
+ # Optional shared token guarding GET /deliveries (the drain read). Empty = unguarded, which is the
52
+ # in-sandbox default: the capture listener binds loopback there, so nothing external can reach it.
53
+ # An ADOPTER-HOSTED catch is reachable over the network, so it should pass one.
54
+ DELIVERIES_TOKEN = sys.argv[5] if len(sys.argv) > 5 else ""
51
55
  try:
52
56
  os.makedirs(os.path.dirname(OUT_FILE), exist_ok=True)
53
57
  except Exception:
@@ -75,9 +79,39 @@ class BaseHandler(BaseHTTPRequestHandler):
75
79
 
76
80
  def do_GET(self):
77
81
  path = self.path.split("?")[0]
78
- if path == "/" or path == "/health":
82
+ if path == "/health":
79
83
  self._json(200, {"ok": True, "service": "humanish-comms-catch"})
80
84
  return
85
+ if path == "/":
86
+ # A persona that trims the /inbox path lands here. It used to get the health JSON and read
87
+ # it as "wrong place / broken", so send it where it meant to go. /health keeps the machine
88
+ # marker: both readiness probes assert on /health specifically, never on /.
89
+ self.send_response(200)
90
+ self.send_header("content-type", "text/html; charset=utf-8")
91
+ self.send_header("content-security-policy", CSP)
92
+ self.end_headers()
93
+ self.wfile.write(b"<!doctype html><title>Mailbox</title><p><a href='/inbox'>Open the inbox</a></p>")
94
+ return
95
+ if path == "/deliveries":
96
+ # The drain read. In-sandbox humanish reads the NDJSON file directly; an adopter-hosted
97
+ # catch is on another machine, so the same bytes are served over HTTP. Capture bodies
98
+ # can contain a verification link, so this is the one route worth guarding.
99
+ if DELIVERIES_TOKEN:
100
+ supplied = self.headers.get("authorization", "")
101
+ if supplied != ("Bearer " + DELIVERIES_TOKEN):
102
+ self._json(401, {"error": "unauthorized"})
103
+ return
104
+ try:
105
+ with open(OUT_FILE, "rb") as handle:
106
+ body = handle.read()
107
+ except Exception:
108
+ body = b""
109
+ self.send_response(200)
110
+ self.send_header("content-type", "application/x-ndjson; charset=utf-8")
111
+ self.send_header("cache-control", "no-store")
112
+ self.end_headers()
113
+ self.wfile.write(body)
114
+ return
81
115
  if path == "/inbox" or path.startswith("/inbox/") or path == "/api/inbox" or path.startswith("/api/inbox/"):
82
116
  rel = unquote(path)
83
117
  if ".." in rel or chr(0) in rel:
@@ -93,10 +127,15 @@ class BaseHandler(BaseHTTPRequestHandler):
93
127
  except Exception:
94
128
  data = None
95
129
  if data is None:
130
+ # A JSON route answers in JSON; only the HTML route answers in HTML.
131
+ if rel.startswith("/api/"):
132
+ self._json(404, {"error": "message not found"})
133
+ return
96
134
  self.send_response(404)
97
135
  self.send_header("content-type", "text/html; charset=utf-8")
136
+ self.send_header("content-security-policy", CSP)
98
137
  self.end_headers()
99
- self.wfile.write(b"<p>message not found</p>")
138
+ self.wfile.write(b"<!doctype html><title>Mailbox</title><p>message not found</p><p><a href='/inbox'>Back to the inbox</a></p>")
100
139
  return
101
140
  is_api = rel.startswith("/api/")
102
141
  self.send_response(200)
@@ -239,6 +278,86 @@ export async function drainCommsCatch(desktop, deployed, cursor = 0, requestTime
239
278
  }
240
279
  return { sends, cursor: lines.length };
241
280
  }
281
+ /**
282
+ * Parse an append-only deliveries NDJSON blob into raw sends. Split out of drainCommsCatch (#380) so
283
+ * the SAME parsing serves a sandbox we own (read over the E2B command channel) and a catch running on
284
+ * a plane we do not own (read from the local filesystem by `humanish comms catch`).
285
+ *
286
+ * A file that does not end in a newline may have a PARTIAL last line — a reader racing an append of a
287
+ * large body. Dropping it is never lossy: the script only ever emits valid JSON lines, so an incomplete
288
+ * line re-reads complete on the next pass.
289
+ */
290
+ export function parseDeliveriesNdjson(text) {
291
+ let lines = text.split("\n").filter((line) => line.trim().length > 0);
292
+ if (!text.endsWith("\n") && lines.length > 0)
293
+ lines = lines.slice(0, -1);
294
+ const sends = [];
295
+ for (const line of lines) {
296
+ try {
297
+ const parsed = JSON.parse(line);
298
+ if (typeof parsed.path === "string" && typeof parsed.body === "string") {
299
+ sends.push({ path: parsed.path, body: parsed.body, t: typeof parsed.t === "number" ? parsed.t : 0 });
300
+ }
301
+ }
302
+ catch {
303
+ // skip a malformed line
304
+ }
305
+ }
306
+ return sends;
307
+ }
308
+ /**
309
+ * The distinct `to` addresses the captured mail was actually sent to, parsed with the SAME profiles
310
+ * that route it. A lab run knows its recipients from the declared roster; a standalone catch does not,
311
+ * so it discovers them from the mail itself — otherwise an operator who forgot to name an address gets
312
+ * a technically-healthy catch rendering an empty inbox forever, which is the false-green class #380 is
313
+ * about.
314
+ */
315
+ export function capturedRecipientAddresses(sends, profiles = DEFAULT_EMAIL_PROFILES) {
316
+ const addresses = new Set();
317
+ for (const send of sends) {
318
+ let parsed;
319
+ try {
320
+ parsed = JSON.parse(send.body.length > 0 ? send.body : "{}");
321
+ }
322
+ catch {
323
+ continue;
324
+ }
325
+ const profile = profiles.find((candidate) => candidate.sendPaths.includes(send.path)) ?? profiles[0];
326
+ if (profile === undefined)
327
+ continue;
328
+ for (const normalized of profile.parse(send.path, parsed)) {
329
+ for (const address of normalized.to) {
330
+ if (address.trim().length > 0)
331
+ addresses.add(address);
332
+ }
333
+ }
334
+ }
335
+ return [...addresses];
336
+ }
337
+ /**
338
+ * Route raw sends into a FRESH FakeInbox and return the deduped, delivery-ordered messages. Split out
339
+ * of refreshInboxSurface (#380) so the rendering pipeline is shared by every transport; the freshness
340
+ * is what makes a full rebuild idempotent (a send is never routed twice, so no duplicate emails).
341
+ */
342
+ export async function inboxMessagesFrom(sends, recipients) {
343
+ const channel = new FakeInbox();
344
+ const inboxes = [];
345
+ for (const recipient of recipients)
346
+ inboxes.push(await channel.provisionAddress(recipient.lane, recipient.address));
347
+ await routeCapturedSends([...sends], channel);
348
+ const seen = new Set();
349
+ const messages = [];
350
+ for (const inbox of inboxes) {
351
+ for (const message of await channel.poll(inbox, 0)) {
352
+ if (seen.has(message.id))
353
+ continue;
354
+ seen.add(message.id);
355
+ messages.push(message);
356
+ }
357
+ }
358
+ messages.sort((a, b) => a.deliveredAt - b.deliveredAt || a.id.localeCompare(b.id));
359
+ return messages;
360
+ }
242
361
  /**
243
362
  * Parse drained raw sends with the host profiles and route them into the CommsChannel (the host-side
244
363
  * FakeInbox). Returns the number of inbox deliveries made. Same profiles as the host catch, so the
@@ -300,6 +419,93 @@ export async function collectCommsThread(args) {
300
419
  messages.sort((a, b) => a.deliveredAt - b.deliveredAt || a.id.localeCompare(b.id));
301
420
  return { artifact: buildCommsThreadArtifact(messages), captured: sends.length, matched: messages.length };
302
421
  }
422
+ /** Trim one trailing slash so `${base}/deliveries` never becomes a double slash. */
423
+ function baseOf(url) {
424
+ return url.replace(/\/+$/, "");
425
+ }
426
+ /** The URL a persona is told to open to read its mail on an adopter-hosted plane. */
427
+ export function externalInboxUrl(external) {
428
+ return `${baseOf(external.inboxBaseUrl ?? external.catchBaseUrl)}/inbox`;
429
+ }
430
+ /**
431
+ * Probe an adopter-hosted catch the way the in-sandbox one is probed: assert OUR service marker in
432
+ * /health, not merely any 2xx — an adopter's reverse proxy or a captive portal will happily return
433
+ * 200 for anything, and a comms lab whose catch is not actually there collects nothing while
434
+ * looking fine. Fail-closed callers treat `false` as a hard stop before spending on a run.
435
+ */
436
+ export async function externalCatchHealthy(external, options = {}) {
437
+ const fetchFn = options.fetchFn ?? fetch;
438
+ try {
439
+ const response = await fetchFn(`${baseOf(external.catchBaseUrl)}/health`, {
440
+ signal: AbortSignal.timeout(options.timeoutMs ?? 15_000)
441
+ });
442
+ if (!response.ok)
443
+ return false;
444
+ const body = await response.text();
445
+ return body.includes("humanish-comms-catch");
446
+ }
447
+ catch {
448
+ return false;
449
+ }
450
+ }
451
+ /**
452
+ * Drain an adopter-hosted catch over HTTP. Same NDJSON contract and same partial-line discipline as
453
+ * the in-sandbox `cat` drain: a body that does not end in a newline may have a torn final append, so
454
+ * that line is dropped rather than parsed into a half-message.
455
+ */
456
+ export async function drainExternalCommsCatch(external, cursor = 0, options = {}) {
457
+ const fetchFn = options.fetchFn ?? fetch;
458
+ const response = await fetchFn(`${baseOf(external.catchBaseUrl)}/deliveries`, {
459
+ signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
460
+ ...(external.authToken ? { headers: { authorization: `Bearer ${external.authToken}` } } : {})
461
+ });
462
+ if (!response.ok) {
463
+ throw new Error(`comms catch GET /deliveries returned ${response.status}`);
464
+ }
465
+ const body = await response.text();
466
+ let lines = body.split("\n").filter((line) => line.trim().length > 0);
467
+ if (!body.endsWith("\n") && lines.length > 0)
468
+ lines = lines.slice(0, -1);
469
+ const sends = [];
470
+ for (const line of lines.slice(cursor)) {
471
+ try {
472
+ const parsed = JSON.parse(line);
473
+ if (typeof parsed.path === "string" && typeof parsed.body === "string") {
474
+ sends.push({ path: parsed.path, body: parsed.body, t: typeof parsed.t === "number" ? parsed.t : 0 });
475
+ }
476
+ }
477
+ catch {
478
+ // skip a malformed line
479
+ }
480
+ }
481
+ return { sends, cursor: lines.length };
482
+ }
483
+ /**
484
+ * The adopter-hosted analogue of collectCommsThread: drain over HTTP, route into the host inbox bus,
485
+ * and build the SAME digest-only humanish.comms-thread.v1 artifact. Evidence shape does not depend
486
+ * on who hosted the catch — only the transport does.
487
+ */
488
+ export async function collectExternalCommsThread(args) {
489
+ const drainOptions = { ...(args.timeoutMs === undefined ? {} : { timeoutMs: args.timeoutMs }), ...(args.fetchFn ? { fetchFn: args.fetchFn } : {}) };
490
+ const { sends } = await drainExternalCommsCatch(args.external, 0, drainOptions);
491
+ if (sends.length === 0)
492
+ return { captured: 0, matched: 0 };
493
+ await routeCapturedSends(sends, args.channel, args.profiles);
494
+ const seen = new Set();
495
+ const messages = [];
496
+ for (const inbox of args.inboxes) {
497
+ for (const message of await args.channel.poll(inbox, 0)) {
498
+ if (seen.has(message.id))
499
+ continue;
500
+ seen.add(message.id);
501
+ messages.push(message);
502
+ }
503
+ }
504
+ if (messages.length === 0)
505
+ return { captured: sends.length, matched: 0 };
506
+ messages.sort((a, b) => a.deliveredAt - b.deliveredAt || a.id.localeCompare(b.id));
507
+ return { artifact: buildCommsThreadArtifact(messages), captured: sends.length, matched: messages.length };
508
+ }
303
509
  /**
304
510
  * Render the persona-facing inbox surface (host-side, typed — see comms-inbox.ts) and write the files
305
511
  * into the sandbox's served dir, so the catch serves a LIVE inbox the persona opens and clicks. Creates
@@ -341,24 +547,9 @@ export async function refreshInboxSurface(args) {
341
547
  return { count: 0, rendered: false };
342
548
  if (args.sinceCount !== undefined && sends.length <= args.sinceCount)
343
549
  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);
349
- const seen = new Set();
350
- const messages = [];
351
- for (const inbox of inboxes) {
352
- for (const message of await channel.poll(inbox, 0)) {
353
- if (seen.has(message.id))
354
- continue;
355
- seen.add(message.id);
356
- messages.push(message);
357
- }
358
- }
550
+ const messages = await inboxMessagesFrom(sends, args.recipients);
359
551
  if (messages.length === 0)
360
552
  return { count: sends.length, rendered: false };
361
- messages.sort((a, b) => a.deliveredAt - b.deliveredAt || a.id.localeCompare(b.id));
362
553
  await writeInboxSurface(args.desktop, args.deployed.surfaceDir, messages, {
363
554
  ...(args.originMap === undefined ? {} : { originMap: args.originMap }),
364
555
  ...(args.requestTimeoutMs === undefined ? {} : { requestTimeoutMs: args.requestTimeoutMs })