@runuai/host 0.9.13 → 0.9.42

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 (97) hide show
  1. package/README.md +22 -5
  2. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  3. package/db/migrations/0015_host_settings.sql +9 -0
  4. package/db/migrations/0016_task_environment.sql +2 -0
  5. package/db/migrations/meta/_journal.json +21 -0
  6. package/db/schema.ts +80 -30
  7. package/images/standard/Dockerfile +36 -10
  8. package/images/standard/README.md +63 -18
  9. package/images/standard/container/corepack-version +1 -0
  10. package/images/standard/container/uai-init +308 -38
  11. package/images/standard/container/uai-materialize-runtimes +1527 -0
  12. package/lib/agent-cli.ts +69 -4
  13. package/lib/agent.ts +46 -7
  14. package/lib/agents/claude.ts +13 -8
  15. package/lib/agents/codex.ts +11 -6
  16. package/lib/agents/cursor.ts +39 -29
  17. package/lib/agents/durable-proc.ts +20 -27
  18. package/lib/agents/factory.ts +9 -25
  19. package/lib/agents/grok.ts +43 -30
  20. package/lib/agents/kimi.ts +44 -29
  21. package/lib/agents/opencode.ts +43 -31
  22. package/lib/agents/proc.ts +149 -114
  23. package/lib/agents/transport.ts +62 -50
  24. package/lib/agents/types.ts +6 -4
  25. package/lib/apple-runtime-recycle.ts +236 -0
  26. package/lib/apple-uninstall-teardown.ts +224 -0
  27. package/lib/browser-testing.ts +233 -93
  28. package/lib/codex-auth.ts +40 -6
  29. package/lib/command-db.ts +20 -0
  30. package/lib/container-runtime.ts +1338 -0
  31. package/lib/db.ts +1 -0
  32. package/lib/docker-exec.ts +87 -5
  33. package/lib/engine-accounts.ts +68 -5
  34. package/lib/engine-login.ts +1952 -0
  35. package/lib/enrollment-state.ts +251 -0
  36. package/lib/env-file.ts +155 -0
  37. package/lib/env.ts +4 -0
  38. package/lib/git-diff.ts +98 -32
  39. package/lib/git-identity.ts +199 -87
  40. package/lib/github-tokens.ts +202 -91
  41. package/lib/host-cloud-url.ts +62 -0
  42. package/lib/host-config.ts +279 -0
  43. package/lib/host-logs.ts +962 -0
  44. package/lib/keyed-promise-tail.ts +23 -0
  45. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  46. package/lib/managed-activation-watcher.ts +72 -0
  47. package/lib/managed-install-owner-watcher.ts +55 -0
  48. package/lib/managed-operation-drain.ts +49 -0
  49. package/lib/managed-runtime.ts +3644 -0
  50. package/lib/managed-update-scheduler.ts +125 -0
  51. package/lib/mcp-gateway.ts +450 -23
  52. package/lib/orchestrator.ts +3070 -223
  53. package/lib/preview-sidecar.ts +57 -13
  54. package/lib/release-manifest.ts +708 -0
  55. package/lib/release-trust.ts +28 -0
  56. package/lib/runtime-activation-tail.ts +232 -0
  57. package/lib/runtime-archive.ts +1086 -0
  58. package/lib/runtime-authority.ts +79 -0
  59. package/lib/runtime-guard.ts +36 -0
  60. package/lib/runtime-provider-state.ts +169 -0
  61. package/lib/runtime-state.ts +232 -12
  62. package/lib/skills.ts +24 -3
  63. package/lib/ssh.ts +18 -0
  64. package/lib/standard-image.ts +1104 -141
  65. package/lib/stopped-task-status-queue.ts +44 -0
  66. package/lib/task-container-cli.ts +269 -0
  67. package/lib/task-diff.ts +66 -46
  68. package/lib/task-environment/apple-container.ts +757 -0
  69. package/lib/task-environment/docker.ts +945 -0
  70. package/lib/task-environment/index.ts +364 -0
  71. package/lib/task-environment/legacy-adoption.ts +443 -0
  72. package/lib/task-environment/registry.ts +58 -0
  73. package/lib/task-environment/types.ts +408 -0
  74. package/lib/task-identity.ts +19 -0
  75. package/lib/task-inventory.ts +585 -0
  76. package/lib/tunnel-registry.ts +135 -19
  77. package/lib/tunnel-runtime.ts +235 -0
  78. package/package.json +1 -1
  79. package/scripts/agent/_common.sh +123 -3
  80. package/scripts/agent/task-down.sh +146 -38
  81. package/scripts/agent/task-status.sh +19 -3
  82. package/scripts/agent/task-up.sh +1463 -109
  83. package/scripts/install/darwin.ts +848 -50
  84. package/scripts/install/linux.ts +838 -35
  85. package/scripts/install/types.ts +43 -0
  86. package/scripts/install/util.ts +215 -8
  87. package/scripts/install/win.ts +12 -0
  88. package/src/apple-tunnel-route.ts +104 -0
  89. package/src/cli.ts +1464 -72
  90. package/src/event-outbox.ts +83 -4
  91. package/src/index.ts +871 -50
  92. package/src/main.ts +1398 -255
  93. package/src/paths.ts +17 -1
  94. package/src/protocol.ts +695 -1
  95. package/src/runtime-bootstrap.ts +165 -0
  96. package/src/ui/server.ts +46 -10
  97. package/src/ui/types.ts +37 -0
