@runuai/host 0.8.43 → 0.8.45
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/lib/browser-testing.ts +72 -3
- package/lib/tunnel-registry.ts +104 -0
- package/package.json +1 -1
- package/src/main.ts +25 -14
package/lib/browser-testing.ts
CHANGED
|
@@ -47,7 +47,18 @@ import { dockerCli } from "./docker-exec";
|
|
|
47
47
|
import { MCP_CONFIG_LOCK_PATH } from "./mcp-config-lock";
|
|
48
48
|
|
|
49
49
|
const SERVER_DISPLAY = ":99";
|
|
50
|
-
|
|
50
|
+
/**
|
|
51
|
+
* The virtual screen, and the ONLY place its size is written.
|
|
52
|
+
*
|
|
53
|
+
* Chromium's window has to be told the same numbers (see CHROMIUM_ARGS). There
|
|
54
|
+
* is no window manager on this display — nothing to maximise a window or
|
|
55
|
+
* service a resize — so the window is whatever size it was created at, forever.
|
|
56
|
+
* Two independent copies of "1440x900" would drift, and the failure is quiet:
|
|
57
|
+
* the page renders at the size you asked for while the window shows part of it.
|
|
58
|
+
*/
|
|
59
|
+
const SCREEN_W = 1440;
|
|
60
|
+
const SCREEN_H = 900;
|
|
61
|
+
const XVFB_CMD = `Xvfb ${SERVER_DISPLAY} -screen 0 ${SCREEN_W}x${SCREEN_H}x24 -nolisten tcp`;
|
|
51
62
|
|
|
52
63
|
/**
|
|
53
64
|
* The MCP server package — PINNED, deliberately, and the single source of
|
|
@@ -127,6 +138,42 @@ const INSTALL_BROWSER =
|
|
|
127
138
|
`PLAYWRIGHT_SKIP_BROWSER_GC=1 timeout --kill-after=10 ${INSTALL_TIMEOUT_S} ` +
|
|
128
139
|
`npx -y ${MCP_PKG} install-browser ${BROWSER}`;
|
|
129
140
|
const INSTALL_LOG = "/tmp/uai-pw-browser.log";
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Chromium flags the MCP has no CLI passthrough for, so they ride a config file
|
|
144
|
+
* (`--config`, `browser.launchOptions.args`).
|
|
145
|
+
*
|
|
146
|
+
* `--window-size`/`--window-position` because there is NO WINDOW MANAGER on the
|
|
147
|
+
* virtual display. Chromium opens at its own default — 1050x880 inside a
|
|
148
|
+
* 1440x900 screen — and with nothing to service resize requests, stays there. A
|
|
149
|
+
* later `browser_resize` then only APPEARS to work: Playwright falls back to a
|
|
150
|
+
* CDP device-metrics override, so the page renders at the requested width while
|
|
151
|
+
* the window still shows the leftmost 1050px. Everything past that edge is
|
|
152
|
+
* painted where nobody can see it and nothing reports an error. Window creation
|
|
153
|
+
* is the only moment the size can be set.
|
|
154
|
+
*
|
|
155
|
+
* `--test-type` suppresses "You are using an unsupported command-line flag:
|
|
156
|
+
* --no-sandbox", which we cannot avoid triggering — no user namespaces in the
|
|
157
|
+
* container, so the sandbox has to go. Measured on this exact launch path
|
|
158
|
+
* (persistent context): the infobar costs 56px of viewport, permanently, in a
|
|
159
|
+
* window a human is watching.
|
|
160
|
+
*/
|
|
161
|
+
const CHROMIUM_ARGS = [
|
|
162
|
+
"--test-type",
|
|
163
|
+
"--window-position=0,0",
|
|
164
|
+
`--window-size=${SCREEN_W},${SCREEN_H}`,
|
|
165
|
+
];
|
|
166
|
+
const MCP_LAUNCH_CONFIG = "/tmp/uai-playwright-mcp.json";
|
|
167
|
+
/**
|
|
168
|
+
* Base64, not raw JSON. This command is embedded three ways — a JSON value in
|
|
169
|
+
* `.mcp.json`, a TOML LITERAL in Codex's config, and an `sh -lc` argument — and
|
|
170
|
+
* a literal TOML string cannot contain a single quote at all (`tomlString`
|
|
171
|
+
* throws rather than emit one). Base64's alphabet has no quotes, so the same
|
|
172
|
+
* bytes survive every layer with no escaping rules to get wrong.
|
|
173
|
+
*/
|
|
174
|
+
const MCP_LAUNCH_CONFIG_B64 = Buffer.from(
|
|
175
|
+
JSON.stringify({ browser: { launchOptions: { args: CHROMIUM_ARGS } } }),
|
|
176
|
+
).toString("base64");
|
|
130
177
|
/** Host-side backstop, deliberately looser than the in-container `timeout`. */
|
|
131
178
|
const INSTALL_TIMEOUT_MS = 300_000;
|
|
132
179
|
const MCP_CONFIG_PATH = "/workspace/.mcp.json";
|
|
@@ -162,12 +209,16 @@ const X_LOCK = `/tmp/.X${SERVER_DISPLAY.slice(1)}-lock`;
|
|
|
162
209
|
// slow. Ensuring the display is fine: Xvfb is local and instant.
|
|
163
210
|
const SERVER_LAUNCHER =
|
|
164
211
|
`cd /workspace; ` +
|
|
212
|
+
// Writing a local file is not a download — it stays off the handshake budget.
|
|
213
|
+
`echo ${MCP_LAUNCH_CONFIG_B64} | base64 -d > ${MCP_LAUNCH_CONFIG}; ` +
|
|
165
214
|
`if command -v Xvfb >/dev/null 2>&1; then ` +
|
|
166
215
|
`[ -e ${X_LOCK} ] || (nohup ${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &); ` +
|
|
167
216
|
`sleep 1; export DISPLAY=${SERVER_DISPLAY}; ` +
|
|
168
|
-
`exec npx -y ${MCP_PKG} --
|
|
217
|
+
`exec npx -y ${MCP_PKG} --config ${MCP_LAUNCH_CONFIG} ` +
|
|
218
|
+
`--browser ${BROWSER} --no-sandbox; ` +
|
|
169
219
|
`else ` +
|
|
170
|
-
`exec npx -y ${MCP_PKG} --
|
|
220
|
+
`exec npx -y ${MCP_PKG} --config ${MCP_LAUNCH_CONFIG} ` +
|
|
221
|
+
`--browser ${BROWSER} --no-sandbox --headless; ` +
|
|
171
222
|
`fi`;
|
|
172
223
|
|
|
173
224
|
const SERVER_COMMAND = "sh";
|
|
@@ -250,6 +301,24 @@ const HISTORICAL_SERVER_DEFS: Array<Record<string, unknown>> = [
|
|
|
250
301
|
`else exec npx -y @playwright/mcp@0.0.78 --browser chromium --no-sandbox --headless; fi`,
|
|
251
302
|
],
|
|
252
303
|
},
|
|
304
|
+
{
|
|
305
|
+
// The pinned launcher BEFORE `--config`: no window geometry and no
|
|
306
|
+
// `--test-type`, so its Chromium opened at 1050x880 on a 1440x900 screen,
|
|
307
|
+
// clipping anything wider, under an infobar. Listed so a container still
|
|
308
|
+
// running it is rewritten rather than left as it is.
|
|
309
|
+
command: "sh",
|
|
310
|
+
args: [
|
|
311
|
+
"-lc",
|
|
312
|
+
`cd /workspace; ` +
|
|
313
|
+
`if command -v Xvfb >/dev/null 2>&1; then ` +
|
|
314
|
+
`[ -e ${X_LOCK} ] || (nohup ${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &); ` +
|
|
315
|
+
`sleep 1; export DISPLAY=${SERVER_DISPLAY}; ` +
|
|
316
|
+
`exec npx -y ${MCP_PKG} --browser ${BROWSER} --no-sandbox; ` +
|
|
317
|
+
`else ` +
|
|
318
|
+
`exec npx -y ${MCP_PKG} --browser ${BROWSER} --no-sandbox --headless; ` +
|
|
319
|
+
`fi`,
|
|
320
|
+
],
|
|
321
|
+
},
|
|
253
322
|
SERVER_DEF,
|
|
254
323
|
];
|
|
255
324
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tunnel upstreams, plus the frames that arrive before an upstream exists.
|
|
3
|
+
*
|
|
4
|
+
* `tunnel.open` is handled ASYNCHRONOUSLY on the host: resolving a target can
|
|
5
|
+
* await the task lifecycle lock and, for an ad-hoc preview, `docker run` of a
|
|
6
|
+
* sidecar (ADR-043). The frames that follow it — `tunnel.data`,
|
|
7
|
+
* `tunnel.requestEnd`, `tunnel.close` — are handled synchronously off the same
|
|
8
|
+
* WebSocket, and the cloud emits `requestEnd` for a bodyless GET on the tick
|
|
9
|
+
* after `open`. So they routinely land while the open is still resolving.
|
|
10
|
+
*
|
|
11
|
+
* Looking the tunnel up with `tunnels.get(id)?.` silently dropped them, and a
|
|
12
|
+
* dropped `requestEnd` is unrecoverable rather than merely late. The HTTP open
|
|
13
|
+
* deliberately leaves its `ClientRequest` unended so request bodies can stream,
|
|
14
|
+
* and node writes no header until `write`/`end` — so the upstream socket
|
|
15
|
+
* CONNECTS and then receives nothing at all. On the wire that reads as an
|
|
16
|
+
* established TCP connection carrying zero bytes, an upstream that never saw a
|
|
17
|
+
* request, and a flat 30s `504 host did not respond in time` from the cloud's
|
|
18
|
+
* ack deadline: four symptoms that each point somewhere different, none of them
|
|
19
|
+
* at the open that never finished.
|
|
20
|
+
*
|
|
21
|
+
* So ops for an opening tunnel are QUEUED and replayed on `register`. The
|
|
22
|
+
* queue is bounded by the request itself — the cloud stops sending once its
|
|
23
|
+
* ack deadline fires — and is dropped by `abandon` when an open fails, so a
|
|
24
|
+
* tunnel that never opens leaves nothing behind.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface TunnelUpstream {
|
|
28
|
+
write(chunk: Buffer): boolean;
|
|
29
|
+
end(): void;
|
|
30
|
+
destroy(): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type TunnelOp = (upstream: TunnelUpstream) => void;
|
|
34
|
+
|
|
35
|
+
export class TunnelRegistry {
|
|
36
|
+
private readonly open = new Map<string, TunnelUpstream>();
|
|
37
|
+
/** tunnelId -> ops that arrived while its `tunnel.open` was still resolving. */
|
|
38
|
+
private readonly opening = new Map<string, TunnelOp[]>();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Mark a tunnel as opening. MUST be called synchronously when `tunnel.open`
|
|
42
|
+
* is received — before the first `await` of target resolution — or the frames
|
|
43
|
+
* that follow have nothing to queue against and are dropped, which is the
|
|
44
|
+
* whole bug this class exists for.
|
|
45
|
+
*/
|
|
46
|
+
markOpening(tunnelId: string): void {
|
|
47
|
+
this.opening.set(tunnelId, []);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Target resolved: publish the upstream and replay what arrived meanwhile. */
|
|
51
|
+
register(tunnelId: string, upstream: TunnelUpstream): void {
|
|
52
|
+
this.open.set(tunnelId, upstream);
|
|
53
|
+
const queued = this.opening.get(tunnelId);
|
|
54
|
+
this.opening.delete(tunnelId);
|
|
55
|
+
if (!queued) return;
|
|
56
|
+
for (const op of queued) op(upstream);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Open failed (no target, or it errored) — nothing will ever service these. */
|
|
60
|
+
abandon(tunnelId: string): void {
|
|
61
|
+
this.opening.delete(tunnelId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get(tunnelId: string): TunnelUpstream | undefined {
|
|
65
|
+
return this.open.get(tunnelId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
delete(tunnelId: string): void {
|
|
69
|
+
this.open.delete(tunnelId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run `op` against the upstream now, or queue it if the tunnel is still
|
|
74
|
+
* opening. A tunnel this registry has never heard of is dropped, exactly as
|
|
75
|
+
* the old `?.` did — that is a frame for a tunnel already torn down, not a
|
|
76
|
+
* race.
|
|
77
|
+
*/
|
|
78
|
+
apply(tunnelId: string, op: TunnelOp): void {
|
|
79
|
+
const upstream = this.open.get(tunnelId);
|
|
80
|
+
if (upstream) {
|
|
81
|
+
op(upstream);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
this.opening.get(tunnelId)?.push(op);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Tear the tunnel down now, or as soon as it opens. Queued ops are discarded
|
|
89
|
+
* rather than replayed first: they exist to be written to an upstream that is
|
|
90
|
+
* about to be destroyed, and a `write` after `destroy` is at best wasted and
|
|
91
|
+
* at worst an unhandled error on a socket nobody is listening to any more.
|
|
92
|
+
*/
|
|
93
|
+
abort(tunnelId: string): void {
|
|
94
|
+
const upstream = this.open.get(tunnelId);
|
|
95
|
+
this.open.delete(tunnelId);
|
|
96
|
+
if (upstream) {
|
|
97
|
+
upstream.destroy();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (this.opening.has(tunnelId)) {
|
|
101
|
+
this.opening.set(tunnelId, [(u) => u.destroy()]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
package/package.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
ensurePreviewSidecar,
|
|
60
60
|
invalidatePreviewSidecar,
|
|
61
61
|
} from "../lib/preview-sidecar";
|
|
62
|
+
import { TunnelRegistry } from "../lib/tunnel-registry";
|
|
62
63
|
import { newId } from "../lib/ulid";
|
|
63
64
|
import {
|
|
64
65
|
capabilities as agentKindCapabilities,
|
|
@@ -126,13 +127,7 @@ let reconnectAttempt = 0;
|
|
|
126
127
|
let inFlight = 0;
|
|
127
128
|
let shutdownRequested = false;
|
|
128
129
|
let pendingBinaryTunnelId: string | null = null;
|
|
129
|
-
const tunnels = new
|
|
130
|
-
|
|
131
|
-
interface TunnelUpstream {
|
|
132
|
-
write(chunk: Buffer): boolean;
|
|
133
|
-
end(): void;
|
|
134
|
-
destroy(): void;
|
|
135
|
-
}
|
|
130
|
+
const tunnels = new TunnelRegistry();
|
|
136
131
|
|
|
137
132
|
interface PausableSource {
|
|
138
133
|
pause(): unknown;
|
|
@@ -301,7 +296,8 @@ function connect(): void {
|
|
|
301
296
|
const tunnelId = pendingBinaryTunnelId;
|
|
302
297
|
pendingBinaryTunnelId = null;
|
|
303
298
|
if (!tunnelId) return;
|
|
304
|
-
|
|
299
|
+
const chunk = rawDataToBuffer(data);
|
|
300
|
+
tunnels.apply(tunnelId, (upstream) => upstream.write(chunk));
|
|
305
301
|
return;
|
|
306
302
|
}
|
|
307
303
|
|
|
@@ -315,6 +311,10 @@ function connect(): void {
|
|
|
315
311
|
void handleCommand(socket, frame);
|
|
316
312
|
break;
|
|
317
313
|
case "tunnel.open":
|
|
314
|
+
// Synchronously, BEFORE handleTunnelOpen's first await: the frames that
|
|
315
|
+
// follow this one (a bodyless GET's `requestEnd` arrives on the next
|
|
316
|
+
// tick) need somewhere to queue while the target resolves.
|
|
317
|
+
tunnels.markOpening(frame.tunnelId);
|
|
318
318
|
void handleTunnelOpen(socket, frame);
|
|
319
319
|
break;
|
|
320
320
|
case "tunnel.data":
|
|
@@ -322,7 +322,10 @@ function connect(): void {
|
|
|
322
322
|
break;
|
|
323
323
|
case "tunnel.requestEnd":
|
|
324
324
|
// Request body complete → finish the upstream request so it can reply.
|
|
325
|
-
|
|
325
|
+
// Queued if the open is still resolving: node flushes no request header
|
|
326
|
+
// until `end`, so losing this strands the upstream on a connected
|
|
327
|
+
// socket that never receives a byte.
|
|
328
|
+
tunnels.apply(frame.tunnelId, (upstream) => upstream.end());
|
|
326
329
|
break;
|
|
327
330
|
case "tunnel.close":
|
|
328
331
|
closeTunnel(socket, frame.tunnelId, frame.reason);
|
|
@@ -497,6 +500,8 @@ async function handleTunnelOpen(
|
|
|
497
500
|
): Promise<void> {
|
|
498
501
|
const target = await resolveTunnelTarget(frame);
|
|
499
502
|
if (!target) {
|
|
503
|
+
// Nothing will ever service what queued behind this open.
|
|
504
|
+
tunnels.abandon(frame.tunnelId);
|
|
500
505
|
send(wsSocket, {
|
|
501
506
|
kind: "tunnel.ack",
|
|
502
507
|
tunnelId: frame.tunnelId,
|
|
@@ -555,7 +560,9 @@ function handleHttpTunnelOpen(
|
|
|
555
560
|
},
|
|
556
561
|
);
|
|
557
562
|
|
|
558
|
-
|
|
563
|
+
// Registering replays anything that arrived while the target resolved — for a
|
|
564
|
+
// bodyless GET that is the `requestEnd` this request cannot reply without.
|
|
565
|
+
tunnels.register(frame.tunnelId, upstream);
|
|
559
566
|
|
|
560
567
|
upstream.on("error", (err) => {
|
|
561
568
|
tunnels.delete(frame.tunnelId);
|
|
@@ -595,7 +602,6 @@ function handleRawTunnelOpen(
|
|
|
595
602
|
target: UpstreamAddr,
|
|
596
603
|
): void {
|
|
597
604
|
const upstream = new Socket();
|
|
598
|
-
tunnels.set(frame.tunnelId, upstream);
|
|
599
605
|
|
|
600
606
|
let acked = false;
|
|
601
607
|
let responseBuffer = Buffer.alloc(0);
|
|
@@ -660,6 +666,11 @@ function handleRawTunnelOpen(
|
|
|
660
666
|
});
|
|
661
667
|
|
|
662
668
|
upstream.connect(target.port, target.host);
|
|
669
|
+
// AFTER `connect`, not before: replaying a queued write onto a socket that
|
|
670
|
+
// has not started connecting errors, while node buffers writes made once it
|
|
671
|
+
// is connecting. An upgrade's own request goes out from the `connect`
|
|
672
|
+
// handler, so only client frames that overtook the open replay here.
|
|
673
|
+
tunnels.register(frame.tunnelId, upstream);
|
|
663
674
|
}
|
|
664
675
|
|
|
665
676
|
function closeTunnel(
|
|
@@ -667,9 +678,9 @@ function closeTunnel(
|
|
|
667
678
|
tunnelId: string,
|
|
668
679
|
reason: string | undefined,
|
|
669
680
|
): void {
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
681
|
+
// `abort`, not get+delete: a client that gives up while the target is still
|
|
682
|
+
// resolving would otherwise leave the tunnel to open onto nobody.
|
|
683
|
+
tunnels.abort(tunnelId);
|
|
673
684
|
send(wsSocket, { kind: "tunnel.close", tunnelId, reason });
|
|
674
685
|
}
|
|
675
686
|
|