@runuai/host 0.4.2 → 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.
@@ -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
+ );
@@ -57,6 +57,13 @@
57
57
  "when": 1779900008000,
58
58
  "tag": "0007_host_github_token_kind",
59
59
  "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "6",
64
+ "when": 1779900009000,
65
+ "tag": "0008_host_mcp_connections",
66
+ "breakpoints": true
60
67
  }
61
68
  ]
62
- }
69
+ }
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
@@ -212,6 +212,11 @@ async function main() {
212
212
  break;
213
213
  }
214
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
+ }
215
220
  default:
216
221
  console.error([
217
222
  "uai — agent CLI. Commands:",
@@ -222,6 +227,7 @@ async function main() {
222
227
  " uai memory search <query>",
223
228
  " uai memory save <text> [--project id] [--tags a,b]",
224
229
  " uai memory delete <id>",
230
+ " uai react <heart|check|x> [--msg #id] (no --msg = the message you were last handed)",
225
231
  " uai whoami",
226
232
  ].join("\\n"));
227
233
  process.exit(argv.length ? 1 : 0);
@@ -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) is the default: the strongest coding model
41
- // for the interactive loop. Update alongside CLAUDE_MODELS.
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
@@ -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.5 is Codex's current frontier coding model. Update alongside
61
- // CODEX_MODELS. When an agent's model is null the adapter omits the override
62
- // entirely and Codex uses the user's own configured default.
63
- const CODEX_DEFAULT_MODEL = "gpt-5.5";
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 the
66
- // picker's "Extra high". UPDATE WHEN CODEX CHANGES its reasoning levels.
67
- const CODEX_EFFORTS = ["low", "medium", "high", "xhigh"];
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 is Codex's default reasoning level. Update alongside CODEX_EFFORTS.
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
+ }