@runuai/host 0.9.148 → 0.9.150

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 (93) 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/codex.ts +9 -1
  12. package/lib/agents/cursor.ts +15 -1
  13. package/lib/agents/grok.ts +8 -0
  14. package/lib/agents/kimi.ts +1 -0
  15. package/lib/agents/machine-durable.ts +2 -0
  16. package/lib/agents/opencode.ts +1 -0
  17. package/lib/agents/proc.ts +6 -1
  18. package/lib/agents/transport.ts +9 -5
  19. package/lib/browser-testing.ts +6 -0
  20. package/lib/codex-auth.ts +2 -0
  21. package/lib/credential-store.ts +141 -6
  22. package/lib/engine-accounts.ts +1 -0
  23. package/lib/env-file.ts +7 -0
  24. package/lib/env-schema.ts +841 -51
  25. package/lib/git-diff.ts +1 -0
  26. package/lib/git-identity.ts +2 -0
  27. package/lib/github-tokens.ts +1 -0
  28. package/lib/host-env.ts +98 -18
  29. package/lib/lease.ts +11 -3
  30. package/lib/machine-gateway-tunnel.ts +195 -17
  31. package/lib/machine-project-env-loader.ts +152 -0
  32. package/lib/managed-runtime.ts +270 -51
  33. package/lib/mcp-gateway.ts +777 -26
  34. package/lib/orchestrator.ts +408 -81
  35. package/lib/project-env-authority.ts +1319 -0
  36. package/lib/project-env-feature.ts +74 -0
  37. package/lib/project-env-loader.ts +237 -0
  38. package/lib/project-env-replica.ts +2094 -0
  39. package/lib/project-env-schema-capture.ts +342 -0
  40. package/lib/project-env-staging.ts +148 -0
  41. package/lib/project-env-task-policy.ts +176 -0
  42. package/lib/runtime-archive.ts +79 -16
  43. package/lib/runtime-authority.ts +44 -0
  44. package/lib/runtime-state.ts +175 -10
  45. package/lib/secrets-proxy/address.ts +72 -0
  46. package/lib/secrets-proxy/audit.ts +30 -0
  47. package/lib/secrets-proxy/ca-store.ts +200 -0
  48. package/lib/secrets-proxy/ca.ts +470 -0
  49. package/lib/secrets-proxy/container-steps.ts +138 -0
  50. package/lib/secrets-proxy/der.ts +273 -0
  51. package/lib/secrets-proxy/engine.ts +1633 -0
  52. package/lib/secrets-proxy/host.ts +93 -0
  53. package/lib/secrets-proxy/hostname.ts +60 -0
  54. package/lib/secrets-proxy/placeholders.ts +196 -0
  55. package/lib/secrets-proxy/policy.ts +141 -0
  56. package/lib/secrets-proxy/readiness.ts +148 -0
  57. package/lib/secrets-proxy/runtime.ts +582 -0
  58. package/lib/secrets-proxy/scrub.ts +559 -0
  59. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  60. package/lib/secrets-proxy/task-scripts.ts +262 -0
  61. package/lib/secrets-proxy/task-trust.ts +574 -0
  62. package/lib/secrets-proxy/trust-watch.ts +80 -0
  63. package/lib/shared-files-s3.ts +3 -0
  64. package/lib/sidecar-guard.ts +58 -0
  65. package/lib/sidecar-resume.ts +51 -3
  66. package/lib/standard-image.ts +50 -12
  67. package/lib/task-env-forwarder.ts +228 -0
  68. package/lib/task-env-source.ts +193 -0
  69. package/lib/task-environment/apple-container.ts +45 -11
  70. package/lib/task-environment/docker.ts +51 -12
  71. package/lib/task-environment/index.ts +6 -2
  72. package/lib/task-environment/machine-task-up.ts +258 -25
  73. package/lib/task-environment/machine.ts +80 -22
  74. package/lib/task-environment/registry.ts +3 -2
  75. package/lib/task-environment/self-identity.ts +20 -0
  76. package/lib/task-environment/self.ts +92 -16
  77. package/lib/task-environment/types.ts +63 -0
  78. package/lib/task-gateway-port.ts +22 -0
  79. package/lib/task-gateway-protocol.ts +190 -0
  80. package/lib/task-gateway-recovery.ts +44 -0
  81. package/lib/task-gateway-state.ts +497 -0
  82. package/lib/transcript.ts +1 -0
  83. package/package.json +13 -1
  84. package/scripts/agent/env-hook.sh +626 -0
  85. package/scripts/agent/task-up.sh +476 -27
  86. package/scripts/agent/uai-env-exec.sh +26 -0
  87. package/src/cli.ts +26 -3
  88. package/src/index.ts +273 -19
  89. package/src/main.ts +939 -48
  90. package/src/project-env-latest.ts +130 -0
  91. package/src/project-env-policy.ts +281 -0
  92. package/src/project-env-wording.ts +47 -0
  93. package/src/protocol.ts +2219 -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
  }
