@runuai/host 0.4.3 → 0.6.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.
@@ -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
+ }
package/lib/command-db.ts CHANGED
@@ -135,12 +135,12 @@ function insertTask(db: Database.Database, task: CommandTaskRow): void {
135
135
  id, owner_user_id, host_id, name, slug, branch, status,
136
136
  global_context, reviewer_order, agents, pr_context,
137
137
  worktree_path, compose_project, code_server_port, preview_ports,
138
- preview_env, locked_at, started_at, ended_at
138
+ preview_env, owner_org_id, shared_files, locked_at, started_at, ended_at
139
139
  ) VALUES (
140
140
  @id, @owner_user_id, @host_id, @name, @slug, @branch, @status,
141
141
  @global_context, @reviewer_order, @agents, @pr_context,
142
142
  @worktree_path, @compose_project, @code_server_port, @preview_ports,
143
- @preview_env, @locked_at, @started_at, @ended_at
143
+ @preview_env, @owner_org_id, @shared_files, @locked_at, @started_at, @ended_at
144
144
  )`,
145
145
  ).run(task);
146
146
  }
@@ -167,6 +167,8 @@ interface CommandTaskRow {
167
167
  code_server_port: number | null;
168
168
  preview_ports: string;
169
169
  preview_env: string | null;
170
+ owner_org_id: string | null;
171
+ shared_files: string | null;
170
172
  locked_at: number | null;
171
173
  started_at: number | null;
172
174
  ended_at: number | null;
@@ -195,6 +197,8 @@ function withRuntime(
195
197
  code_server_port: runtime?.codeServerPort ?? null,
196
198
  preview_ports: runtime?.previewPorts ?? "[]",
197
199
  preview_env: task.previewEnv ? JSON.stringify(task.previewEnv) : null,
200
+ owner_org_id: task.ownerOrgId ?? null,
201
+ shared_files: task.sharedFiles ?? null,
198
202
  locked_at: runtime?.lockedAt ?? null,
199
203
  started_at: runtime?.startedAt ?? null,
200
204
  ended_at: runtime?.endedAt ?? null,
@@ -219,6 +223,8 @@ function runtimeTask(taskId: string, runtime: HostTask | null): CommandTaskRow {
219
223
  code_server_port: runtime?.codeServerPort ?? null,
220
224
  preview_ports: runtime?.previewPorts ?? "[]",
221
225
  preview_env: null,
226
+ owner_org_id: null,
227
+ shared_files: null,
222
228
  locked_at: runtime?.lockedAt ?? null,
223
229
  started_at: runtime?.startedAt ?? null,
224
230
  ended_at: runtime?.endedAt ?? null,
@@ -266,6 +272,8 @@ CREATE TABLE IF NOT EXISTS uai_tasks (
266
272
  code_server_port integer,
267
273
  preview_ports text NOT NULL DEFAULT '[]',
268
274
  preview_env text,
275
+ owner_org_id text,
276
+ shared_files text,
269
277
  pr_url text,
270
278
  locked_at integer,
271
279
  started_at integer,
@@ -368,31 +368,23 @@ async function oauthComplete(connectionId: string, code: string): Promise<McpAck
368
368
  throw new Error("connection has no pending OAuth flow");
369
369
  }
370
370
 
371
- const body = new URLSearchParams({
372
- grant_type: "authorization_code",
373
- code,
374
- redirect_uri: row.redirectUri,
375
- client_id: row.clientId,
376
- code_verifier: unpack(row.pkceVerifierEnc),
377
- // RFC 8707, mirrored from the authorize request.
378
- resource: row.url,
379
- });
380
- const headers: Record<string, string> = {
381
- "content-type": "application/x-www-form-urlencoded",
382
- accept: "application/json",
383
- };
384
- if (row.clientSecretEnc) {
385
- headers.authorization =
386
- "Basic " +
387
- Buffer.from(`${row.clientId}:${unpack(row.clientSecretEnc)}`).toString(
388
- "base64",
389
- );
390
- }
391
- const res = await timedFetch(row.tokenEndpoint, { method: "POST", headers, body });
392
- const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
393
- if (!res.ok || typeof json.access_token !== "string") {
371
+ const { ok, status, json } = await postTokenEndpoint(
372
+ row.tokenEndpoint,
373
+ {
374
+ grant_type: "authorization_code",
375
+ code,
376
+ redirect_uri: row.redirectUri,
377
+ client_id: row.clientId,
378
+ code_verifier: unpack(row.pkceVerifierEnc),
379
+ // RFC 8707, mirrored from the authorize request.
380
+ resource: row.url,
381
+ },
382
+ row.clientId,
383
+ row.clientSecretEnc ? unpack(row.clientSecretEnc) : undefined,
384
+ );
385
+ if (!ok || typeof json.access_token !== "string") {
394
386
  const detail =
395
- typeof json.error === "string" ? json.error : `status ${res.status}`;
387
+ typeof json.error === "string" ? json.error : `status ${status}`;
396
388
  throw new Error(`token exchange failed (${detail})`);
397
389
  }
398
390
 
@@ -473,6 +465,54 @@ export async function authHeaderFor(
473
465
  return { name: "authorization", value: `Bearer ${secret.accessToken}` };
474
466
  }
475
467
 
468
+ /**
469
+ * POST a token-endpoint request. A client secret rides HTTP Basic first
470
+ * (RFC 6749's MUST-support method, what DCR vendors expect); on failure it
471
+ * retries once with `client_secret` in the body (client_secret_post) —
472
+ * some manual-creds vendors (Slack's user-token endpoint) only read body
473
+ * credentials and answer Basic with bad_client_secret.
474
+ */
475
+ async function postTokenEndpoint(
476
+ tokenEndpoint: string,
477
+ params: Record<string, string>,
478
+ clientId: string,
479
+ clientSecret: string | undefined,
480
+ ): Promise<{ ok: boolean; status: number; json: Record<string, unknown> }> {
481
+ const attempt = async (mode: "basic" | "post") => {
482
+ const body = new URLSearchParams(params);
483
+ const headers: Record<string, string> = {
484
+ "content-type": "application/x-www-form-urlencoded",
485
+ accept: "application/json",
486
+ };
487
+ if (clientSecret) {
488
+ if (mode === "basic") {
489
+ headers.authorization =
490
+ "Basic " +
491
+ Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
492
+ } else {
493
+ body.set("client_secret", clientSecret);
494
+ }
495
+ }
496
+ const res = await timedFetch(tokenEndpoint, {
497
+ method: "POST",
498
+ headers,
499
+ body,
500
+ });
501
+ const json = (await res.json().catch(() => ({}))) as Record<
502
+ string,
503
+ unknown
504
+ >;
505
+ return {
506
+ ok: res.ok && typeof json.access_token === "string",
507
+ status: res.status,
508
+ json,
509
+ };
510
+ };
511
+ const basic = await attempt("basic");
512
+ if (basic.ok || !clientSecret) return basic;
513
+ return attempt("post");
514
+ }
515
+
476
516
  async function refreshTokens(
477
517
  row: schema.McpConnection,
478
518
  secret: OauthSecret,
@@ -480,27 +520,19 @@ async function refreshTokens(
480
520
  if (!secret.refreshToken || !row.tokenEndpoint || !row.clientId) {
481
521
  throw new Error("access token expired and no refresh token is stored");
482
522
  }
483
- const body = new URLSearchParams({
484
- grant_type: "refresh_token",
485
- refresh_token: secret.refreshToken,
486
- client_id: row.clientId,
487
- resource: row.url,
488
- });
489
- const headers: Record<string, string> = {
490
- "content-type": "application/x-www-form-urlencoded",
491
- accept: "application/json",
492
- };
493
- if (row.clientSecretEnc) {
494
- headers.authorization =
495
- "Basic " +
496
- Buffer.from(`${row.clientId}:${unpack(row.clientSecretEnc)}`).toString(
497
- "base64",
498
- );
499
- }
500
- const res = await timedFetch(row.tokenEndpoint, { method: "POST", headers, body });
501
- const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
502
- if (!res.ok || typeof json.access_token !== "string") {
503
- throw new Error(`token refresh failed (status ${res.status})`);
523
+ const { ok, status, json } = await postTokenEndpoint(
524
+ row.tokenEndpoint,
525
+ {
526
+ grant_type: "refresh_token",
527
+ refresh_token: secret.refreshToken,
528
+ client_id: row.clientId,
529
+ resource: row.url,
530
+ },
531
+ row.clientId,
532
+ row.clientSecretEnc ? unpack(row.clientSecretEnc) : undefined,
533
+ );
534
+ if (!ok || typeof json.access_token !== "string") {
535
+ throw new Error(`token refresh failed (status ${status})`);
504
536
  }
505
537
  const next: OauthSecret = {
506
538
  accessToken: json.access_token,
@@ -19,7 +19,7 @@
19
19
  import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
20
20
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
21
21
  import { randomBytes, timingSafeEqual } from "node:crypto";
22
- import { Readable } from "node:stream";
22
+ import { Readable, pipeline } from "node:stream";
23
23
  import { resolve } from "node:path";
24
24
 
25
25
  import { env } from "./env";
@@ -31,7 +31,11 @@ const BIND = process.env.UAI_MCP_GATEWAY_BIND ?? "127.0.0.1";
31
31
  /** MCP request bodies are small JSON-RPC frames; cap so audit parsing (and a
32
32
  * hostile container) can't balloon host memory. Responses stream freely. */
33
33
  const MAX_BODY_BYTES = 4 * 1024 * 1024;
34
- const UPSTREAM_TIMEOUT_MS = 120_000;
34
+ /** Bounds the upstream CONNECT (until response headers) only. The response
35
+ * BODY must never be time-bounded: MCP streamable-HTTP clients hold SSE
36
+ * streams open indefinitely, and an abort mid-pipe once took the whole host
37
+ * process down as an unhandled Readable 'error' (2026-07-13). */
38
+ const UPSTREAM_CONNECT_TIMEOUT_MS = 60_000;
35
39
 
36
40
  export interface TaskMcpConnection {
37
41
  id: string;
@@ -181,15 +185,29 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
181
185
  if (typeof v === "string") headers[name] = v;
182
186
  }
183
187
 
188
+ // The controller lives for the whole exchange: the timer only guards the
189
+ // connect phase (cleared once headers land), and a client hang-up reaps the
190
+ // upstream so dead streams don't accumulate.
191
+ const controller = new AbortController();
192
+ res.on("close", () => controller.abort());
193
+
184
194
  const attempt = async (forceRefresh: boolean): Promise<Response> => {
185
195
  const auth = await authHeaderFor(conn, forceRefresh);
186
196
  if (auth) headers[auth.name] = auth.value;
187
- return fetch(conn.url, {
188
- method: req.method,
189
- headers,
190
- body: body ?? undefined,
191
- signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
192
- });
197
+ const connectTimer = setTimeout(
198
+ () => controller.abort(),
199
+ UPSTREAM_CONNECT_TIMEOUT_MS,
200
+ );
201
+ try {
202
+ return await fetch(conn.url, {
203
+ method: req.method,
204
+ headers,
205
+ body: body ?? undefined,
206
+ signal: controller.signal,
207
+ });
208
+ } finally {
209
+ clearTimeout(connectTimer);
210
+ }
193
211
  };
194
212
 
195
213
  let upstream: Response;
@@ -220,8 +238,17 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
220
238
  }
221
239
  res.writeHead(upstream.status, resHeaders);
222
240
  if (upstream.body) {
223
- Readable.fromWeb(upstream.body as Parameters<typeof Readable.fromWeb>[0]).pipe(
241
+ // pipeline (NOT .pipe) so a mid-stream error — client gone, upstream
242
+ // reset, abort — tears both ends down instead of crashing the process
243
+ // as an unhandled Readable 'error'.
244
+ pipeline(
245
+ Readable.fromWeb(upstream.body as Parameters<typeof Readable.fromWeb>[0]),
224
246
  res,
247
+ (err) => {
248
+ if (err && err.name !== "AbortError") {
249
+ audit({ taskId, slug, stream: "ended", error: err.name });
250
+ }
251
+ },
225
252
  );
226
253
  } else {
227
254
  res.end();
@@ -248,13 +275,14 @@ const MERGE_MCP_JSON = `
248
275
  const fs = require("fs");
