@runuai/host 0.4.2 → 0.5.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/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/meta/_journal.json +15 -1
- package/db/schema.ts +61 -0
- package/images/standard/Dockerfile +15 -0
- package/lib/agent-cli.ts +6 -0
- package/lib/agents/claude.ts +22 -16
- package/lib/agents/codex.ts +36 -22
- package/lib/agents/durable-proc.ts +306 -0
- package/lib/agents/transport.ts +229 -0
- package/lib/browser-testing.ts +235 -0
- package/lib/mcp-connections.ts +554 -0
- package/lib/mcp-gateway.ts +342 -0
- package/lib/orchestrator.ts +274 -14
- package/lib/standard-image.ts +137 -10
- package/package.json +2 -1
- package/runner/runner.mjs +208 -0
- package/scripts/agent/task-up.sh +10 -0
- package/src/index.ts +52 -1
- package/src/main.ts +102 -0
- package/src/protocol.ts +82 -2
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent transport selection (ADR-061). Adapters call createAgentTransport()
|
|
3
|
+
* where they used to build a LineProcess directly; it returns either:
|
|
4
|
+
*
|
|
5
|
+
* - legacy pipes (`docker exec -i <cli>`, LineProcess) when
|
|
6
|
+
* UAI_DURABLE_SESSIONS=0, or
|
|
7
|
+
* - the durable path: an in-container runner owning the CLI, driven through
|
|
8
|
+
* inbox/outbox files on the workspace mount (DurableProcess).
|
|
9
|
+
*
|
|
10
|
+
* Durable mode makes host restarts transparent WITHOUT orchestrator
|
|
11
|
+
* changes: the orchestrator's lazy respawn calls factory.create as always,
|
|
12
|
+
* and this helper quietly ATTACHES to a still-live runner (fresh heartbeat +
|
|
13
|
+
* a `running` host_agent_sessions row) at the persisted outbox offset
|
|
14
|
+
* instead of spawning a new CLI — the agent's in-memory context survives.
|
|
15
|
+
* Adapters that re-handshake per process (codex) pass allowAttach:false and
|
|
16
|
+
* get a fresh runner each create — the stale predecessor is asked to stop
|
|
17
|
+
* via its inbox first.
|
|
18
|
+
*
|
|
19
|
+
* The runner script is COPIED into each session dir (not bind-mounted):
|
|
20
|
+
* every existing container already sees the workspace at its identical host
|
|
21
|
+
* path, so durable sessions work for containers created before this
|
|
22
|
+
* feature, and each spawn ships the runner version matching this host.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { copyFileSync, mkdirSync, statSync, promises as fsp } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
|
|
28
|
+
import { and, eq } from "drizzle-orm";
|
|
29
|
+
|
|
30
|
+
import { getDb, schema } from "../db";
|
|
31
|
+
import { taskWorkspaceDir } from "../env";
|
|
32
|
+
import { DurableProcess, runnerScriptPath } from "./durable-proc";
|
|
33
|
+
import { LineProcess, dockerExecArgs, type ExitHandler, type LineHandler } from "./proc";
|
|
34
|
+
|
|
35
|
+
/** The shared surface adapters program against (LineProcess's shape). */
|
|
36
|
+
export interface LineTransport {
|
|
37
|
+
onLine(handler: LineHandler): void;
|
|
38
|
+
onExit(handler: ExitHandler): void;
|
|
39
|
+
writeLine(value: unknown): void;
|
|
40
|
+
readonly stderrTail: string;
|
|
41
|
+
readonly isClosed: boolean;
|
|
42
|
+
close(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AgentTransportOptions {
|
|
46
|
+
taskId: string;
|
|
47
|
+
agentId: string;
|
|
48
|
+
containerName: string;
|
|
49
|
+
/** CLI + args exactly as they'd follow `docker exec -i <container>`. */
|
|
50
|
+
cli: string;
|
|
51
|
+
cliArgs: string[];
|
|
52
|
+
/** Env names forwarded from the host's own env (`-e NAME`). */
|
|
53
|
+
passEnv?: string[];
|
|
54
|
+
/** Explicit per-exec env values (`-e NAME=value`). */
|
|
55
|
+
explicitEnv?: Record<string, string>;
|
|
56
|
+
/**
|
|
57
|
+
* Whether a live runner from a previous host process may be attached to.
|
|
58
|
+
* True for stateless adapters (claude); false for ones whose host-side
|
|
59
|
+
* protocol state can't outlive the host process (codex handshake).
|
|
60
|
+
*/
|
|
61
|
+
allowAttach: boolean;
|
|
62
|
+
kind: string;
|
|
63
|
+
debugLabel?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Runner heartbeat is 5 s; older than this = not attachable. */
|
|
67
|
+
const ATTACH_HEARTBEAT_FRESH_MS = 20_000;
|
|
68
|
+
|
|
69
|
+
function durableEnabled(): boolean {
|
|
70
|
+
return process.env.UAI_DURABLE_SESSIONS !== "0";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createAgentTransport(opts: AgentTransportOptions): LineTransport {
|
|
74
|
+
if (!durableEnabled()) {
|
|
75
|
+
const { command, args } = dockerExecArgs(
|
|
76
|
+
opts.containerName,
|
|
77
|
+
opts.cli,
|
|
78
|
+
opts.cliArgs,
|
|
79
|
+
opts.passEnv ?? [],
|
|
80
|
+
opts.explicitEnv ?? {},
|
|
81
|
+
);
|
|
82
|
+
return new LineProcess({ command, args, debugLabel: opts.debugLabel });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const db = getDb();
|
|
86
|
+
const row = db
|
|
87
|
+
.select()
|
|
88
|
+
.from(schema.hostAgentSessions)
|
|
89
|
+
.where(
|
|
90
|
+
and(
|
|
91
|
+
eq(schema.hostAgentSessions.taskId, opts.taskId),
|
|
92
|
+
eq(schema.hostAgentSessions.agentId, opts.agentId),
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
.get();
|
|
96
|
+
|
|
97
|
+
const persistOffset = (offset: number): void => {
|
|
98
|
+
db.update(schema.hostAgentSessions)
|
|
99
|
+
.set({ outboxOffset: offset, updatedAt: Date.now() })
|
|
100
|
+
.where(
|
|
101
|
+
and(
|
|
102
|
+
eq(schema.hostAgentSessions.taskId, opts.taskId),
|
|
103
|
+
eq(schema.hostAgentSessions.agentId, opts.agentId),
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
.run();
|
|
107
|
+
};
|
|
108
|
+
const markClosed = (): void => {
|
|
109
|
+
db.update(schema.hostAgentSessions)
|
|
110
|
+
.set({ status: "closed", updatedAt: Date.now() })
|
|
111
|
+
.where(
|
|
112
|
+
and(
|
|
113
|
+
eq(schema.hostAgentSessions.taskId, opts.taskId),
|
|
114
|
+
eq(schema.hostAgentSessions.agentId, opts.agentId),
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
.run();
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// ---- Attach: a previous host process left this agent's runner alive. ----
|
|
121
|
+
if (opts.allowAttach && row && row.status === "running" && heartbeatFresh(row.sessionDir)) {
|
|
122
|
+
const proc = new DurableProcess({
|
|
123
|
+
hostSessionDir: row.sessionDir,
|
|
124
|
+
initialOutboxOffset: row.outboxOffset,
|
|
125
|
+
onOffsetAdvance: persistOffset,
|
|
126
|
+
onCloseRequested: markClosed,
|
|
127
|
+
debugLabel: opts.debugLabel,
|
|
128
|
+
});
|
|
129
|
+
proc.onExit(markClosed);
|
|
130
|
+
return proc;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- Spawn a fresh runner, asking any predecessor to stop. --------------
|
|
134
|
+
// Unconditional (not gated on row.status): a runner falsely marked closed
|
|
135
|
+
// by a stale-heartbeat verdict may still be alive, and a stop appended to
|
|
136
|
+
// a dead session's inbox is harmless — this is what guarantees one live
|
|
137
|
+
// CLI per (task, agent).
|
|
138
|
+
if (row) {
|
|
139
|
+
void fsp
|
|
140
|
+
.appendFile(join(row.sessionDir, "inbox.jsonl"), '{"__uai":"stop"}\n')
|
|
141
|
+
.catch(() => {
|
|
142
|
+
// Dir already gone / runner already dead — nothing to stop.
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const sessionDir = join(
|
|
147
|
+
taskWorkspaceDir(opts.taskId),
|
|
148
|
+
".uai",
|
|
149
|
+
"sessions",
|
|
150
|
+
opts.agentId,
|
|
151
|
+
String(Date.now()),
|
|
152
|
+
);
|
|
153
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
154
|
+
// Ship this host's runner with the session: works in containers created
|
|
155
|
+
// before this feature (the workspace is mounted at its host path) and
|
|
156
|
+
// never skews against the host version.
|
|
157
|
+
const runnerInSession = join(sessionDir, "runner.mjs");
|
|
158
|
+
copyFileSync(runnerScriptPath(), runnerInSession);
|
|
159
|
+
|
|
160
|
+
const envArgs: string[] = [];
|
|
161
|
+
for (const name of opts.passEnv ?? []) {
|
|
162
|
+
if (process.env[name]) envArgs.push("-e", name);
|
|
163
|
+
}
|
|
164
|
+
for (const [name, value] of Object.entries(opts.explicitEnv ?? {})) {
|
|
165
|
+
envArgs.push("-e", `${name}=${value}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const proc = new DurableProcess({
|
|
169
|
+
hostSessionDir: sessionDir,
|
|
170
|
+
onOffsetAdvance: persistOffset,
|
|
171
|
+
onCloseRequested: markClosed,
|
|
172
|
+
debugLabel: opts.debugLabel,
|
|
173
|
+
spawnCommand: {
|
|
174
|
+
command: "docker",
|
|
175
|
+
args: [
|
|
176
|
+
"exec",
|
|
177
|
+
"-d",
|
|
178
|
+
...envArgs,
|
|
179
|
+
opts.containerName,
|
|
180
|
+
"node",
|
|
181
|
+
runnerInSession, // identical path in-container (workspace self-mount)
|
|
182
|
+
sessionDir,
|
|
183
|
+
"--",
|
|
184
|
+
opts.cli,
|
|
185
|
+
...opts.cliArgs,
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
db.insert(schema.hostAgentSessions)
|
|
192
|
+
.values({
|
|
193
|
+
taskId: opts.taskId,
|
|
194
|
+
agentId: opts.agentId,
|
|
195
|
+
sessionDir,
|
|
196
|
+
containerName: opts.containerName,
|
|
197
|
+
kind: opts.kind,
|
|
198
|
+
outboxOffset: 0,
|
|
199
|
+
status: "running",
|
|
200
|
+
createdAt: now,
|
|
201
|
+
updatedAt: now,
|
|
202
|
+
})
|
|
203
|
+
.onConflictDoUpdate({
|
|
204
|
+
target: [schema.hostAgentSessions.taskId, schema.hostAgentSessions.agentId],
|
|
205
|
+
set: {
|
|
206
|
+
sessionDir,
|
|
207
|
+
containerName: opts.containerName,
|
|
208
|
+
kind: opts.kind,
|
|
209
|
+
outboxOffset: 0,
|
|
210
|
+
status: "running",
|
|
211
|
+
updatedAt: now,
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
.run();
|
|
215
|
+
|
|
216
|
+
proc.onExit(markClosed);
|
|
217
|
+
return proc;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function heartbeatFresh(sessionDir: string): boolean {
|
|
221
|
+
try {
|
|
222
|
+
return (
|
|
223
|
+
Date.now() - statSync(join(sessionDir, "heartbeat")).mtimeMs <
|
|
224
|
+
ATTACH_HEARTBEAT_FRESH_MS
|
|
225
|
+
);
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -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
|
+
}
|