@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.
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +33 -0
- package/lib/agents/claude.ts +28 -13
- package/lib/agents/codex.ts +21 -14
- package/lib/agents/durable-proc.ts +306 -0
- package/lib/agents/transport.ts +229 -0
- package/lib/command-db.ts +10 -2
- package/lib/mcp-connections.ts +77 -45
- package/lib/mcp-gateway.ts +42 -12
- package/lib/orchestrator.ts +103 -9
- package/lib/shared-files.ts +156 -0
- package/lib/standard-image.ts +10 -0
- package/package.json +2 -1
- package/runner/runner.mjs +208 -0
- package/scripts/agent/task-up.sh +36 -0
- package/src/index.ts +10 -0
- package/src/main.ts +51 -0
- package/src/protocol.ts +47 -0
|
@@ -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,
|
package/lib/mcp-connections.ts
CHANGED
|
@@ -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
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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 ${
|
|
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
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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,
|
package/lib/mcp-gateway.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =>
|