@runuai/host 0.9.147 → 0.9.149

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 (98) hide show
  1. package/db/migrations/0024_task_env_mode.sql +9 -0
  2. package/db/migrations/0025_project_env_authority.sql +147 -0
  3. package/db/migrations/0026_project_env_replica.sql +150 -0
  4. package/db/migrations/meta/_journal.json +21 -0
  5. package/db/schema.ts +312 -2
  6. package/images/standard/Dockerfile +44 -6
  7. package/images/standard/container/uai-init +85 -2
  8. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  9. package/lib/agent-cli.ts +18 -1
  10. package/lib/agent.ts +99 -34
  11. package/lib/agents/attach-compatibility.ts +51 -0
  12. package/lib/agents/codex.ts +137 -13
  13. package/lib/agents/cursor.ts +15 -1
  14. package/lib/agents/durable-proc.ts +2 -0
  15. package/lib/agents/grok.ts +8 -0
  16. package/lib/agents/kimi.ts +1 -0
  17. package/lib/agents/machine-durable.ts +17 -0
  18. package/lib/agents/opencode.ts +1 -0
  19. package/lib/agents/proc.ts +6 -1
  20. package/lib/agents/session-state.ts +107 -0
  21. package/lib/agents/transport.ts +125 -58
  22. package/lib/agents/types.ts +3 -3
  23. package/lib/browser-testing.ts +6 -0
  24. package/lib/codex-auth.ts +2 -0
  25. package/lib/credential-store.ts +141 -6
  26. package/lib/engine-accounts.ts +1 -0
  27. package/lib/env-file.ts +7 -0
  28. package/lib/env-schema.ts +841 -51
  29. package/lib/git-diff.ts +1 -0
  30. package/lib/git-identity.ts +2 -0
  31. package/lib/github-tokens.ts +1 -0
  32. package/lib/host-env.ts +98 -18
  33. package/lib/lease.ts +11 -3
  34. package/lib/machine-gateway-tunnel.ts +195 -17
  35. package/lib/machine-project-env-loader.ts +152 -0
  36. package/lib/managed-runtime.ts +270 -51
  37. package/lib/mcp-gateway.ts +777 -26
  38. package/lib/orchestrator.ts +1681 -138
  39. package/lib/project-env-authority.ts +1319 -0
  40. package/lib/project-env-feature.ts +74 -0
  41. package/lib/project-env-loader.ts +237 -0
  42. package/lib/project-env-replica.ts +2094 -0
  43. package/lib/project-env-schema-capture.ts +342 -0
  44. package/lib/project-env-staging.ts +148 -0
  45. package/lib/project-env-task-policy.ts +176 -0
  46. package/lib/runtime-archive.ts +79 -16
  47. package/lib/runtime-authority.ts +44 -0
  48. package/lib/runtime-state.ts +175 -10
  49. package/lib/secrets-proxy/address.ts +72 -0
  50. package/lib/secrets-proxy/audit.ts +30 -0
  51. package/lib/secrets-proxy/ca-store.ts +200 -0
  52. package/lib/secrets-proxy/ca.ts +470 -0
  53. package/lib/secrets-proxy/container-steps.ts +138 -0
  54. package/lib/secrets-proxy/der.ts +273 -0
  55. package/lib/secrets-proxy/engine.ts +1633 -0
  56. package/lib/secrets-proxy/host.ts +93 -0
  57. package/lib/secrets-proxy/hostname.ts +60 -0
  58. package/lib/secrets-proxy/placeholders.ts +196 -0
  59. package/lib/secrets-proxy/policy.ts +141 -0
  60. package/lib/secrets-proxy/readiness.ts +148 -0
  61. package/lib/secrets-proxy/runtime.ts +582 -0
  62. package/lib/secrets-proxy/scrub.ts +559 -0
  63. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  64. package/lib/secrets-proxy/task-scripts.ts +262 -0
  65. package/lib/secrets-proxy/task-trust.ts +574 -0
  66. package/lib/secrets-proxy/trust-watch.ts +80 -0
  67. package/lib/shared-files-s3.ts +3 -0
  68. package/lib/sidecar-guard.ts +58 -0
  69. package/lib/sidecar-resume.ts +51 -3
  70. package/lib/standard-image.ts +50 -12
  71. package/lib/task-env-forwarder.ts +228 -0
  72. package/lib/task-env-source.ts +193 -0
  73. package/lib/task-environment/apple-container.ts +45 -11
  74. package/lib/task-environment/docker.ts +51 -12
  75. package/lib/task-environment/index.ts +6 -2
  76. package/lib/task-environment/machine-task-up.ts +258 -25
  77. package/lib/task-environment/machine.ts +80 -22
  78. package/lib/task-environment/registry.ts +3 -2
  79. package/lib/task-environment/self-identity.ts +20 -0
  80. package/lib/task-environment/self.ts +92 -16
  81. package/lib/task-environment/types.ts +63 -0
  82. package/lib/task-gateway-port.ts +22 -0
  83. package/lib/task-gateway-protocol.ts +190 -0
  84. package/lib/task-gateway-recovery.ts +44 -0
  85. package/lib/task-gateway-state.ts +497 -0
  86. package/lib/transcript.ts +1 -0
  87. package/lib/ulid.ts +10 -1
  88. package/package.json +13 -1
  89. package/scripts/agent/env-hook.sh +626 -0
  90. package/scripts/agent/task-up.sh +476 -27
  91. package/scripts/agent/uai-env-exec.sh +26 -0
  92. package/src/cli.ts +26 -3
  93. package/src/index.ts +273 -19
  94. package/src/main.ts +941 -48
  95. package/src/project-env-latest.ts +130 -0
  96. package/src/project-env-policy.ts +281 -0
  97. package/src/project-env-wording.ts +47 -0
  98. package/src/protocol.ts +2239 -15