@@ -418,7 +418,15 @@ export class CodexSession implements AgentSession {
418
418
  for (const override of args.cliConfigOverrides ?? []) {
419
419
  codexArgs.push("-c", override);
420
420
  }
421
- 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
+ );
422
430
  // ADR-061: the CLI is owned by an in-container runner (durable across
423
431
  // host restarts); legacy pipes behind UAI_DURABLE_SESSIONS=0. Codex
424
432
  // re-handshakes per host process (initialize + thread/start live in this
@@ -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
  }
@@ -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,
@@ -228,6 +229,7 @@ export class MachineDurableProcess implements LineTransport {
228
229
  String(this.#offset + 1),
229
230
  this.#outboxPath,
230
231
  ],
232
+ projectEnvProfile: "maintenance",
231
233
  maxOutputBytes: 8 * 1024 * 1024,
232
234
  });
233
235
  } catch {
@@ -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
 
@@ -55,6 +55,7 @@ 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";
59
60
  import { attachCompatibilityKeysMatch } from "./attach-compatibility";
60
61
 
@@ -326,15 +327,16 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
326
327
  onCloseRequested: markClosed,
327
328
  debugLabel: opts.debugLabel,
328
329
  launch: async () => {
330
+ const cliArgv = agentCliArgv(opts.environment, [opts.cli, ...opts.cliArgs]);
329
331
  const result = await opts.environment.launchDetachedSession({
330
332
  argv: [
331
333
  "node",
332
334
  runnerInEnvironment,
333
335
  environmentSessionDir,
334
336
  "--",
335
- opts.cli,
336
- ...opts.cliArgs,
337
+ ...cliArgv,
337
338
  ],
339
+ projectEnvProfile: "task",
338
340
  inheritEnv: opts.passEnv ?? [],
339
341
  env: opts.explicitEnv ?? {},
340
342
  launchTimeoutMs: 30_000,
@@ -484,15 +486,16 @@ function machineDurableTransport(opts: AgentTransportOptions): LineTransport {
484
486
  }
485
487
  const runnerPath = await deployRunner(environment, sessionDir);
486
488
  await publishMachineCurrentSession(environment, sessionDir);
489
+ const cliArgv = agentCliArgv(environment, [opts.cli, ...opts.cliArgs]);
487
490
  const result = await environment.launchDetachedSession({
488
491
  argv: [
489
492
  "node",
490
493
  runnerPath,
491
494
  sessionDir,
492
495
  "--",
493
- opts.cli,
494
- ...opts.cliArgs,
496
+ ...cliArgv,
495
497
  ],
498
+ projectEnvProfile: "task",
496
499
  inheritEnv: opts.passEnv ?? [],
497
500
  env: opts.explicitEnv ?? {},
498
501
  launchTimeoutMs: 30_000,
@@ -538,7 +541,8 @@ function directEnvironmentTransport(opts: AgentTransportOptions): LineTransport
538
541
  return new EnvironmentLineProcess({
539
542
  environment: opts.environment,
540
543
  request: {
541
- argv: [opts.cli, ...opts.cliArgs],
544
+ argv: agentCliArgv(opts.environment, [opts.cli, ...opts.cliArgs]),
545
+ projectEnvProfile: "task",
542
546
  inheritEnv: opts.passEnv ?? [],
543
547
  env: opts.explicitEnv ?? {},
544
548
  maxOutputBytes: AGENT_SESSION_OUTPUT_BUFFER_BYTES,
@@ -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 });