package/lib/agent-cli.ts CHANGED
@@ -11,16 +11,31 @@
11
11
  * verifies it. Agents invoke the CLI as `node /workspace/.uai/cli.mjs <cmd>`
12
12
  * (surfaced in the system preamble).
13
13
  */
14
- import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import {
15
+ chmodSync,
16
+ lstatSync,
17
+ mkdirSync,
18
+ readFileSync,
19
+ rmSync,
20
+ writeFileSync,
21
+ } from "node:fs";
15
22
  import { resolve } from "node:path";
16
23
 
17
24
  import { env, taskWorkspaceDir } from "./env";
25
+ import { RUNTIME_AUTHORITY_ENV } from "./runtime-authority";
18
26
  import { signTaskToken } from "./task-token";
27
+ import { assertSafeHostTaskId } from "./task-identity";
19
28
  import type { RosterAgent } from "./agents/types";
20
29
 
21
30
  /** Container path of the CLI — what the preamble tells agents to run. */
22
31
  export const CONTAINER_CLI_PATH = "/workspace/.uai/cli.mjs";
23
32
 
33
+ /** Runtime/cache authority for every agent docker exec, including preserved
34
+ * pre-L4 containers whose Config.Env may still contain project-controlled
35
+ * PATH/asdf/Corepack/loader values. Callers merge engine-account credentials
36
+ * underneath this object so those credentials cannot replace host policy. */
37
+ export const AGENT_RUNTIME_AUTHORITY_ENV = RUNTIME_AUTHORITY_ENV;
38
+
24
39
  // The task's cli secret lives HOST-PRIVATE (never the container), so it survives
25
40
  // host restarts without a DB migration. The cloud re-sends it on each task-up.
26
41
  function taskSecretPath(taskId: string): string {
@@ -46,6 +61,20 @@ export function loadTaskCliSecret(taskId: string): string | null {
46
61
  }
47
62
  }
48
63
 
64
+ /** Orphan GC is an authority deletion, not best-effort housekeeping. */
65
+ export function removeTaskCliSecretStrict(taskId: string): void {
66
+ assertSafeHostTaskId(taskId);
67
+ const path = taskSecretPath(taskId);
68
+ rmSync(path, { force: true });
69
+ try {
70
+ lstatSync(path);
71
+ } catch (error) {
72
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
73
+ throw error;
74
+ }
75
+ throw new Error("task CLI secret remains after removal");
76
+ }
77
+
49
78
  /** The union used only to decide whether this task needs the shared CLI file. */