package/lib/agent.ts CHANGED
@@ -32,14 +32,24 @@ import { containerRuntimeStandardAssetsRoot } from "./container-runtime";
32
32
  import { getDecryptedForProject } from "./host-env";
33
33
  import { PreviewPortRuntimesSchema } from "./preview-ports";
34
34
  import { getHostTask } from "./runtime-state";
35
+ import { activeSecretsProxy } from "./secrets-proxy/host";
36
+ import { closeTaskUpTrustSteps, finishTaskUpTrustSteps, writeTaskUpTrustSteps } from "./secrets-proxy/task-trust";
35
37
  import { removeTaskIdentity, writeTaskIdentity } from "./ssh";
36
- import type { TaskDownInput, TaskLaunchInput } from "../src/protocol";
38
+ import { taskGatewayHost } from "./mcp-gateway";
39
+ import type { ProjectEnvMode } from "./task-environment/types";
40
+ import { isProjectEnvSchemaObservation, type ProjectEnvSchemaObservation, type TaskDownInput, type TaskLaunchInput } from "../src/protocol";
37
41
 
38
42
  export type { TaskLaunchInput } from "../src/protocol";
39
43
 
40
44
  export interface TaskUpCredentials {
41
45
  /** Path to the task owner's private, ephemeral Git credential cache. */
42
46
  githubCredentialSocket?: string;
47
+ /** Fresh ADR-153 generation credential. The raw bytes are forwarded only
48
+ * over stdin and are never serialized into a task row, argv, or env. */
49
+ taskGateway?: {
50
+ generation: string;
51
+ secret: Uint8Array;
52
+ };
43
53
  }
44
54
 
45
55
  // ---------------------------------------------------------------------------
@@ -86,6 +96,13 @@ const TaskUpData = z.object({
86
96
  previewPorts: PreviewPortRuntimesSchema.optional(),
87
97
  /** Degraded start: uai-init failed twice; surfaced as a feed system note. */
88
98
  initWarning: z.string().optional(),
99
+ /** ADR-153: whether this start put the secrets proxy's CA, and its pins,
100
+ * into the task. Absent from a task-up that predates them. */
101
+ secretsProxyTrust: z.enum(["installed", "failed", "skipped"]).optional(),
102
+ secretsProxyPins: z.enum(["installed", "failed", "skipped"]).optional(),
103
+ /** ADR-153 §4: each project's schema observation, as the runner stored it
104
+ * before anything in the task started (a task machine reports its own). */
105
+ envSchemas: z.array(z.custom<ProjectEnvSchemaObservation>(isProjectEnvSchemaObservation)).optional(),
89
106
  });
90
107
  export type TaskUpResult = z.infer<typeof TaskUpData>;
91
108
 
