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.
- package/dist/comms-catch-host.d.ts +50 -0
- package/dist/comms-catch-host.js +147 -0
- package/dist/comms-catch-host.js.map +1 -0
- package/dist/comms-sandbox-catch.d.ts +71 -1
- package/dist/comms-sandbox-catch.js +209 -18
- package/dist/comms-sandbox-catch.js.map +1 -1
- package/dist/concurrent-shared-world-lab.d.ts +4 -1
- package/dist/concurrent-shared-world-lab.js +65 -8
- package/dist/concurrent-shared-world-lab.js.map +1 -1
- package/dist/cua-actor-lab.d.ts +8 -0
- package/dist/cua-actor-lab.js +26 -5
- package/dist/cua-actor-lab.js.map +1 -1
- package/dist/lab-config.d.ts +21 -1
- package/dist/lab-config.js +55 -6
- package/dist/lab-config.js.map +1 -1
- package/dist/persona-resolve.d.ts +44 -0
- package/dist/persona-resolve.js +109 -0
- package/dist/persona-resolve.js.map +1 -0
- package/dist/program.js +47 -0
- package/dist/program.js.map +1 -1
- package/dist/shared-world-lab.js +7 -2
- package/dist/shared-world-lab.js.map +1 -1
- package/docs/contracts/schemas.md +19 -3
- package/docs/goals/current.md +1 -1
- package/docs/ramp/README.md +1 -1
- package/package.json +1 -1
|
@@ -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 == \"
|
|
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 == "/
|
|
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"
|
|
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
|
|
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 })
|