249
276
  const p = "/workspace/.mcp.json";
250
277
  let j = {};
251
- try { j = JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
278
+ let existed = true;
279
+ try { j = JSON.parse(fs.readFileSync(p, "utf8")); } catch { existed = false; }
252
280
  j.mcpServers = j.mcpServers || {};
253
281
  let changed = false;
254
282
  for (const [k, v] of Object.entries(JSON.parse(process.argv[1]))) {
255
283
  if (JSON.stringify(j.mcpServers[k]) !== JSON.stringify(v)) { j.mcpServers[k] = v; changed = true; }
256
284
  }
257
- if (changed) fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\\n");
285
+ if (changed || !existed) fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\\n");
258
286
  `.trim();
259
287
 
260
288
  function shellQuote(value: string): string {
@@ -273,7 +301,9 @@ export async function setupMcpTaskConfig(
273
301
  connections: TaskMcpConnection[],
274
302
  hasCodex: boolean,
275
303
  ): Promise<void> {
276
- if (connections.length === 0) return;
304
+ // No early return on empty: the claude adapter passes
305
+ // `--mcp-config /workspace/.mcp.json` unconditionally (ADR-057), so the
306
+ // file must exist — an empty mcpServers map — even with no connections.
277
307
  try {
278
308
  const acl = ensureTaskGatewayAcl(taskId, connections);
279
309
  const urlFor = (slug: string): string =>