@@ -303,46 +320,94 @@ export const agent = {
303
320
  async taskUp(
304
321
  input: TaskLaunchInput,
305
322
  credentials: TaskUpCredentials = {},
323
+ envMode: ProjectEnvMode = "baked",
306
324
  ): Promise<TaskUpResult> {
307
325
  const commandDbPath = createTaskUpCommandDb(input);
308
326
  // Materialize the creator's per-user SSH key for commit signing and the
309
327
  // explicit no-GitHub-connection transport fallback (ADR-029).
310
328
  const identityDir = writeTaskIdentity(input.task.id, input.task.ownerUserId);
311
- // Per-(project, key) env values must NEVER enter task-up's host process
312
- // environment. Besides ordinary app names, projects can declare names such
313
- // as BASH_ENV, PATH, GIT_CONFIG_*, or UAI_*; exposing those before the host
314
- // clone would let container-owned configuration influence host commands or
315
- // read another credential boundary. Send one JSON object over stdin
316
- // instead. task-up feeds it directly to Compose as an in-memory override,
317
- // so values reach only the task container and are never written to disk.
318
- // On a key collision the LEAD project (position 0) wins: iterate in
319
- // descending position order so position-0 assignments apply last.
320
- const projectEnv: Record<string, string> = {};
321
- const orderedProjects = [...input.projects].sort(
322
- (a, b) => b.position - a.position,
323
- );
324
- for (const project of orderedProjects) {
325
- Object.assign(projectEnv, getDecryptedForProject(project.id));
326
- }
327
- const extraEnv: Record<string, string> = {};
328
- if (identityDir) extraEnv.UAI_TASK_IDENTITY_DIR = identityDir;
329
- // Only a non-secret socket path crosses into Bash. The token itself was
330
- // seeded over stdin and never enters argv, env, project data, Compose, or
331
- // the task container.
332
- if (credentials.githubCredentialSocket) {
333
- extraEnv.UAI_GITHUB_CREDENTIAL_SOCKET =
334
- credentials.githubCredentialSocket;
335
- }
336
329
  try {
337
- return await runAgent(
338
- "task-up.sh",
339
- [input.task.id],
340
- TaskUpData,
341
- commandDbPath,
342
- extraEnv,
343
- JSON.stringify(projectEnv),
344
- );
330
+ // Per-(project, key) env values must NEVER enter task-up's host process
331
+ // environment. Besides ordinary app names, projects can declare names such
332
+ // as BASH_ENV, PATH, GIT_CONFIG_*, or UAI_*; exposing those before the host
333
+ // clone would let container-owned configuration influence host commands or
334
+ // read another credential boundary. Send one JSON object over stdin
335
+ // instead. task-up feeds it directly to Compose as an in-memory override,
336
+ // so values reach only the task container and are never written to disk.
337
+ // On a key collision the LEAD project (position 0) wins: iterate in
338
+ // descending position order so position-0 assignments apply last.
339
+ const projectEnv: Record<string, string> = {};
340
+ if (envMode === "baked") {
341
+ const orderedProjects = [...input.projects].sort(
342
+ (a, b) => b.position - a.position,
343
+ );
344
+ for (const project of orderedProjects) {
345
+ Object.assign(projectEnv, getDecryptedForProject(project.id));
346
+ }
347
+ }
348
+ const extraEnv: Record<string, string> = {
349
+ UAI_PROJECT_ENV_MODE: envMode,
350
+ };
351
+ if (identityDir) extraEnv.UAI_TASK_IDENTITY_DIR = identityDir;
352
+ // Only a non-secret socket path crosses into Bash. The token itself was
353
+ // seeded over stdin and never enters argv, env, project data, Compose, or
354
+ // the task container.
355
+ if (credentials.githubCredentialSocket) {
356
+ extraEnv.UAI_GITHUB_CREDENTIAL_SOCKET =
357
+ credentials.githubCredentialSocket;
358
+ }
359
+ let stdinPayload: string;
360
+ if (envMode === "loader") {
361
+ const gateway = credentials.taskGateway;
362
+ if (!gateway || gateway.secret.byteLength !== 32) {
363
+ throw new Error(
364
+ "loader task is missing its gateway generation credential",
365
+ );
366
+ }
367
+ extraEnv.UAI_TASK_GATEWAY_GENERATION = gateway.generation;
368
+ extraEnv.UAI_TASK_GATEWAY_HOST = await taskGatewayHost();
369
+ // ADR-153: what task-up installs before anything runs in the task.
370
+ // Public material only; absent under the kill switch.
371
+ // The start holds the task's root writes until its result is
372
+ // recorded, so no older writer (a recovery, a re-pin) lands over
373
+ // them while uai-init runs.
374
+ const secretsProxy = activeSecretsProxy();
375
+ if (secretsProxy !== null) {
376
+ // task-up reports each step's outcome into it as it goes, so the
377
+ // task's readiness is exact even while uai-init already runs.
378
+ const stepsDir = await writeTaskUpTrustSteps(env.dataDir, input.task.id, secretsProxy.runtime);
379
+ if (stepsDir !== null) extraEnv.UAI_SECRETS_PROXY_DIR = stepsDir;
380
+ }
381
+ const secretCopy = Buffer.from(gateway.secret);
382
+ try {
383
+ stdinPayload = secretCopy.toString("base64");
384
+ } finally {
385
+ secretCopy.fill(0);
386
+ }
387
+ } else {
388
+ stdinPayload = JSON.stringify(projectEnv);
389
+ }
390
+ let result: TaskUpResult;
391
+ try {
392
+ result = await runAgent(
393
+ "task-up.sh",
394
+ [input.task.id],
395
+ TaskUpData,
396
+ commandDbPath,
397
+ extraEnv,
398
+ stdinPayload,
399
+ );
400
+ } catch (error) {
401
+ finishTaskUpTrustSteps(env.dataDir, input.task.id, null);
402
+ throw error;
403
+ }
404
+ // What this start actually installed. A start that did not get the CA
405
+ // in is retried by the trust watcher once the task runs.
406
+ finishTaskUpTrustSteps(env.dataDir, input.task.id, result);
407
+ if (result.secretsProxyTrust === "failed") activeSecretsProxy()?.runtime.requestTrustDelivery();
408
+ return result;
345
409
  } finally {
410
+ closeTaskUpTrustSteps(env.dataDir, input.task.id);
346
411
  removeCommandDb(commandDbPath);
347
412
  removeTaskIdentity(input.task.id);
348
413
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Compatibility for the v0.9.147 -> v0.9.148 durable-session key rollout.
3
+ *
4
+ * Older hosts could not receive live roster edits and persisted only the
5
+ * execution-policy key: null for crew, or one of the Secretary policy
6
+ * constants. v0.9.148 adds kind/model/effort to that key. Treat the old row as
7
+ * compatible with the richer desired key only when its policy is unchanged;
8
+ * the transition is deliberately one-way so future/unknown keys remain exact.
9
+ */
10
+
11
+ const LEGACY_SECRETARY_POLICY_KEYS = new Set([
12
+ "secretary-normal-tools-v1",
13
+ "secretary-communicator-v1",
14
+ ]);
15
+
16
+ function rosterLaunchPolicy(key: string | null): string | null | undefined {
17
+ if (key === null) return undefined;
18
+ try {
19
+ const parsed: unknown = JSON.parse(key);
20
+ if (
21
+ !Array.isArray(parsed) ||
22
+ parsed.length !== 5 ||
23
+ parsed[0] !== "roster-launch-v1" ||
24
+ typeof parsed[1] !== "string" ||
25
+ typeof parsed[2] !== "string" ||
26
+ typeof parsed[3] !== "string" ||
27
+ typeof parsed[4] !== "string"
28
+ ) {
29
+ return undefined;
30
+ }
31
+ return parsed[4] === "" ? null : parsed[4];
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ export function attachCompatibilityKeysMatch(
38
+ stored: string | null | undefined,
39
+ desired: string | null | undefined,
40
+ ): boolean {
41
+ const storedKey = stored ?? null;
42
+ const desiredKey = desired ?? null;
43
+ if (storedKey === desiredKey) return true;
44
+
45
+ const desiredPolicy = rosterLaunchPolicy(desiredKey);
46
+ if (storedKey === null) return desiredPolicy === null;
47
+ return (
48
+ LEGACY_SECRETARY_POLICY_KEYS.has(storedKey) &&
49
+ desiredPolicy === storedKey
50
+ );
51
+ }
@@ -32,12 +32,13 @@ import { existsSync } from "node:fs";
32
32
  import { homedir } from "node:os";
33
33
  import { join } from "node:path";
34
34
 
35
- import { and, eq } from "drizzle-orm";
35
+ import { and, eq, isNull } from "drizzle-orm";
36
36
 
37
37
  import { getDb, schema } from "../db";
38
38
  import { requireContainerRuntimeOperational } from "../runtime-guard";
39
39
  import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
40
40
  import { newId } from "../ulid";
41
+ import { attachCompatibilityKeysMatch } from "./attach-compatibility";
41
42
  import { createAgentTransport, type LineTransport } from "./transport";
42
43
  import { isRateLimitMessage } from "./rate-limit";
43
44
  import { register } from "./registry";
@@ -233,11 +234,21 @@ const delay = (ms: number): Promise<void> =>
233
234
  // Thread persistence (ADR-078) — resume the SAME thread across a host restart.
234
235
  // ---------------------------------------------------------------------------
235
236
 
236
- /** The Codex thread id persisted for (task, agent), or null if none yet. */
237
- function readCodexThreadId(taskId: string, agentId: string): string | null {
237
+ /** The compatible Codex thread id persisted for (task, agent), or null. */
238
+ function readCodexThreadId(
239
+ taskId: string,
240
+ agentId: string,
241
+ attachCompatibilityKey: string | undefined,
242
+ ): string | null {
238
243
  try {
239
244
  const row = getDb()
240
- .select({ id: schema.hostAgentSessions.codexThreadId })
245
+ .select({
246
+ id: schema.hostAgentSessions.codexThreadId,
247
+ kind: schema.hostAgentSessions.kind,
248
+ sessionDir: schema.hostAgentSessions.sessionDir,
249
+ attachCompatibilityKey:
250
+ schema.hostAgentSessions.attachCompatibilityKey,
251
+ })
241
252
  .from(schema.hostAgentSessions)
242
253
  .where(
243
254
  and(
@@ -246,6 +257,31 @@ function readCodexThreadId(taskId: string, agentId: string): string | null {
246
257
  ),
247
258
  )
248
259
  .get();
260
+ if (
261
+ row?.kind !== "codex" ||
262
+ !attachCompatibilityKeysMatch(
263
+ row.attachCompatibilityKey,
264
+ attachCompatibilityKey,
265
+ )
266
+ ) {
267
+ // Transport will promote the row to the new launch key while preserving
268
+ // codexThreadId. Clear the incompatible id first so a crash between that
269
+ // upsert and thread/started cannot make the next host resume it.
270
+ if (row?.id && row.sessionDir) {
271
+ getDb()
272
+ .update(schema.hostAgentSessions)
273
+ .set({ codexThreadId: null, updatedAt: Date.now() })
274
+ .where(
275
+ and(
276
+ eq(schema.hostAgentSessions.taskId, taskId),
277
+ eq(schema.hostAgentSessions.agentId, agentId),
278
+ eq(schema.hostAgentSessions.sessionDir, row.sessionDir),
279
+ ),
280
+ )
281
+ .run();
282
+ }
283
+ return null;
284
+ }
249
285
  return row?.id ?? null;
250
286
  } catch {
251
287
  return null;
@@ -258,15 +294,57 @@ function readCodexThreadId(taskId: string, agentId: string): string | null {
258
294
  * thread (today's behavior). The transport's session-row upsert never clears
259
295
  * this column, so it survives the kill+respawn of a host restart.
260
296
  */
261
- function saveCodexThreadId(taskId: string, agentId: string, threadId: string): void {
297
+ function saveCodexThreadId(
298
+ taskId: string,
299
+ agentId: string,
300
+ threadId: string,
301
+ sessionDir: string | undefined,
302
+ attachCompatibilityKey: string | undefined,
303
+ ): void {
304
+ if (!sessionDir) return;
262
305
  try {
263
- getDb()
306
+ const db = getDb();
307
+ const row = db
308
+ .select({
309
+ kind: schema.hostAgentSessions.kind,
310
+ sessionDir: schema.hostAgentSessions.sessionDir,
311
+ attachCompatibilityKey:
312
+ schema.hostAgentSessions.attachCompatibilityKey,
313
+ })
314
+ .from(schema.hostAgentSessions)
315
+ .where(
316
+ and(
317
+ eq(schema.hostAgentSessions.taskId, taskId),
318
+ eq(schema.hostAgentSessions.agentId, agentId),
319
+ ),
320
+ )
321
+ .get();
322
+ const persistedKey = row?.attachCompatibilityKey ?? null;
323
+ if (
324
+ row?.kind !== "codex" ||
325
+ row.sessionDir !== sessionDir ||
326
+ !attachCompatibilityKeysMatch(
327
+ row.attachCompatibilityKey,
328
+ attachCompatibilityKey,
329
+ )
330
+ ) {
331
+ return;
332
+ }
333
+ db
264
334
  .update(schema.hostAgentSessions)
265
335
  .set({ codexThreadId: threadId, updatedAt: Date.now() })
266
336
  .where(
267
337
  and(
268
338
  eq(schema.hostAgentSessions.taskId, taskId),
269
339
  eq(schema.hostAgentSessions.agentId, agentId),
340
+ eq(schema.hostAgentSessions.kind, "codex"),
341
+ eq(schema.hostAgentSessions.sessionDir, sessionDir),
342
+ persistedKey === null
343
+ ? isNull(schema.hostAgentSessions.attachCompatibilityKey)
344
+ : eq(
345
+ schema.hostAgentSessions.attachCompatibilityKey,
346
+ persistedKey,
347
+ ),
270
348
  ),
271
349
  )
272
350
  .run();
@@ -302,6 +380,7 @@ export class CodexSession implements AgentSession {
302
380
 
303
381
  private threadId: string | null = null;
304
382
  private readonly ready: Promise<void>;
383
+ private readonly attachCompatibilityKey: string | undefined;
305
384
 
306
385
  constructor(args: {
307
386
  taskId: string;
@@ -309,15 +388,21 @@ export class CodexSession implements AgentSession {
309
388
  environment: TaskEnvironmentAgentSessionSurface;
310
389
  systemPreamble: string;
311
390
  agentEnv?: Record<string, string>;
391
+ attachCompatibilityKey?: string;
312
392
  /** ADR-128: `-c key=value` overrides a delegating adapter prepends. */
313
393
  cliConfigOverrides?: string[];
314
394
  }) {
315
395
  this.agentId = args.agent.id;
316
396
  this.systemPreamble = args.systemPreamble;
317
397
  this.taskId = args.taskId;
398
+ this.attachCompatibilityKey = args.attachCompatibilityKey;
318
399
  // ADR-078: read BEFORE createAgentTransport (whose session-row upsert
319
400
  // preserves this column). Non-null → a host restart; resume the thread.
320
- this.resumeThreadId = readCodexThreadId(args.taskId, args.agent.id);
401
+ this.resumeThreadId = readCodexThreadId(
402
+ args.taskId,
403
+ args.agent.id,
404
+ args.attachCompatibilityKey,
405
+ );
321
406
 
322
407
  // The agent's model / effort (when set) are selected via config overrides
323
408
  // (`-c model=<model>`, `-c model_reasoning_effort=<effort>`) on the
@@ -333,7 +418,15 @@ export class CodexSession implements AgentSession {
333
418
  for (const override of args.cliConfigOverrides ?? []) {
334
419
  codexArgs.push("-c", override);
335
420
  }
336
- codexArgs.push("app-server");
421
+ // These are two independent features in Codex 0.157. Append after every
422
+ // user override so task env can never be frozen into an on-disk snapshot.
423
+ codexArgs.push(
424
+ "--disable",
425
+ "shell_snapshot",
426
+ "--disable",
427
+ "shell_snapshot_v2",
428
+ "app-server",
429
+ );
337
430
  // ADR-061: the CLI is owned by an in-container runner (durable across
338
431
  // host restarts); legacy pipes behind UAI_DURABLE_SESSIONS=0. Codex
339
432
  // re-handshakes per host process (initialize + thread/start live in this
@@ -349,6 +442,7 @@ export class CodexSession implements AgentSession {
349
442
  passEnv: [],
350
443
  explicitEnv: args.agentEnv ?? {},
351
444
  allowAttach: false,
445
+ attachCompatibilityKey: args.attachCompatibilityKey,
352
446
  kind: "codex",
353
447
  debugLabel: `codex:${this.agentId}`,
354
448
  });
@@ -387,6 +481,10 @@ export class CodexSession implements AgentSession {
387
481
  // -- JSON-RPC plumbing ----------------------------------------------------
388
482
 
389
483
  private onLine(line: string): void {
484
+ // close() severs this generation before the durable runner necessarily
485
+ // exits. Ignore buffered notifications so a late thread/started cannot
486
+ // restore the resume id that a live roster edit just cleared.
487
+ if (this.closed) return;
390
488
  let msg: unknown;
391
489
  try {
392
490
  msg = JSON.parse(line);
@@ -439,7 +537,7 @@ export class CodexSession implements AgentSession {
439
537
  const thread = msg.params.thread;
440
538
  if (isObj(thread) && typeof thread.id === "string") {
441
539
  this.threadId = thread.id;
442
- saveCodexThreadId(this.taskId, this.agentId, thread.id);
540
+ this.persistThreadId(thread.id);
443
541
  }
444
542
  }
445
543
  for (const ev of mapCodexNotification(method, msg.params)) {
@@ -568,7 +666,7 @@ export class CodexSession implements AgentSession {
568
666
  if (isObj(result) && isObj(result.thread)) {
569
667
  const id = str(result.thread.id) || this.threadId;
570
668
  this.threadId = id;
571
- if (id) saveCodexThreadId(this.taskId, this.agentId, id);
669
+ if (id) this.persistThreadId(id);
572
670
  }
573
671
  }
574
672
 
@@ -594,7 +692,7 @@ export class CodexSession implements AgentSession {
594
692
  ? str(result.thread.id)
595
693
  : threadId;
596
694
  this.threadId = id;
597
- saveCodexThreadId(this.taskId, this.agentId, id);
695
+ this.persistThreadId(id);
598
696
  return true;
599
697
  } catch (err) {
600
698
  const code = (err as { rpcCode?: number }).rpcCode;
@@ -615,6 +713,16 @@ export class CodexSession implements AgentSession {
615
713
 
616
714
  // -- AgentSession ---------------------------------------------------------
617
715
 
716
+ private persistThreadId(threadId: string): void {
717
+ saveCodexThreadId(
718
+ this.taskId,
719
+ this.agentId,
720
+ threadId,
721
+ this.proc.durableSessionDir,
722
+ this.attachCompatibilityKey,
723
+ );
724
+ }
725
+
618
726
  onEvent(handler: AgentEventHandler): () => void {
619
727
  this.handlers.add(handler);
620
728
  return () => this.handlers.delete(handler);
@@ -704,6 +812,22 @@ register({
704
812
  // provider block rides it, and this destructuring silently dropped it on
705
813
  // the first live gpt-5-mini smoke (2026-09-12, host 0.9.136 — codex still
706
814
  // dialed api.openai.com).
707
- create: async ({ taskId, agent, environment, systemPreamble, agentEnv, cliConfigOverrides }) =>
708
- new CodexSession({ taskId, agent, environment, systemPreamble, agentEnv, cliConfigOverrides }),
815
+ create: async ({
816
+ taskId,
817
+ agent,
818
+ environment,
819
+ systemPreamble,
820
+ agentEnv,
821
+ attachCompatibilityKey,
822
+ cliConfigOverrides,
823
+ }) =>
824
+ new CodexSession({
825
+ taskId,
826
+ agent,
827
+ environment,
828
+ systemPreamble,
829
+ agentEnv,
830
+ attachCompatibilityKey,
831
+ cliConfigOverrides,
832
+ }),
709
833
  });
@@ -50,6 +50,13 @@ import type {
50
50
 
51
51
  const CURSOR_BIN = "/home/node/.local/bin/cursor-agent";
52
52
 
53
+ // Cursor replays its captured sandbox environment immediately before it
54
+ // evaluates each shell command. In loader tasks this restore hook is the last
55
+ // step in that replay, so the command sees the current project environment
56
+ // rather than Cursor's stale launch snapshot.
57
+ export const CURSOR_SANDBOX_ENV_RESTORE =
58
+ ". /usr/local/share/uai/env-hook.sh; __uai_env_sync";
59
+
53
60
  // A curated slice of Cursor's ~32 models (full ids from `cursor-agent
54
61
  // --list-models`). `auto` lets Cursor pick; the rest are the frontier. The UI
55
62
  // also accepts any typed id (unverified round-trip). UPDATE as Cursor's lineup
@@ -233,8 +240,15 @@ export class CursorSession implements AgentSession {
233
240
  if (this.sessionId) argv.push("--resume", this.sessionId);
234
241
  return {
235
242
  argv: argv as [string, ...string[]],
243
+ projectEnvProfile: "task",
236
244
  user: "node",
237
- env: { ...this.agentEnv, CURSOR_API_KEY: this.apiKey },
245
+ env: {
246
+ ...this.agentEnv,
247
+ CURSOR_API_KEY: this.apiKey,
248
+ ...(this.environment.descriptor.envMode === "loader"
249
+ ? { __CURSOR_SANDBOX_ENV_RESTORE: CURSOR_SANDBOX_ENV_RESTORE }
250
+ : {}),
251
+ },
238
252
  maxOutputBytes: AGENT_SESSION_OUTPUT_BUFFER_BYTES,
239
253
  };
240
254
  }
@@ -72,6 +72,7 @@ interface RunnerMeta {
72
72
  }
73
73
 
74
74
  export class DurableProcess {
75
+ readonly durableSessionDir: string;
75
76
  private readonly dir: string;
76
77
  private readonly outboxPath: string;
77
78
  private readonly inboxPath: string;
@@ -95,6 +96,7 @@ export class DurableProcess {
95
96
  private readonly debug: string | null;
96
97
 
97
98
  constructor(opts: DurableProcessOptions) {
99
+ this.durableSessionDir = opts.hostSessionDir;
98
100
  this.dir = opts.hostSessionDir;
99
101
  this.outboxPath = join(this.dir, "outbox.jsonl");
100
102
  this.inboxPath = join(this.dir, "inbox.jsonl");
@@ -52,6 +52,7 @@ import type {
52
52
  } from "./types";
53
53
 
54
54
  const GROK_BIN = "/home/node/.grok/bin/grok";
55
+ export const GROK_LOADER_LOGIN_ENV = "0";
55
56
 
56
57
  // `grok models` lists what the account can run. grok-4.5 is the current
57
58
  // default; UPDATE WHEN xAI CHANGES the lineup.
@@ -181,6 +182,7 @@ export class GrokSession implements AgentSession {
181
182
  if (this.sessionId) argv.push("-r", this.sessionId);
182
183
  return {
183
184
  argv: argv as [string, ...string[]],
185
+ projectEnvProfile: "task",
184
186
  user: "node",
185
187
  inheritEnv,
186
188
  env: {
@@ -193,6 +195,12 @@ export class GrokSession implements AgentSession {
193
195
  // the same reasoning as --permission-mode bypassPermissions above
194
196
  // (live 2026-09-08, first Grok agents with MCP servers).
195
197
  GROK_FOLDER_TRUST: "0",
198
+ // grok 1.0.5 otherwise captures a login-shell environment once and
199
+ // reuses that static state for LocalNonPersistent commands. Loader
200
+ // tasks refresh per command, so disable that stale snapshot path.
201
+ ...(this.environment.descriptor.envMode === "loader"
202
+ ? { GROK_LOGIN_ENV: GROK_LOADER_LOGIN_ENV }
203
+ : {}),
196
204
  },
197
205
  maxOutputBytes: AGENT_SESSION_OUTPUT_BUFFER_BYTES,
198
206
  };
@@ -223,6 +223,7 @@ export class KimiSession implements AgentSession {
223
223
  if (this.sessionId) argv.push("-r", this.sessionId);
224
224
  return {
225
225
  argv: argv as [string, ...string[]],
226
+ projectEnvProfile: "task",
226
227
  user: "node",
227
228
  env: this.agentEnv,
228
229
  maxOutputBytes: AGENT_SESSION_OUTPUT_BUFFER_BYTES,
@@ -59,6 +59,7 @@ async function execStep(
59
59
  ): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
60
60
  const result = await environment.exec({
61
61
  argv,
62
+ projectEnvProfile: "maintenance",
62
63
  ...(stdin ? { stdin } : {}),
63
64
  timeoutMs: STEP_TIMEOUT_MS,
64
65
  maxOutputBytes: STEP_OUTPUT_BYTES,
@@ -129,6 +130,7 @@ export interface MachineDurableOptions {
129
130
  }
130
131
 
131
132
  export class MachineDurableProcess implements LineTransport {
133
+ readonly durableSessionDir: string;
132
134
  readonly #environment: MachineSessionEnvironment;
133
135
  readonly #sessionDir: string;
134
136
  readonly #outboxPath: string;
@@ -158,6 +160,7 @@ export class MachineDurableProcess implements LineTransport {
158
160
  readonly #debug: string | null;
159
161
 
160
162
  constructor(opts: MachineDurableOptions) {
163
+ this.durableSessionDir = opts.sessionDir;
161
164
  this.#environment = opts.environment;
162
165
  this.#sessionDir = opts.sessionDir;
163
166
  this.#outboxPath = posix.join(opts.sessionDir, "outbox.jsonl");
@@ -226,6 +229,7 @@ export class MachineDurableProcess implements LineTransport {
226
229
  String(this.#offset + 1),
227
230
  this.#outboxPath,
228
231
  ],
232
+ projectEnvProfile: "maintenance",
229
233
  maxOutputBytes: 8 * 1024 * 1024,
230
234
  });
231
235
  } catch {
@@ -563,3 +567,16 @@ export async function stopMachinePredecessor(
563
567
  // Dir gone / runner dead — nothing to stop.
564
568
  }
565
569
  }
570
+
571
+ /** Removal/recovery variant: failure is not success because no later attach
572
+ * will provide the transport layer's usual predecessor-stop backstop. */
573
+ export async function stopMachinePredecessorStrict(
574
+ environment: MachineSessionEnvironment,
575
+ sessionDir: string,
576
+ ): Promise<void> {
577
+ await appendEnvironmentFile(
578
+ environment,
579
+ posix.join(sessionDir, "inbox.jsonl"),
580
+ '{"__uai":"stop"}\n',
581
+ );
582
+ }
@@ -357,6 +357,7 @@ export class OpencodeSession implements AgentSession {
357
357
  argv.push(prompt);
358
358
  return {
359
359
  argv: argv as [string, ...string[]],
360
+ projectEnvProfile: "task",
360
361
  user: "node",
361
362
  env: this.agentEnv,
362
363
  maxOutputBytes: AGENT_SESSION_OUTPUT_BUFFER_BYTES,
@@ -7,6 +7,7 @@ import type {
7
7
  TaskEnvironmentProcess,
8
8
  TaskEnvironmentSessionRequest,
9
9
  } from "../task-environment/types";
10
+ import { agentCliArgv } from "../agent-cli";
10
11
 
11
12
  export type LineHandler = (line: string) => void;
12
13
  export type ExitHandler = (code: number | null) => void;
@@ -168,6 +169,10 @@ export function spawnEnvironmentProcess(
168
169
  environment: TaskEnvironmentAgentSessionSurface,
169
170
  request: TaskEnvironmentSessionRequest,
170
171
  ): AgentEnvironmentProcess {
172
+ const launchRequest: TaskEnvironmentSessionRequest = {
173
+ ...request,
174
+ argv: agentCliArgv(environment, request.argv),
175
+ };
171
176
  class EnvironmentProcess extends EventEmitter implements AgentEnvironmentProcess {
172
177
  readonly stdout = new EventEmitter();
173
178
  readonly stderr = new EventEmitter();
@@ -176,7 +181,7 @@ export function spawnEnvironmentProcess(
176
181
 
177
182
  constructor() {
178
183
  super();
179
- this.processPromise = environment.spawnSession(request);
184
+ this.processPromise = environment.spawnSession(launchRequest);
180
185
  void this.consume();
181
186
  }
182
187