@runuai/host 0.5.0 → 0.6.1
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/lib/agents/claude.ts +10 -0
- package/lib/agents/transport.ts +18 -1
- package/lib/command-db.ts +10 -2
- package/lib/mcp-gateway.ts +6 -3
- package/lib/orchestrator.ts +68 -0
- package/lib/shared-files.ts +156 -0
- package/package.json +1 -1
- 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/agents/claude.ts
CHANGED
|
@@ -187,6 +187,16 @@ const CLAUDE_ARGS = [
|
|
|
187
187
|
// sandbox. Without this, stream-json has no interactive approver and
|
|
188
188
|
// every Write/Bash silently self-denies.
|
|
189
189
|
"--dangerously-skip-permissions",
|
|
190
|
+
// MCP servers come from the uai-managed file, EXPLICITLY (ADR-057/061).
|
|
191
|
+
// Discovered project .mcp.json servers sit behind a per-server approval
|
|
192
|
+
// that headless --print can never answer ("⏸ Pending approval"), which
|
|
193
|
+
// silently loaded a stale subset. --mcp-config bypasses the approval
|
|
194
|
+
// gate; --strict-mcp-config keeps stray host-copied user configs out.
|
|
195
|
+
// setupMcpTaskConfig always writes this file (empty when no connections)
|
|
196
|
+
// before sessions spawn.
|
|
197
|
+
"--mcp-config",
|
|
198
|
+
"/workspace/.mcp.json",
|
|
199
|
+
"--strict-mcp-config",
|
|
190
200
|
];
|
|
191
201
|
|
|
192
202
|
export class ClaudeSession implements AgentSession {
|
package/lib/agents/transport.ts
CHANGED
|
@@ -155,7 +155,24 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
155
155
|
// before this feature (the workspace is mounted at its host path) and
|
|
156
156
|
// never skews against the host version.
|
|
157
157
|
const runnerInSession = join(sessionDir, "runner.mjs");
|
|
158
|
-
|
|
158
|
+
try {
|
|
159
|
+
copyFileSync(runnerScriptPath(), runnerInSession);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
// Runner asset missing (a packaging miss — the 0.3.0/0.3.1 desktop
|
|
162
|
+
// builds vendored the agent without runner/). Degrade to legacy pipes:
|
|
163
|
+
// sessions work, they just don't survive host restarts.
|
|
164
|
+
console.warn(
|
|
165
|
+
`[transport] runner unavailable (${err instanceof Error ? err.message : err}) — falling back to direct pipes for ${opts.agentId}`,
|
|
166
|
+
);
|
|
167
|
+
const { command, args } = dockerExecArgs(
|
|
168
|
+
opts.containerName,
|
|
169
|
+
opts.cli,
|
|
170
|
+
opts.cliArgs,
|
|
171
|
+
opts.passEnv ?? [],
|
|
172
|
+
opts.explicitEnv ?? {},
|
|
173
|
+
);
|
|
174
|
+
return new LineProcess({ command, args, debugLabel: opts.debugLabel });
|
|
175
|
+
}
|
|
159
176
|
|
|
160
177
|
const envArgs: string[] = [];
|
|
161
178
|
for (const name of opts.passEnv ?? []) {
|
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-gateway.ts
CHANGED
|
@@ -275,13 +275,14 @@ const MERGE_MCP_JSON = `
|
|
|
275
275
|
const fs = require("fs");
|
|
276
276
|
const p = "/workspace/.mcp.json";
|
|
277
277
|
let j = {};
|
|
278
|
-
|
|
278
|
+
let existed = true;
|
|
279
|
+
try { j = JSON.parse(fs.readFileSync(p, "utf8")); } catch { existed = false; }
|
|
279
280
|
j.mcpServers = j.mcpServers || {};
|
|
280
281
|
let changed = false;
|
|
281
282
|
for (const [k, v] of Object.entries(JSON.parse(process.argv[1]))) {
|
|
282
283
|
if (JSON.stringify(j.mcpServers[k]) !== JSON.stringify(v)) { j.mcpServers[k] = v; changed = true; }
|
|
283
284
|
}
|
|
284
|
-
if (changed) fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\\n");
|
|
285
|
+
if (changed || !existed) fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\\n");
|
|
285
286
|
`.trim();
|
|
286
287
|
|
|
287
288
|
function shellQuote(value: string): string {
|
|
@@ -300,7 +301,9 @@ export async function setupMcpTaskConfig(
|
|
|
300
301
|
connections: TaskMcpConnection[],
|
|
301
302
|
hasCodex: boolean,
|
|
302
303
|
): Promise<void> {
|
|
303
|
-
|
|
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.
|
|
304
307
|
try {
|
|
305
308
|
const acl = ensureTaskGatewayAcl(taskId, connections);
|
|
306
309
|
const urlFor = (slug: string): string =>
|
package/lib/orchestrator.ts
CHANGED
|
@@ -100,6 +100,8 @@ interface Channel {
|
|
|
100
100
|
humans: ChannelHuman[];
|
|
101
101
|
/** ADR-053: wire the Playwright MCP browser at session start. */
|
|
102
102
|
browserTesting: boolean;
|
|
103
|
+
/** ADR-062: shared-files mount mode ("off" | "ro" | "rw") for the preamble. */
|
|
104
|
+
sharedFiles: string;
|
|
103
105
|
/** ADR-057: the owner's MCP connections exposed through the host gateway. */
|
|
104
106
|
mcpConnections: Array<{ id: string; slug: string }>;
|
|
105
107
|
/** Last connection set written into the container (skip repeat execs —
|
|
@@ -180,6 +182,7 @@ class Orchestrator {
|
|
|
180
182
|
}
|
|
181
183
|
channel.humans = spec.humans ?? [];
|
|
182
184
|
channel.browserTesting = spec.browserTesting === true;
|
|
185
|
+
channel.sharedFiles = spec.sharedFiles ?? "ro";
|
|
183
186
|
channel.mcpConnections = spec.mcpConnections ?? [];
|
|
184
187
|
for (const agent of channel.roster) {
|
|
185
188
|
channel.preambles.set(
|
|
@@ -193,6 +196,7 @@ class Orchestrator {
|
|
|
193
196
|
spec.branch,
|
|
194
197
|
channel.humans,
|
|
195
198
|
channel.browserTesting,
|
|
199
|
+
channel.sharedFiles,
|
|
196
200
|
),
|
|
197
201
|
);
|
|
198
202
|
}
|
|
@@ -220,6 +224,7 @@ class Orchestrator {
|
|
|
220
224
|
spec.branch,
|
|
221
225
|
spec.humans,
|
|
222
226
|
spec.browserTesting,
|
|
227
|
+
spec.sharedFiles ?? "ro",
|
|
223
228
|
),
|
|
224
229
|
);
|
|
225
230
|
// ADR-046: materialise this agent's skills to its per-agent SKILL.md in
|
|
@@ -249,6 +254,7 @@ class Orchestrator {
|
|
|
249
254
|
respawnLastAt: new Map(),
|
|
250
255
|
humans: spec.humans ?? [],
|
|
251
256
|
browserTesting: spec.browserTesting === true,
|
|
257
|
+
sharedFiles: spec.sharedFiles ?? "ro",
|
|
252
258
|
mcpConnections: spec.mcpConnections ?? [],
|
|
253
259
|
spawning: new Set(),
|
|
254
260
|
};
|
|
@@ -309,6 +315,7 @@ class Orchestrator {
|
|
|
309
315
|
private async ensureMcpConfig(channel: Channel): Promise<void> {
|
|
310
316
|
const fingerprint = JSON.stringify(channel.mcpConnections.map((c) => c.id));
|
|
311
317
|
if (channel.mcpConfigFingerprint === fingerprint) return;
|
|
318
|
+
const hadPrevious = channel.mcpConfigFingerprint !== undefined;
|
|
312
319
|
channel.mcpConfigFingerprint = fingerprint;
|
|
313
320
|
await setupMcpTaskConfig(
|
|
314
321
|
channel.taskId,
|
|
@@ -316,6 +323,41 @@ class Orchestrator {
|
|
|
316
323
|
channel.mcpConnections,
|
|
317
324
|
channel.roster.some((a) => a.kind === "codex"),
|
|
318
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
|
+
}
|
|
319
361
|
}
|
|
320
362
|
|
|
321
363
|
/** Spawn sessions for roster agents added after the initial start. */
|
|
@@ -951,6 +993,7 @@ export function buildSystemPreamble(
|
|
|
951
993
|
taskBranch: string,
|
|
952
994
|
humans?: ChannelHuman[],
|
|
953
995
|
browserTesting?: boolean,
|
|
996
|
+
sharedFiles?: string,
|
|
954
997
|
): string {
|
|
955
998
|
const channelList = roster
|
|
956
999
|
.map((a) =>
|
|
@@ -1091,6 +1134,31 @@ export function buildSystemPreamble(
|
|
|
1091
1134
|
"part of the project. Never review, edit, stage, commit, or flag it;",
|
|
1092
1135
|
"treat it as ignored, even though git may show it as untracked.",
|
|
1093
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
|
+
: []),
|
|
1094
1162
|
// ADR-046: link/document skills are materialised to a per-agent SKILL.md.
|
|
1095
1163
|
// Point the agent at its own file (package skills are handled below).
|
|
1096
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/package.json
CHANGED
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) —
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { agent, AgentError } from "../lib/agent";
|
|
2
2
|
import { cloneRepo } from "../lib/repo-clone";
|
|
3
|
+
import { handleFilesOp } from "../lib/shared-files";
|
|
3
4
|
import { getOrchestrator } from "../lib/orchestrator";
|
|
4
5
|
import { storeTaskCliSecret } from "../lib/agent-cli";
|
|
5
6
|
import { setupTaskGithub, clearRefresh } from "../lib/github-tokens";
|
|
@@ -308,6 +309,15 @@ export const hostCommands: HostCommands = {
|
|
|
308
309
|
}
|
|
309
310
|
},
|
|
310
311
|
|
|
312
|
+
async filesOp(ctx, input) {
|
|
313
|
+
logCommand(ctx, "filesOp", input.op, `${input.scope}:${input.path}`);
|
|
314
|
+
try {
|
|
315
|
+
return ok(await handleFilesOp(input));
|
|
316
|
+
} catch (err) {
|
|
317
|
+
return failFromUnknown(err);
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
|
|
311
321
|
async channelInterrupt(ctx, taskId, agentId) {
|
|
312
322
|
logCommand(ctx, "channelInterrupt", taskId, agentId);
|
|
313
323
|
try {
|
package/src/main.ts
CHANGED
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
type TaskDiffInput,
|
|
85
85
|
type TaskDownInput,
|
|
86
86
|
type TaskLaunchInput,
|
|
87
|
+
FilesOpInput,
|
|
87
88
|
} from "./protocol";
|
|
88
89
|
|
|
89
90
|
const PING_INTERVAL_MS = 15_000;
|
|
@@ -875,6 +876,8 @@ function dispatchCommand(
|
|
|
875
876
|
ctx,
|
|
876
877
|
expectAttachmentReadInput(args, 0),
|
|
877
878
|
);
|
|
879
|
+
case "filesOp":
|
|
880
|
+
return hostCommands.filesOp(ctx, expectFilesOpInput(args, 0));
|
|
878
881
|
case "channelInterrupt":
|
|
879
882
|
return hostCommands.channelInterrupt(
|
|
880
883
|
ctx,
|
|
@@ -1123,6 +1126,7 @@ function isHostCommand(command: string): command is keyof HostCommands {
|
|
|
1123
1126
|
"taskDiff",
|
|
1124
1127
|
"attachmentWrite",
|
|
1125
1128
|
"attachmentRead",
|
|
1129
|
+
"filesOp",
|
|
1126
1130
|
"channelInterrupt",
|
|
1127
1131
|
"appendTranscript",
|
|
1128
1132
|
"previewEnsure",
|
|
@@ -1277,6 +1281,37 @@ function expectAttachmentReadInput(
|
|
|
1277
1281
|
};
|
|
1278
1282
|
}
|
|
1279
1283
|
|
|
1284
|
+
/** ADR-062: the THIRD whitelist for filesOp — op/scope enums, relative path
|
|
1285
|
+
* only (containment is re-checked in lib/shared-files.ts, the final gate). */
|
|
1286
|
+
function expectFilesOpInput(args: unknown[], index: number): FilesOpInput {
|
|
1287
|
+
const input = expectRecord(args[index], "files op input");
|
|
1288
|
+
const op = expectStringValue(input.op, "op");
|
|
1289
|
+
if (!["list", "read", "write", "mkdir", "delete"].includes(op)) {
|
|
1290
|
+
throw new Error(`invalid files op: ${op}`);
|
|
1291
|
+
}
|
|
1292
|
+
const scope = expectStringValue(input.scope, "scope");
|
|
1293
|
+
if (scope !== "org" && scope !== "me") {
|
|
1294
|
+
throw new Error(`invalid files scope: ${scope}`);
|
|
1295
|
+
}
|
|
1296
|
+
const out: FilesOpInput = {
|
|
1297
|
+
op: op as FilesOpInput["op"],
|
|
1298
|
+
hostId: typeof input.hostId === "string" ? input.hostId : "",
|
|
1299
|
+
scope,
|
|
1300
|
+
orgId: typeof input.orgId === "string" ? input.orgId : "",
|
|
1301
|
+
userId: typeof input.userId === "string" ? input.userId : "",
|
|
1302
|
+
path: typeof input.path === "string" ? input.path : "",
|
|
1303
|
+
};
|
|
1304
|
+
if (typeof input.offset === "number" && Number.isFinite(input.offset)) {
|
|
1305
|
+
out.offset = input.offset;
|
|
1306
|
+
}
|
|
1307
|
+
if (typeof input.length === "number" && Number.isFinite(input.length)) {
|
|
1308
|
+
out.length = input.length;
|
|
1309
|
+
}
|
|
1310
|
+
if (typeof input.dataBase64 === "string") out.dataBase64 = input.dataBase64;
|
|
1311
|
+
if (input.append === true) out.append = true;
|
|
1312
|
+
return out;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1280
1315
|
function expectChannelEnsureInput(
|
|
1281
1316
|
args: unknown[],
|
|
1282
1317
|
index: number,
|
|
@@ -1334,6 +1369,14 @@ function expectChannelEnsureInput(
|
|
|
1334
1369
|
});
|
|
1335
1370
|
if (connections.length > 0) out.mcpConnections = connections;
|
|
1336
1371
|
}
|
|
1372
|
+
// ADR-062: shared-files mode for the preamble "## Files" section.
|
|
1373
|
+
if (
|
|
1374
|
+
input.sharedFiles === "off" ||
|
|
1375
|
+
input.sharedFiles === "ro" ||
|
|
1376
|
+
input.sharedFiles === "rw"
|
|
1377
|
+
) {
|
|
1378
|
+
out.sharedFiles = input.sharedFiles;
|
|
1379
|
+
}
|
|
1337
1380
|
return out;
|
|
1338
1381
|
}
|
|
1339
1382
|
|
|
@@ -1373,6 +1416,14 @@ function expectTaskCommandTask(value: unknown): TaskCommandTask {
|
|
|
1373
1416
|
}
|
|
1374
1417
|
if (Object.keys(pe).length > 0) out.previewEnv = pe;
|
|
1375
1418
|
}
|
|
1419
|
+
if (typeof row.ownerOrgId === "string") out.ownerOrgId = row.ownerOrgId;
|
|
1420
|
+
if (
|
|
1421
|
+
row.sharedFiles === "off" ||
|
|
1422
|
+
row.sharedFiles === "ro" ||
|
|
1423
|
+
row.sharedFiles === "rw"
|
|
1424
|
+
) {
|
|
1425
|
+
out.sharedFiles = row.sharedFiles;
|
|
1426
|
+
}
|
|
1376
1427
|
return out;
|
|
1377
1428
|
}
|
|
1378
1429
|
|
package/src/protocol.ts
CHANGED
|
@@ -144,6 +144,10 @@ export interface TaskCommandTask {
|
|
|
144
144
|
* `urlEnv` and the cloud has a preview base domain configured.
|
|
145
145
|
*/
|
|
146
146
|
previewEnv?: Record<string, string>;
|
|
147
|
+
/** ADR-062: owning org — locates the org shared-files root on the host. */
|
|
148
|
+
ownerOrgId?: string;
|
|
149
|
+
/** ADR-062: shared-files mount mode ("off" | "ro" | "rw"; default "ro"). */
|
|
150
|
+
sharedFiles?: string;
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
/**
|
|
@@ -203,6 +207,9 @@ export interface ChannelEnsureInput {
|
|
|
203
207
|
/** ADR-057: the owner's usable MCP connections (policy "on", connected,
|
|
204
208
|
* remote). The host writes gateway-URL MCP configs at session start. */
|
|
205
209
|
mcpConnections?: Array<{ id: string; slug: string }>;
|
|
210
|
+
/** ADR-062: shared-files mount mode — drives the preamble "## Files"
|
|
211
|
+
* section ("off" | "ro" | "rw"). */
|
|
212
|
+
sharedFiles?: string;
|
|
206
213
|
globalContext?: string;
|
|
207
214
|
projects: Array<{ slug: string; defaultPrompt: string }>;
|
|
208
215
|
branch: string;
|
|
@@ -256,6 +263,39 @@ export interface TaskDiffResult {
|
|
|
256
263
|
repos: TaskDiffRepo[];
|
|
257
264
|
}
|
|
258
265
|
|
|
266
|
+
/** ADR-062 shared-files op (cloud → host). Scope picks the root:
|
|
267
|
+
* "org" → <workspaceRoot>/shared/orgs/<orgId>, "me" → shared/users/<userId>. */
|
|
268
|
+
export interface FilesOpInput {
|
|
269
|
+
op: "list" | "read" | "write" | "mkdir" | "delete";
|
|
270
|
+
/** Routing only (cloud→bridge): which host's roots to touch. */
|
|
271
|
+
hostId: string;
|
|
272
|
+
scope: "org" | "me";
|
|
273
|
+
orgId: string;
|
|
274
|
+
userId: string;
|
|
275
|
+
/** Relative path inside the scope root ("" = root). */
|
|
276
|
+
path: string;
|
|
277
|
+
/** read: byte offset; default 0. */
|
|
278
|
+
offset?: number;
|
|
279
|
+
/** read: max bytes per chunk (host caps it regardless). */
|
|
280
|
+
length?: number;
|
|
281
|
+
/** write: base64 chunk. */
|
|
282
|
+
dataBase64?: string;
|
|
283
|
+
/** write: append to existing (true) or truncate/create (false/absent). */
|
|
284
|
+
append?: boolean;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export interface FilesEntry {
|
|
288
|
+
name: string;
|
|
289
|
+
dir: boolean;
|
|
290
|
+
size: number;
|
|
291
|
+
mtimeMs: number;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export type FilesOpValue =
|
|
295
|
+
| { entries: FilesEntry[] } // list
|
|
296
|
+
| { dataBase64: string; size: number; eof: boolean } // read
|
|
297
|
+
| Record<string, never>; // write / mkdir / delete
|
|
298
|
+
|
|
259
299
|
export interface HostCommands {
|
|
260
300
|
taskUp(
|
|
261
301
|
ctx: CommandContext,
|
|
@@ -321,6 +361,13 @@ export interface HostCommands {
|
|
|
321
361
|
ctx: CommandContext,
|
|
322
362
|
input: { taskId: string; filename: string },
|
|
323
363
|
): Promise<HostCommandResult<{ dataBase64: string }>>;
|
|
364
|
+
/** ADR-062 shared files — list/read/write/mkdir/delete inside the host's
|
|
365
|
+
* org or personal shared root. Paths are RELATIVE; the host normalizes and
|
|
366
|
+
* rejects escapes. Reads/writes are chunked base64 (offset/append). */
|
|
367
|
+
filesOp(
|
|
368
|
+
ctx: CommandContext,
|
|
369
|
+
input: FilesOpInput,
|
|
370
|
+
): Promise<HostCommandResult<FilesOpValue>>;
|
|
324
371
|
channelInterrupt(
|
|
325
372
|
ctx: CommandContext,
|
|
326
373
|
taskId: string,
|