@runuai/host 0.4.1 → 0.4.3
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/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/meta/_journal.json +8 -1
- package/db/schema.ts +28 -0
- package/images/standard/Dockerfile +15 -0
- package/lib/agent-cli.ts +14 -0
- package/lib/agents/claude.ts +4 -3
- package/lib/agents/codex.ts +15 -8
- package/lib/browser-testing.ts +235 -0
- package/lib/mcp-connections.ts +522 -0
- package/lib/mcp-gateway.ts +315 -0
- package/lib/orchestrator.ts +250 -14
- package/lib/standard-image.ts +127 -10
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +10 -0
- package/src/index.ts +52 -1
- package/src/main.ts +107 -0
- package/src/protocol.ts +82 -2
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- ADR-057: user-authed MCP connections. Secrets sealed with the host master
|
|
2
|
+
-- key (ct.nonce base64, like host_project_env.value_enc); the cloud holds only
|
|
3
|
+
-- non-secret metadata.
|
|
4
|
+
CREATE TABLE `host_mcp_connections` (
|
|
5
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
6
|
+
`user_id` text NOT NULL,
|
|
7
|
+
`url` text NOT NULL,
|
|
8
|
+
`auth_kind` text NOT NULL,
|
|
9
|
+
`token_endpoint` text,
|
|
10
|
+
`redirect_uri` text,
|
|
11
|
+
`client_id` text,
|
|
12
|
+
`client_secret_enc` text,
|
|
13
|
+
`pkce_verifier_enc` text,
|
|
14
|
+
`secret_enc` text,
|
|
15
|
+
`status` text DEFAULT 'pending' NOT NULL,
|
|
16
|
+
`scopes` text,
|
|
17
|
+
`updated_at` integer NOT NULL
|
|
18
|
+
);
|
package/db/schema.ts
CHANGED
|
@@ -115,3 +115,31 @@ export const hostProjectEnv = sqliteTable(
|
|
|
115
115
|
|
|
116
116
|
export type HostProjectEnv = typeof hostProjectEnv.$inferSelect;
|
|
117
117
|
export type NewHostProjectEnv = typeof hostProjectEnv.$inferInsert;
|
|
118
|
+
|
|
119
|
+
// User-authed MCP connections (ADR-057). One row per cloud connection id; all
|
|
120
|
+
// secret material (static header, OAuth client secret, PKCE verifier, tokens)
|
|
121
|
+
// is sealed with the host master key in single-column `ct.nonce` base64 form
|
|
122
|
+
// (same packing as host_project_env). The cloud only ever holds the non-secret
|
|
123
|
+
// metadata; acks never echo secrets back.
|
|
124
|
+
export const mcpConnections = sqliteTable("host_mcp_connections", {
|
|
125
|
+
id: text("id").primaryKey(), // cloud uai_mcp_connections id
|
|
126
|
+
userId: text("user_id").notNull(),
|
|
127
|
+
url: text("url").notNull(),
|
|
128
|
+
// "oauth" | "token" | "none"
|
|
129
|
+
authKind: text("auth_kind").notNull(),
|
|
130
|
+
// OAuth machinery discovered at probe time.
|
|
131
|
+
tokenEndpoint: text("token_endpoint"),
|
|
132
|
+
redirectUri: text("redirect_uri"),
|
|
133
|
+
clientId: text("client_id"),
|
|
134
|
+
clientSecretEnc: text("client_secret_enc"),
|
|
135
|
+
// PKCE verifier held between probe and oauth.complete.
|
|
136
|
+
pkceVerifierEnc: text("pkce_verifier_enc"),
|
|
137
|
+
// Sealed JSON: {"headerName","headerValue"} (token kind) or
|
|
138
|
+
// {"accessToken","refreshToken","expiresAt"} (oauth kind). Null for "none".
|
|
139
|
+
secretEnc: text("secret_enc"),
|
|
140
|
+
status: text("status").notNull().default("pending"), // pending|connected
|
|
141
|
+
scopes: text("scopes"),
|
|
142
|
+
updatedAt: integer("updated_at", { mode: "number" }).notNull(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
export type McpConnection = typeof mcpConnections.$inferSelect;
|
|
@@ -70,6 +70,21 @@ RUN apt-get update \
|
|
|
70
70
|
zsh \
|
|
71
71
|
&& rm -rf /var/lib/apt/lists/*
|
|
72
72
|
|
|
73
|
+
# ADR-053: agent browser (Playwright/Chromium) runtime libs + the watchable
|
|
74
|
+
# display stack (Xvfb → x11vnc → noVNC). Baked here because the
|
|
75
|
+
# per-container background apt raced session start (headless-stuck MCP
|
|
76
|
+
# server, viewer lagging the preview by minutes — Debian's novnc drags a
|
|
77
|
+
# large Python tree). The Chromium BINARY still comes from the shared
|
|
78
|
+
# uai-playwright volume (downloaded once per host); these are its deps.
|
|
79
|
+
RUN apt-get update \
|
|
80
|
+
&& apt-get install -y --no-install-recommends \
|
|
81
|
+
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
|
82
|
+
libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 \
|
|
83
|
+
libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \
|
|
84
|
+
fonts-liberation fonts-unifont \
|
|
85
|
+
xvfb x11vnc novnc websockify \
|
|
86
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
87
|
+
|
|
73
88
|
# GitHub CLI (`gh`) — used by the ship/PR flow inside the container.
|
|
74
89
|
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|
75
90
|
| tee /usr/share/keyrings/githubcli-archive-keyring.gpg >/dev/null \
|
package/lib/agent-cli.ts
CHANGED
|
@@ -193,6 +193,13 @@ async function main() {
|
|
|
193
193
|
})).memory);
|
|
194
194
|
break;
|
|
195
195
|
}
|
|
196
|
+
case "memory delete": {
|
|
197
|
+
const id = pos[0] || flags.id;
|
|
198
|
+
if (!id) { console.error("uai: memory delete needs an id"); process.exit(1); }
|
|
199
|
+
await api("DELETE", "/api/agent/memory/" + encodeURIComponent(id));
|
|
200
|
+
out("deleted " + id);
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
196
203
|
case "task create": {
|
|
197
204
|
out((await api("POST", "/api/agent/tasks", {
|
|
198
205
|
name: flags.name,
|
|
@@ -205,6 +212,11 @@ async function main() {
|
|
|
205
212
|
break;
|
|
206
213
|
}
|
|
207
214
|
case "whoami ": case "whoami undefined": out({ apiUrl: API_URL }); break;
|
|
215
|
+
case "react heart": case "react check": case "react x": {
|
|
216
|
+
const r = await api("POST", "/api/agent/react", { emoji: action, msg: flags.msg || undefined });
|
|
217
|
+
out((r.reacted ? "reacted to " : "un-reacted from ") + r.messageId);
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
208
220
|
default:
|
|
209
221
|
console.error([
|
|
210
222
|
"uai — agent CLI. Commands:",
|
|
@@ -214,6 +226,8 @@ async function main() {
|
|
|
214
226
|
" uai task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]",
|
|
215
227
|
" uai memory search <query>",
|
|
216
228
|
" uai memory save <text> [--project id] [--tags a,b]",
|
|
229
|
+
" uai memory delete <id>",
|
|
230
|
+
" uai react <heart|check|x> [--msg #id] (no --msg = the message you were last handed)",
|
|
217
231
|
" uai whoami",
|
|
218
232
|
].join("\\n"));
|
|
219
233
|
process.exit(argv.length ? 1 : 0);
|
package/lib/agents/claude.ts
CHANGED
|
@@ -35,10 +35,11 @@ import type {
|
|
|
35
35
|
// resolves these aliases to the current dated snapshots, so this list is
|
|
36
36
|
// stable across point releases. UPDATE WHEN MODELS CHANGE (new family or a
|
|
37
37
|
// retired alias). Order is display order in the cloud picker.
|
|
38
|
-
const CLAUDE_MODELS = ["opus", "sonnet", "haiku"];
|
|
38
|
+
const CLAUDE_MODELS = ["fable", "opus", "sonnet", "haiku"];
|
|
39
39
|
|
|
40
|
-
// Opus ("opus" alias = Opus 4.8)
|
|
41
|
-
//
|
|
40
|
+
// Opus ("opus" alias = Opus 4.8) stays the default; "fable" (Claude
|
|
41
|
+
// Fable 5, the Mythos-class flagship) is opt-in per agent. Update
|
|
42
|
+
// alongside CLAUDE_MODELS.
|
|
42
43
|
const CLAUDE_DEFAULT_MODEL = "opus";
|
|
43
44
|
|
|
44
45
|
// Reasoning levels passed through via `claude --effort <level>`. Taken from
|
package/lib/agents/codex.ts
CHANGED
|
@@ -49,6 +49,9 @@ import type {
|
|
|
49
49
|
// Sourced from that picker; UPDATE WHEN MODELS CHANGE. Order = display order
|
|
50
50
|
// in the cloud picker. Legacy models remain reachable via config.toml.
|
|
51
51
|
const CODEX_MODELS = [
|
|
52
|
+
"gpt-5.6-sol",
|
|
53
|
+
"gpt-5.6-terra",
|
|
54
|
+
"gpt-5.6-luna",
|
|
52
55
|
"gpt-5.5",
|
|
53
56
|
"gpt-5.4",
|
|
54
57
|
"gpt-5.4-mini",
|
|
@@ -57,16 +60,20 @@ const CODEX_MODELS = [
|
|
|
57
60
|
"gpt-5.2",
|
|
58
61
|
];
|
|
59
62
|
|
|
60
|
-
// gpt-5.
|
|
61
|
-
// CODEX_MODELS. When an agent's model is null the adapter
|
|
62
|
-
// entirely and Codex uses the user's own configured
|
|
63
|
-
|
|
63
|
+
// gpt-5.6-sol is Codex's current frontier coding model (the CLI default).
|
|
64
|
+
// Update alongside CODEX_MODELS. When an agent's model is null the adapter
|
|
65
|
+
// omits the override entirely and Codex uses the user's own configured
|
|
66
|
+
// default.
|
|
67
|
+
const CODEX_DEFAULT_MODEL = "gpt-5.6-sol";
|
|
64
68
|
|
|
65
|
-
// Reasoning levels set via `-c model_reasoning_effort=<level>`. "xhigh" is
|
|
66
|
-
// picker's "Extra high".
|
|
67
|
-
|
|
69
|
+
// Reasoning levels set via `-c model_reasoning_effort=<level>`. "xhigh" is
|
|
70
|
+
// the picker's "Extra high"; gpt-5.6 added "max" and "ultra" (ultra =
|
|
71
|
+
// maximum reasoning with automatic task delegation). UPDATE WHEN CODEX
|
|
72
|
+
// CHANGES its reasoning levels.
|
|
73
|
+
const CODEX_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"];
|
|
68
74
|
|
|
69
|
-
// medium
|
|
75
|
+
// medium stays OUR default (the 5.6 CLI defaults to low — too light for
|
|
76
|
+
// agentic task work). Update alongside CODEX_EFFORTS.
|
|
70
77
|
const CODEX_DEFAULT_EFFORT = "medium";
|
|
71
78
|
|
|
72
79
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-053: in-container browser for agents — Playwright MCP wiring.
|
|
3
|
+
*
|
|
4
|
+
* When a task's project opted into browser testing, session start calls
|
|
5
|
+
* `setupBrowserTesting`, which (idempotently, via a versioned marker file)
|
|
6
|
+
* writes the MCP config for BOTH engines and kicks the installs in the
|
|
7
|
+
* background:
|
|
8
|
+
*
|
|
9
|
+
* - `/workspace/.mcp.json` — Claude Code's project-scoped MCP config,
|
|
10
|
+
* declaring the `browser` server (headFUL on DISPLAY :99).
|
|
11
|
+
* - `/workspace/.claude/settings.json` — `enableAllProjectMcpServers` so
|
|
12
|
+
* headless sessions load it without an approval prompt.
|
|
13
|
+
* - `~/.codex/config.toml` — an `[mcp_servers.browser]` block (appended
|
|
14
|
+
* once; the task owns a private copy of this file).
|
|
15
|
+
*
|
|
16
|
+
* Phase 2 (the WATCHABLE browser): Chromium runs headful under Xvfb (:99),
|
|
17
|
+
* mirrored by x11vnc → websockify/noVNC on container port 6080 — which the
|
|
18
|
+
* cloud surfaces as the synthetic "browser" preview, so the human can watch
|
|
19
|
+
* the agents click around from the preview menu. The viewer stack is
|
|
20
|
+
* (re)started on every setup call, pgrep-guarded, so it survives container
|
|
21
|
+
* restarts.
|
|
22
|
+
*
|
|
23
|
+
* Browser binaries land on the host-wide `uai-playwright` volume
|
|
24
|
+
* (PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers, mounted by the generated
|
|
25
|
+
* compose) so Chromium downloads once per HOST. Everything here is
|
|
26
|
+
* best-effort and never blocks session start.
|
|
27
|
+
*
|
|
28
|
+
* Hard-won asdf rules (2026-07-07): npx resolves ONLY where a .tool-versions
|
|
29
|
+
* applies → always `-w /workspace`; and root has no ~/.tool-versions →
|
|
30
|
+
* root execs also need `-e HOME=/home/node`.
|
|
31
|
+
*/
|
|
32
|
+
import { dockerCli } from "./docker-exec";
|
|
33
|
+
|
|
34
|
+
// v3: self-sufficient MCP launcher + loop-wrapped viewer daemons. Bumping
|
|
35
|
+
// re-runs setup on live channels; config writes stay `[ -f ] ||`-guarded so
|
|
36
|
+
// a task keeps the configs it started with.
|
|
37
|
+
const MARKER = "/workspace/.uai/.browser-mcp-ready-v3";
|
|
38
|
+
|
|
39
|
+
const SERVER_DISPLAY = ":99";
|
|
40
|
+
const XVFB_CMD = `Xvfb ${SERVER_DISPLAY} -screen 0 1440x900x24 -nolisten tcp`;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The MCP server launcher — self-sufficient by design (learned live: a
|
|
44
|
+
* session that spawns while the apt installs are still running got a DEAD
|
|
45
|
+
* mcp server, and the agent had to improvise). It ensures its own display
|
|
46
|
+
* when Xvfb exists (headful → watchable), and falls back to a HEADLESS
|
|
47
|
+
* browser while the viewer packages are still installing — the agent always
|
|
48
|
+
* gets working tools. `sh -lc` + cd /workspace for the asdf shims. No
|
|
49
|
+
* single quotes in here: it's embedded in single-quoted JSON/TOML strings.
|
|
50
|
+
*/
|
|
51
|
+
// X's own lockfile is the display-up signal — no pgrep (pgrep -f guards
|
|
52
|
+
// self-matched their launcher's cmdline and skipped every start; found
|
|
53
|
+
// live 2026-07-08), and it also detects displays an AGENT started itself.
|
|
54
|
+
const X_LOCK = `/tmp/.X${SERVER_DISPLAY.slice(1)}-lock`;
|
|
55
|
+
|
|
56
|
+
const SERVER_LAUNCHER =
|
|
57
|
+
`cd /workspace; ` +
|
|
58
|
+
`if command -v Xvfb >/dev/null 2>&1; then ` +
|
|
59
|
+
`[ -e ${X_LOCK} ] || (nohup ${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &); ` +
|
|
60
|
+
`sleep 1; export DISPLAY=${SERVER_DISPLAY}; ` +
|
|
61
|
+
`exec npx -y @playwright/mcp@latest --browser chromium --no-sandbox; ` +
|
|
62
|
+
`else ` +
|
|
63
|
+
`exec npx -y @playwright/mcp@latest --browser chromium --no-sandbox --headless; ` +
|
|
64
|
+
`fi`;
|
|
65
|
+
|
|
66
|
+
const SERVER_COMMAND = "sh";
|
|
67
|
+
const SERVER_ARGS = ["-lc", SERVER_LAUNCHER];
|
|
68
|
+
|
|
69
|
+
const MCP_JSON = JSON.stringify(
|
|
70
|
+
{
|
|
71
|
+
mcpServers: {
|
|
72
|
+
browser: { command: SERVER_COMMAND, args: SERVER_ARGS },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
null,
|
|
76
|
+
2,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const CLAUDE_SETTINGS_JSON = JSON.stringify(
|
|
80
|
+
{ enableAllProjectMcpServers: true },
|
|
81
|
+
null,
|
|
82
|
+
2,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const CODEX_TOML = [
|
|
86
|
+
"",
|
|
87
|
+
"# uai ADR-053: in-container browser (Playwright MCP)",
|
|
88
|
+
"[mcp_servers.browser]",
|
|
89
|
+
`command = '${SERVER_COMMAND}'`,
|
|
90
|
+
`args = [${SERVER_ARGS.map((a) => `'${a}'`).join(", ")}]`,
|
|
91
|
+
"",
|
|
92
|
+
].join("\n");
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Start the watchable-browser stack (Xvfb → x11vnc → noVNC on :6080) if its
|
|
96
|
+
* binaries are installed and it isn't already up. x11vnc and websockify run
|
|
97
|
+
* under tiny restart loops — x11vnc EXITS whenever the X server it watches
|
|
98
|
+
* isn't up yet (the race that killed the first live run), and the loops
|
|
99
|
+
* also reattach after the display owner changes. Safe to run repeatedly:
|
|
100
|
+
* every piece is pgrep-guarded, including the loops themselves.
|
|
101
|
+
*/
|
|
102
|
+
/**
|
|
103
|
+
* The viewer daemons, one docker-exec each (the single-line nohup shape is
|
|
104
|
+
* the only one that reliably survives `docker exec -d`). Guards use the X
|
|
105
|
+
* lockfile and PIDFILES — never pgrep: a pgrep -f guard sharing a cmdline
|
|
106
|
+
* with its own payload self-matches and skips the start (this exact bug
|
|
107
|
+
* kept the stack down in every container until 2026-07-08).
|
|
108
|
+
*/
|
|
109
|
+
const VIEWER_STEPS: string[] = [
|
|
110
|
+
// Xvfb — one-shot; the X lockfile is the truth (also set when an agent
|
|
111
|
+
// started the display itself). If it dies the lock clears and the next
|
|
112
|
+
// session ensure relaunches it.
|
|
113
|
+
`command -v Xvfb >/dev/null 2>&1 || exit 0; [ -e ${X_LOCK} ] || nohup ${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &`,
|
|
114
|
+
// x11vnc under a restart loop (it exits whenever the X server isn't up
|
|
115
|
+
// yet). Pidfile-guarded.
|
|
116
|
+
`command -v x11vnc >/dev/null 2>&1 || exit 0; [ -f /tmp/uai-x11vnc.pid ] && kill -0 "$(cat /tmp/uai-x11vnc.pid)" 2>/dev/null && exit 0; nohup sh -c 'echo $$ > /tmp/uai-x11vnc.pid; while true; do x11vnc -display ${SERVER_DISPLAY} -forever -shared -nopw -quiet >>/tmp/uai-x11vnc.log 2>&1; sleep 2; done' >/dev/null 2>&1 &`,
|
|
117
|
+
// websockify/noVNC under the same pattern.
|
|
118
|
+
`command -v websockify >/dev/null 2>&1 || exit 0; [ -f /tmp/uai-novnc.pid ] && kill -0 "$(cat /tmp/uai-novnc.pid)" 2>/dev/null && exit 0; nohup sh -c 'echo $$ > /tmp/uai-novnc.pid; while true; do websockify --web=/usr/share/novnc 0.0.0.0:6080 localhost:5900 >>/tmp/uai-websockify.log 2>&1; sleep 2; done' >/dev/null 2>&1 &`,
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
export async function setupBrowserTesting(
|
|
122
|
+
taskId: string,
|
|
123
|
+
containerName: string,
|
|
124
|
+
hasCodex: boolean,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
try {
|
|
127
|
+
// The viewer stack restarts whenever it died (container restart, crash) —
|
|
128
|
+
// outside the marker guard on purpose. No-op until its packages install.
|
|
129
|
+
// ONE exec per daemon: the single-line nohup shape is the only one that
|
|
130
|
+
// reliably survives `docker exec -d`.
|
|
131
|
+
for (const step of VIEWER_STEPS) {
|
|
132
|
+
await dockerCli(["exec", "-d", containerName, "sh", "-c", step], {
|
|
133
|
+
timeoutMs: 10_000,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const marked = await dockerCli(
|
|
138
|
+
["exec", containerName, "test", "-f", MARKER],
|
|
139
|
+
{ timeoutMs: 5_000 },
|
|
140
|
+
);
|
|
141
|
+
if (marked.status === 0) return;
|
|
142
|
+
|
|
143
|
+
const script = [
|
|
144
|
+
"set -e",
|
|
145
|
+
"mkdir -p /workspace/.uai /workspace/.claude",
|
|
146
|
+
// Claude: project-scoped MCP config + auto-approve setting. The
|
|
147
|
+
// workspace is fresh per task, so plain writes are safe; keep them
|
|
148
|
+
// conditional anyway so a human's later edits survive re-runs.
|
|
149
|
+
`[ -f /workspace/.mcp.json ] || printf '%s\\n' ${shellQuote(MCP_JSON)} > /workspace/.mcp.json`,
|
|
150
|
+
`[ -f /workspace/.claude/settings.json ] || printf '%s\\n' ${shellQuote(CLAUDE_SETTINGS_JSON)} > /workspace/.claude/settings.json`,
|
|
151
|
+
...(hasCodex
|
|
152
|
+
? [
|
|
153
|
+
// Codex: append once to the task's PRIVATE config copy.
|
|
154
|
+
`grep -q "mcp_servers.browser" /home/node/.codex/config.toml 2>/dev/null || printf '%s' ${shellQuote(CODEX_TOML)} >> /home/node/.codex/config.toml`,
|
|
155
|
+
]
|
|
156
|
+
: []),
|
|
157
|
+
`touch ${MARKER}`,
|
|
158
|
+
].join(" && ");
|
|
159
|
+
|
|
160
|
+
const wrote = await dockerCli(
|
|
161
|
+
["exec", containerName, "sh", "-lc", script],
|
|
162
|
+
{ timeoutMs: 20_000 },
|
|
163
|
+
);
|
|
164
|
+
if (wrote.status !== 0) {
|
|
165
|
+
console.warn(
|
|
166
|
+
`[browser] task ${taskId}: MCP config write failed: ${wrote.stderr.slice(0, 300)}`,
|
|
167
|
+
);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Volume ownership, then the browser download as NODE (host-wide
|
|
172
|
+
// one-timer on the shared volume).
|
|
173
|
+
await dockerCli(
|
|
174
|
+
["exec", "-u", "root", containerName, "chown", "node:node", "/opt/pw-browsers"],
|
|
175
|
+
{ timeoutMs: 5_000 },
|
|
176
|
+
);
|
|
177
|
+
await dockerCli(
|
|
178
|
+
[
|
|
179
|
+
"exec",
|
|
180
|
+
"-d",
|
|
181
|
+
"-w",
|
|
182
|
+
"/workspace",
|
|
183
|
+
containerName,
|
|
184
|
+
"sh",
|
|
185
|
+
"-lc",
|
|
186
|
+
"npx -y playwright@latest install chromium >/tmp/uai-pw-browser.log 2>&1 || true",
|
|
187
|
+
],
|
|
188
|
+
{ timeoutMs: 10_000 },
|
|
189
|
+
);
|
|
190
|
+
// Chromium apt libs + the viewer stack packages, then start the stack —
|
|
191
|
+
// one ordered root chain, backgrounded, per-container.
|
|
192
|
+
await dockerCli(
|
|
193
|
+
[
|
|
194
|
+
"exec",
|
|
195
|
+
"-d",
|
|
196
|
+
"-u",
|
|
197
|
+
"root",
|
|
198
|
+
// HOME=/home/node is for asdf version resolution ONLY — without the
|
|
199
|
+
// cache redirect below, root's npx writes ROOT-OWNED entries into
|
|
200
|
+
// node's ~/.npm and breaks the node user's own npx (which launches
|
|
201
|
+
// the MCP server). Found live 2026-07-08.
|
|
202
|
+
"-e",
|
|
203
|
+
"HOME=/home/node",
|
|
204
|
+
"-e",
|
|
205
|
+
"npm_config_cache=/tmp/uai-root-npm-cache",
|
|
206
|
+
"-w",
|
|
207
|
+
"/workspace",
|
|
208
|
+
containerName,
|
|
209
|
+
"sh",
|
|
210
|
+
"-lc",
|
|
211
|
+
"{ npx -y playwright@latest install-deps chromium && " +
|
|
212
|
+
"apt-get install -y --no-install-recommends xvfb x11vnc novnc websockify && " +
|
|
213
|
+
// Older-image containers get their packages only HERE (post-apt),
|
|
214
|
+
// so kick the viewer daemons now. Each step subshelled: the steps'
|
|
215
|
+
// internal `exit 0` guards must not abort their siblings.
|
|
216
|
+
`su -s /bin/sh node -c '${VIEWER_STEPS.map((s) => `( ${s} )`)
|
|
217
|
+
.join("; ")
|
|
218
|
+
.replace(/'/g, `'\\''`)}'; ` +
|
|
219
|
+
// Heal any damage a pre-fix host already did to the cache.
|
|
220
|
+
"chown -R node:node /home/node/.npm 2>/dev/null; } " +
|
|
221
|
+
">/tmp/uai-pw-deps.log 2>&1 || true",
|
|
222
|
+
],
|
|
223
|
+
{ timeoutMs: 10_000 },
|
|
224
|
+
);
|
|
225
|
+
console.log(`[browser] task ${taskId}: Playwright MCP wired (installs backgrounded)`);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
// Best-effort by design — a task without a browser still runs.
|
|
228
|
+
console.warn(`[browser] task ${taskId}: setup failed`, err);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Single-quote for `sh -c` (POSIX-safe). */
|
|
233
|
+
function shellQuote(text: string): string {
|
|
234
|
+
return `'${text.replace(/'/g, `'\\''`)}'`;
|
|
235
|
+
}
|