@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.
@@ -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 {
@@ -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
- copyFileSync(runnerScriptPath(), runnerInSession);
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,
@@ -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
- 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; }
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
- 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.
304
307
  try {
305
308
  const acl = ensureTaskGatewayAcl(taskId, connections);
306
309
  const urlFor = (slug: string): string =>
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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>",
@@ -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,