50
79
  export function rosterPermissions(roster: RosterAgent[]): string[] {
51
80
  return Array.from(new Set(roster.flatMap((a) => a.permissions ?? [])));
@@ -117,6 +146,7 @@ export function agentCliEnv(
117
146
  cliSecret: string | null,
118
147
  allowPermissionless = false,
119
148
  ): Record<string, string> {
149
+ const runtimeAuthority = { ...AGENT_RUNTIME_AUTHORITY_ENV };
120
150
  const permissions = agent.permissions ?? [];
121
151
  if (
122
152
  !ownerUserId ||
@@ -124,9 +154,10 @@ export function agentCliEnv(
124
154
  !cliSecret ||
125
155
  (permissions.length === 0 && !allowPermissionless)
126
156
  ) {
127
- return {};
157
+ return runtimeAuthority;
128
158
  }
129
159
  return {
160
+ ...runtimeAuthority,
130
161
  UAI_TASK_TOKEN: signTaskToken(
131
162
  { taskId, userId: ownerUserId, permissions, agentId: agent.id },
132
163
  cliSecret,
@@ -168,6 +199,22 @@ function parseFlags(args) {
168
199
  return { flags, rest };
169
200
  }
170
201
 
202
+ // A punchlist item's text is prose, and prose written in THIS repo contains
203
+ // --id and --text. parseFlags is global and greedy, so routing todo text
204
+ // through it silently rewrote the request: "todo edit aaaaaa the --id flag"
205
+ // resolved to { ref: "flag", text: "aaaaaa the" } and edited a different item,
206
+ // and a valueless --text became the literal string "true" — an item
207
+ // overwritten with garbage, or worse, the wrong item overwritten at all. Todo
208
+ // text is therefore read from the raw argv tail and never flag-parsed. The
209
+ // --id <ref> form survives only in leading position, where it cannot be
210
+ // mistaken for prose.
211
+ function todoRef(args) {
212
+ return args[0] === "--id"
213
+ ? { ref: args[1], tail: args.slice(2) }
214
+ : { ref: args[0] && !args[0].startsWith("--") ? args[0] : "", tail: args.slice(1) };
215
+ }
216
+ function todoText(args) { return args.join(" ").trim(); }
217
+
171
218
  async function api(method, path, body) {
172
219
  const res = await fetch(API_URL + path, {
173
220
  method,
@@ -292,14 +339,30 @@ async function main() {
292
339
  break;
293
340
  }
294
341
  case "todo add": {
295
- const text = pos.join(" ") || flags.text || "";
342
+ const text = todoText(rest);
296
343
  if (!text) { console.error("uai: todo add needs text"); process.exit(1); }
297
344
  const t = (await api("POST", "/api/agent/todos", { op: "add", text })).todo;
298
345
  out("added #" + t.shortId + ": " + t.text);
299
346
  break;
300
347
  }
348
+ case "todo edit": {
349
+ const { ref, tail } = todoRef(rest);
350
+ const text = todoText(tail);
351
+ if (!ref) { console.error("uai: todo edit needs an item id (see 'uai todo list')"); process.exit(1); }
352
+ if (!text) { console.error("uai: todo edit needs replacement text"); process.exit(1); }
353
+ const t = (await api("POST", "/api/agent/todos", { op: "edit", ref, text })).todo;
354
+ out("edited #" + t.shortId + ": " + t.text);
355
+ break;
356
+ }
357
+ case "todo remove": {
358
+ const { ref } = todoRef(rest);
359
+ if (!ref) { console.error("uai: todo remove needs an item id (see 'uai todo list')"); process.exit(1); }
360
+ await api("POST", "/api/agent/todos", { op: "remove", ref });
361
+ out("removed #" + String(ref).replace(/^#/, ""));
362
+ break;
363
+ }
301
364
  case "todo claim": case "todo done": case "todo reopen": case "todo unclaim": {
302
- const ref = pos[0] || flags.id;
365
+ const { ref } = todoRef(rest);
303
366
  if (!ref) { console.error("uai: todo " + action + " needs an item id (see 'uai todo list')"); process.exit(1); }
304
367
  const t = (await api("POST", "/api/agent/todos", { op: action, ref })).todo;
305
368
  out(action + " #" + t.shortId + ": " + t.text);
@@ -359,6 +422,8 @@ async function main() {
359
422
  " uai memory delete <id>",
360
423
  " uai todo list",
361
424
  " uai todo add <text>",
425
+ " uai todo edit <#id> <text>",
426
+ " uai todo remove <#id>",
362
427
  " uai todo claim|done|reopen|unclaim <#id>",
363
428
  " uai preview add <name> <containerPort>",
364
429
  " uai project create --name <n> --repo <git-url> [--prompt <p>]",
package/lib/agent.ts CHANGED
@@ -16,24 +16,28 @@
16
16
  */
17
17
 
18
18
  import { spawn } from "node:child_process";
19
- import { dirname, resolve } from "node:path";
19
+ import { dirname, join, resolve } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { z } from "zod";
22
22
 
23
23
  import {
24
+ createTaskEnvironmentDownCommandDb,
24
25
  createTaskDownCommandDb,
25
26
  createTaskStatusCommandDb,
26
27
  createTaskUpCommandDb,
27
28
  removeCommandDb,
28
29
  } from "./command-db";
29
30
  import { env } from "./env";
31
+ import { containerRuntimeStandardAssetsRoot } from "./container-runtime";
30
32
  import { getDecryptedForProject } from "./host-env";
31
33
  import { PreviewPortRuntimesSchema } from "./preview-ports";
32
34
  import { getHostTask } from "./runtime-state";
33
35
  import { removeTaskIdentity, writeTaskIdentity } from "./ssh";
34
36
  import type { TaskDownInput, TaskLaunchInput } from "../src/protocol";
35
37
 
36
- interface TaskUpCredentials {
38
+ export type { TaskLaunchInput } from "../src/protocol";
39
+
40
+ export interface TaskUpCredentials {
37
41
  /** Path to the task owner's private, ephemeral Git credential cache. */
38
42
  githubCredentialSocket?: string;
39
43
  }
@@ -151,6 +155,9 @@ async function runAgent<T extends z.ZodTypeAny>(
151
155
  ...process.env,
152
156
  UAI_DB_PATH: commandDbPath,
153
157
  UAI_DATA_DIR: env.dataDir,
158
+ UAI_STANDARD_CONTAINER_ASSETS_DIR:
159
+ containerRuntimeStandardAssetsRoot() ??
160
+ join(packageAgentScriptsDir(), "..", "..", "images", "standard", "container"),
154
161
  ...extraEnv,
155
162
  },
156
163
  });
@@ -191,13 +198,9 @@ async function runAgent<T extends z.ZodTypeAny>(
191
198
  if (parsed) {
192
199
  const failure = FailureEnvelope.safeParse(parsed);
193
200
  if (failure.success) {
194
- // Prefer the actual tool output over the bash envelope's generic
195
- // "agent step failed" — the docker/git/apt error is what the operator
196
- // needs, and it must reach the cloud, not just the host log.
197
- const detail = toolStderrDetail(stderrBuf);
198
201
  throw new AgentError(
199
202
  failure.data.error.code,
200
- detail || failure.data.error.message,
203
+ composeFailureMessage(failure.data.error.message, stderrBuf),
201
204
  {
202
205
  step: failure.data.error.step,
203
206
  exitCode: failure.data.error.exit_code ?? exitCode,
@@ -262,6 +265,23 @@ function tryParseLastJsonLine(buf: string): unknown {
262
265
  * host is a remote user's machine (live 2026-07-22, "compose_up_failed" with
263
266
  * no why). Bounded to the last few lines so a build log doesn't flood the UI.
264
267
  */
268
+ /**
269
+ * The operator-facing failure text: the envelope's message LEADS and the
270
+ * tool output follows. The scripts' emit_err messages are curated
271
+ * instructions ("close another running task, then press Resume") —
272
+ * replacing them with the stderr tail buried exactly the sentence the
273
+ * operator needed under VZ/git noise (live 2026-08-17, twice in one day).
274
+ * The tool detail still ships, because a message alone can lack the WHY
275
+ * (the 2026-07-22 lesson the old detail-first ordering encoded).
276
+ */
277
+ export function composeFailureMessage(
278
+ envelopeMessage: string,
279
+ stderrBuf: string,
280
+ ): string {
281
+ const detail = toolStderrDetail(stderrBuf);
282
+ return detail ? `${envelopeMessage}\n${detail}` : envelopeMessage;
283
+ }
284
+
265
285
  export function toolStderrDetail(stderr: string): string {
266
286
  const lines = stderr
267
287
  .split(/\r?\n/)
@@ -345,6 +365,25 @@ export const agent = {
345
365
  }
346
366
  },
347
367
 
368
+ /** ADR-101 Phase C: idempotent teardown from a durable environment locator.
369
+ * Cloud command metadata is intentionally unnecessary at this boundary. */
370
+ async taskEnvironmentDown(taskId: string): Promise<TaskDownResult> {
371
+ const commandDbPath = createTaskEnvironmentDownCommandDb(
372
+ taskId,
373
+ getHostTask(taskId),
374
+ );
375
+ try {
376
+ return await runAgent(
377
+ "task-down.sh",
378
+ [taskId],
379
+ TaskDownData,
380
+ commandDbPath,
381
+ );
382
+ } finally {
383
+ removeCommandDb(commandDbPath);
384
+ }
385
+ },
386
+
348
387
  async taskStatus(taskId: string): Promise<TaskStatusResult> {
349
388
  const commandDbPath = createTaskStatusCommandDb(taskId, getHostTask(taskId));
350
389
  try {
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * ClaudeSession — a real AgentSession backed by the Claude Code CLI in
3
- * stream-json mode, run inside the task container (ADR-010).
3
+ * stream-json mode, run inside the task environment (ADR-010).
4
4
  *
5
- * docker exec -i task-<id>-app-1 \
6
- * claude --print --input-format stream-json --output-format stream-json \
7
- * --verbose --no-session-persistence
5
+ * claude --print --input-format stream-json --output-format stream-json \
6
+ * --verbose --no-session-persistence
8
7
  *
9
8
  * The CLI is a persistent bidirectional process: uai writes one JSON
10
9
  * line per user turn to stdin and reads a stream of JSON event lines
@@ -20,6 +19,8 @@
20
19
  * those fixups stay in one place.
21
20
  */
22
21
 
22
+ import { requireContainerRuntimeOperational } from "../runtime-guard";
23
+ import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
23
24
  import { newId } from "../ulid";
24
25
  import { createAgentTransport, type LineTransport } from "./transport";
25
26
  import { isRateLimitMessage } from "./rate-limit";
@@ -437,7 +438,7 @@ export class ClaudeSession implements AgentSession {
437
438
  constructor(args: {
438
439
  taskId: string;
439
440
  agent: RosterAgent;
440
- containerName: string;
441
+ environment: TaskEnvironmentAgentSessionSurface;
441
442
  systemPreamble: string;
442
443
  executionProfile?: "communicator";
443
444
  attachCompatibilityKey?: string;
@@ -494,7 +495,7 @@ export class ClaudeSession implements AgentSession {
494
495
  this.proc = createAgentTransport({
495
496
  taskId: args.taskId,
496
497
  agentId: this.agentId,
497
- containerName: args.containerName,
498
+ environment: args.environment,
498
499
  cli: "claude",
499
500
  cliArgs,
500
501
  passEnv: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
@@ -635,6 +636,10 @@ export class ClaudeSession implements AgentSession {
635
636
 
636
637
  async send(text: string): Promise<void> {
637
638
  if (this.closed) return;
639
+ // The transport can outlive the command that created it. Gate each new
640
+ // turn at its actual runner boundary, while still allowing interrupt and
641
+ // close traffic during runtime recovery.
642
+ requireContainerRuntimeOperational();
638
643
  this.proc.writeLine({
639
644
  type: "user",
640
645
  message: { role: "user", content: text },
@@ -711,7 +716,7 @@ register({
711
716
  create: async ({
712
717
  taskId,
713
718
  agent,
714
- containerName,
719
+ environment,
715
720
  systemPreamble,
716
721
  executionProfile,
717
722
  attachCompatibilityKey,
@@ -720,7 +725,7 @@ register({
720
725
  new ClaudeSession({
721
726
  taskId,
722
727
  agent,
723
- containerName,
728
+ environment,
724
729
  systemPreamble,
725
730
  executionProfile,
726
731
  attachCompatibilityKey,
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * CodexSession — a real AgentSession backed by `codex app-server`, run
3
- * inside the task container (ADR-010):
3
+ * inside the task environment (ADR-010):
4
4
  *
5
- * docker exec -i task-<id>-app-1 codex app-server
5
+ * codex app-server
6
6
  *
7
7
  * JSON-RPC 2.0 over stdio, newline-delimited. The protocol below is
8
8
  * verified against `codex app-server generate-json-schema` (the
@@ -35,6 +35,8 @@ import { join } from "node:path";
35
35
  import { and, eq } from "drizzle-orm";
36
36
 
37
37
  import { getDb, schema } from "../db";
38
+ import { requireContainerRuntimeOperational } from "../runtime-guard";
39
+ import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
38
40
  import { newId } from "../ulid";
39
41
  import { createAgentTransport, type LineTransport } from "./transport";
40
42
  import { isRateLimitMessage } from "./rate-limit";
@@ -303,7 +305,7 @@ export class CodexSession implements AgentSession {
303
305
  constructor(args: {
304
306
  taskId: string;
305
307
  agent: RosterAgent;
306
- containerName: string;
308
+ environment: TaskEnvironmentAgentSessionSurface;
307
309
  systemPreamble: string;
308
310
  agentEnv?: Record<string, string>;
309
311
  }) {
@@ -335,7 +337,7 @@ export class CodexSession implements AgentSession {
335
337
  this.proc = createAgentTransport({
336
338
  taskId: args.taskId,
337
339
  agentId: this.agentId,
338
- containerName: args.containerName,
340
+ environment: args.environment,
339
341
  cli: "codex",
340
342
  cliArgs: codexArgs,
341
343
  passEnv: [],
@@ -630,6 +632,9 @@ export class CodexSession implements AgentSession {
630
632
  return;
631
633
  }
632
634
 
635
+ // `ready` may have waited across a runtime generation change. Recheck at
636
+ // the turn/start write rather than trusting session construction time.
637
+ requireContainerRuntimeOperational();
633
638
  this.fireRequest("turn/start", {
634
639
  threadId: this.threadId,
635
640
  input: [{ type: "text", text }],
@@ -689,6 +694,6 @@ register({
689
694
  existsSync(
690
695
  join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex", "auth.json"),
691
696
  ),
692
- create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
693
- new CodexSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
697
+ create: async ({ taskId, agent, environment, systemPreamble, agentEnv }) =>
698
+ new CodexSession({ taskId, agent, environment, systemPreamble, agentEnv }),
694
699
  });
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * CursorSession — a real AgentSession backed by **Cursor Agent** (`cursor-agent`),
3
- * run inside the task container:
3
+ * run inside the task environment:
4
4
  *
5
- * docker exec -i -e CURSOR_API_KEY=… task-<id>-app-1 \
6
- * /home/node/.local/bin/cursor-agent -p "<prompt>" \
5
+ * CURSOR_API_KEY=… /home/node/.local/bin/cursor-agent -p "<prompt>" \
7
6
  * --output-format stream-json --stream-partial-output --force --trust \
8
7
  * -m <model> [--resume <sessionId>]
9
8
  *
@@ -27,10 +26,18 @@
27
26
  * `--trust` (skip the workspace-trust prompt). Cursor has no system-prompt
28
27
  * flag, so the briefing folds into the first turn's prompt.
29
28
  */
30
- import { spawn, type ChildProcess } from "node:child_process";
31
-
29
+ import { requireContainerRuntimeOperational } from "../runtime-guard";
30
+ import type {
31
+ TaskEnvironmentAgentSessionSurface,
32
+ TaskEnvironmentSessionRequest,
33
+ } from "../task-environment/types";
32
34
  import { newId } from "../ulid";
33
35
  import { register } from "./registry";
36
+ import {
37
+ AGENT_SESSION_OUTPUT_BUFFER_BYTES,
38
+ spawnEnvironmentProcess,
39
+ type AgentEnvironmentProcess,
40
+ } from "./proc";
34
41
  import { extractResultUsage } from "./usage";
35
42
  import type {
36
43
  AgentEvent,
@@ -147,15 +154,15 @@ export function mapCursorLine(line: string): MappedCursorLine {
147
154
  // Session
148
155
  // ---------------------------------------------------------------------------
149
156
 
150
- export type Spawner = (args: string[]) => ChildProcess;
151
- const defaultSpawn: Spawner = (args) =>
152
- spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
157
+ export type Spawner = (
158
+ request: TaskEnvironmentSessionRequest,
159
+ ) => AgentEnvironmentProcess;
153
160
 
154
161
  export class CursorSession implements AgentSession {
155
162
  readonly agentId: string;
156
163
  readonly kind: AgentKind = "cursor";
157
164
 
158
- private readonly containerName: string;
165
+ private readonly environment: TaskEnvironmentAgentSessionSurface;
159
166
  private readonly model?: string;
160
167
  private readonly systemPreamble: string;
161
168
  private readonly agentEnv: Record<string, string>;
@@ -164,7 +171,7 @@ export class CursorSession implements AgentSession {
164
171
 
165
172
  private readonly handlers = new Set<AgentEventHandler>();
166
173
  private sessionId: string | null = null;
167
- private current: ChildProcess | null = null;
174
+ private current: AgentEnvironmentProcess | null = null;
168
175
  private queue: Promise<void> = Promise.resolve();
169
176
  private sentPreamble = false;
170
177
  private closed = false;
@@ -172,18 +179,19 @@ export class CursorSession implements AgentSession {
172
179
  constructor(args: {
173
180
  taskId: string;
174
181
  agent: RosterAgent;
175
- containerName: string;
182
+ environment: TaskEnvironmentAgentSessionSurface;
176
183
  systemPreamble: string;
177
184
  agentEnv?: Record<string, string>;
178
185
  spawner?: Spawner;
179
186
  apiKey?: string;
180
187
  }) {
181
188
  this.agentId = args.agent.id;
182
- this.containerName = args.containerName;
189
+ this.environment = args.environment;
183
190
  this.model = args.agent.model;
184
191
  this.systemPreamble = args.systemPreamble;
185
192
  this.agentEnv = args.agentEnv ?? {};
186
- this.spawner = args.spawner ?? defaultSpawn;
193
+ this.spawner =
194
+ args.spawner ?? ((request) => spawnEnvironmentProcess(this.environment, request));
187
195
  this.apiKey = args.apiKey ?? process.env.CURSOR_API_KEY ?? "";
188
196
  }
189
197
 
@@ -199,17 +207,13 @@ export class CursorSession implements AgentSession {
199
207
 
200
208
  async send(text: string): Promise<void> {
201
209
  if (this.closed) return;
202
- this.queue = this.queue.then(() => this.runTurn(text));
203
- return this.queue;
210
+ const turn = this.queue.catch(() => {}).then(() => this.runTurn(text));
211
+ this.queue = turn;
212
+ return turn;
204
213
  }
205
214
 
206
- private buildArgs(prompt: string): string[] {
207
- const args = ["exec", "-i", "-u", "node", "-e", `CURSOR_API_KEY=${this.apiKey}`];
208
- for (const [k, v] of Object.entries(this.agentEnv)) {
209
- args.push("-e", `${k}=${v}`);
210
- }
211
- args.push(
212
- this.containerName,
215
+ private buildRequest(prompt: string): TaskEnvironmentSessionRequest {
216
+ const argv = [
213
217
  CURSOR_BIN,
214
218
  "-p",
215
219
  prompt,
@@ -219,14 +223,20 @@ export class CursorSession implements AgentSession {
219
223
  "--force", // container is the sandbox — auto-run tools
220
224
  "--trust", // skip the workspace-trust prompt in headless
221
225
  "--approve-mcps", // load the gateway MCP servers from ~/.cursor/mcp.json (ADR-057)
222
- );
223
- if (this.model && this.model !== "auto") args.push("-m", this.model);
224
- if (this.sessionId) args.push("--resume", this.sessionId);
225
- return args;
226
+ ];
227
+ if (this.model && this.model !== "auto") argv.push("-m", this.model);
228
+ if (this.sessionId) argv.push("--resume", this.sessionId);
229
+ return {
230
+ argv: argv as [string, ...string[]],
231
+ user: "node",
232
+ env: { ...this.agentEnv, CURSOR_API_KEY: this.apiKey },
233
+ maxOutputBytes: AGENT_SESSION_OUTPUT_BUFFER_BYTES,
234
+ };
226
235
  }
227
236
 
228
237
  private async runTurn(text: string): Promise<void> {
229
238
  if (this.closed) return;
239
+ requireContainerRuntimeOperational();
230
240
  // No system-prompt flag — fold the briefing into the first turn; later
231
241
  // turns carry it via the resumed session.
232
242
  const prompt =
@@ -235,7 +245,7 @@ export class CursorSession implements AgentSession {
235
245
  : text;
236
246
  this.sentPreamble = true;
237
247
 
238
- const child = this.spawner(this.buildArgs(prompt));
248
+ const child = this.spawner(this.buildRequest(prompt));
239
249
  this.current = child;
240
250
 
241
251
  let acc = "";
@@ -352,6 +362,6 @@ register({
352
362
  // Gated on a Cursor API key in the host env (loaded from .env.local); the
353
363
  // adapter injects it into the container. No key → engine not advertised.
354
364
  available: () => Boolean(process.env.CURSOR_API_KEY),
355
- create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
356
- new CursorSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
365
+ create: async ({ taskId, agent, environment, systemPreamble, agentEnv }) =>
366
+ new CursorSession({ taskId, agent, environment, systemPreamble, agentEnv }),
357
367
  });
@@ -18,7 +18,6 @@
18
18
  * LineProcess gave them.
19
19
  */
20
20
 
21
- import { spawn } from "node:child_process";
22
21
  import {
23
22
  closeSync,
24
23
  fstatSync,
@@ -47,12 +46,11 @@ export function runnerScriptPath(): string {
47
46
  export interface DurableProcessOptions {
48
47
  /** Session dir as seen FROM THE HOST (on the workspace bind mount). */
49
48
  hostSessionDir: string;
50
- /**
51
- * Command that launches the runner (e.g. `docker exec -d … node
52
- * /opt/uai/runner.mjs <containerSessionDir> -- claude …`). Omit to ATTACH
53
- * to a runner that is already alive (boot reconciliation).
54
- */
55
- spawnCommand?: { command: string; args: string[] };
49
+ /** Provider-owned detached launch. Omit to attach to an existing runner. */
50
+ launch?: () => Promise<{
51
+ exitCode: number | null;
52
+ stderr: Uint8Array;
53
+ }>;
56
54
  /** Resume consumption from this outbox byte offset (attach path). */
57
55
  initialOutboxOffset?: number;
58
56
  /** Called after each poll batch whose lines were delivered — persist this
@@ -109,26 +107,21 @@ export class DurableProcess {
109
107
 
110
108
  mkdirSync(this.dir, { recursive: true });
111
109
 
112
- if (opts.spawnCommand) {
113
- if (this.debug) {
114
- this.log(
115
- `spawn: ${opts.spawnCommand.command} ${opts.spawnCommand.args.join(" ")}`,
116
- );
117
- }
118
- const child = spawn(opts.spawnCommand.command, opts.spawnCommand.args, {
119
- stdio: ["ignore", "ignore", "pipe"],
120
- });
121
- child.stderr?.setEncoding("utf8");
122
- child.stderr?.on("data", (chunk: string) => {
123
- this.stderrBuf = (this.stderrBuf + chunk).slice(-8192);
124
- });
125
- // `docker exec -d` exits 0 immediately on success; non-zero means the
126
- // runner never started (bad container, bad mount) — fail loudly now.
127
- child.on("exit", (code) => {
128
- if (code !== null && code !== 0) this.finish(null);
129
- });
130
- child.on("error", () => this.finish(null));
131
- child.unref();
110
+ if (opts.launch) {
111
+ void opts.launch().then(
112
+ (result) => {
113
+ const stderr = Buffer.from(result.stderr).toString("utf8");
114
+ if (stderr) this.stderrBuf = (this.stderrBuf + stderr).slice(-8192);
115
+ if (result.exitCode !== 0) this.finish(null);
116
+ },
117
+ (error: unknown) => {
118
+ this.stderrBuf = (
119
+ this.stderrBuf +
120
+ (error instanceof Error ? error.message : String(error))
121
+ ).slice(-8192);
122
+ this.finish(null);
123
+ },
124
+ );
132
125
  } else {
133
126
  this.sawSpawnMeta = true; // attach: the runner pre-exists
134
127
  }
@@ -8,10 +8,10 @@
8
8
  * change here. Unknown kinds fail loudly so a typo in a roster surfaces as an
9
9
  * error rather than silently falling back to the wrong CLI.
10
10
  *
11
- * Both adapters run their CLI inside the task container via `docker exec`
12
- * (ADR-010); the agent's `model` (if set) is passed through. Swapping mock ⇄
13
- * real is changing which factory the orchestrator is constructed with — see
14
- * `getOrchestrator`.
11
+ * Adapters run their CLI through the task environment's provider-owned
12
+ * session surface; the agent's `model` (if set) is passed through. Swapping
13
+ * mock ⇄ real is changing which factory the orchestrator is constructed with
14
+ * — see `getOrchestrator`.
15
15
  */
16
16
 
17
17
  // Side-effect imports: register the built-in adapters with the registry.
@@ -26,26 +26,6 @@ import { agentClisReady } from "../standard-image";
26
26
  import { factoryFor, supportsExecutionProfile } from "./registry";
27
27
  import type { AgentSession, AgentSessionFactory } from "./types";
28
28
 
29
- /** Cap the wait on agentClisReady so a spawn can never hang forever if the
30
- * boot reconcile never signals (e.g. an unexpected code path). Generous —
31
- * the reconcile normally settles in seconds; this only guards a stuck boot. */
32
- const CLIS_READY_TIMEOUT_MS = 90_000;
33
-
34
- /** Await the shared-volume CLI reconcile, bounded by a self-clearing timeout so
35
- * neither a stuck boot nor a per-spawn timer leak can bite. */
36
- async function awaitAgentClisReady(): Promise<void> {
37
- let timer: ReturnType<typeof setTimeout> | undefined;
38
- const bound = new Promise<void>((resolve) => {
39
- timer = setTimeout(resolve, CLIS_READY_TIMEOUT_MS);
40
- timer.unref?.();
41
- });
42
- try {
43
- await Promise.race([agentClisReady, bound]);
44
- } finally {
45
- if (timer) clearTimeout(timer);
46
- }
47
- }
48
-
49
29
  export const realAgentFactory: AgentSessionFactory = {
50
30
  create: async (args): Promise<AgentSession> => {
51
31
  const factory = factoryFor(args.agent.kind);
@@ -66,7 +46,11 @@ export const realAgentFactory: AgentSessionFactory = {
66
46
  // at boot the CLI auto-upgrade briefly removes then reinstalls codex/claude,
67
47
  // and a resume that races that window dies with "No codex executable found
68
48
  // for nodejs X". Post-boot this is already resolved (instant).
69
- await awaitAgentClisReady();
49
+ // Fail closed: the maintenance commands have their own process timeouts
50
+ // and settle this latch even on failure. A second timeout here would let a
51
+ // CLI spawn enter the shared volume while boot reconciliation still owns
52
+ // its remove/reinstall window.
53
+ await agentClisReady;
70
54
  return factory.create(args);
71
55
  },
72
56
  };