@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
package/lib/orchestrator.ts
CHANGED
|
@@ -92,10 +92,16 @@ interface Channel {
|
|
|
92
92
|
/** Per-agent respawn counter — bounded so a broken agent can't
|
|
93
93
|
* loop forever rewriting its config. */
|
|
94
94
|
respawns: Map<string, number>;
|
|
95
|
+
/** When each agent last burned a respawn — a budget older than the
|
|
96
|
+
* cooldown resets, so a task self-heals once the underlying failure
|
|
97
|
+
* (missing CLI, broken config) clears instead of needing an operator. */
|
|
98
|
+
respawnLastAt: Map<string, number>;
|
|
95
99
|
/** ADR-049: humans in the chat (from the latest channel spec). */
|
|
96
100
|
humans: ChannelHuman[];
|
|
97
101
|
/** ADR-053: wire the Playwright MCP browser at session start. */
|
|
98
102
|
browserTesting: boolean;
|
|
103
|
+
/** ADR-062: shared-files mount mode ("off" | "ro" | "rw") for the preamble. */
|
|
104
|
+
sharedFiles: string;
|
|
99
105
|
/** ADR-057: the owner's MCP connections exposed through the host gateway. */
|
|
100
106
|
mcpConnections: Array<{ id: string; slug: string }>;
|
|
101
107
|
/** Last connection set written into the container (skip repeat execs —
|
|
@@ -109,6 +115,8 @@ interface Channel {
|
|
|
109
115
|
|
|
110
116
|
/** Hard cap on automatic respawns per agent per channel lifetime. */
|
|
111
117
|
const MAX_RESPAWNS_PER_AGENT = 5;
|
|
118
|
+
/** A burned respawn budget resets after this quiet period (see reconcile). */
|
|
119
|
+
const RESPAWN_COOLDOWN_MS = 10 * 60_000;
|
|
112
120
|
|
|
113
121
|
/** Substrings in an agent's error output that mean "config was
|
|
114
122
|
* unlinked between runs" — repair-and-respawn covers the common
|
|
@@ -174,6 +182,7 @@ class Orchestrator {
|
|
|
174
182
|
}
|
|
175
183
|
channel.humans = spec.humans ?? [];
|
|
176
184
|
channel.browserTesting = spec.browserTesting === true;
|
|
185
|
+
channel.sharedFiles = spec.sharedFiles ?? "ro";
|
|
177
186
|
channel.mcpConnections = spec.mcpConnections ?? [];
|
|
178
187
|
for (const agent of channel.roster) {
|
|
179
188
|
channel.preambles.set(
|
|
@@ -187,6 +196,7 @@ class Orchestrator {
|
|
|
187
196
|
spec.branch,
|
|
188
197
|
channel.humans,
|
|
189
198
|
channel.browserTesting,
|
|
199
|
+
channel.sharedFiles,
|
|
190
200
|
),
|
|
191
201
|
);
|
|
192
202
|
}
|
|
@@ -214,6 +224,7 @@ class Orchestrator {
|
|
|
214
224
|
spec.branch,
|
|
215
225
|
spec.humans,
|
|
216
226
|
spec.browserTesting,
|
|
227
|
+
spec.sharedFiles ?? "ro",
|
|
217
228
|
),
|
|
218
229
|
);
|
|
219
230
|
// ADR-046: materialise this agent's skills to its per-agent SKILL.md in
|
|
@@ -240,8 +251,10 @@ class Orchestrator {
|
|
|
240
251
|
openTurns: new Set(),
|
|
241
252
|
interrupted: new Set(),
|
|
242
253
|
respawns: new Map(),
|
|
254
|
+
respawnLastAt: new Map(),
|
|
243
255
|
humans: spec.humans ?? [],
|
|
244
256
|
browserTesting: spec.browserTesting === true,
|
|
257
|
+
sharedFiles: spec.sharedFiles ?? "ro",
|
|
245
258
|
mcpConnections: spec.mcpConnections ?? [],
|
|
246
259
|
spawning: new Set(),
|
|
247
260
|
};
|
|
@@ -273,7 +286,14 @@ class Orchestrator {
|
|
|
273
286
|
(ok) => {
|
|
274
287
|
if (!ok) channel.sessionsReady = null;
|
|
275
288
|
},
|
|
276
|
-
() => {
|
|
289
|
+
(err: unknown) => {
|
|
290
|
+
// A start failure retries on the next ensure — but it must be
|
|
291
|
+
// VISIBLE: an unlogged throw here once looped silently every ~2s
|
|
292
|
+
// while a task sat dead with no sessions and no trace.
|
|
293
|
+
console.warn(
|
|
294
|
+
`[orchestrator] ${channel.taskId}: session start failed: ` +
|
|
295
|
+
`${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
|
|
296
|
+
);
|
|
277
297
|
channel.sessionsReady = null;
|
|
278
298
|
},
|
|
279
299
|
);
|
|
@@ -295,6 +315,7 @@ class Orchestrator {
|
|
|
295
315
|
private async ensureMcpConfig(channel: Channel): Promise<void> {
|
|
296
316
|
const fingerprint = JSON.stringify(channel.mcpConnections.map((c) => c.id));
|
|
297
317
|
if (channel.mcpConfigFingerprint === fingerprint) return;
|
|
318
|
+
const hadPrevious = channel.mcpConfigFingerprint !== undefined;
|
|
298
319
|
channel.mcpConfigFingerprint = fingerprint;
|
|
299
320
|
await setupMcpTaskConfig(
|
|
300
321
|
channel.taskId,
|
|
@@ -302,18 +323,62 @@ class Orchestrator {
|
|
|
302
323
|
channel.mcpConnections,
|
|
303
324
|
channel.roster.some((a) => a.kind === "codex"),
|
|
304
325
|
);
|
|
326
|
+
// Agent CLIs read MCP servers once, at process start — and durable
|
|
327
|
+
// sessions (ADR-061) make processes long-lived, so without this a
|
|
328
|
+
// connection added mid-task stays invisible indefinitely. Recycle IDLE
|
|
329
|
+
// sessions so the change actually reaches the agents; busy agents keep
|
|
330
|
+
// their turn (and in-memory context) and pick it up on a later change or
|
|
331
|
+
// natural respawn. Trade-off: a recycled agent loses its in-memory
|
|
332
|
+
// context and is re-briefed from chat — same as any respawn, and better
|
|
333
|
+
// than never seeing the connection the user just added. Skipped on the
|
|
334
|
+
// channel's first fingerprint (sessions just spawned with this config).
|
|
335
|
+
if (hadPrevious) await this.recycleIdleSessions(channel);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Close + respawn every session with no open turn (MCP set changed). */
|
|
339
|
+
private async recycleIdleSessions(channel: Channel): Promise<void> {
|
|
340
|
+
let recycled = 0;
|
|
341
|
+
for (const [agentId, session] of [...channel.sessions]) {
|
|
342
|
+
if (channel.openTurns.has(agentId)) continue; // never mid-turn
|
|
343
|
+
if (channel.spawning.has(agentId)) continue;
|
|
344
|
+
channel.sessions.delete(agentId);
|
|
345
|
+
try {
|
|
346
|
+
// close() flips the adapter's `closed` flag first, so the session's
|
|
347
|
+
// exit is suppressed — no budget increment, no agent.exit noise, no
|
|
348
|
+
// race with the respawn below (recoverClaudeAgent's pattern).
|
|
349
|
+
await session.close();
|
|
350
|
+
} catch {
|
|
351
|
+
// Already dead — close is idempotent for our adapters.
|
|
352
|
+
}
|
|
353
|
+
recycled += 1;
|
|
354
|
+
}
|
|
355
|
+
if (recycled > 0) {
|
|
356
|
+
console.log(
|
|
357
|
+
`[orchestrator] ${channel.taskId}: recycled ${recycled} idle session(s) for MCP config change`,
|
|
358
|
+
);
|
|
359
|
+
await this.reconcileSessions(channel);
|
|
360
|
+
}
|
|
305
361
|
}
|
|
306
362
|
|
|
307
363
|
/** Spawn sessions for roster agents added after the initial start. */
|
|
308
364
|
private async reconcileSessions(channel: Channel): Promise<void> {
|
|
309
|
-
const missing = channel.roster.filter(
|
|
310
|
-
(agent)
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
365
|
+
const missing = channel.roster.filter((agent) => {
|
|
366
|
+
if (channel.sessions.has(agent.id) || channel.spawning.has(agent.id)) {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
// Crash-loop budget: an agent whose session keeps dying stops being
|
|
370
|
+
// respawned after MAX_RESPAWNS_PER_AGENT (the exit paths increment) —
|
|
371
|
+
// but a budget that has been cold for RESPAWN_COOLDOWN_MS resets, so
|
|
372
|
+
// the task heals itself once the cause (a missing CLI on the shared
|
|
373
|
+
// volume, a broken config) is fixed, instead of staying dead until a
|
|
374
|
+
// host restart.
|
|
375
|
+
if ((channel.respawns.get(agent.id) ?? 0) > MAX_RESPAWNS_PER_AGENT) {
|
|
376
|
+
const lastAt = channel.respawnLastAt.get(agent.id) ?? 0;
|
|
377
|
+
if (Date.now() - lastAt < RESPAWN_COOLDOWN_MS) return false;
|
|
378
|
+
channel.respawns.set(agent.id, 0);
|
|
379
|
+
}
|
|
380
|
+
return true;
|
|
381
|
+
});
|
|
317
382
|
if (missing.length === 0) return;
|
|
318
383
|
|
|
319
384
|
const task = getHostTask(channel.taskId);
|
|
@@ -613,6 +678,7 @@ class Orchestrator {
|
|
|
613
678
|
// after a double SIGKILL). Bounded by the respawn budget, checked in
|
|
614
679
|
// reconcileSessions.
|
|
615
680
|
channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
|
|
681
|
+
channel.respawnLastAt.set(agentId, Date.now());
|
|
616
682
|
channel.sessions.delete(agentId);
|
|
617
683
|
break;
|
|
618
684
|
}
|
|
@@ -635,6 +701,7 @@ class Orchestrator {
|
|
|
635
701
|
// Same zombie hazard as the error path — a session whose process
|
|
636
702
|
// ended (even cleanly) can never carry another turn.
|
|
637
703
|
channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
|
|
704
|
+
channel.respawnLastAt.set(agentId, Date.now());
|
|
638
705
|
channel.sessions.delete(agentId);
|
|
639
706
|
break;
|
|
640
707
|
}
|
|
@@ -653,6 +720,7 @@ class Orchestrator {
|
|
|
653
720
|
): Promise<void> {
|
|
654
721
|
const tries = (channel.respawns.get(agentId) ?? 0) + 1;
|
|
655
722
|
channel.respawns.set(agentId, tries);
|
|
723
|
+
channel.respawnLastAt.set(agentId, Date.now());
|
|
656
724
|
|
|
657
725
|
if (tries > MAX_RESPAWNS_PER_AGENT) {
|
|
658
726
|
this.emitHost({
|
|
@@ -925,6 +993,7 @@ export function buildSystemPreamble(
|
|
|
925
993
|
taskBranch: string,
|
|
926
994
|
humans?: ChannelHuman[],
|
|
927
995
|
browserTesting?: boolean,
|
|
996
|
+
sharedFiles?: string,
|
|
928
997
|
): string {
|
|
929
998
|
const channelList = roster
|
|
930
999
|
.map((a) =>
|
|
@@ -1065,6 +1134,31 @@ export function buildSystemPreamble(
|
|
|
1065
1134
|
"part of the project. Never review, edit, stage, commit, or flag it;",
|
|
1066
1135
|
"treat it as ignored, even though git may show it as untracked.",
|
|
1067
1136
|
"",
|
|
1137
|
+
// ADR-062: shared files — only when this container actually carries the
|
|
1138
|
+
// mounts (task-up drops a marker; pre-feature containers have none).
|
|
1139
|
+
...(sharedFiles &&
|
|
1140
|
+
sharedFiles !== "off" &&
|
|
1141
|
+
existsSync(join(workspacePath, ".uai", "files-mounted"))
|
|
1142
|
+
? [
|
|
1143
|
+
"## Shared files",
|
|
1144
|
+
"",
|
|
1145
|
+
`Non-code files (${sharedFiles === "rw" ? "read-write" : "READ-ONLY"} for this task):`,
|
|
1146
|
+
"- `/workspace/files/org` — the org's shared files (logos, specs,",
|
|
1147
|
+
" datasets), visible to every task in the org on this host.",
|
|
1148
|
+
"- `/workspace/files/me` — the task owner's personal files, shared",
|
|
1149
|
+
" across their tasks on this host.",
|
|
1150
|
+
...(sharedFiles === "rw"
|
|
1151
|
+
? [
|
|
1152
|
+
"When producing artifacts for humans, write them here (use a",
|
|
1153
|
+
"subdirectory named after the task to avoid collisions).",
|
|
1154
|
+
]
|
|
1155
|
+
: [
|
|
1156
|
+
"Read-only here: to hand a file back, use the chat attachment",
|
|
1157
|
+
"flow instead.",
|
|
1158
|
+
]),
|
|
1159
|
+
"",
|
|
1160
|
+
]
|
|
1161
|
+
: []),
|
|
1068
1162
|
// ADR-046: link/document skills are materialised to a per-agent SKILL.md.
|
|
1069
1163
|
// Point the agent at its own file (package skills are handled below).
|
|
1070
1164
|
...((agent.skills ?? []).some((s) => s.type !== "package")
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-062 shared files — host-side handler for the `filesOp` bridge command.
|
|
3
|
+
* Two roots per host: org files (per host+org) and personal files (per
|
|
4
|
+
* host+user), both under <workspaceRoot>/shared/. The cloud is a window
|
|
5
|
+
* only; this module is the sole writer host-side.
|
|
6
|
+
*
|
|
7
|
+
* SECURITY: the cloud relays paths from browsers, and containers mount these
|
|
8
|
+
* same dirs — every path is treated as hostile. Two gates on every op:
|
|
9
|
+
* `resolveInsideRoot` (relative-only, ".."-free, resolved-prefix check) and
|
|
10
|
+
* `assertRealInsideRoot` (realpath containment, so a symlink the container
|
|
11
|
+
* planted inside a rw mount cannot point ops outside the root).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { promises as fsp } from "node:fs";
|
|
15
|
+
import { dirname, join, normalize, resolve, sep } from "node:path";
|
|
16
|
+
|
|
17
|
+
import type { FilesEntry, FilesOpInput, FilesOpValue } from "../src/protocol";
|
|
18
|
+
import { env } from "./env";
|
|
19
|
+
|
|
20
|
+
/** Max bytes returned per read chunk (base64 expands ~4/3; WS cap is 16MB). */
|
|
21
|
+
const MAX_READ_CHUNK = 4 * 1024 * 1024;
|
|
22
|
+
/** Max bytes accepted per write chunk. */
|
|
23
|
+
const MAX_WRITE_CHUNK = 8 * 1024 * 1024;
|
|
24
|
+
/** Max entries returned by a single list. */
|
|
25
|
+
const MAX_LIST_ENTRIES = 2_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Filesystem-safe directory name for an org/user id. Personal org ids are
|
|
29
|
+
* `personal:<userId>` — the colon breaks docker's short-syntax bind spec
|
|
30
|
+
* (src:dst:mode), so it is mapped to "_". MUST stay in lockstep with the
|
|
31
|
+
* identical mapping in scripts/agent/task-up.sh (shared_fs_name).
|
|
32
|
+
*/
|
|
33
|
+
export function sharedDirName(id: string): string {
|
|
34
|
+
return id.replace(/:/g, "_");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function sharedRoot(scope: "org" | "me", orgId: string, userId: string): string {
|
|
38
|
+
return scope === "org"
|
|
39
|
+
? resolve(env.workspaceRoot, "shared", "orgs", sharedDirName(orgId))
|
|
40
|
+
: resolve(env.workspaceRoot, "shared", "users", sharedDirName(userId));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveInsideRoot(root: string, relPath: string): string {
|
|
44
|
+
// Relative paths only — an absolute path is rejected, never reinterpreted.
|
|
45
|
+
if (/^[/\\]/.test(relPath) || /^[A-Za-z]:/.test(relPath)) {
|
|
46
|
+
throw new Error("invalid path");
|
|
47
|
+
}
|
|
48
|
+
const cleaned = normalize(relPath);
|
|
49
|
+
if (
|
|
50
|
+
cleaned.split(/[/\\]/).some((seg) => seg === "..") ||
|
|
51
|
+
cleaned.includes("\0")
|
|
52
|
+
) {
|
|
53
|
+
throw new Error("invalid path");
|
|
54
|
+
}
|
|
55
|
+
const target = resolve(root, cleaned);
|
|
56
|
+
if (target !== root && !target.startsWith(root + sep)) {
|
|
57
|
+
throw new Error("invalid path");
|
|
58
|
+
}
|
|
59
|
+
return target;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* String containment (resolveInsideRoot) can't see symlinks the container
|
|
64
|
+
* planted inside a rw mount — `root/evil → /` would pass it. Walk up from
|
|
65
|
+
* the target to its deepest EXISTING ancestor, realpath it, and require the
|
|
66
|
+
* REAL location to stay inside the real root (ADR-062 "no symlink escapes").
|
|
67
|
+
*/
|
|
68
|
+
async function assertRealInsideRoot(root: string, target: string): Promise<void> {
|
|
69
|
+
const realRoot = await fsp.realpath(root);
|
|
70
|
+
let probe = target;
|
|
71
|
+
for (;;) {
|
|
72
|
+
try {
|
|
73
|
+
const real = await fsp.realpath(probe);
|
|
74
|
+
if (real !== realRoot && !real.startsWith(realRoot + sep)) {
|
|
75
|
+
throw new Error("invalid path");
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
80
|
+
const parent = dirname(probe);
|
|
81
|
+
if (parent === probe) throw new Error("invalid path");
|
|
82
|
+
probe = parent;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function handleFilesOp(input: FilesOpInput): Promise<FilesOpValue> {
|
|
88
|
+
if (!input.orgId && input.scope === "org") throw new Error("missing orgId");
|
|
89
|
+
if (!input.userId && input.scope === "me") throw new Error("missing userId");
|
|
90
|
+
const root = sharedRoot(input.scope, input.orgId, input.userId);
|
|
91
|
+
await fsp.mkdir(root, { recursive: true });
|
|
92
|
+
const target = resolveInsideRoot(root, input.path ?? "");
|
|
93
|
+
await assertRealInsideRoot(root, target);
|
|
94
|
+
|
|
95
|
+
switch (input.op) {
|
|
96
|
+
case "list": {
|
|
97
|
+
const names = await fsp.readdir(target);
|
|
98
|
+
const entries: FilesEntry[] = [];
|
|
99
|
+
for (const name of names.slice(0, MAX_LIST_ENTRIES)) {
|
|
100
|
+
try {
|
|
101
|
+
const st = await fsp.lstat(join(target, name));
|
|
102
|
+
entries.push({
|
|
103
|
+
name,
|
|
104
|
+
dir: st.isDirectory(),
|
|
105
|
+
size: st.isDirectory() ? 0 : st.size,
|
|
106
|
+
mtimeMs: st.mtimeMs,
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
// Raced deletion — skip.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
entries.sort((a, b) =>
|
|
113
|
+
a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1,
|
|
114
|
+
);
|
|
115
|
+
return { entries };
|
|
116
|
+
}
|
|
117
|
+
case "read": {
|
|
118
|
+
const st = await fsp.stat(target);
|
|
119
|
+
if (st.isDirectory()) throw new Error("is a directory");
|
|
120
|
+
const offset = Math.max(0, input.offset ?? 0);
|
|
121
|
+
const length = Math.min(input.length ?? MAX_READ_CHUNK, MAX_READ_CHUNK);
|
|
122
|
+
const fh = await fsp.open(target, "r");
|
|
123
|
+
try {
|
|
124
|
+
const buf = Buffer.alloc(Math.min(length, Math.max(0, st.size - offset)));
|
|
125
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, offset);
|
|
126
|
+
return {
|
|
127
|
+
dataBase64: buf.subarray(0, bytesRead).toString("base64"),
|
|
128
|
+
size: st.size,
|
|
129
|
+
eof: offset + bytesRead >= st.size,
|
|
130
|
+
};
|
|
131
|
+
} finally {
|
|
132
|
+
await fh.close();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
case "write": {
|
|
136
|
+
if (target === root) throw new Error("invalid path");
|
|
137
|
+
const data = Buffer.from(input.dataBase64 ?? "", "base64");
|
|
138
|
+
if (data.length > MAX_WRITE_CHUNK) throw new Error("chunk too large");
|
|
139
|
+
await fsp.mkdir(resolve(target, ".."), { recursive: true });
|
|
140
|
+
await fsp.writeFile(target, data, { flag: input.append ? "a" : "w" });
|
|
141
|
+
return {};
|
|
142
|
+
}
|
|
143
|
+
case "mkdir": {
|
|
144
|
+
if (target === root) throw new Error("invalid path");
|
|
145
|
+
await fsp.mkdir(target, { recursive: true });
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
case "delete": {
|
|
149
|
+
if (target === root) throw new Error("invalid path");
|
|
150
|
+
await fsp.rm(target, { recursive: true, force: true });
|
|
151
|
+
return {};
|
|
152
|
+
}
|
|
153
|
+
default:
|
|
154
|
+
throw new Error(`unknown op: ${String(input.op)}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
package/lib/standard-image.ts
CHANGED
|
@@ -238,6 +238,12 @@ async function upgradeVolumeAgentClis(): Promise<void> {
|
|
|
238
238
|
const upgrade = await run("docker", [
|
|
239
239
|
"run",
|
|
240
240
|
"--rm",
|
|
241
|
+
// The container NAME is the mutex: overlapping boots (or a crash-looping
|
|
242
|
+
// service) must never race two npm installs on the shared volume — that
|
|
243
|
+
// once left it with no `claude` at all (2026-07-13). Docker rejects the
|
|
244
|
+
// duplicate name; we treat that as "already upgrading, skip".
|
|
245
|
+
"--name",
|
|
246
|
+
"uai-cli-upgrade",
|
|
241
247
|
"-u",
|
|
242
248
|
"root",
|
|
243
249
|
"-w",
|
|
@@ -250,6 +256,10 @@ async function upgradeVolumeAgentClis(): Promise<void> {
|
|
|
250
256
|
`npm install -g ${pkgs} >/dev/null 2>&1 || { rm -rf ${scopeDirs}; npm install -g ${pkgs} >/dev/null 2>&1; } && asdf reshim nodejs >/dev/null 2>&1; ` +
|
|
251
257
|
bins.map((b) => `printf '%s ' "$(${b} --version 2>/dev/null | head -1)"`).join("; "),
|
|
252
258
|
]);
|
|
259
|
+
if (upgrade.code !== 0 && /already in use/i.test(upgrade.stderr)) {
|
|
260
|
+
console.log("[host-agent] agent CLI upgrade already running — skipped");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
253
263
|
if (upgrade.code === 0) {
|
|
254
264
|
console.log(
|
|
255
265
|
`[host-agent] agent CLIs current on ${ASDF_DATA_VOLUME}: ${upgrade.stdout.trim()}`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runuai/host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Diogo Perillo <diogo.perillo@gmail.com>",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"db",
|
|
41
41
|
"scripts/agent",
|
|
42
42
|
"scripts/install",
|
|
43
|
+
"runner",
|
|
43
44
|
"images/standard",
|
|
44
45
|
"ui",
|
|
45
46
|
"README.md",
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* uai session runner (ADR-061) — runs INSIDE the task container and owns an
|
|
4
|
+
* agent CLI process, so the CLI's lifetime is decoupled from the host
|
|
5
|
+
* process. The host talks to it only through files on the task-workspace
|
|
6
|
+
* bind mount (no sockets/FIFOs — they don't cross the macOS↔VM boundary):
|
|
7
|
+
*
|
|
8
|
+
* <sessionDir>/inbox.jsonl host appends; runner tails → CLI stdin
|
|
9
|
+
* <sessionDir>/outbox.jsonl CLI stdout lines verbatim + __uai meta lines
|
|
10
|
+
* <sessionDir>/heartbeat rewritten every HEARTBEAT_MS (host checks mtime)
|
|
11
|
+
* <sessionDir>/runner.json pid, protocol, argv, startedAt
|
|
12
|
+
*
|
|
13
|
+
* Meta lines are `{"__uai":"spawn"|"exit", ...}`; the host filters them out
|
|
14
|
+
* before handing lines to the protocol adapters. Control lines the host
|
|
15
|
+
* appends to the inbox use the same shape (`{"__uai":"stop"}`); everything
|
|
16
|
+
* else in the inbox goes to the CLI's stdin untouched.
|
|
17
|
+
*
|
|
18
|
+
* Plain Node ≥18, dependency-free, ESM. Testable outside docker: point it
|
|
19
|
+
* at a tmp dir and any line-oriented fake CLI.
|
|
20
|
+
*
|
|
21
|
+
* Usage: node runner.mjs <sessionDir> -- <command> [args...]
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { appendFileSync, mkdirSync, openSync, readSync, fstatSync, closeSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import process from "node:process";
|
|
28
|
+
|
|
29
|
+
const PROTOCOL = 1;
|
|
30
|
+
const POLL_MS = 50;
|
|
31
|
+
const HEARTBEAT_MS = 5_000;
|
|
32
|
+
const STDERR_CAP = 8 * 1024;
|
|
33
|
+
const STOP_GRACE_MS = 5_000;
|
|
34
|
+
|
|
35
|
+
// ---- argv ------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const sep = process.argv.indexOf("--");
|
|
38
|
+
const sessionDir = process.argv[2];
|
|
39
|
+
if (!sessionDir || sep < 0 || sep + 1 >= process.argv.length) {
|
|
40
|
+
process.stderr.write("usage: runner.mjs <sessionDir> -- <command> [args...]\n");
|
|
41
|
+
process.exit(2);
|
|
42
|
+
}
|
|
43
|
+
const command = process.argv[sep + 1];
|
|
44
|
+
const args = process.argv.slice(sep + 2);
|
|
45
|
+
|
|
46
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
47
|
+
const inboxPath = join(sessionDir, "inbox.jsonl");
|
|
48
|
+
const outboxPath = join(sessionDir, "outbox.jsonl");
|
|
49
|
+
const heartbeatPath = join(sessionDir, "heartbeat");
|
|
50
|
+
const runnerJsonPath = join(sessionDir, "runner.json");
|
|
51
|
+
|
|
52
|
+
// Single append point — appendFileSync with O_APPEND keeps lines atomic for
|
|
53
|
+
// the sizes we write; the runner is the outbox's only writer.
|
|
54
|
+
function outbox(line) {
|
|
55
|
+
appendFileSync(outboxPath, line.endsWith("\n") ? line : `${line}\n`);
|
|
56
|
+
}
|
|
57
|
+
function meta(kind, extra = {}) {
|
|
58
|
+
outbox(JSON.stringify({ __uai: kind, ts: Date.now(), ...extra }));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---- the CLI ---------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
// The runner is launched through the container's asdf `node` shim, which
|
|
64
|
+
// exports its resolved version (ASDF_NODEJS_VERSION et al.) into our env.
|
|
65
|
+
// Passing that through would pin the CLI's own asdf shim to the WORKSPACE's
|
|
66
|
+
// node version — "No claude executable found for nodejs X" when the agent
|
|
67
|
+
// CLIs are installed under a different one. Strip ASDF_* so the CLI shim
|
|
68
|
+
// resolves exactly as a direct `docker exec <cli>` would.
|
|
69
|
+
const cliEnv = { ...process.env };
|
|
70
|
+
for (const key of Object.keys(cliEnv)) {
|
|
71
|
+
if (key.startsWith("ASDF_")) delete cliEnv[key];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env: cliEnv });
|
|
75
|
+
|
|
76
|
+
writeFileSync(
|
|
77
|
+
runnerJsonPath,
|
|
78
|
+
JSON.stringify({
|
|
79
|
+
protocol: PROTOCOL,
|
|
80
|
+
runnerPid: process.pid,
|
|
81
|
+
cliPid: child.pid ?? null,
|
|
82
|
+
command,
|
|
83
|
+
args,
|
|
84
|
+
startedAt: Date.now(),
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
meta("spawn", { protocol: PROTOCOL, runnerPid: process.pid, cliPid: child.pid ?? null });
|
|
88
|
+
|
|
89
|
+
let stderrTail = "";
|
|
90
|
+
child.stderr?.setEncoding("utf8");
|
|
91
|
+
child.stderr?.on("data", (chunk) => {
|
|
92
|
+
stderrTail = (stderrTail + chunk).slice(-STDERR_CAP);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// CLI stdout → outbox, complete lines only (partial line buffered).
|
|
96
|
+
let stdoutBuf = "";
|
|
97
|
+
child.stdout?.setEncoding("utf8");
|
|
98
|
+
child.stdout?.on("data", (chunk) => {
|
|
99
|
+
stdoutBuf += chunk;
|
|
100
|
+
for (;;) {
|
|
101
|
+
const nl = stdoutBuf.indexOf("\n");
|
|
102
|
+
if (nl < 0) break;
|
|
103
|
+
const line = stdoutBuf.slice(0, nl);
|
|
104
|
+
stdoutBuf = stdoutBuf.slice(nl + 1);
|
|
105
|
+
if (line.trim().length > 0) outbox(line);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
let exiting = false;
|
|
110
|
+
child.on("exit", (code, signal) => {
|
|
111
|
+
if (exiting) return;
|
|
112
|
+
exiting = true;
|
|
113
|
+
if (stdoutBuf.trim().length > 0) outbox(stdoutBuf); // flush the partial tail
|
|
114
|
+
meta("exit", { code, signal: signal ?? null, stderrTail });
|
|
115
|
+
process.exit(0);
|
|
116
|
+
});
|
|
117
|
+
child.on("error", (err) => {
|
|
118
|
+
if (exiting) return;
|
|
119
|
+
exiting = true;
|
|
120
|
+
meta("exit", { code: null, signal: null, stderrTail: String(err?.message ?? err) });
|
|
121
|
+
process.exit(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ---- inbox tail → CLI stdin --------------------------------------------------
|
|
125
|
+
|
|
126
|
+
let inboxOffset = 0; // fresh session dir per spawn — always start at 0
|
|
127
|
+
let inboxBuf = "";
|
|
128
|
+
|
|
129
|
+
function stopCli() {
|
|
130
|
+
if (exiting) return;
|
|
131
|
+
try {
|
|
132
|
+
child.kill("SIGTERM");
|
|
133
|
+
} catch {
|
|
134
|
+
/* already gone */
|
|
135
|
+
}
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
try {
|
|
138
|
+
child.kill("SIGKILL");
|
|
139
|
+
} catch {
|
|
140
|
+
/* already gone */
|
|
141
|
+
}
|
|
142
|
+
}, STOP_GRACE_MS).unref();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function handleInboxLine(line) {
|
|
146
|
+
if (line.trim().length === 0) return;
|
|
147
|
+
if (line.startsWith('{"__uai"')) {
|
|
148
|
+
try {
|
|
149
|
+
const ctl = JSON.parse(line);
|
|
150
|
+
if (ctl.__uai === "stop") stopCli();
|
|
151
|
+
} catch {
|
|
152
|
+
/* malformed control — ignore */
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
child.stdin?.write(`${line}\n`);
|
|
158
|
+
} catch {
|
|
159
|
+
/* EPIPE after CLI death — the exit meta already tells the host */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function pollInbox() {
|
|
164
|
+
let fd;
|
|
165
|
+
try {
|
|
166
|
+
fd = openSync(inboxPath, "r");
|
|
167
|
+
} catch {
|
|
168
|
+
return; // inbox not created yet
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const size = fstatSync(fd).size;
|
|
172
|
+
if (size > inboxOffset) {
|
|
173
|
+
const len = size - inboxOffset;
|
|
174
|
+
const buf = Buffer.alloc(len);
|
|
175
|
+
const read = readSync(fd, buf, 0, len, inboxOffset);
|
|
176
|
+
inboxOffset += read;
|
|
177
|
+
inboxBuf += buf.toString("utf8", 0, read);
|
|
178
|
+
for (;;) {
|
|
179
|
+
const nl = inboxBuf.indexOf("\n");
|
|
180
|
+
if (nl < 0) break;
|
|
181
|
+
const line = inboxBuf.slice(0, nl);
|
|
182
|
+
inboxBuf = inboxBuf.slice(nl + 1);
|
|
183
|
+
handleInboxLine(line);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} finally {
|
|
187
|
+
closeSync(fd);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
setInterval(pollInbox, POLL_MS);
|
|
192
|
+
|
|
193
|
+
// ---- heartbeat ---------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
function beat() {
|
|
196
|
+
try {
|
|
197
|
+
writeFileSync(heartbeatPath, `${process.pid} ${Date.now()}\n`);
|
|
198
|
+
} catch {
|
|
199
|
+
/* disk hiccup — next beat retries */
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
beat();
|
|
203
|
+
setInterval(beat, HEARTBEAT_MS);
|
|
204
|
+
|
|
205
|
+
// The runner dies only with its CLI (or on stop/SIGTERM) — never because a
|
|
206
|
+
// host-side consumer went away. That is the entire point (ADR-061).
|
|
207
|
+
process.on("SIGTERM", stopCli);
|
|
208
|
+
process.on("SIGINT", stopCli);
|
package/scripts/agent/task-up.sh
CHANGED
|
@@ -121,6 +121,28 @@ fi
|
|
|
121
121
|
|
|
122
122
|
mkdir -p "$task_workspace"
|
|
123
123
|
|
|
124
|
+
# ADR-062 shared files: two host-resident roots mounted into the container
|
|
125
|
+
# when the task's mode isn't "off". Created here so Docker doesn't create
|
|
126
|
+
# them root-owned at mount time. Mode defaults to ro; org mount only when
|
|
127
|
+
# the cloud sent an org id (older clouds won't).
|
|
128
|
+
shared_files_mode=$(jq -r '.[0].shared_files // "ro"' <<<"$task_json")
|
|
129
|
+
shared_owner_org=$(jq -r '.[0].owner_org_id // ""' <<<"$task_json")
|
|
130
|
+
shared_owner_user=$(jq -r '.[0].owner_user_id // ""' <<<"$task_json")
|
|
131
|
+
# Colons break docker's src:dst:mode bind syntax, and personal org ids are
|
|
132
|
+
# "personal:<userId>" — map ":" to "_". MUST match sharedDirName() in
|
|
133
|
+
# host-agent/lib/shared-files.ts.
|
|
134
|
+
shared_owner_org=$(printf '%s' "$shared_owner_org" | tr ':' '_')
|
|
135
|
+
shared_owner_user=$(printf '%s' "$shared_owner_user" | tr ':' '_')
|
|
136
|
+
shared_root="$UAI_WORKSPACE_ROOT/shared"
|
|
137
|
+
if [ "$shared_files_mode" != "off" ]; then
|
|
138
|
+
[ -n "$shared_owner_org" ] && mkdir -p "$shared_root/orgs/$shared_owner_org"
|
|
139
|
+
[ -n "$shared_owner_user" ] && mkdir -p "$shared_root/users/$shared_owner_user"
|
|
140
|
+
# Marker for the preamble: only containers that actually carry the mounts
|
|
141
|
+
# get the "## Shared files" briefing (pre-feature containers don't).
|
|
142
|
+
mkdir -p "$task_workspace/.uai"
|
|
143
|
+
: > "$task_workspace/.uai/files-mounted"
|
|
144
|
+
fi
|
|
145
|
+
|
|
124
146
|
# Iterate using jq -c so each project is a single JSON object per line.
|
|
125
147
|
# Bash 3.2 friendly: a while-read loop rather than mapfile/readarray.
|
|
126
148
|
while IFS= read -r project_obj; do
|
|
@@ -313,6 +335,20 @@ fi
|
|
|
313
335
|
# ADR-053: host-wide Playwright browser cache. Always mounted (harmless
|
|
314
336
|
# when browser testing is off); Chromium downloads once per HOST.
|
|
315
337
|
printf ' - "%s:/opt/pw-browsers"\n' "$PW_VOLUME"
|
|
338
|
+
# ADR-062 shared files (org + personal) at /workspace/files/{org,me};
|
|
339
|
+
# ro/rw fixed here for the container's lifetime.
|
|
340
|
+
if [ "$shared_files_mode" != "off" ]; then
|
|
341
|
+
shared_suffix=""
|
|
342
|
+
[ "$shared_files_mode" = "ro" ] && shared_suffix=":ro"
|
|
343
|
+
if [ -n "$shared_owner_org" ]; then
|
|
344
|
+
printf ' - "%s/orgs/%s:/workspace/files/org%s"\n' \
|
|
345
|
+
"$shared_root" "$shared_owner_org" "$shared_suffix"
|
|
346
|
+
fi
|
|
347
|
+
if [ -n "$shared_owner_user" ]; then
|
|
348
|
+
printf ' - "%s/users/%s:/workspace/files/me%s"\n' \
|
|
349
|
+
"$shared_root" "$shared_owner_user" "$shared_suffix"
|
|
350
|
+
fi
|
|
351
|
+
fi
|
|
316
352
|
printf ' ports:\n'
|
|
317
353
|
# code-server (the Editor tunnel) is the only auto-published port. Preview
|
|
318
354
|
# ports are NOT published at task-up (ADR-043 Stage 2: opt-in previews) —
|