@runuai/host 0.9.150 → 0.9.151

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.
Files changed (107) hide show
  1. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  2. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  3. package/db/migrations/meta/_journal.json +14 -0
  4. package/db/schema.ts +188 -0
  5. package/lib/agent-cli.ts +11 -13
  6. package/lib/agent.ts +177 -7
  7. package/lib/agents/durable-proc.ts +56 -44
  8. package/lib/agents/registry.ts +19 -3
  9. package/lib/agents/session-state.ts +23 -10
  10. package/lib/agents/transport.ts +101 -69
  11. package/lib/apple-one-off.ts +277 -0
  12. package/lib/apple-runtime-recycle.ts +4 -3
  13. package/lib/attachments.ts +19 -17
  14. package/lib/capture-flow.ts +319 -0
  15. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  16. package/lib/cloud-credential-host.ts +512 -0
  17. package/lib/cloud-credential-leases.ts +2544 -0
  18. package/lib/cloud-credential-runtime.ts +1035 -0
  19. package/lib/codex-auth.ts +21 -1
  20. package/lib/container-runtime.ts +76 -0
  21. package/lib/credential-cleanup-inventory.ts +405 -0
  22. package/lib/credential-consumers/engine-anchor.ts +1876 -0
  23. package/lib/credential-consumers/engines.ts +773 -0
  24. package/lib/credential-consumers/github.ts +594 -0
  25. package/lib/credential-consumers/mcp.ts +384 -0
  26. package/lib/credential-consumers/task-files.ts +127 -0
  27. package/lib/credential-store-pickup.ts +15 -2
  28. package/lib/credential-store.ts +361 -33
  29. package/lib/credentials-activation-authority.ts +546 -0
  30. package/lib/credentials-purge.ts +1006 -0
  31. package/lib/db.ts +11 -0
  32. package/lib/docker-exec.ts +3 -1
  33. package/lib/engine-accounts.ts +42 -32
  34. package/lib/engine-connect.ts +47 -2
  35. package/lib/engine-credential-env.ts +50 -0
  36. package/lib/engine-login.ts +83 -1
  37. package/lib/env-file.ts +62 -8
  38. package/lib/env.ts +5 -0
  39. package/lib/fs-aliases.ts +49 -0
  40. package/lib/git-clone-credentials.ts +334 -0
  41. package/lib/git-diff.ts +219 -177
  42. package/lib/git-identity.ts +59 -14
  43. package/lib/github-origin.ts +28 -0
  44. package/lib/github-tokens.ts +56 -21
  45. package/lib/host-env.ts +17 -4
  46. package/lib/launch-mode.ts +25 -0
  47. package/lib/legacy-runtime-retirement.ts +388 -0
  48. package/lib/machine-exec.ts +14 -3
  49. package/lib/machine-provider-aws.ts +50 -8
  50. package/lib/machine-provider-local.ts +4 -0
  51. package/lib/machine-provider.ts +4 -0
  52. package/lib/managed-operation-drain.ts +8 -0
  53. package/lib/managed-runtime.ts +371 -7
  54. package/lib/mcp-capture.ts +860 -0
  55. package/lib/mcp-connections.ts +8 -1
  56. package/lib/mcp-gateway.ts +168 -10
  57. package/lib/obs.ts +2 -6
  58. package/lib/orchestrator.ts +1830 -224
  59. package/lib/person-capture.ts +327 -0
  60. package/lib/project-env-legacy-tags.ts +130 -0
  61. package/lib/project-env-task-replica.ts +1752 -0
  62. package/lib/runner-boot.ts +10 -0
  63. package/lib/runtime-activation-tail.ts +50 -3
  64. package/lib/runtime-archive.ts +68 -1
  65. package/lib/runtime-reconnect.ts +86 -0
  66. package/lib/runtime-state.ts +429 -13
  67. package/lib/safe-http.ts +364 -0
  68. package/lib/secrets-proxy/task-trust.ts +34 -3
  69. package/lib/sidecar-resume.ts +18 -5
  70. package/lib/skills.ts +47 -8
  71. package/lib/standard-image.ts +157 -103
  72. package/lib/stopped-task-status-queue.ts +68 -10
  73. package/lib/task-credential-cleanup.ts +525 -0
  74. package/lib/task-diff-sandbox.ts +464 -0
  75. package/lib/task-diff.ts +66 -163
  76. package/lib/task-env-source.ts +68 -14
  77. package/lib/task-environment/anchor.ts +31 -0
  78. package/lib/task-environment/apple-container.ts +69 -28
  79. package/lib/task-environment/docker.ts +101 -20
  80. package/lib/task-environment/machine-environment.ts +61 -0
  81. package/lib/task-environment/machine-task-up.ts +459 -96
  82. package/lib/task-environment/machine.ts +101 -11
  83. package/lib/task-environment/self.ts +108 -50
  84. package/lib/task-environment/types.ts +89 -5
  85. package/lib/task-environment/workspace-files.ts +623 -25
  86. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  87. package/lib/task-gateway-recovery.ts +4 -1
  88. package/lib/task-gateway-state.ts +15 -0
  89. package/lib/task-machine-swap-safety.ts +729 -0
  90. package/lib/token-broker-adopt.ts +344 -0
  91. package/lib/token-broker-lambda.ts +56 -2
  92. package/lib/token-broker-local.ts +28 -4
  93. package/lib/token-broker-revision.ts +310 -0
  94. package/lib/token-broker-ssm.ts +368 -28
  95. package/lib/token-lanes/openai-oauth.ts +7 -3
  96. package/lib/token-lanes/xai-oauth.ts +7 -3
  97. package/lib/transcript.ts +9 -5
  98. package/package.json +6 -2
  99. package/scripts/agent/task-up.sh +242 -100
  100. package/src/cloud-credentials.ts +2399 -0
  101. package/src/event-outbox.ts +8 -6
  102. package/src/index.ts +1386 -875
  103. package/src/main.ts +1061 -139
  104. package/src/outbound-frame.ts +31 -0
  105. package/src/project-env-policy.ts +125 -10
  106. package/src/protocol.ts +699 -23
  107. package/src/ui/server.ts +48 -10
