@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
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Durable agent-session state used by live roster reconciliation.
3
+ *
4
+ * The orchestrator normally owns a live AgentSession and closes it through the
5
+ * adapter. After a host restart, however, a removed agent can exist only as a
6
+ * host_agent_sessions row. These helpers stop that persisted runner
7
+ * without deleting its row or generation directory, and clear Codex's durable
8
+ * thread id when launch settings change.
9
+ */
10
+
11
+ import { promises as fsp } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ import { and, eq } from "drizzle-orm";
15
+
16
+ import { getDb, schema } from "../db";
17
+ import type { TaskEnvironmentHandle } from "../task-environment/types";
18
+ import { isEnvironmentTransportProvider } from "../task-environment/types";
19
+ import {
20
+ stopMachinePredecessorStrict,
21
+ type MachineSessionEnvironment,
22
+ } from "./machine-durable";
23
+
24
+ /** Durable-session ids persisted for one task, including rows marked closed.
25
+ * A crash can land after markClosed but before its stop reaches the runner. */
26
+ export function persistedAgentIds(taskId: string): string[] {
27
+ return getDb()
28
+ .select({ agentId: schema.hostAgentSessions.agentId })
29
+ .from(schema.hostAgentSessions)
30
+ .where(eq(schema.hostAgentSessions.taskId, taskId))
31
+ .all()
32
+ .map((row) => row.agentId);
33
+ }
34
+
35
+ /**
36
+ * A changed model/engine/effort starts a fresh Codex thread. The channel
37
+ * transcript is the durable memory; resuming a thread created under the old
38
+ * launch settings can make the replacement keep using the old model.
39
+ */
40
+ export function clearPersistedCodexThread(
41
+ taskId: string,
42
+ agentId: string,
43
+ ): void {
44
+ getDb()
45
+ .update(schema.hostAgentSessions)
46
+ .set({ codexThreadId: null, updatedAt: Date.now() })
47
+ .where(
48
+ and(
49
+ eq(schema.hostAgentSessions.taskId, taskId),
50
+ eq(schema.hostAgentSessions.agentId, agentId),
51
+ ),
52
+ )
53
+ .run();
54
+ }
55
+
56
+ /**
57
+ * Stop a persisted runner that has no in-memory AgentSession (boot recovery).
58
+ * The session directory and DB row deliberately remain for diagnostics.
59
+ */
60
+ export async function stopPersistedAgentSession(
61
+ taskId: string,
62
+ agentId: string,
63
+ environment: TaskEnvironmentHandle<unknown>,
64
+ ): Promise<string | null> {
65
+ const db = getDb();
66
+ const row = db
67
+ .select()
68
+ .from(schema.hostAgentSessions)
69
+ .where(
70
+ and(
71
+ eq(schema.hostAgentSessions.taskId, taskId),
72
+ eq(schema.hostAgentSessions.agentId, agentId),
73
+ ),
74
+ )
75
+ .get();
76
+ if (!row) return null;
77
+
78
+ if (isEnvironmentTransportProvider(environment.descriptor.locator.provider)) {
79
+ await stopMachinePredecessorStrict(
80
+ environment as MachineSessionEnvironment,
81
+ row.sessionDir,
82
+ );
83
+ } else {
84
+ 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;
93
+ }
94
+ }
95
+
96
+ db.update(schema.hostAgentSessions)
97
+ .set({ status: "closed", updatedAt: Date.now() })
98
+ .where(
99
+ and(
100
+ eq(schema.hostAgentSessions.taskId, taskId),
101
+ eq(schema.hostAgentSessions.agentId, agentId),
102
+ eq(schema.hostAgentSessions.sessionDir, row.sessionDir),
103
+ ),
104
+ )
105
+ .run();
106
+ return row.sessionDir;
107
+ }
@@ -33,9 +33,9 @@ import {
33
33
  } from "node:fs";
34
34
  import { basename, dirname, join } from "node:path";
35
35
 
36
- import { and, eq } from "drizzle-orm";
36
+ import { and, eq, isNull } from "drizzle-orm";
37
37
 
38
- import { getDb, schema } from "../db";
38
+ import { getDb, schema, type Db } from "../db";
39
39
  import { taskWorkspaceDir } from "../env";
40
40
  import { machineSessionAuthorityEnv } from "../runtime-authority";
