humanish 0.28.0 → 0.30.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-sandbox-catch.d.ts +39 -19
- package/dist/comms-sandbox-catch.js +173 -70
- package/dist/comms-sandbox-catch.js.map +1 -1
- package/dist/concurrent-shared-world-lab.js +80 -5
- package/dist/concurrent-shared-world-lab.js.map +1 -1
- package/dist/cua-actor-lab.js +11 -17
- package/dist/cua-actor-lab.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/lab-config.js +3 -0
- package/dist/lab-config.js.map +1 -1
- package/docs/contracts/schemas.md +1 -1
- package/docs/goals/current.md +1 -1
- package/docs/ramp/README.md +1 -1
- package/package.json +1 -1
|
@@ -9,16 +9,28 @@ import type { E2BDesktopSandbox } from "./e2b-desktop-launch.js";
|
|
|
9
9
|
* unlikely to collide with a subject app; override via config if it does. */
|
|
10
10
|
export declare const DEFAULT_SANDBOX_CATCH_PORT = 8025;
|
|
11
11
|
/**
|
|
12
|
-
* The self-contained in-sandbox capture server
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* The self-contained in-sandbox capture server — a plain **python3** script (stdlib only), because the
|
|
13
|
+
* stock E2B desktop template ships python3 but NOT node, and the co-located catcher must run in a
|
|
14
|
+
* runtime the sandbox guarantees (the precedented choice: LocalStack is a python catcher the app points
|
|
15
|
+
* at; you pick the runtime the environment has). It runs on the sandbox's own python3, imports nothing
|
|
16
|
+
* from humanish. DELIBERATELY dumb: it records each POST verbatim as an NDJSON line `{t, path, body}`
|
|
17
|
+
* and returns a plausible provider success — all normalization/profile parsing happens host-side on the
|
|
18
|
+
* drained lines, so the typed, tested profiles stay in one place. It also serves the host-rendered inbox
|
|
19
|
+
* surface statically at /inbox + /api/inbox (with a script-forbidding CSP). argv: <port> <deliveriesFile>
|
|
20
|
+
* <servedDir> [inboxPort]. The capture listener binds 127.0.0.1 (loopback) — the app under test reaches
|
|
21
|
+
* it in-sandbox, nothing on the internet can inject a fake send. When an [inboxPort] is given (the
|
|
22
|
+
* shared-world route, where the persona lives in a DIFFERENT sandbox), it ALSO starts a READ-ONLY inbox
|
|
23
|
+
* listener on 0.0.0.0:<inboxPort> so getHost can proxy the persona's inbox reads to it; that listener
|
|
24
|
+
* serves GET only (POST → 405). The CUA same-sandbox route omits it and stays loopback-only.
|
|
17
25
|
*/
|
|
18
|
-
export declare const SANDBOX_CATCH_SCRIPT:
|
|
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";
|
|
19
27
|
export interface DeployCommsCatchOptions {
|
|
20
28
|
/** Fixed loopback port the catch listens on (default 8025). Must be free inside the sandbox. */
|
|
21
29
|
port?: number;
|
|
30
|
+
/** Optional SECOND fixed port for a READ-ONLY inbox listener bound to 0.0.0.0, so a persona in a
|
|
31
|
+
* DIFFERENT sandbox can reach the inbox surface via getHost (the shared-world route). Omit on the
|
|
32
|
+
* CUA same-sandbox route (loopback is enough). Must differ from `port` and be free in the sandbox. */
|
|
33
|
+
inboxPort?: number;
|
|
22
34
|
/** In-sandbox working dir for the script + NDJSON (default /tmp/humanish-comms). */
|
|
23
35
|
dir?: string;
|
|
24
36
|
/** Detached-process name ([a-z0-9-]); default "comms-catch". */
|
|
@@ -36,6 +48,9 @@ export interface DeployedCommsCatch {
|
|
|
36
48
|
/** In-sandbox dir the HOST renders the persona-facing inbox-surface files into (via writeInboxSurface);
|
|
37
49
|
* the catch serves them at /inbox and /api/inbox. */
|
|
38
50
|
surfaceDir: string;
|
|
51
|
+
/** The 0.0.0.0 read-only inbox port, when one was requested — getHost-expose THIS to give a
|
|
52
|
+
* different-sandbox persona a reachable inbox URL. Absent on the loopback-only (CUA) route. */
|
|
53
|
+
inboxPort?: number;
|
|
39
54
|
/** Whether the catch's /health returned OUR service marker within the readiness budget. Callers MUST
|
|
40
55
|
* treat `ready === false` as fatal (do not inject baseUrl into a dead catch — the app's sends would
|
|
41
56
|
* silently fail with nothing captured). */
|
|
@@ -106,27 +121,32 @@ export declare function collectCommsThread(args: {
|
|
|
106
121
|
export declare function writeInboxSurface(desktop: E2BDesktopSandbox, surfaceDir: string, messages: CommsMessage[], options?: InboxRenderOptions & {
|
|
107
122
|
requestTimeoutMs?: number;
|
|
108
123
|
}): Promise<number>;
|
|
124
|
+
/** A declared inbox recipient the surface renders for (lane + the literal address the app sends to). */
|
|
125
|
+
export interface InboxSurfaceRecipient {
|
|
126
|
+
lane: string;
|
|
127
|
+
address: string;
|
|
128
|
+
}
|
|
109
129
|
/**
|
|
110
|
-
* One mid-run inbox-surface refresh cycle:
|
|
111
|
-
*
|
|
112
|
-
* persona sees new mail while the session is live. Returns the
|
|
130
|
+
* One mid-run inbox-surface refresh cycle: FULL rebuild from the append-only NDJSON (drain from cursor 0)
|
|
131
|
+
* into a FRESH FakeInbox each call, provisioning the declared `recipients`, then (re)render the surface
|
|
132
|
+
* so the persona sees new mail while the session is live. Returns the total captured-send `count` + whether
|
|
133
|
+
* it rendered.
|
|
113
134
|
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
135
|
+
* The full rebuild is deliberate — it is IDEMPOTENT and RETRY-SAFE: a transient writeInboxSurface failure
|
|
136
|
+
* PROPAGATES (the caller retries next tick without advancing its `sinceCount`), and because each rebuild
|
|
137
|
+
* starts from a clean channel, a send is never routed twice, so the persona never sees duplicate emails.
|
|
138
|
+
* Pass `sinceCount` (the last SUCCESSFULLY-rendered send count) to skip the (N-file) render when nothing
|
|
139
|
+
* new has arrived. This is independent of the teardown collectCommsThread drain (its own fresh channel,
|
|
140
|
+
* also cursor 0) — no evidence is lost or altered. The NDJSON is small for a run, so re-reading it is cheap.
|
|
120
141
|
*/
|
|
121
142
|
export declare function refreshInboxSurface(args: {
|
|
122
143
|
desktop: E2BDesktopSandbox;
|
|
123
144
|
deployed: Pick<DeployedCommsCatch, "deliveriesPath" | "surfaceDir">;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
cursor: number;
|
|
145
|
+
recipients: InboxSurfaceRecipient[];
|
|
146
|
+
sinceCount?: number;
|
|
127
147
|
originMap?: InboxRenderOptions["originMap"];
|
|
128
148
|
requestTimeoutMs?: number;
|
|
129
149
|
}): Promise<{
|
|
130
|
-
|
|
150
|
+
count: number;
|
|
131
151
|
rendered: boolean;
|
|
132
152
|
}>;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// each poll `cat` its append-only NDJSON of captured sends back to the host, where the real profiles
|
|
7
7
|
// parse them and route into the CommsChannel. A FIXED loopback port is chosen up front so the app's
|
|
8
8
|
// injected base-URL env (`http://127.0.0.1:<port>`) is known before the sandbox is created.
|
|
9
|
+
import { FakeInbox } from "./comms-fake-inbox.js";
|
|
9
10
|
import { buildCommsThreadArtifact } from "./comms-evidence.js";
|
|
10
11
|
import { buildInboxSurface } from "./comms-inbox.js";
|
|
11
12
|
import { DEFAULT_EMAIL_PROFILES } from "./comms-email-catch.js";
|
|
@@ -20,53 +21,137 @@ function shq(value) {
|
|
|
20
21
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
21
22
|
}
|
|
22
23
|
/**
|
|
23
|
-
* The self-contained in-sandbox capture server
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* The self-contained in-sandbox capture server — a plain **python3** script (stdlib only), because the
|
|
25
|
+
* stock E2B desktop template ships python3 but NOT node, and the co-located catcher must run in a
|
|
26
|
+
* runtime the sandbox guarantees (the precedented choice: LocalStack is a python catcher the app points
|
|
27
|
+
* at; you pick the runtime the environment has). It runs on the sandbox's own python3, imports nothing
|
|
28
|
+
* from humanish. DELIBERATELY dumb: it records each POST verbatim as an NDJSON line `{t, path, body}`
|
|
29
|
+
* and returns a plausible provider success — all normalization/profile parsing happens host-side on the
|
|
30
|
+
* drained lines, so the typed, tested profiles stay in one place. It also serves the host-rendered inbox
|
|
31
|
+
* surface statically at /inbox + /api/inbox (with a script-forbidding CSP). argv: <port> <deliveriesFile>
|
|
32
|
+
* <servedDir> [inboxPort]. The capture listener binds 127.0.0.1 (loopback) — the app under test reaches
|
|
33
|
+
* it in-sandbox, nothing on the internet can inject a fake send. When an [inboxPort] is given (the
|
|
34
|
+
* shared-world route, where the persona lives in a DIFFERENT sandbox), it ALSO starts a READ-ONLY inbox
|
|
35
|
+
* listener on 0.0.0.0:<inboxPort> so getHost can proxy the persona's inbox reads to it; that listener
|
|
36
|
+
* serves GET only (POST → 405). The CUA same-sandbox route omits it and stays loopback-only.
|
|
28
37
|
*/
|
|
29
|
-
export const SANDBOX_CATCH_SCRIPT =
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
38
|
+
export const SANDBOX_CATCH_SCRIPT = `import json
|
|
39
|
+
import os
|
|
40
|
+
import random
|
|
41
|
+
import sys
|
|
42
|
+
import threading
|
|
43
|
+
import time
|
|
44
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
45
|
+
from urllib.parse import unquote
|
|
46
|
+
|
|
47
|
+
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8025
|
|
48
|
+
OUT_FILE = sys.argv[2] if len(sys.argv) > 2 else "/tmp/humanish-comms/deliveries.ndjson"
|
|
49
|
+
SERVED_DIR = sys.argv[3] if len(sys.argv) > 3 else (os.path.dirname(OUT_FILE) + "/surface")
|
|
50
|
+
INBOX_PORT = int(sys.argv[4]) if len(sys.argv) > 4 else 0
|
|
51
|
+
try:
|
|
52
|
+
os.makedirs(os.path.dirname(OUT_FILE), exist_ok=True)
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
MAX_BODY = 5 * 1024 * 1024
|
|
56
|
+
CSP = "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:"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def message_id():
|
|
60
|
+
return "humanish-catch-" + format(int(time.time() * 1000), "x") + format(random.randrange(16 ** 8), "08x")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BaseHandler(BaseHTTPRequestHandler):
|
|
64
|
+
def log_message(self, *args):
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
def _json(self, status, obj, extra_headers=None):
|
|
68
|
+
payload = json.dumps(obj).encode("utf-8")
|
|
69
|
+
self.send_response(status)
|
|
70
|
+
self.send_header("content-type", "application/json; charset=utf-8")
|
|
71
|
+
for key, value in (extra_headers or {}).items():
|
|
72
|
+
self.send_header(key, value)
|
|
73
|
+
self.end_headers()
|
|
74
|
+
self.wfile.write(payload)
|
|
75
|
+
|
|
76
|
+
def do_GET(self):
|
|
77
|
+
path = self.path.split("?")[0]
|
|
78
|
+
if path == "/" or path == "/health":
|
|
79
|
+
self._json(200, {"ok": True, "service": "humanish-comms-catch"})
|
|
80
|
+
return
|
|
81
|
+
if path == "/inbox" or path.startswith("/inbox/") or path == "/api/inbox" or path.startswith("/api/inbox/"):
|
|
82
|
+
rel = unquote(path)
|
|
83
|
+
if ".." in rel or chr(0) in rel:
|
|
84
|
+
self.send_response(400)
|
|
85
|
+
self.end_headers()
|
|
86
|
+
return
|
|
87
|
+
data = None
|
|
88
|
+
for candidate in (SERVED_DIR + rel, SERVED_DIR + rel + "/index"):
|
|
89
|
+
try:
|
|
90
|
+
with open(candidate, "rb") as handle:
|
|
91
|
+
data = handle.read()
|
|
92
|
+
break
|
|
93
|
+
except Exception:
|
|
94
|
+
data = None
|
|
95
|
+
if data is None:
|
|
96
|
+
self.send_response(404)
|
|
97
|
+
self.send_header("content-type", "text/html; charset=utf-8")
|
|
98
|
+
self.end_headers()
|
|
99
|
+
self.wfile.write(b"<p>message not found</p>")
|
|
100
|
+
return
|
|
101
|
+
is_api = rel.startswith("/api/")
|
|
102
|
+
self.send_response(200)
|
|
103
|
+
self.send_header("content-type", "application/json; charset=utf-8" if is_api else "text/html; charset=utf-8")
|
|
104
|
+
if not is_api:
|
|
105
|
+
self.send_header("content-security-policy", CSP)
|
|
106
|
+
self.end_headers()
|
|
107
|
+
self.wfile.write(data)
|
|
108
|
+
return
|
|
109
|
+
self._json(404, {"error": "not found"})
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class CaptureHandler(BaseHandler):
|
|
113
|
+
def do_POST(self):
|
|
114
|
+
path = self.path.split("?")[0]
|
|
115
|
+
try:
|
|
116
|
+
length = int(self.headers.get("content-length") or 0)
|
|
117
|
+
except Exception:
|
|
118
|
+
length = 0
|
|
119
|
+
if length > MAX_BODY:
|
|
120
|
+
self.send_response(413)
|
|
121
|
+
self.end_headers()
|
|
122
|
+
return
|
|
123
|
+
body = self.rfile.read(length).decode("utf-8", "replace") if length > 0 else ""
|
|
124
|
+
try:
|
|
125
|
+
with open(OUT_FILE, "a", encoding="utf-8") as handle:
|
|
126
|
+
print(json.dumps({"t": int(time.time() * 1000), "path": path, "body": body}), file=handle)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
mid = message_id()
|
|
130
|
+
if path == "/v3/mail/send":
|
|
131
|
+
self.send_response(202)
|
|
132
|
+
self.send_header("x-message-id", mid)
|
|
133
|
+
self.end_headers()
|
|
134
|
+
elif path.endswith("/batch"):
|
|
135
|
+
self._json(200, {"data": [{"id": mid}]})
|
|
136
|
+
else:
|
|
137
|
+
self._json(200, {"id": mid})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ReadOnlyHandler(BaseHandler):
|
|
141
|
+
def do_POST(self):
|
|
142
|
+
self.send_response(405)
|
|
143
|
+
self.end_headers()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Optional read-only inbox listener on 0.0.0.0 (getHost-reachable from a DIFFERENT sandbox on the
|
|
147
|
+
# shared-world route). Serves GET /inbox + /api/inbox + /health only; POST capture stays on the
|
|
148
|
+
# 127.0.0.1 listener so nothing on the internet can inject a fake captured send. Started only when a
|
|
149
|
+
# distinct inbox port is provided (the CUA same-sandbox route omits it and stays loopback-only).
|
|
150
|
+
if INBOX_PORT and INBOX_PORT != PORT:
|
|
151
|
+
threading.Thread(target=lambda: ThreadingHTTPServer(("0.0.0.0", INBOX_PORT), ReadOnlyHandler).serve_forever(), daemon=True).start()
|
|
152
|
+
|
|
153
|
+
ThreadingHTTPServer(("127.0.0.1", PORT), CaptureHandler).serve_forever()
|
|
154
|
+
`;
|
|
70
155
|
/** Readiness probe that asserts OUR service marker in the /health body (not merely any 2xx) — so a
|
|
71
156
|
* process squatting on the fixed port cannot produce a false "ready" while the app's sends bypass us. */
|
|
72
157
|
async function catchHealthy(desktop, port, options) {
|
|
@@ -95,25 +180,36 @@ export async function deployCommsCatch(desktop, options = {}) {
|
|
|
95
180
|
if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
|
|
96
181
|
throw new Error(`deployCommsCatch: invalid port ${JSON.stringify(options.port)}`);
|
|
97
182
|
}
|
|
183
|
+
const inboxPort = options.inboxPort === undefined ? undefined : Math.trunc(Number(options.inboxPort));
|
|
184
|
+
if (inboxPort !== undefined && (!Number.isInteger(inboxPort) || inboxPort <= 0 || inboxPort > 65_535 || inboxPort === port)) {
|
|
185
|
+
throw new Error(`deployCommsCatch: invalid inboxPort ${JSON.stringify(options.inboxPort)}`);
|
|
186
|
+
}
|
|
98
187
|
const dir = options.dir ?? DEFAULT_CATCH_DIR;
|
|
99
188
|
const name = options.name ?? "comms-catch";
|
|
100
189
|
const requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
|
|
101
|
-
const scriptPath = `${dir}/catch.
|
|
190
|
+
const scriptPath = `${dir}/catch.py`;
|
|
102
191
|
const deliveriesPath = `${dir}/deliveries.ndjson`;
|
|
103
192
|
const surfaceDir = `${dir}/surface`;
|
|
104
193
|
await desktop.commands.run(`mkdir -p ${shq(dir)} ${shq(surfaceDir)}`, { requestTimeoutMs });
|
|
105
194
|
await desktop.files.write(scriptPath, SANDBOX_CATCH_SCRIPT);
|
|
106
195
|
await startDetachedProcess(desktop, {
|
|
107
196
|
name,
|
|
108
|
-
command: `
|
|
197
|
+
command: `python3 ${shq(scriptPath)} ${port} ${shq(deliveriesPath)} ${shq(surfaceDir)}${inboxPort === undefined ? "" : ` ${inboxPort}`}`,
|
|
109
198
|
requestTimeoutMs
|
|
110
199
|
});
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return {
|
|
200
|
+
const probe = { timeoutMs: options.readyTimeoutMs ?? 15_000, requestTimeoutMs, ...(options.timers ?? {}) };
|
|
201
|
+
const ready = await catchHealthy(desktop, port, probe);
|
|
202
|
+
// Confirm the read-only inbox listener bound too (loopback-reachable at its own port), when requested —
|
|
203
|
+
// else a getHost-exposed inbox would 502. Fail closed by folding it into `ready`.
|
|
204
|
+
const inboxReady = inboxPort === undefined ? true : await catchHealthy(desktop, inboxPort, probe);
|
|
205
|
+
return {
|
|
206
|
+
port,
|
|
207
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
208
|
+
deliveriesPath,
|
|
209
|
+
surfaceDir,
|
|
210
|
+
...(inboxPort === undefined ? {} : { inboxPort }),
|
|
211
|
+
ready: ready && inboxReady
|
|
212
|
+
};
|
|
117
213
|
}
|
|
118
214
|
/**
|
|
119
215
|
* Drain new captured sends from the in-sandbox NDJSON since `cursor` (a line count). Returns the fresh
|
|
@@ -227,26 +323,33 @@ export async function writeInboxSurface(desktop, surfaceDir, messages, options =
|
|
|
227
323
|
return files.length;
|
|
228
324
|
}
|
|
229
325
|
/**
|
|
230
|
-
* One mid-run inbox-surface refresh cycle:
|
|
231
|
-
*
|
|
232
|
-
* persona sees new mail while the session is live. Returns the
|
|
326
|
+
* One mid-run inbox-surface refresh cycle: FULL rebuild from the append-only NDJSON (drain from cursor 0)
|
|
327
|
+
* into a FRESH FakeInbox each call, provisioning the declared `recipients`, then (re)render the surface
|
|
328
|
+
* so the persona sees new mail while the session is live. Returns the total captured-send `count` + whether
|
|
329
|
+
* it rendered.
|
|
233
330
|
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
331
|
+
* The full rebuild is deliberate — it is IDEMPOTENT and RETRY-SAFE: a transient writeInboxSurface failure
|
|
332
|
+
* PROPAGATES (the caller retries next tick without advancing its `sinceCount`), and because each rebuild
|
|
333
|
+
* starts from a clean channel, a send is never routed twice, so the persona never sees duplicate emails.
|
|
334
|
+
* Pass `sinceCount` (the last SUCCESSFULLY-rendered send count) to skip the (N-file) render when nothing
|
|
335
|
+
* new has arrived. This is independent of the teardown collectCommsThread drain (its own fresh channel,
|
|
336
|
+
* also cursor 0) — no evidence is lost or altered. The NDJSON is small for a run, so re-reading it is cheap.
|
|
240
337
|
*/
|
|
241
338
|
export async function refreshInboxSurface(args) {
|
|
242
|
-
const { sends
|
|
339
|
+
const { sends } = await drainCommsCatch(args.desktop, args.deployed, 0, args.requestTimeoutMs);
|
|
243
340
|
if (sends.length === 0)
|
|
244
|
-
return {
|
|
245
|
-
|
|
341
|
+
return { count: 0, rendered: false };
|
|
342
|
+
if (args.sinceCount !== undefined && sends.length <= args.sinceCount)
|
|
343
|
+
return { count: sends.length, rendered: false };
|
|
344
|
+
const channel = new FakeInbox();
|
|
345
|
+
const inboxes = [];
|
|
346
|
+
for (const recipient of args.recipients)
|
|
347
|
+
inboxes.push(await channel.provisionAddress(recipient.lane, recipient.address));
|
|
348
|
+
await routeCapturedSends(sends, channel);
|
|
246
349
|
const seen = new Set();
|
|
247
350
|
const messages = [];
|
|
248
|
-
for (const inbox of
|
|
249
|
-
for (const message of await
|
|
351
|
+
for (const inbox of inboxes) {
|
|
352
|
+
for (const message of await channel.poll(inbox, 0)) {
|
|
250
353
|
if (seen.has(message.id))
|
|
251
354
|
continue;
|
|
252
355
|
seen.add(message.id);
|
|
@@ -254,12 +357,12 @@ export async function refreshInboxSurface(args) {
|
|
|
254
357
|
}
|
|
255
358
|
}
|
|
256
359
|
if (messages.length === 0)
|
|
257
|
-
return {
|
|
360
|
+
return { count: sends.length, rendered: false };
|
|
258
361
|
messages.sort((a, b) => a.deliveredAt - b.deliveredAt || a.id.localeCompare(b.id));
|
|
259
362
|
await writeInboxSurface(args.desktop, args.deployed.surfaceDir, messages, {
|
|
260
363
|
...(args.originMap === undefined ? {} : { originMap: args.originMap }),
|
|
261
364
|
...(args.requestTimeoutMs === undefined ? {} : { requestTimeoutMs: args.requestTimeoutMs })
|
|
262
365
|
});
|
|
263
|
-
return {
|
|
366
|
+
return { count: sends.length, rendered: true };
|
|
264
367
|
}
|
|
265
368
|
//# sourceMappingURL=comms-sandbox-catch.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"comms-sandbox-catch.js","sourceRoot":"","sources":["../src/comms-sandbox-catch.ts"],"names":[],"mappings":"AAAA,sGAAsG;AACtG,wGAAwG;AACxG,uGAAuG;AACvG,qGAAqG;AACrG,qGAAqG;AACrG,qGAAqG;AACrG,oGAAoG;AACpG,4FAA4F;AAG5F,OAAO,EAAE,wBAAwB,EAA4B,MAAM,qBAAqB,CAAC;AACzF,OAAO,EAAE,iBAAiB,EAA2B,MAAM,kBAAkB,CAAC;AAC9E,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
|
|
1
|
+
{"version":3,"file":"comms-sandbox-catch.js","sourceRoot":"","sources":["../src/comms-sandbox-catch.ts"],"names":[],"mappings":"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"}
|
|
@@ -39,9 +39,10 @@ import { adapterScoreFailureMessage, applyBrowserAdapterHooks } from "./adapter-
|
|
|
39
39
|
import { actorRegistry, isCuaActorDescriptor } from "./actor-registry.js";
|
|
40
40
|
import { toErrorMessage } from "./command-failure.js";
|
|
41
41
|
import { mapWithConcurrency } from "./concurrency.js";
|
|
42
|
-
import { commandDigestOf, composeLaneInstructions, defaultPackLocalTree, provisionCloneSubject, provisionLocalTreeSubject, declaredScreenForRender, resolveLaneDevice, resolveSubjectState, runCuaLane } from "./cua-actor-lab.js";
|
|
43
|
-
import { DEFAULT_SANDBOX_CATCH_PORT, collectCommsThread, deployCommsCatch } from "./comms-sandbox-catch.js";
|
|
42
|
+
import { commandDigestOf, composeLaneInstructions, defaultPackLocalTree, provisionCloneSubject, provisionLocalTreeSubject, declaredScreenForRender, laneHasInboxRecipient, resolveLaneDevice, resolveSubjectState, runCuaLane, withInboxMission } from "./cua-actor-lab.js";
|
|
43
|
+
import { DEFAULT_SANDBOX_CATCH_PORT, collectCommsThread, deployCommsCatch, refreshInboxSurface, writeInboxSurface } from "./comms-sandbox-catch.js";
|
|
44
44
|
import { FakeInbox } from "./comms-fake-inbox.js";
|
|
45
|
+
import { buildOriginMap } from "./comms-inbox.js";
|
|
45
46
|
import { createDesktopSandbox, loadE2BDesktopModule } from "./e2b-desktop-launch.js";
|
|
46
47
|
import { concurrentSharedWorldValidationReason, externalPublicSharedWorldValidationReason } from "./lab-config.js";
|
|
47
48
|
import { buildObserverData } from "./observer-data.js";
|
|
@@ -558,6 +559,13 @@ export async function runConcurrentSharedWorld(options) {
|
|
|
558
559
|
let subjectSandboxId;
|
|
559
560
|
let subjectKilled = false;
|
|
560
561
|
let getHostUrl;
|
|
562
|
+
// Persona inbox SURFACE (#297 slice B, shared-world): the getHost-exposed inbox URL a persona (in a
|
|
563
|
+
// DIFFERENT sandbox) opens, the serve->getHost origin-rewrite map (REQUIRED here so the app's loopback
|
|
564
|
+
// verify links resolve to a reachable host), and the dedicated surface channel + render loop.
|
|
565
|
+
let commsInboxUrl;
|
|
566
|
+
let commsOriginMap = [];
|
|
567
|
+
let surfaceRenderedCount = 0;
|
|
568
|
+
let surfaceLoop;
|
|
561
569
|
let runError;
|
|
562
570
|
let snapshotIndex = 0;
|
|
563
571
|
let liveObserver;
|
|
@@ -698,9 +706,11 @@ export async function runConcurrentSharedWorld(options) {
|
|
|
698
706
|
// (injected into its env at create) resolves the moment it boots. Fail closed if the catch can't
|
|
699
707
|
// stand up rather than let a comms-declared app silently send real mail to the internet.
|
|
700
708
|
if (commsEmail && commsPort !== undefined) {
|
|
701
|
-
|
|
709
|
+
// A SECOND (0.0.0.0) read-only inbox listener on commsPort+1 so the persona — which lives in a
|
|
710
|
+
// DIFFERENT sandbox here — can reach the inbox surface via getHost; capture stays loopback.
|
|
711
|
+
deployedComms = await deployCommsCatch(subjectDesktop, { port: commsPort, inboxPort: commsPort + 1, requestTimeoutMs, timers });
|
|
702
712
|
if (!deployedComms.ready) {
|
|
703
|
-
throw new Error(`comms email catch did not become ready
|
|
713
|
+
throw new Error(`comms email catch did not become ready in the subject sandbox (loopback capture ${commsPort} / inbox ${commsPort + 1})`);
|
|
704
714
|
}
|
|
705
715
|
}
|
|
706
716
|
// Provision the ONE shared plane: clone + install/build + seed + serve on 0.0.0.0 + probe
|
|
@@ -748,6 +758,61 @@ export async function runConcurrentSharedWorld(options) {
|
|
|
748
758
|
throw new Error("getHost returned a non-tokenless URL; refusing to persist a host URL that may carry a credential (invariant 1)");
|
|
749
759
|
}
|
|
750
760
|
getHostUrl = hostUrl;
|
|
761
|
+
// Persona inbox SURFACE (#297 slice B, shared-world): getHost-expose the read-only inbox listener so
|
|
762
|
+
// a persona in a DIFFERENT sandbox can open it; build the serve->getHost origin map (REQUIRED here —
|
|
763
|
+
// the app's loopback verify links must be rewritten to a reachable host); provision the surface
|
|
764
|
+
// channel; write the EMPTY inbox up front (so /inbox never 404s); and start a render loop that drains
|
|
765
|
+
// + re-renders on a cadence. The loop shares the prober's dispose signal (disposed together, before
|
|
766
|
+
// the teardown evidence drain), and uses a DEDICATED FakeInbox + cursor (independent of that drain).
|
|
767
|
+
if (commsEmail && deployedComms?.inboxPort !== undefined) {
|
|
768
|
+
const rawInboxHost = subjectDesktop.getHost(deployedComms.inboxPort);
|
|
769
|
+
const inboxHostUrl = /^https?:\/\//i.test(rawInboxHost) ? rawInboxHost : `https://${rawInboxHost}`;
|
|
770
|
+
if (!isTokenlessHost(inboxHostUrl)) {
|
|
771
|
+
throw new Error("getHost returned a non-tokenless URL for the comms inbox; refusing to advertise it (invariant 1)");
|
|
772
|
+
}
|
|
773
|
+
commsInboxUrl = `${inboxHostUrl}/inbox`;
|
|
774
|
+
commsOriginMap = buildOriginMap({
|
|
775
|
+
internalServeUrl: serve.url,
|
|
776
|
+
reachableBaseUrl: getHostUrl,
|
|
777
|
+
...(commsEmail.linkOrigin === undefined ? {} : { linkOrigin: commsEmail.linkOrigin })
|
|
778
|
+
});
|
|
779
|
+
const surfaceRecipients = (commsEmail.recipients ?? [])
|
|
780
|
+
.filter((recipient) => recipient.address !== undefined)
|
|
781
|
+
.map((recipient) => ({ lane: recipient.lane, address: recipient.address }));
|
|
782
|
+
await writeInboxSurface(subjectDesktop, deployedComms.surfaceDir, [], { originMap: commsOriginMap, requestTimeoutMs });
|
|
783
|
+
const surfaceDeployed = deployedComms;
|
|
784
|
+
const surfaceCadenceMs = 2500;
|
|
785
|
+
surfaceLoop = (async () => {
|
|
786
|
+
// Full, idempotent rebuild each tick; surfaceRenderedCount advances only on a successful render,
|
|
787
|
+
// so a transient failure retries cleanly. Real timer (dispose-interruptible + cleared) — an
|
|
788
|
+
// unbounded loop must not busy-spin on the injected instant clock.
|
|
789
|
+
for (;;) {
|
|
790
|
+
try {
|
|
791
|
+
const refreshed = await refreshInboxSurface({
|
|
792
|
+
desktop: subjectDesktop,
|
|
793
|
+
deployed: surfaceDeployed,
|
|
794
|
+
recipients: surfaceRecipients,
|
|
795
|
+
sinceCount: surfaceRenderedCount,
|
|
796
|
+
originMap: commsOriginMap,
|
|
797
|
+
requestTimeoutMs
|
|
798
|
+
});
|
|
799
|
+
if (refreshed.rendered)
|
|
800
|
+
surfaceRenderedCount = refreshed.count;
|
|
801
|
+
}
|
|
802
|
+
catch {
|
|
803
|
+
// Never throw into the render loop; the teardown drain + by-id teardown must still run.
|
|
804
|
+
}
|
|
805
|
+
if (proberDisposed)
|
|
806
|
+
break;
|
|
807
|
+
await new Promise((resolve) => {
|
|
808
|
+
const timer = setTimeout(resolve, surfaceCadenceMs);
|
|
809
|
+
void disposeSignal.then(() => { clearTimeout(timer); resolve(); });
|
|
810
|
+
});
|
|
811
|
+
if (proberDisposed)
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
})();
|
|
815
|
+
}
|
|
751
816
|
// Baseline state snapshot, then start the background cadence prober.
|
|
752
817
|
await proberSnapshot();
|
|
753
818
|
if (options.onObserverReady) {
|
|
@@ -840,8 +905,13 @@ export async function runConcurrentSharedWorld(options) {
|
|
|
840
905
|
};
|
|
841
906
|
actorResults = await mapWithConcurrency(actorSpecs, Math.max(1, concurrency), async (spec, i) => {
|
|
842
907
|
const route = resolveActorSeatUrl(getHostUrl, roles[i]?.entry);
|
|
908
|
+
// Tell this persona its (getHost-reachable) inbox URL — but only when comms is live AND this lane
|
|
909
|
+
// has a declared recipient it can actually receive mail into (else it would stall on an empty inbox).
|
|
910
|
+
const laneSpec = commsEmail && commsInboxUrl && laneHasInboxRecipient(commsEmail, spec.laneId)
|
|
911
|
+
? withInboxMission(spec, commsInboxUrl)
|
|
912
|
+
: spec;
|
|
843
913
|
const startedAt = now();
|
|
844
|
-
const outcome = await runCuaLane(
|
|
914
|
+
const outcome = await runCuaLane(laneSpec, { ...baseActorDeps, appUrl: route });
|
|
845
915
|
const endedAt = now();
|
|
846
916
|
return { spec, outcome, startedAt, endedAt, route };
|
|
847
917
|
});
|
|
@@ -858,6 +928,11 @@ export async function runConcurrentSharedWorld(options) {
|
|
|
858
928
|
if (proberLoop) {
|
|
859
929
|
await proberLoop.catch(() => undefined);
|
|
860
930
|
}
|
|
931
|
+
// Stop the inbox-surface render loop too (shares the prober's dispose signal), before the teardown
|
|
932
|
+
// evidence drain below — so the two in-sandbox reads never overlap and the surface state is final.
|
|
933
|
+
if (surfaceLoop) {
|
|
934
|
+
await surfaceLoop.catch(() => undefined);
|
|
935
|
+
}
|
|
861
936
|
if (subjectDesktop && getHostUrl) {
|
|
862
937
|
await proberSnapshot().catch(() => undefined);
|
|
863
938
|
}
|