@@ -18,18 +18,16 @@
18
18
  * LineProcess gave them.
19
19
  */
20
20
 
21
- import {
22
- closeSync,
23
- fstatSync,
24
- mkdirSync,
25
- openSync,
26
- promises as fsp,
27
- readSync,
28
- statSync,
29
- } from "node:fs";
30
- import { join } from "node:path";
31
21
  import { fileURLToPath } from "node:url";
32
22
 
23
+ import {
24
+ appendContainedFile,
25
+ containedModifiedAt,
26
+ NotARegularFileError,
27
+ readContainedFrom,
28
+ WorkspacePathRefusedError,
29
+ } from "../task-environment/workspace-files";
30
+
33
31
  import type { ExitHandler, LineHandler } from "./proc";
34
32
 
35
33
  const POLL_MS = 75;
@@ -46,6 +44,14 @@ export function runnerScriptPath(): string {
46
44
  export interface DurableProcessOptions {
47
45
  /** Session dir as seen FROM THE HOST (on the workspace bind mount). */
48
46
  hostSessionDir: string;
47
+ /** The same directory as the task's workspace root and its components
48
+ * below it: every file in it is reached never through a link the task
49
+ * put there (#cb3j0z). It exists before the runner is launched. */
50
+ sessionFiles: { root: string; parts: readonly string[] };
51
+ /** Settles once that directory has been made, where only the task's
52
+ * runtime can make it (macOS, #cb3j0z): no inbox write, the stop
53
+ * included, is attempted before. Absent: it exists already. */
54
+ ready?: Promise<void>;
49
55
  /** Provider-owned detached launch. Omit to attach to an existing runner. */
50
56
  launch?: () => Promise<{
51
57
  exitCode: number | null;
@@ -73,10 +79,10 @@ interface RunnerMeta {
73
79
 
74
80
  export class DurableProcess {
75
81
  readonly durableSessionDir: string;
76
- private readonly dir: string;
77
- private readonly outboxPath: string;
78
- private readonly inboxPath: string;
79
- private readonly heartbeatPath: string;
82
+ private readonly root: string;
83
+ private readonly outbox: readonly string[];
84
+ private readonly inbox: readonly string[];
85
+ private readonly heartbeat: readonly string[];
80
86
 
81
87
  private readonly lineHandlers = new Set<LineHandler>();
82
88
  private readonly exitHandlers = new Set<ExitHandler>();
@@ -97,18 +103,22 @@ export class DurableProcess {
97
103
 
98
104
  constructor(opts: DurableProcessOptions) {
99
105
  this.durableSessionDir = opts.hostSessionDir;
100
- this.dir = opts.hostSessionDir;
101
- this.outboxPath = join(this.dir, "outbox.jsonl");
102
- this.inboxPath = join(this.dir, "inbox.jsonl");
103
- this.heartbeatPath = join(this.dir, "heartbeat");
106
+ this.root = opts.sessionFiles.root;
107
+ this.outbox = [...opts.sessionFiles.parts, "outbox.jsonl"];
108
+ this.inbox = [...opts.sessionFiles.parts, "inbox.jsonl"];
109
+ this.heartbeat = [...opts.sessionFiles.parts, "heartbeat"];
104
110
  this.offset = opts.initialOutboxOffset ?? 0;
105
111
  this.onOffsetAdvance = opts.onOffsetAdvance ?? null;
106
112
  this.onCloseRequested = opts.onCloseRequested ?? null;
113
+ if (opts.ready) {
114
+ this.inboxChain = opts.ready.then(
115
+ () => undefined,
116
+ () => undefined,
117
+ );
118
+ }
107
119
  this.debug =
108
120
  opts.debugLabel && process.env.UAI_DEBUG_AGENTS ? opts.debugLabel : null;
109
121
 
110
- mkdirSync(this.dir, { recursive: true });
111
-
112
122
  if (opts.launch) {
113
123
  void opts.launch().then(
114
124
  (result) => {
@@ -139,27 +149,25 @@ export class DurableProcess {
139
149
 
140
150
  private poll(): void {
141
151
  if (this.closed || this.detached) return;
142
- let fd: number;
143
- try {
144
- fd = openSync(this.outboxPath, "r");
145
- } catch {
146
- this.checkLiveness();
147
- return; // outbox not created yet
148
- }
149
152
  const before = this.offset;
150
153
  try {
151
- const size = fstatSync(fd).size;
152
- while (this.offset < size) {
153
- const len = Math.min(size - this.offset, 256 * 1024);
154
- const buf = Buffer.alloc(len);
155
- const read = readSync(fd, buf, 0, len, this.offset);
156
- if (read <= 0) break;
157
- this.offset += read;
158
- this.lineBuf += buf.toString("utf8", 0, read);
154
+ for (;;) {
155
+ const read = readContainedFrom(this.root, this.outbox, this.offset, 256 * 1024);
156
+ if (read === null) break; // outbox not created yet
157
+ if (read.bytes.length === 0) break;
158
+ this.offset += read.bytes.length;
159
+ this.lineBuf += read.bytes.toString("utf8");
159
160
  this.drainLines();
161
+ if (this.offset >= read.size) break;
160
162
  }
161
- } finally {
162
- closeSync(fd);
163
+ } catch (error) {
164
+ if (error instanceof WorkspacePathRefusedError || error instanceof NotARegularFileError) {
165
+ // A link, or anything but a regular file, where the outbox is: the
166
+ // task broke its own session, and it ends (#cb3j0z).
167
+ this.finish(null);
168
+ return;
169
+ }
170
+ // Anything else may pass: the heartbeat decides, below.
163
171
  }
164
172
  if (this.offset !== before && this.onOffsetAdvance && !this.closed) {
165
173
  try {
@@ -212,17 +220,21 @@ export class DurableProcess {
212
220
 
213
221
  /** Backstop: no exit meta, but the runner stopped beating → it's gone. */
214
222
  private checkLiveness(): void {
215
- const path = this.sawSpawnMeta ? this.heartbeatPath : null;
216
- if (!path) {
223
+ if (!this.sawSpawnMeta) {
217
224
  // Runner never wrote anything; give the spawn a grace window.
218
225
  if (Date.now() - this.startedAt > HEARTBEAT_STALE_MS) this.finish(null);
219
226
  return;
220
227
  }
228
+ let beat: number | null = null;
221
229
  try {
222
- const age = Date.now() - statSync(path).mtimeMs;
223
- if (age > HEARTBEAT_STALE_MS) this.finish(null);
230
+ beat = containedModifiedAt(this.root, this.heartbeat);
224
231
  } catch {
225
- if (Date.now() - this.startedAt > HEARTBEAT_STALE_MS) this.finish(null);
232
+ beat = null;
233
+ }
234
+ if (beat !== null) {
235
+ if (Date.now() - beat > HEARTBEAT_STALE_MS) this.finish(null);
236
+ } else if (Date.now() - this.startedAt > HEARTBEAT_STALE_MS) {
237
+ this.finish(null);
226
238
  }
227
239
  }
228
240
 
@@ -265,7 +277,7 @@ export class DurableProcess {
265
277
  if (this.debug) this.log(`-> ${json.slice(0, 1000)}`);
266
278
  // Chain appends so concurrent writes can't interleave bytes.
267
279
  this.inboxChain = this.inboxChain
268
- .then(() => fsp.appendFile(this.inboxPath, `${inputMeta}\n${json}\n`))
280
+ .then(() => appendContainedFile(this.root, this.inbox, Buffer.from(`${inputMeta}\n${json}\n`)))
269
281
  .catch(() => {
270
282
  // Disk error — liveness checks will surface a dead session.
271
283
  });
@@ -306,7 +318,7 @@ export class DurableProcess {
306
318
  }
307
319
  try {
308
320
  await this.inboxChain;
309
- await fsp.appendFile(this.inboxPath, '{"__uai":"stop"}\n');
321
+ await appendContainedFile(this.root, this.inbox, Buffer.from('{"__uai":"stop"}\n'));
310
322
  } catch {
311
323
  // Inbox unwritable — fall through to the grace timer.
312
324
  }
@@ -87,6 +87,20 @@ export function setAdvertisementFallback(
87
87
  advertisementFallback = fallback ?? (() => false);
88
88
  }
89
89
 
90
+ /**
91
+ * ADR-154: on cloud credentials the runner holds no engine credential of its
92
+ * own, so availability and the fallback above say nothing: the kinds the
93
+ * cloud can lease an account for are advertised, and only those. Null while
94
+ * the runner runs its tasks on its own credentials. Installed by main.ts.
95
+ */
96
+ let leasedKinds: () => ReadonlySet<string> | null = () => null;
97
+
98
+ export function setLeasedAdvertisement(
99
+ source: (() => ReadonlySet<string> | null) | null,
100
+ ): void {
101
+ leasedKinds = source ?? (() => null);
102
+ }
103
+
90
104
  /**
91
105
  * Register (or replace) an adapter for a kind. Called at module load by the
92
106
  * adapter modules. Replacing is allowed so a later import wins deterministically.
@@ -121,10 +135,12 @@ export function supportsExecutionProfile(kind: string, profileId: string): boole
121
135
  * Each adapter's `supportedModels()` / `supportedEfforts()` is evaluated here.
122
136
  */
123
137
  export function capabilities(): AgentKindCapability[] {
138
+ const leased = leasedKinds();
124
139
  return list()
125
- .filter(
126
- (adapter) =>
127
- (adapter.available?.() ?? true) || advertisementFallback(adapter.kind),
140
+ .filter((adapter) =>
141
+ leased !== null
142
+ ? leased.has(adapter.kind)
143
+ : (adapter.available?.() ?? true) || advertisementFallback(adapter.kind),
128
144
  )
129
145
  .map((adapter) => {
130
146
  const out: AgentKindCapability = {
@@ -8,12 +8,16 @@
8
8
  * thread id when launch settings change.
9
9
  */
10
10
 
11
- import { promises as fsp } from "node:fs";
12
- import { join } from "node:path";
13
11
 
14
12
  import { and, eq } from "drizzle-orm";
15
13
 
16
14
  import { getDb, schema } from "../db";
15
+ import { taskWorkspaceDir } from "../env";
16
+ import {
17
+ appendContainedFile,
18
+ hostPathParts,
19
+ WorkspaceDirectoryMissingError,
20
+ } from "../task-environment/workspace-files";
17
21
  import type { TaskEnvironmentHandle } from "../task-environment/types";
18
22
  import { isEnvironmentTransportProvider } from "../task-environment/types";
19
23
  import {
@@ -81,15 +85,24 @@ export async function stopPersistedAgentSession(
81
85
  row.sessionDir,
82
86
  );
83
87
  } else {
88
+ // Never through a link the task put in the session's place (#cb3j0z).
89
+ const root = taskWorkspaceDir(taskId);
90
+ let parts: string[] | null;
84
91
  try {
85
- await fsp.appendFile(
86
- join(row.sessionDir, "inbox.jsonl"),
87
- '{"__uai":"stop"}\n',
88
- );
89
- } catch (err) {
90
- // A removed generation directory proves there is no runner left to read
91
- // it. Permission, I/O, and other failures are not proof: retry them.
92
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
92
+ parts = hostPathParts(root, row.sessionDir);
93
+ } catch {
94
+ // Not below the task's workspace: no runner of this task reads it.
95
+ parts = null;
96
+ }
97
+ if (parts !== null) {
98
+ try {
99
+ await appendContainedFile(root, [...parts, "inbox.jsonl"], Buffer.from('{"__uai":"stop"}\n'));
100
+ } catch (err) {
101
+ // A removed generation directory proves there is no runner left to
102
+ // read it. Permission, I/O, and other failures are not proof: retry
103
+ // them.
104
+ if (!(err instanceof WorkspaceDirectoryMissingError)) throw err;
105
+ }
93
106
  }
94
107
  }
95
108
 
@@ -21,16 +21,7 @@
21
21
  * durable spawn ships the runner version matching this host.
22
22
  */
23
23
 
24
- import {
25
- copyFileSync,
26
- readFileSync,
27
- mkdirSync,
28
- renameSync,
29
- rmSync,
30
- statSync,
31
- symlinkSync,
32
- promises as fsp,
33
- } from "node:fs";
24
+ import { readFileSync } from "node:fs";
34
25
  import { basename, dirname, join } from "node:path";
35
26
 
36
27
  import { and, eq, isNull } from "drizzle-orm";
@@ -39,7 +30,20 @@ import { getDb, schema, type Db } from "../db";
39
30
  import { taskWorkspaceDir } from "../env";
40
31
  import { machineSessionAuthorityEnv } from "../runtime-authority";
41
32
  import { requireContainerRuntimeOperational } from "../runtime-guard";
42
- import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
33
+ import type { TaskEnvironmentAgentSessionSurface, TaskEnvironmentHandle } from "../task-environment/types";
34
+ import {
35
+ appendContainedFile,
36
+ containedModifiedAt,
37
+ hostPathParts,
38
+ makeContainedDirectories,
39
+ makeContainedDirectoriesSync,
40
+ removeContainedEntry,
41
+ replaceContainedLink,
42
+ runtimeNamespaceOf,
43
+ writeContainedFile,
44
+ writeContainedFileSync,
45
+ type RuntimeNamespace,
46
+ } from "../task-environment/workspace-files";
43
47
  import { DurableProcess, runnerScriptPath } from "./durable-proc";
44
48
  import {
45
49
  MachineDurableProcess,
@@ -206,7 +210,7 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
206
210
  explicitEnv: machineSessionAuthorityEnv(opts.explicitEnv),
207
211
  };
208
212
  if (!durableEnabled()) {
209
- clearCurrentSession(machineOpts.taskId, machineOpts.agentId);
213
+ clearCurrentSession(machineOpts.taskId, machineOpts.agentId, workspaceRuntime(machineOpts.environment));
210
214
  return directEnvironmentTransport(machineOpts);
211
215
  }
212
216
  return machineDurableTransport(machineOpts);
@@ -216,7 +220,7 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
216
220
  // spawn boundary so a cached ready verdict cannot launch container work.
217
221
  requireContainerRuntimeOperational();
218
222
  if (!durableEnabled()) {
219
- clearCurrentSession(opts.taskId, opts.agentId);
223
+ clearCurrentSession(opts.taskId, opts.agentId, workspaceRuntime(opts.environment));
220
224
  return directEnvironmentTransport(opts);
221
225
  }
222
226
 
@@ -234,14 +238,18 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
234
238
 
235
239
  const tailKey = `${opts.taskId}:${opts.agentId}`;
236
240
 
241
+ const runtime = workspaceRuntime(opts.environment);
242
+ const rowFiles = row ? sessionFilesOf(opts.taskId, row.sessionDir) : null;
243
+
237
244
  // ---- Attach: a previous host process left this agent's runner alive. ----
238
245
  if (
239
246
  opts.allowAttach &&
240
247
  row &&
248
+ rowFiles &&
241
249
  row.status === "running" &&
242
250
  row.containerName === opts.environment.durableIdentity &&
243
251
  row.kind === opts.kind &&
244
- heartbeatFresh(row.sessionDir) &&
252
+ heartbeatFresh(rowFiles) &&
245
253
  claimCompatibleAttachRow(
246
254
  db,
247
255
  opts.taskId,
@@ -259,13 +267,14 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
259
267
  );
260
268
  const proc = new DurableProcess({
261
269
  hostSessionDir: row.sessionDir,
270
+ sessionFiles: rowFiles,
262
271
  initialOutboxOffset: row.outboxOffset,
263
272
  onOffsetAdvance: persistOffset,
264
273
  onCloseRequested: markClosed,
265
274
  debugLabel: opts.debugLabel,
266
275
  });
267
276
  proc.onExit(markClosed);
268
- publishCurrentSession(row.sessionDir);
277
+ publishCurrentSession(rowFiles, runtime);
269
278
  return claimTail(tailKey, proc);
270
279
  }
271
280
 
@@ -274,26 +283,20 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
274
283
  // by a stale-heartbeat verdict may still be alive, and a stop appended to
275
284
  // a dead session's inbox is harmless — this is what guarantees one live
276
285
  // CLI per (task, agent).
277
- if (row) {
278
- void fsp
279
- .appendFile(join(row.sessionDir, "inbox.jsonl"), '{"__uai":"stop"}\n')
280
- .catch(() => {
286
+ if (rowFiles) {
287
+ void appendContainedFile(rowFiles.root, [...rowFiles.parts, "inbox.jsonl"], Buffer.from('{"__uai":"stop"}\n')).catch(
288
+ () => {
281
289
  // Dir already gone / runner already dead — nothing to stop.
282
- });
290
+ },
291
+ );
283
292
  }
284
293
 
285
- const sessionDir = join(
286
- taskWorkspaceDir(opts.taskId),
287
- ".uai",
288
- "sessions",
289
- opts.agentId,
290
- `${String(Date.now())}-${process.pid}`,
291
- );
292
- mkdirSync(sessionDir, { recursive: true });
293
- // Ship this host's runner with the session: works in containers created
294
- // before this feature (the workspace is mounted at its host path) and
295
- // never skews against the host version.
296
- const runnerInSession = join(sessionDir, "runner.mjs");
294
+ const workspace = taskWorkspaceDir(opts.taskId);
295
+ const sessionFiles = {
296
+ root: workspace,
297
+ parts: [".uai", "sessions", opts.agentId, `${String(Date.now())}-${process.pid}`],
298
+ };
299
+ const sessionDir = join(workspace, ...sessionFiles.parts);
297
300
  const environmentSessionDir = join(
298
301
  opts.environment.descriptor.workspacePath,
299
302
  ".uai",
@@ -308,8 +311,9 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
308
311
  sessionDir,
309
312
  );
310
313
  const runnerInEnvironment = join(environmentSessionDir, "runner.mjs");
314
+ let runnerSource: Buffer;
311
315
  try {
312
- copyFileSync(runnerScriptPath(), runnerInSession);
316
+ runnerSource = readFileSync(runnerScriptPath());
313
317
  } catch (err) {
314
318
  // Runner asset missing (a packaging miss — the 0.3.0/0.3.1 desktop
315
319
  // builds vendored the agent without runner/). Degrade to legacy pipes:
@@ -317,16 +321,45 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
317
321
  console.warn(
318
322
  `[transport] runner unavailable (${err instanceof Error ? err.message : err}) — falling back to direct pipes for ${opts.agentId}`,
319
323
  );
320
- clearCurrentSession(opts.taskId, opts.agentId);
324
+ clearCurrentSession(opts.taskId, opts.agentId, workspaceRuntime(opts.environment));
321
325
  return directEnvironmentTransport(opts);
322
326
  }
323
327
 
328
+ // Ship this host's runner with the session: works in containers created
329
+ // before this feature (the workspace is mounted at its host path) and
330
+ // never skews against the host version. Its directory and the runner are
331
+ // made in the task's workspace never through a link the task put there
332
+ // (#cb3j0z), before the runner launches.
333
+ // On Linux both are made at once; on macOS the directory is made through
334
+ // the task's runtime first.
335
+ let preparedNow = false;
336
+ let prepared: Promise<void>;
337
+ try {
338
+ if (makeContainedDirectoriesSync(workspace, sessionFiles.parts)) {
339
+ writeContainedFileSync(workspace, [...sessionFiles.parts, "runner.mjs"], runnerSource);
340
+ preparedNow = true;
341
+ prepared = Promise.resolve();
342
+ } else {
343
+ prepared = (async () => {
344
+ await makeContainedDirectories(workspace, sessionFiles.parts, runtime);
345
+ await writeContainedFile(workspace, [...sessionFiles.parts, "runner.mjs"], runnerSource);
346
+ })();
347
+ }
348
+ } catch (error) {
349
+ prepared = Promise.reject(error);
350
+ }
351
+ prepared.catch(() => {
352
+ // Reported through the launch, which awaits it.
353
+ });
324
354
  const proc = new DurableProcess({
325
355
  hostSessionDir: sessionDir,
356
+ sessionFiles,
357
+ ...(preparedNow ? {} : { ready: prepared }),
326
358
  onOffsetAdvance: persistOffset,
327
359
  onCloseRequested: markClosed,
328
360
  debugLabel: opts.debugLabel,
329
361
  launch: async () => {
362
+ await prepared;
330
363
  const cliArgv = agentCliArgv(opts.environment, [opts.cli, ...opts.cliArgs]);
331
364
  const result = await opts.environment.launchDetachedSession({
332
365
  argv: [
@@ -375,7 +408,8 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
375
408
  .run();
376
409
 
377
410
  proc.onExit(markClosed);
378
- publishCurrentSession(sessionDir);
411
+ if (preparedNow) publishCurrentSession(sessionFiles, runtime);
412
+ else void prepared.then(() => publishCurrentSession(sessionFiles, runtime), () => {});
379
413
  return claimTail(tailKey, proc);
380
414
  }
381
415
 
@@ -401,7 +435,7 @@ function machineDurableTransport(opts: AgentTransportOptions): LineTransport {
401
435
  }
402
436
  }
403
437
  if (!capable || runnerSource === null) {
404
- clearCurrentSession(opts.taskId, opts.agentId);
438
+ clearCurrentSession(opts.taskId, opts.agentId, workspaceRuntime(opts.environment));
405
439
  return directEnvironmentTransport(opts);
406
440
  }
407
441
  const environment = surface as unknown as MachineSessionEnvironment &
@@ -551,51 +585,49 @@ function directEnvironmentTransport(opts: AgentTransportOptions): LineTransport
551
585
  });
552
586
  }
553
587
 
588
+ /** #cb3j0z: the task's runtime, where only it can make, remove or link an
589
+ * entry in the task's workspace without following its links (macOS). */
590
+ function workspaceRuntime(environment: TaskEnvironmentAgentSessionSurface): RuntimeNamespace | undefined {
591
+ return "exec" in environment
592
+ ? runtimeNamespaceOf(environment as unknown as Pick<TaskEnvironmentHandle, "exec" | "descriptor">)
593
+ : undefined;
594
+ }
595
+
596
+ /** A stored session directory as the task's workspace root and its parts
597
+ * below it; null when it is not below the workspace root. */
598
+ function sessionFilesOf(taskId: string, sessionDir: string): { root: string; parts: string[] } | null {
599
+ const root = taskWorkspaceDir(taskId);
600
+ try {
601
+ return { root, parts: hostPathParts(root, sessionDir) };
602
+ } catch {
603
+ return null;
604
+ }
605
+ }
606
+
554
607
  /**
555
608
  * Make the opaque generation directory discoverable from inside the task.
556
609
  * The relative link resolves on both sides of the workspace bind mount. The
557
610
  * pointer is diagnostic only: failure to publish it must not kill a session.
611
+ * Made beside the generation, never through a link the task put there.
558
612
  */
559
- function publishCurrentSession(sessionDir: string): void {
560
- const parent = dirname(sessionDir);
561
- const current = join(parent, "current");
562
- const pending = join(parent, `.current-${process.pid}-${Date.now()}`);
563
- try {
564
- symlinkSync(basename(sessionDir), pending, "dir");
565
- renameSync(pending, current);
566
- } catch {
567
- try {
568
- rmSync(pending, { force: true });
569
- } catch {
570
- // Best-effort navigation aid; the generation path remains authoritative.
571
- }
572
- }
613
+ function publishCurrentSession(files: { root: string; parts: readonly string[] }, runtime?: RuntimeNamespace): void {
614
+ const generation = files.parts[files.parts.length - 1]!;
615
+ void replaceContainedLink(files.root, [...files.parts.slice(0, -1), "current"], generation, runtime).catch(() => {
616
+ // Best-effort navigation aid; the generation path remains authoritative.
617
+ });
573
618
  }
574
619
 
575
620
  /** A direct-pipe session must not leave an older durable inbox looking live. */
576
- function clearCurrentSession(taskId: string, agentId: string): void {
577
- try {
578
- rmSync(
579
- join(
580
- taskWorkspaceDir(taskId),
581
- ".uai",
582
- "sessions",
583
- agentId,
584
- "current",
585
- ),
586
- { force: true },
587
- );
588
- } catch {
621
+ function clearCurrentSession(taskId: string, agentId: string, runtime?: RuntimeNamespace): void {
622
+ void removeContainedEntry(taskWorkspaceDir(taskId), [".uai", "sessions", agentId, "current"], runtime).catch(() => {
589
623
  // Same best-effort rule as publishCurrentSession.
590
- }
624
+ });
591
625
  }
592
626
 
593
- function heartbeatFresh(sessionDir: string): boolean {
627
+ function heartbeatFresh(files: { root: string; parts: readonly string[] }): boolean {
594
628
  try {
595
- return (
596
- Date.now() - statSync(join(sessionDir, "heartbeat")).mtimeMs <
597
- ATTACH_HEARTBEAT_FRESH_MS
598
- );
629
+ const beat = containedModifiedAt(files.root, [...files.parts, "heartbeat"]);
630
+ return beat !== null && Date.now() - beat < ATTACH_HEARTBEAT_FRESH_MS;
599
631
  } catch {
600
632
  return false;
601
633
  }