41
41
  import { requireContainerRuntimeOperational } from "../runtime-guard";
@@ -55,10 +55,14 @@ import {
55
55
  type ExitHandler,
56
56
  type LineHandler,
57
57
  } from "./proc";
58
+ import { agentCliArgv } from "../agent-cli";
58
59
  import { isEnvironmentTransportProvider } from "../task-environment/types";
60
+ import { attachCompatibilityKeysMatch } from "./attach-compatibility";
59
61
 
60
62
  /** The shared surface adapters program against (LineProcess's shape). */
61
63
  export interface LineTransport {
64
+ /** Exact durable row generation owned by this transport, when durable. */
65
+ readonly durableSessionDir?: string;
62
66
  onLine(handler: LineHandler): void;
63
67
  onExit(handler: ExitHandler): void;
64
68
  writeLine(value: unknown): void;
@@ -84,7 +88,7 @@ export interface AgentTransportOptions {
84
88
  * protocol state can't outlive the host process (codex handshake).
85
89
  */
86
90
  allowAttach: boolean;
87
- /** Host-owned launch-policy generation. Both absent remains compatible. */
91
+ /** Host-owned launch-settings generation. Both absent remains compatible. */
88
92
  attachCompatibilityKey?: string;
89
93
  kind: string;
90
94
  debugLabel?: string;
@@ -120,6 +124,73 @@ function durableEnabled(): boolean {
120
124
  return process.env.UAI_DURABLE_SESSIONS !== "0";
121
125
  }
122
126
 
127
+ /** Durable rows are keyed by task + agent for lookup, but callbacks belong to
128
+ * one exact generation. A delayed offset/exit from the predecessor must never
129
+ * mutate the replacement row after its upsert. */
130
+ function durableRowCallbacks(
131
+ db: Db,
132
+ taskId: string,
133
+ agentId: string,
134
+ sessionDir: string,
135
+ ): {
136
+ persistOffset(offset: number): void;
137
+ markClosed(): void;
138
+ } {
139
+ const ownedRow = () =>
140
+ and(
141
+ eq(schema.hostAgentSessions.taskId, taskId),
142
+ eq(schema.hostAgentSessions.agentId, agentId),
143
+ eq(schema.hostAgentSessions.sessionDir, sessionDir),
144
+ );
145
+ return {
146
+ persistOffset(offset: number): void {
147
+ db.update(schema.hostAgentSessions)
148
+ .set({ outboxOffset: offset, updatedAt: Date.now() })
149
+ .where(ownedRow())
150
+ .run();
151
+ },
152
+ markClosed(): void {
153
+ db.update(schema.hostAgentSessions)
154
+ .set({ status: "closed", updatedAt: Date.now() })
155
+ .where(ownedRow())
156
+ .run();
157
+ },
158
+ };
159
+ }
160
+
161
+ /** Claim a compatible durable row and promote a one-release legacy key to the
162
+ * rich launch fingerprint. The exact generation/key fence prevents a stale
163
+ * host process from rewriting a replacement row after reading its snapshot. */
164
+ function claimCompatibleAttachRow(
165
+ db: Db,
166
+ taskId: string,
167
+ agentId: string,
168
+ sessionDir: string,
169
+ stored: string | null | undefined,
170
+ desired: string | null | undefined,
171
+ ): boolean {
172
+ if (!attachCompatibilityKeysMatch(stored, desired)) return false;
173
+ const storedKey = stored ?? null;
174
+ const desiredKey = desired ?? null;
175
+ if (storedKey === desiredKey) return true;
176
+ if (desiredKey === null) return false;
177
+ const result = db
178
+ .update(schema.hostAgentSessions)
179
+ .set({ attachCompatibilityKey: desiredKey, updatedAt: Date.now() })
180
+ .where(
181
+ and(
182
+ eq(schema.hostAgentSessions.taskId, taskId),
183
+ eq(schema.hostAgentSessions.agentId, agentId),
184
+ eq(schema.hostAgentSessions.sessionDir, sessionDir),
185
+ storedKey === null
186
+ ? isNull(schema.hostAgentSessions.attachCompatibilityKey)
187
+ : eq(schema.hostAgentSessions.attachCompatibilityKey, storedKey),
188
+ ),
189
+ )
190
+ .run();
191
+ return result.changes === 1;
192
+ }
193
+
123
194
  export function createAgentTransport(opts: AgentTransportOptions): LineTransport {
124
195
  // ADR-121: machine-backed sessions ride the environment's own transport —
125
196
  // the container-runtime preflight is a docker/apple concern a machine task
@@ -161,29 +232,6 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
161
232
  )
162
233
  .get();
163
234
 
164
- const persistOffset = (offset: number): void => {
165
- db.update(schema.hostAgentSessions)
166
- .set({ outboxOffset: offset, updatedAt: Date.now() })
167
- .where(
168
- and(
169
- eq(schema.hostAgentSessions.taskId, opts.taskId),
170
- eq(schema.hostAgentSessions.agentId, opts.agentId),
171
- ),
172
- )
173
- .run();
174
- };
175
- const markClosed = (): void => {
176
- db.update(schema.hostAgentSessions)
177
- .set({ status: "closed", updatedAt: Date.now() })
178
- .where(
179
- and(
180
- eq(schema.hostAgentSessions.taskId, opts.taskId),
181
- eq(schema.hostAgentSessions.agentId, opts.agentId),
182
- ),
183
- )
184
- .run();
185
- };
186
-
187
235
  const tailKey = `${opts.taskId}:${opts.agentId}`;
188
236
 
189
237
  // ---- Attach: a previous host process left this agent's runner alive. ----
@@ -192,10 +240,23 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
192
240
  row &&
193
241
  row.status === "running" &&
194
242
  row.containerName === opts.environment.durableIdentity &&
195
- (row.attachCompatibilityKey ?? null) ===
196
- (opts.attachCompatibilityKey ?? null) &&
197
- heartbeatFresh(row.sessionDir)
243
+ row.kind === opts.kind &&
244
+ heartbeatFresh(row.sessionDir) &&
245
+ claimCompatibleAttachRow(
246
+ db,
247
+ opts.taskId,
248
+ opts.agentId,
249
+ row.sessionDir,
250
+ row.attachCompatibilityKey,
251
+ opts.attachCompatibilityKey,
252
+ )
198
253
  ) {
254
+ const { persistOffset, markClosed } = durableRowCallbacks(
255
+ db,
256
+ opts.taskId,
257
+ opts.agentId,
258
+ row.sessionDir,
259
+ );
199
260
  const proc = new DurableProcess({
200
261
  hostSessionDir: row.sessionDir,
201
262
  initialOutboxOffset: row.outboxOffset,
@@ -240,6 +301,12 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
240
301
  opts.agentId,
241
302
  basename(sessionDir),
242
303
  );
304
+ const { persistOffset, markClosed } = durableRowCallbacks(
305
+ db,
306
+ opts.taskId,
307
+ opts.agentId,
308
+ sessionDir,
309
+ );
243
310
  const runnerInEnvironment = join(environmentSessionDir, "runner.mjs");
244
311
  try {
245
312
  copyFileSync(runnerScriptPath(), runnerInSession);
@@ -260,15 +327,16 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
260
327
  onCloseRequested: markClosed,
261
328
  debugLabel: opts.debugLabel,
262
329
  launch: async () => {
330
+ const cliArgv = agentCliArgv(opts.environment, [opts.cli, ...opts.cliArgs]);
263
331
  const result = await opts.environment.launchDetachedSession({
264
332
  argv: [
265
333
  "node",
266
334
  runnerInEnvironment,
267
335
  environmentSessionDir,
268
336
  "--",
269
- opts.cli,
270
- ...opts.cliArgs,
337
+ ...cliArgv,
271
338
  ],
339
+ projectEnvProfile: "task",
272
340
  inheritEnv: opts.passEnv ?? [],
273
341
  env: opts.explicitEnv ?? {},
274
342
  launchTimeoutMs: 30_000,
@@ -350,28 +418,6 @@ function machineDurableTransport(opts: AgentTransportOptions): LineTransport {
350
418
  ),
351
419
  )
352
420
  .get();
353
- const persistOffset = (offset: number): void => {
354
- db.update(schema.hostAgentSessions)
355
- .set({ outboxOffset: offset, updatedAt: Date.now() })
356
- .where(
357
- and(
358
- eq(schema.hostAgentSessions.taskId, opts.taskId),
359
- eq(schema.hostAgentSessions.agentId, opts.agentId),
360
- ),
361
- )
362
- .run();
363
- };
364
- const markClosed = (): void => {
365
- db.update(schema.hostAgentSessions)
366
- .set({ status: "closed", updatedAt: Date.now() })
367
- .where(
368
- and(
369
- eq(schema.hostAgentSessions.taskId, opts.taskId),
370
- eq(schema.hostAgentSessions.agentId, opts.agentId),
371
- ),
372
- )
373
- .run();
374
- };
375
421
  const tailKey = `${opts.taskId}:${opts.agentId}`;
376
422
 
377
423
  // ---- Attach: a previous host process left this machine's runner alive. --
@@ -382,9 +428,22 @@ function machineDurableTransport(opts: AgentTransportOptions): LineTransport {
382
428
  row &&
383
429
  row.status === "running" &&
384
430
  row.containerName === environment.durableIdentity &&
385
- (row.attachCompatibilityKey ?? null) ===
386
- (opts.attachCompatibilityKey ?? null)
431
+ row.kind === opts.kind &&
432
+ claimCompatibleAttachRow(
433
+ db,
434
+ opts.taskId,
435
+ opts.agentId,
436
+ row.sessionDir,
437
+ row.attachCompatibilityKey,
438
+ opts.attachCompatibilityKey,
439
+ )
387
440
  ) {
441
+ const { persistOffset, markClosed } = durableRowCallbacks(
442
+ db,
443
+ opts.taskId,
444
+ opts.agentId,
445
+ row.sessionDir,
446
+ );
388
447
  const proc = new MachineDurableProcess({
389
448
  environment,
390
449
  sessionDir: row.sessionDir,
@@ -406,6 +465,12 @@ function machineDurableTransport(opts: AgentTransportOptions): LineTransport {
406
465
  environment.descriptor.workspacePath,
407
466
  opts.agentId,
408
467
  );
468
+ const { persistOffset, markClosed } = durableRowCallbacks(
469
+ db,
470
+ opts.taskId,
471
+ opts.agentId,
472
+ sessionDir,
473
+ );
409
474
  const proc = new MachineDurableProcess({
410
475
  environment,
411
476
  sessionDir,
@@ -421,15 +486,16 @@ function machineDurableTransport(opts: AgentTransportOptions): LineTransport {
421
486
  }
422
487
  const runnerPath = await deployRunner(environment, sessionDir);
423
488
  await publishMachineCurrentSession(environment, sessionDir);
489
+ const cliArgv = agentCliArgv(environment, [opts.cli, ...opts.cliArgs]);
424
490
  const result = await environment.launchDetachedSession({
425
491
  argv: [
426
492
  "node",
427
493
  runnerPath,
428
494
  sessionDir,
429
495
  "--",
430
- opts.cli,
431
- ...opts.cliArgs,
496
+ ...cliArgv,
432
497
  ],
498
+ projectEnvProfile: "task",
433
499
  inheritEnv: opts.passEnv ?? [],
434
500
  env: opts.explicitEnv ?? {},
435
501
  launchTimeoutMs: 30_000,
@@ -475,7 +541,8 @@ function directEnvironmentTransport(opts: AgentTransportOptions): LineTransport
475
541
  return new EnvironmentLineProcess({
476
542
  environment: opts.environment,
477
543
  request: {
478
- argv: [opts.cli, ...opts.cliArgs],
544
+ argv: agentCliArgv(opts.environment, [opts.cli, ...opts.cliArgs]),
545
+ projectEnvProfile: "task",
479
546
  inheritEnv: opts.passEnv ?? [],
480
547
  env: opts.explicitEnv ?? {},
481
548
  maxOutputBytes: AGENT_SESSION_OUTPUT_BUFFER_BYTES,
@@ -180,9 +180,9 @@ export interface AgentSessionFactory {
180
180
  /** Host-derived restricted execution policy (ADR-083). Never supplied by
181
181
  * the browser or stored on the roster entry. */
182
182
  executionProfile?: "communicator";
183
- /** Durable runners are keyed by task+agent. A host-owned policy change
184
- * supplies a new key so restart recovery replaces, rather than attaches,
185
- * a process launched with incompatible argv/preamble/tooling. */
183
+ /** Durable runners are keyed by task+agent. Host-owned launch settings
184
+ * supply a compatibility key so restart recovery replaces, rather than
185
+ * attaches, a process launched with incompatible model/effort/policy. */
186
186
  attachCompatibilityKey?: string;
187
187
  /** ADR-048: extra per-agent process environment (e.g. this agent's own
188
188
  * UAI_TASK_TOKEN so its `uai` CLI carries only its own permissions). */
@@ -1157,6 +1157,7 @@ export async function setupBrowserTesting(
1157
1157
  for (const step of VIEWER_STEPS) {
1158
1158
  if (environment) {
1159
1159
  await environment.launchDetachedSession({
1160
+ projectEnvProfile: "maintenance",
1160
1161
  argv: ["sh", "-c", step],
1161
1162
  env: authorityEnv,
1162
1163
  maxOutputBytes: 64 * 1024,
@@ -1194,6 +1195,7 @@ export async function setupBrowserTesting(
1194
1195
  const installed = environment
1195
1196
  ? await (async () => {
1196
1197
  const chown = await environment.exec({
1198
+ projectEnvProfile: "maintenance",
1197
1199
  argv: ["/usr/bin/chown", "node:node", "/opt/pw-browsers"],
1198
1200
  user: "root",
1199
1201
  env: authorityEnv,
@@ -1208,6 +1210,7 @@ export async function setupBrowserTesting(
1208
1210
  );
1209
1211
  }
1210
1212
  return environment.exec({
1213
+ projectEnvProfile: "maintenance",
1211
1214
  argv: ["sh", "-lc", PREWARM_CMD],
1212
1215
  cwd: "/workspace",
1213
1216
  env: authorityEnv,
@@ -1309,6 +1312,7 @@ export async function setupBrowserTesting(
1309
1312
  ] as [string, ...string[]];
1310
1313
  const wrote = environment
1311
1314
  ? await environment.exec({
1315
+ projectEnvProfile: "maintenance",
1312
1316
  argv: writeArgv,
1313
1317
  cwd: "/workspace",
1314
1318
  env: writeEnv,
@@ -1397,6 +1401,7 @@ export async function setupBrowserTesting(
1397
1401
  ] as [string, ...string[]];
1398
1402
  const registered = environment
1399
1403
  ? await environment.exec({
1404
+ projectEnvProfile: "maintenance",
1400
1405
  argv: grokArgv,
1401
1406
  cwd: "/workspace",
1402
1407
  env: authorityEnv,
@@ -1469,6 +1474,7 @@ export async function setupBrowserTesting(
1469
1474
  };
1470
1475
  if (environment) {
1471
1476
  await environment.exec({
1477
+ projectEnvProfile: "maintenance",
1472
1478
  argv: ["/bin/sh", "-c", guardedAptStart],
1473
1479
  user: "root",
1474
1480
  cwd: "/workspace",
package/lib/codex-auth.ts CHANGED
@@ -261,6 +261,7 @@ export async function injectCodexIntoMachine(
261
261
  environment: {
262
262
  exec(request: {
263
263
  argv: readonly [string, ...string[]];
264
+ projectEnvProfile: "maintenance" | "task";
264
265
  timeoutMs: number;
265
266
  maxOutputBytes: number;
266
267
  }): Promise<{ exitCode: number | null; stderr: Uint8Array }>;
@@ -277,6 +278,7 @@ export async function injectCodexIntoMachine(
277
278
  if (!exists(join(dir, "auth.json"))) return true; // nothing to inject
278
279
  try {
279
280
  const made = await environment.exec({
281
+ projectEnvProfile: "maintenance",
280
282
  argv: ["/bin/mkdir", "-p", "/home/node/.codex"],
281
283
  timeoutMs: EXEC_TIMEOUT_MS,
282
284
  maxOutputBytes: 64 * 1024,
@@ -36,12 +36,57 @@ export interface StoredParameter {
36
36
  value: string;
37
37
  }
38
38
 
39
+ export interface StoredParameterVersion extends StoredParameter {
40
+ version: number;
41
+ }
42
+
43
+ export type ImmutableParameterPutResult = "created" | "matched";
44
+
45
+ /** The immutable name exists, but its authenticated identity is different. */
46
+ export class ImmutableParameterConflictError extends Error {
47
+ constructor(message = "immutable parameter identity conflicts with the existing value") {
48
+ super(message);
49
+ this.name = "ImmutableParameterConflictError";
50
+ }
51
+ }
52
+
53
+ /** A failed/ambiguous create could not yet be proved to be our prior write. */
54
+ export class ImmutableParameterUnverifiedError extends Error {
55
+ constructor(message = "immutable parameter create could not be verified") {
56
+ super(message);
57
+ this.name = "ImmutableParameterUnverifiedError";
58
+ }
59
+ }
60
+
39
61
  export interface CredentialStore {
40
62
  readonly kind: "aws-ssm";
41
63
  /** Create or overwrite one credential. Throws on failure. */
42
64
  put(name: string, value: string): Promise<void>;
65
+ /**
66
+ * Create one immutable SecureString with its identity tags in the same AWS
67
+ * request. A retry never overwrites or reads the value: metadata proves that
68
+ * an already-existing name is the exact prior write.
69
+ *
70
+ * Optional for custom/older stores. Callers that require create-once
71
+ * semantics must fail closed when it is absent.
72
+ */
73
+ putImmutable?(
74
+ name: string,
75
+ value: string,
76
+ tags: Record<string, string>,
77
+ ): Promise<ImmutableParameterPutResult>;
43
78
  /** The credential's plaintext, or null when it does not exist. */
44
79
  get(name: string): Promise<string | null>;
80
+ /**
81
+ * One exact immutable parameter version. Project-env replica migration
82
+ * uses this for legacy value references: reading the latest value and then
83
+ * assuming its version would allow a late old writer to substitute a new
84
+ * secret for the head the cloud actually named.
85
+ *
86
+ * Optional for older/custom stores. Callers that require a pinned read
87
+ * must fail closed when it is absent.
88
+ */
89
+ getVersion?(name: string, version: number): Promise<StoredParameterVersion | null>;
45
90
  /** Idempotent: a missing parameter is not an error. */
46
91
  delete(name: string): Promise<void>;
47
92
  /** Every credential under `path` (recursive), decrypted. Throws on failure. */
@@ -162,6 +207,19 @@ function isAccessDenied(result: CliResult): boolean {
162
207
  return /AccessDenied/.test(result.stderr);
163
208
  }
164
209
 
210
+ function checkedTagEntries(tags: Record<string, string>): Array<[string, string]> {
211
+ const entries = Object.entries(tags);
212
+ for (const [key, value] of entries) {
213
+ if (
214
+ !/^[A-Za-z0-9_.:/=+@-]{1,128}$/.test(key) ||
215
+ !/^[A-Za-z0-9_.:/=+@ -]{0,256}$/.test(value)
216
+ ) {
217
+ throw new Error(`invalid tag ${key}`);
218
+ }
219
+ }
220
+ return entries;
221
+ }
222
+
165
223
  /** Summarize a CLI failure WITHOUT echoing stdout (which could carry a value). */
166
224
  function failure(op: string, result: CliResult): Error {
167
225
  const line =
@@ -207,15 +265,59 @@ export class AwsSsmCredentialStore implements CredentialStore {
207
265
  if (result.status !== 0) throw failure("put-parameter", result);
208
266
  }
209
267
 
210
- async tag(name: string, tags: Record<string, string>): Promise<void> {
268
+ async putImmutable(
269
+ name: string,
270
+ value: string,
271
+ tags: Record<string, string>,
272
+ ): Promise<ImmutableParameterPutResult> {
211
273
  if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
212
- const entries = Object.entries(tags);
213
- if (entries.length === 0) return;
214
- for (const [key, value] of entries) {
215
- if (!/^[A-Za-z0-9_.:/=+@-]{1,128}$/.test(key) || !/^[A-Za-z0-9_.:/=+@ -]{0,256}$/.test(value)) {
216
- throw new Error(`invalid tag ${key}`);
274
+ const entries = checkedTagEntries(tags);
275
+ if (entries.length === 0) {
276
+ throw new Error("immutable parameter identity tags are required");
277
+ }
278
+ const input = JSON.stringify({
279
+ Name: name,
280
+ Value: value,
281
+ Type: "SecureString",
282
+ KeyId: this.kmsKeyId,
283
+ Tier: "Advanced",
284
+ Overwrite: false,
285
+ Tags: entries.map(([Key, Value]) => ({ Key, Value })),
286
+ });
287
+ const result = await withPrivateInputFile(input, (path) =>
288
+ this.run(["ssm", "put-parameter", "--cli-input-json", `file://${path}`]),
289
+ );
290
+ if (result.status === 0) return "created";
291
+
292
+ // The CLI can lose the successful response, and a retry then receives
293
+ // ParameterAlreadyExists. The sign-in-box identity is intentionally
294
+ // write-only, so authenticate the prior write using metadata only.
295
+ let observed: Record<string, string>;
296
+ try {
297
+ observed = await this.listTags(name);
298
+ } catch {
299
+ throw new ImmutableParameterUnverifiedError();
300
+ }
301
+ let missing = false;
302
+ for (const [key, expected] of entries) {
303
+ const actual = observed[key];
304
+ if (actual === undefined) {
305
+ missing = true;
306
+ } else if (actual !== expected) {
307
+ throw new ImmutableParameterConflictError();
217
308
  }
218
309
  }
310
+ if (!missing) return "matched";
311
+
312
+ // Tags are eventually consistent with the create. Missing/partial tags
313
+ // are therefore retryable, never permission to add tags or overwrite.
314
+ throw new ImmutableParameterUnverifiedError();
315
+ }
316
+
317
+ async tag(name: string, tags: Record<string, string>): Promise<void> {
318
+ if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
319
+ const entries = checkedTagEntries(tags);
320
+ if (entries.length === 0) return;
219
321
  const result = await this.run([
220
322
  "ssm",
221
323
  "add-tags-to-resource",
@@ -248,6 +350,39 @@ export class AwsSsmCredentialStore implements CredentialStore {
248
350
  return value;
249
351
  }
250
352
 
353
+ async getVersion(
354
+ name: string,
355
+ version: number,
356
+ ): Promise<StoredParameterVersion | null> {
357
+ if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
358
+ if (!Number.isSafeInteger(version) || version < 1) {
359
+ throw new Error(`invalid parameter version: ${version}`);
360
+ }
361
+ const result = await this.run([
362
+ "ssm",
363
+ "get-parameter",
364
+ "--name",
365
+ `${name}:${version}`,
366
+ "--with-decryption",
367
+ ]);
368
+ if (result.status !== 0) {
369
+ if (isNotFound(result)) return null;
370
+ throw failure("get-parameter", result);
371
+ }
372
+ const parameter = (JSON.parse(result.stdout) as {
373
+ Parameter?: { Name?: unknown; Value?: unknown; Version?: unknown };
374
+ }).Parameter;
375
+ if (
376
+ parameter === undefined ||
377
+ parameter.Name !== name ||
378
+ typeof parameter.Value !== "string" ||
379
+ parameter.Version !== version
380
+ ) {
381
+ throw new Error("aws ssm get-parameter: malformed versioned response");
382
+ }
383
+ return { name, value: parameter.Value, version };
384
+ }
385
+
251
386
  async delete(name: string): Promise<void> {
252
387
  if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
253
388
  const result = await this.run(["ssm", "delete-parameter", "--name", name]);
@@ -654,6 +654,7 @@ async function copyDirIntoRaw(
654
654
  if (environment) {
655
655
  const run = async (argv: [string, ...string[]], step: string) => {
656
656
  const result = await environment.exec({
657
+ projectEnvProfile: "maintenance",
657
658
  argv,
658
659
  user: "root",
659
660
  env: RUNTIME_AUTHORITY_ENV,
package/lib/env-file.ts CHANGED
@@ -133,6 +133,13 @@ export function restoreEnvFileSnapshotAtomic(
133
133
  }
134
134
  }
135
135
 
136
+ /** Write `body` to `file` so a crash or power loss leaves either the old
137
+ * file or the new one, never a torn or empty one: fsync the data, rename,
138
+ * then fsync the directory. 0600, parent created 0700. */
139
+ export function writeDurableFileAtomic(file: string, body: string): void {
140
+ replaceEnvFileAtomic(file, body);
141
+ }
142
+
136
143
  function replaceEnvFileAtomic(file: string, body: string): void {
137
144
  const parent = dirname(file);
138
145
  mkdirSync(parent, { recursive: true, mode: 0o700 });