@runuai/host 0.9.150 → 0.9.151

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  2. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  3. package/db/migrations/meta/_journal.json +14 -0
  4. package/db/schema.ts +188 -0
  5. package/lib/agent-cli.ts +11 -13
  6. package/lib/agent.ts +177 -7
  7. package/lib/agents/durable-proc.ts +56 -44
  8. package/lib/agents/registry.ts +19 -3
  9. package/lib/agents/session-state.ts +23 -10
  10. package/lib/agents/transport.ts +101 -69
  11. package/lib/apple-one-off.ts +277 -0
  12. package/lib/apple-runtime-recycle.ts +4 -3
  13. package/lib/attachments.ts +19 -17
  14. package/lib/capture-flow.ts +319 -0
  15. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  16. package/lib/cloud-credential-host.ts +512 -0
  17. package/lib/cloud-credential-leases.ts +2544 -0
  18. package/lib/cloud-credential-runtime.ts +1035 -0
  19. package/lib/codex-auth.ts +21 -1
  20. package/lib/container-runtime.ts +76 -0
  21. package/lib/credential-cleanup-inventory.ts +405 -0
  22. package/lib/credential-consumers/engine-anchor.ts +1876 -0
  23. package/lib/credential-consumers/engines.ts +773 -0
  24. package/lib/credential-consumers/github.ts +594 -0
  25. package/lib/credential-consumers/mcp.ts +384 -0
  26. package/lib/credential-consumers/task-files.ts +127 -0
  27. package/lib/credential-store-pickup.ts +15 -2
  28. package/lib/credential-store.ts +361 -33
  29. package/lib/credentials-activation-authority.ts +546 -0
  30. package/lib/credentials-purge.ts +1006 -0
  31. package/lib/db.ts +11 -0
  32. package/lib/docker-exec.ts +3 -1
  33. package/lib/engine-accounts.ts +42 -32
  34. package/lib/engine-connect.ts +47 -2
  35. package/lib/engine-credential-env.ts +50 -0
  36. package/lib/engine-login.ts +83 -1
  37. package/lib/env-file.ts +62 -8
  38. package/lib/env.ts +5 -0
  39. package/lib/fs-aliases.ts +49 -0
  40. package/lib/git-clone-credentials.ts +334 -0
  41. package/lib/git-diff.ts +219 -177
  42. package/lib/git-identity.ts +59 -14
  43. package/lib/github-origin.ts +28 -0
  44. package/lib/github-tokens.ts +56 -21
  45. package/lib/host-env.ts +17 -4
  46. package/lib/launch-mode.ts +25 -0
  47. package/lib/legacy-runtime-retirement.ts +388 -0
  48. package/lib/machine-exec.ts +14 -3
  49. package/lib/machine-provider-aws.ts +50 -8
  50. package/lib/machine-provider-local.ts +4 -0
  51. package/lib/machine-provider.ts +4 -0
  52. package/lib/managed-operation-drain.ts +8 -0
  53. package/lib/managed-runtime.ts +371 -7
  54. package/lib/mcp-capture.ts +860 -0
  55. package/lib/mcp-connections.ts +8 -1
  56. package/lib/mcp-gateway.ts +168 -10
  57. package/lib/obs.ts +2 -6
  58. package/lib/orchestrator.ts +1830 -224
  59. package/lib/person-capture.ts +327 -0
  60. package/lib/project-env-legacy-tags.ts +130 -0
  61. package/lib/project-env-task-replica.ts +1752 -0
  62. package/lib/runner-boot.ts +10 -0
  63. package/lib/runtime-activation-tail.ts +50 -3
  64. package/lib/runtime-archive.ts +68 -1
  65. package/lib/runtime-reconnect.ts +86 -0
  66. package/lib/runtime-state.ts +429 -13
  67. package/lib/safe-http.ts +364 -0
  68. package/lib/secrets-proxy/task-trust.ts +34 -3
  69. package/lib/sidecar-resume.ts +18 -5
  70. package/lib/skills.ts +47 -8
  71. package/lib/standard-image.ts +157 -103
  72. package/lib/stopped-task-status-queue.ts +68 -10
  73. package/lib/task-credential-cleanup.ts +525 -0
  74. package/lib/task-diff-sandbox.ts +464 -0
  75. package/lib/task-diff.ts +66 -163
  76. package/lib/task-env-source.ts +68 -14
  77. package/lib/task-environment/anchor.ts +31 -0
  78. package/lib/task-environment/apple-container.ts +69 -28
  79. package/lib/task-environment/docker.ts +101 -20
  80. package/lib/task-environment/machine-environment.ts +61 -0
  81. package/lib/task-environment/machine-task-up.ts +459 -96
  82. package/lib/task-environment/machine.ts +101 -11
  83. package/lib/task-environment/self.ts +108 -50
  84. package/lib/task-environment/types.ts +89 -5
  85. package/lib/task-environment/workspace-files.ts +623 -25
  86. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  87. package/lib/task-gateway-recovery.ts +4 -1
  88. package/lib/task-gateway-state.ts +15 -0
  89. package/lib/task-machine-swap-safety.ts +729 -0
  90. package/lib/token-broker-adopt.ts +344 -0
  91. package/lib/token-broker-lambda.ts +56 -2
  92. package/lib/token-broker-local.ts +28 -4
  93. package/lib/token-broker-revision.ts +310 -0
  94. package/lib/token-broker-ssm.ts +368 -28
  95. package/lib/token-lanes/openai-oauth.ts +7 -3
  96. package/lib/token-lanes/xai-oauth.ts +7 -3
  97. package/lib/transcript.ts +9 -5
  98. package/package.json +6 -2
  99. package/scripts/agent/task-up.sh +242 -100
  100. package/src/cloud-credentials.ts +2399 -0
  101. package/src/event-outbox.ts +8 -6
  102. package/src/index.ts +1386 -875
  103. package/src/main.ts +1061 -139
  104. package/src/outbound-frame.ts +31 -0
  105. package/src/project-env-policy.ts +125 -10
  106. package/src/protocol.ts +699 -23
  107. package/src/ui/server.ts +48 -10
@@ -10,18 +10,34 @@
10
10
  * null as "credentials stay on this machine".
11
11
  *
12
12
  * Secret material never travels on argv where `ps` would show it: a put
13
- * writes its request to a 0600 file in a fresh 0700 temp dir and hands the CLI
13
+ * writes its request to a 0600 file in a fresh 0700 dir and hands the CLI
14
14
  * `--cli-input-json file://<path>`, removed as soon as the CLI exits. (Not
15
15
  * `file:///dev/stdin`: the CLI opens the file twice and a pipe reads empty the
16
16
  * second time — verified against aws-cli 2.33.) Nothing here logs a value.
17
+ *
18
+ * That dir is on a memory-backed filesystem (tmpfs), never a disk: a process
19
+ * killed between the write and its cleanup leaves the file in memory only,
20
+ * gone at the next boot, and the next process to open a store removes any a
21
+ * dead process left. With no memory-backed dir to use, a write is refused
22
+ * rather than staged on disk.
17
23
  */
18
24
 
19
25
  import { spawn } from "node:child_process";
20
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
26
+ import {
27
+ lstatSync,
28
+ mkdtempSync,
29
+ readdirSync,
30
+ rmSync,
31
+ statfsSync,
32
+ statSync,
33
+ writeFileSync,
34
+ type Stats,
35
+ } from "node:fs";
21
36
  import { tmpdir } from "node:os";
22
- import { join } from "node:path";
37
+ import { join, resolve } from "node:path";
23
38
 
24
39
  import { env } from "./env";
40
+ import { assertNoAliases } from "./fs-aliases";
25
41
  import { leaseAwsEnv } from "./lease";
26
42
 
27
43
  const DEFAULT_TIMEOUT_MS = 30_000;
@@ -51,6 +67,29 @@ export class ImmutableParameterConflictError extends Error {
51
67
  }
52
68
 
53
69
  /** A failed/ambiguous create could not yet be proved to be our prior write. */
70
+ /** A read a caller's deadline ends: the AWS CLI is killed when its signal
71
+ * fires, and the read fails (the caller checks `signal.aborted`). */
72
+ export interface StoreCallOptions {
73
+ signal?: AbortSignal;
74
+ }
75
+
76
+ export interface ImmutablePutOptions {
77
+ /**
78
+ * Ends the write where it is: the AWS CLI is killed, so nothing is retried
79
+ * past it. A request already sent may still have been applied; the caller
80
+ * treats an aborted write as unknown, never as absent.
81
+ */
82
+ signal?: AbortSignal;
83
+ }
84
+
85
+ /** The write was stopped by its caller's signal; it may or may not exist. */
86
+ export class ImmutableParameterAbortedError extends Error {
87
+ constructor(message = "immutable parameter write was stopped before it finished") {
88
+ super(message);
89
+ this.name = "ImmutableParameterAbortedError";
90
+ }
91
+ }
92
+
54
93
  export class ImmutableParameterUnverifiedError extends Error {
55
94
  constructor(message = "immutable parameter create could not be verified") {
56
95
  super(message);
@@ -74,9 +113,10 @@ export interface CredentialStore {
74
113
  name: string,
75
114
  value: string,
76
115
  tags: Record<string, string>,
116
+ options?: ImmutablePutOptions,
77
117
  ): Promise<ImmutableParameterPutResult>;
78
118
  /** The credential's plaintext, or null when it does not exist. */
79
- get(name: string): Promise<string | null>;
119
+ get(name: string, options?: StoreCallOptions): Promise<string | null>;
80
120
  /**
81
121
  * One exact immutable parameter version. Project-env replica migration
82
122
  * uses this for legacy value references: reading the latest value and then
@@ -109,7 +149,7 @@ export interface CredentialStore {
109
149
  /** The tags on one parameter. Metadata only: `ssm:ListTagsForResource`
110
150
  * reveals no value and needs no KMS grant — the sign-in box (ADR-136)
111
151
  * rebuilds an account's label from here without ever reading the secret. */
112
- listTags(name: string): Promise<Record<string, string>>;
152
+ listTags(name: string, options?: StoreCallOptions): Promise<Record<string, string>>;
113
153
  /** Every parameter NAME under `path` (recursive), no values — the
114
154
  * `describe-parameters` listing that is allowed on `*` and never decrypts. */
115
155
  describeNames(path: string): Promise<string[]>;
@@ -124,8 +164,8 @@ export interface CliResult {
124
164
  stderr: string;
125
165
  }
126
166
 
127
- /** `aws --region <r> --output json <args>`. */
128
- export type AwsCliRunner = (args: string[]) => Promise<CliResult>;
167
+ /** `aws --region <r> --output json <args>`. A signal kills the process. */
168
+ export type AwsCliRunner = (args: string[], options?: { signal?: AbortSignal }) => Promise<CliResult>;
129
169
 
130
170
  export function isParameterName(name: string): boolean {
131
171
  return PARAMETER_NAME.test(name);
@@ -151,11 +191,16 @@ export function parameterName(prefix: string, ...segments: string[]): string {
151
191
  }
152
192
 
153
193
  export function defaultAwsCliRunner(region: string): AwsCliRunner {
154
- return (args) =>
194
+ return (args, options = {}) =>
155
195
  new Promise<CliResult>((resolve) => {
156
196
  let stdout = "";
157
197
  let stderr = "";
158
198
  let settled = false;
199
+ const { signal } = options;
200
+ if (signal?.aborted) {
201
+ resolve({ status: null, stdout: "", stderr: "aborted before it started" });
202
+ return;
203
+ }
159
204
  const child = spawn("aws", ["--region", region, "--output", "json", ...args], {
160
205
  stdio: ["ignore", "pipe", "pipe"],
161
206
  // ADR-133: a task machine acts under its lease (empty elsewhere — an
@@ -166,6 +211,7 @@ export function defaultAwsCliRunner(region: string): AwsCliRunner {
166
211
  if (settled) return;
167
212
  settled = true;
168
213
  clearTimeout(timer);
214
+ signal?.removeEventListener("abort", abort);
169
215
  resolve({ status, stdout, stderr });
170
216
  };
171
217
  const timer = setTimeout(() => {
@@ -174,6 +220,13 @@ export function defaultAwsCliRunner(region: string): AwsCliRunner {
174
220
  grace.unref?.();
175
221
  }, DEFAULT_TIMEOUT_MS);
176
222
  timer.unref?.();
223
+ // The caller has moved on: the process dies now, and whatever it had
224
+ // done is unknown to it, so nothing waits for its output.
225
+ const abort = (): void => {
226
+ child.kill("SIGKILL");
227
+ settle(null);
228
+ };
229
+ signal?.addEventListener("abort", abort, { once: true });
177
230
  child.stdout?.setEncoding("utf8");
178
231
  child.stderr?.setEncoding("utf8");
179
232
  child.stdout?.on("data", (chunk: string) => (stdout += chunk));
@@ -183,22 +236,240 @@ export function defaultAwsCliRunner(region: string): AwsCliRunner {
183
236
  });
184
237
  }
185
238
 
186
- /** Run `work` with a 0600 file holding `content`, inside a private temp dir
187
- * that is removed afterwards whatever happens. */
239
+ /** `statfs` f_type of tmpfs (linux/magic.h). */
240
+ const TMPFS_MAGIC = 0x01021994;
241
+ const STAGING_PREFIX = "uai-credential-store-";
242
+
243
+ export interface StagingDeps {
244
+ statfs?: (path: string) => { type: number | bigint };
245
+ candidates?: Array<string | undefined>;
246
+ }
247
+
248
+ /**
249
+ * The memory-backed dir a write's request (its value included) is staged in
250
+ * while the CLI reads it: the person's runtime dir first (0700, tmpfs under
251
+ * systemd), then /dev/shm. Null when none here is tmpfs.
252
+ */
253
+ export function memoryBackedDir(deps: StagingDeps = {}): string | null {
254
+ const statfs = deps.statfs ?? ((path: string) => statfsSync(path));
255
+ for (const dir of deps.candidates ?? [process.env.XDG_RUNTIME_DIR, "/dev/shm", "/run/shm"]) {
256
+ if (!dir) continue;
257
+ try {
258
+ if (Number(statfs(dir).type) === TMPFS_MAGIC) return dir;
259
+ } catch {
260
+ // Absent here: the next candidate.
261
+ }
262
+ }
263
+ return null;
264
+ }
265
+
266
+ function processAlive(pid: number): boolean {
267
+ try {
268
+ process.kill(pid, 0);
269
+ return true;
270
+ } catch (err) {
271
+ // EPERM: alive, someone else's.
272
+ return (err as NodeJS.ErrnoException).code === "EPERM";
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Remove what a dead process left staged: in `root`, a dir naming a process
278
+ * that is gone; in the old on-disk temp dir, any staged dir from before this
279
+ * moved to memory, once older than a CLI call could be. Never a live
280
+ * process's.
281
+ */
282
+ export function scrubStagedWrites(
283
+ root: string,
284
+ legacyRoot: string | null = tmpdir(),
285
+ now: number = Date.now(),
286
+ ): { removed: number; legacyDueAt: number | null } {
287
+ let removed = 0;
288
+ for (const name of safeList(root)) {
289
+ const match = OWN_STAGED.exec(name);
290
+ if (!match) continue;
291
+ const pid = Number(match[1]);
292
+ if (pid === process.pid || processAlive(pid)) continue;
293
+ rmSync(join(root, name), { recursive: true, force: true });
294
+ removed += 1;
295
+ }
296
+ if (!legacyRoot || legacyRoot === root) return { removed, legacyDueAt: null };
297
+ const legacy = scrubLegacyStagedWrites(legacyRoot, now);
298
+ return { removed: removed + legacy.removed, legacyDueAt: legacy.dueAt };
299
+ }
300
+
301
+ /** A staged dir this build names: by the pid of the process it belongs to. */
302
+ const OWN_STAGED = new RegExp(`^${STAGING_PREFIX}(\\d+)-`);
303
+ /** Longer than any CLI call: a staged dir of a build from before writes
304
+ * moved to memory, this old, belongs to nothing still running. */
305
+ export const LEGACY_STAGED_MAX_AGE_MS = 10 * 60_000;
306
+
307
+ /** Remove the old build's staged dirs past LEGACY_STAGED_MAX_AGE_MS; `dueAt`
308
+ * is when the youngest one left will be, or null when none is left. */
309
+ function scrubLegacyStagedWrites(legacyRoot: string, now: number): { removed: number; dueAt: number | null } {
310
+ let removed = 0;
311
+ let dueAt: number | null = null;
312
+ for (const name of safeList(legacyRoot)) {
313
+ if (!name.startsWith(STAGING_PREFIX) || OWN_STAGED.test(name)) continue;
314
+ let modified: number;
315
+ try {
316
+ modified = statSync(join(legacyRoot, name)).mtimeMs;
317
+ } catch {
318
+ continue;
319
+ }
320
+ if (now - modified < LEGACY_STAGED_MAX_AGE_MS) {
321
+ dueAt = Math.min(dueAt ?? Infinity, modified + LEGACY_STAGED_MAX_AGE_MS);
322
+ continue;
323
+ }
324
+ rmSync(join(legacyRoot, name), { recursive: true, force: true });
325
+ removed += 1;
326
+ }
327
+ return { removed, dueAt };
328
+ }
329
+
330
+ /** Come back for the old build's staged dirs too young to remove yet: a
331
+ * write killed just before this process started is still removed, once
332
+ * nothing can be using it. */
333
+ function scheduleLegacyScrub(legacyRoot: string, dueAt: number): void {
334
+ const timer = setTimeout(() => {
335
+ const next = scrubLegacyStagedWrites(legacyRoot, Date.now()).dueAt;
336
+ if (next !== null) scheduleLegacyScrub(legacyRoot, next);
337
+ }, Math.max(0, dueAt - Date.now()) + 1_000);
338
+ timer.unref?.();
339
+ }
340
+
341
+ export interface StagedWritesLeftBehind {
342
+ removed: string[];
343
+ /** A write that may be under way: this process's own still staged, or
344
+ * another live process's or an older build's too young to tell. */
345
+ pending: string[];
346
+ failed: Array<{ path: string; error: unknown }>;
347
+ }
348
+
349
+ /**
350
+ * ADR-154 §13, the cutover purge: remove every staged write in `roots`,
351
+ * this build's or an older one's, that nothing can still be using.
352
+ *
353
+ * This process knows its own: a write under way is staged
354
+ * (`withPrivateInputFile`), and anything else named for it is stale, left by
355
+ * a write whose cleanup failed. Another live process's write is under way
356
+ * while it is younger than any CLI call can last (LEGACY_STAGED_MAX_AGE_MS),
357
+ * and its writer removes it; past that age it is a leftover whatever its
358
+ * pid now names (a reused pid is not the writer). An older build's names no
359
+ * process. A write that may be under way is reported pending, for a later
360
+ * call: the purge is not done while one is. Another user's entry is never
361
+ * ours. Every entry is tried. Reported, not thrown: a root that cannot be
362
+ * listed or is not there (what it holds is unknown), an entry that is a
363
+ * link, holds one leading out of it or a file with a second name (removing
364
+ * it would keep what it holds under the other), and a failed removal.
365
+ */
366
+ export function removeStagedWritesLeftBehind(
367
+ roots: readonly string[],
368
+ now: number = Date.now(),
369
+ ): StagedWritesLeftBehind {
370
+ const uid = process.getuid?.();
371
+ const result: StagedWritesLeftBehind = { removed: [], pending: [], failed: [] };
372
+ for (const root of new Set(roots)) {
373
+ let names: string[];
374
+ try {
375
+ names = readdirSync(root);
376
+ } catch (error) {
377
+ // Not there proves nothing either: a root this runner stages in is
378
+ // always there, unless something unmounted or removed it.
379
+ result.failed.push({ path: root, error });
380
+ continue;
381
+ }
382
+ for (const name of names) {
383
+ if (!name.startsWith(STAGING_PREFIX)) continue;
384
+ const path = join(root, name);
385
+ let stat: Stats;
386
+ try {
387
+ stat = lstatSync(path);
388
+ } catch (error) {
389
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") result.failed.push({ path, error });
390
+ continue;
391
+ }
392
+ if (uid !== undefined && stat.uid !== uid) continue;
393
+ const young = now - stat.mtimeMs < LEGACY_STAGED_MAX_AGE_MS;
394
+ const own = OWN_STAGED.exec(name);
395
+ const mayBeUnderWay = own
396
+ ? Number(own[1]) === process.pid
397
+ ? stagedInUse.has(resolve(path))
398
+ : young && processAlive(Number(own[1]))
399
+ : young;
400
+ if (mayBeUnderWay) {
401
+ result.pending.push(path);
402
+ continue;
403
+ }
404
+ // Removed by this name only when no other keeps what it holds.
405
+ if (stat.isSymbolicLink()) {
406
+ result.failed.push({
407
+ path,
408
+ error: new Error("a symbolic link where a write is staged; neither it nor what it leads to is removed"),
409
+ });
410
+ continue;
411
+ }
412
+ try {
413
+ assertNoAliases(path);
414
+ } catch (error) {
415
+ result.failed.push({ path, error });
416
+ continue;
417
+ }
418
+ try {
419
+ rmSync(path, { recursive: true, force: true });
420
+ result.removed.push(path);
421
+ } catch (error) {
422
+ result.failed.push({ path, error });
423
+ }
424
+ }
425
+ }
426
+ return result;
427
+ }
428
+
429
+ function safeList(dir: string): string[] {
430
+ try {
431
+ return readdirSync(dir);
432
+ } catch {
433
+ return [];
434
+ }
435
+ }
436
+
437
+ /** This process's staged writes under way, by their directory. */
438
+ const stagedInUse = new Set<string>();
439
+
440
+ /** Scrubbed once per process per root, when its first store is made. */
441
+ const scrubbed = new Set<string>();
442
+
443
+ /** Run `work` with a 0600 file holding `content`, inside a private dir under
444
+ * `root` (memory-backed) that is removed afterwards whatever happens. */
188
445
  async function withPrivateInputFile<T>(
446
+ root: string | null,
189
447
  content: string,
190
448
  work: (path: string) => Promise<T>,
191
449
  ): Promise<T> {
192
- const dir = mkdtempSync(join(tmpdir(), "uai-credential-store-"));
450
+ // Fail closed: a credential is never staged on disk.
451
+ if (root === null) throw new Error("no memory-backed directory to stage a credential write in");
452
+ const dir = mkdtempSync(join(root, `${STAGING_PREFIX}${process.pid}-`));
453
+ stagedInUse.add(resolve(dir));
193
454
  try {
194
455
  const path = join(dir, "input.json");
195
456
  writeFileSync(path, content, { mode: 0o600 });
196
457
  return await work(path);
197
458
  } finally {
198
459
  rmSync(dir, { recursive: true, force: true });
460
+ stagedInUse.delete(resolve(dir));
199
461
  }
200
462
  }
201
463
 
464
+ /** Tests only: stage `content` as a store write does, around `work`. */
465
+ export function withStagedWriteForTests<T>(
466
+ root: string,
467
+ content: string,
468
+ work: (path: string) => Promise<T>,
469
+ ): Promise<T> {
470
+ return withPrivateInputFile(root, content, work);
471
+ }
472
+
202
473
  function isNotFound(result: CliResult): boolean {
203
474
  return /ParameterNotFound/.test(result.stderr);
204
475
  }
@@ -220,6 +491,20 @@ function checkedTagEntries(tags: Record<string, string>): Array<[string, string]
220
491
  return entries;
221
492
  }
222
493
 
494
+ /** A CLI failure: the AWS error code (a fixed vocabulary) and exit status
495
+ * ride as fields a log may print; the message keeps stderr's first line. */
496
+ export class CredentialStoreCommandError extends Error {
497
+ constructor(
498
+ message: string,
499
+ readonly op: string,
500
+ readonly status: number | null,
501
+ readonly awsCode: string | null,
502
+ ) {
503
+ super(message);
504
+ this.name = "CredentialStoreCommandError";
505
+ }
506
+ }
507
+
223
508
  /** Summarize a CLI failure WITHOUT echoing stdout (which could carry a value). */
224
509
  function failure(op: string, result: CliResult): Error {
225
510
  const line =
@@ -227,13 +512,39 @@ function failure(op: string, result: CliResult): Error {
227
512
  .split("\n")
228
513
  .map((l) => l.trim())
229
514
  .find((l) => l.length > 0) ?? (result.status === null ? "spawn failed or timed out" : "");
230
- return new Error(`aws ssm ${op} failed (exit ${result.status ?? "none"}): ${line}`);
515
+ const awsCode = /An error occurred \(([A-Za-z0-9]+)\)/.exec(result.stderr)?.[1] ?? null;
516
+ return new CredentialStoreCommandError(
517
+ `aws ssm ${op} failed (exit ${result.status ?? "none"}): ${line}`,
518
+ op,
519
+ result.status,
520
+ awsCode,
521
+ );
522
+ }
523
+
524
+ /**
525
+ * A store failure as a log may name it: a category, never a message, which
526
+ * can carry what AWS or another dependency said (or echoed back).
527
+ */
528
+ export function storeFailureKind(err: unknown): string {
529
+ if (err instanceof ImmutableParameterConflictError) return "conflict";
530
+ if (err instanceof ImmutableParameterUnverifiedError) return "unverified";
531
+ if (err instanceof ImmutableParameterAbortedError) return "aborted";
532
+ if (err instanceof CredentialStoreCommandError) {
533
+ return `${err.op}: ${err.awsCode ?? `exit ${err.status ?? "none"}`}`;
534
+ }
535
+ return err instanceof Error ? err.name : "unknown";
231
536
  }
232
537
 
233
538
  export interface AwsSsmCredentialStoreOptions {
234
539
  region: string;
235
540
  kmsKeyId: string;
236
541
  runner?: AwsCliRunner;
542
+ /** Tests only: where writes are staged instead of a memory-backed dir;
543
+ * null as if this machine had none. */
544
+ stagingDir?: string | null;
545
+ /** Tests only: where an older build staged writes on disk (the temp dir
546
+ * by default; none when `stagingDir` is given). */
547
+ legacyStagingDir?: string | null;
237
548
  }
238
549
 
239
550
  export class AwsSsmCredentialStore implements CredentialStore {
@@ -241,9 +552,25 @@ export class AwsSsmCredentialStore implements CredentialStore {
241
552
  private readonly run: AwsCliRunner;
242
553
  private readonly kmsKeyId: string;
243
554
 
555
+ private readonly stagingRoot: string | null;
556
+
244
557
  constructor(opts: AwsSsmCredentialStoreOptions) {
245
558
  this.run = opts.runner ?? defaultAwsCliRunner(opts.region);
246
559
  this.kmsKeyId = opts.kmsKeyId;
560
+ this.stagingRoot = opts.stagingDir !== undefined ? opts.stagingDir : memoryBackedDir();
561
+ if (this.stagingRoot !== null && !scrubbed.has(this.stagingRoot)) {
562
+ scrubbed.add(this.stagingRoot);
563
+ scrubStagedWrites(this.stagingRoot, null);
564
+ }
565
+ // An older build's plaintext on disk goes whether or not this host can
566
+ // stage in memory: one that now refuses every write may still hold it.
567
+ const legacyRoot =
568
+ opts.legacyStagingDir !== undefined ? opts.legacyStagingDir : opts.stagingDir !== undefined ? null : tmpdir();
569
+ if (legacyRoot !== null && legacyRoot !== this.stagingRoot && !scrubbed.has(`legacy:${legacyRoot}`)) {
570
+ scrubbed.add(`legacy:${legacyRoot}`);
571
+ const { dueAt } = scrubLegacyStagedWrites(legacyRoot, Date.now());
572
+ if (dueAt !== null) scheduleLegacyScrub(legacyRoot, dueAt);
573
+ }
247
574
  }
248
575
 
249
576
  async put(name: string, value: string): Promise<void> {
@@ -259,7 +586,7 @@ export class AwsSsmCredentialStore implements CredentialStore {
259
586
  Tier: "Advanced",
260
587
  Overwrite: true,
261
588
  });
262
- const result = await withPrivateInputFile(input, (path) =>
589
+ const result = await withPrivateInputFile(this.stagingRoot, input, (path) =>
263
590
  this.run(["ssm", "put-parameter", "--cli-input-json", `file://${path}`]),
264
591
  );
265
592
  if (result.status !== 0) throw failure("put-parameter", result);
@@ -269,12 +596,15 @@ export class AwsSsmCredentialStore implements CredentialStore {
269
596
  name: string,
270
597
  value: string,
271
598
  tags: Record<string, string>,
599
+ options: ImmutablePutOptions = {},
272
600
  ): Promise<ImmutableParameterPutResult> {
273
601
  if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
274
602
  const entries = checkedTagEntries(tags);
275
603
  if (entries.length === 0) {
276
604
  throw new Error("immutable parameter identity tags are required");
277
605
  }
606
+ const { signal } = options;
607
+ if (signal?.aborted) throw new ImmutableParameterAbortedError();
278
608
  const input = JSON.stringify({
279
609
  Name: name,
280
610
  Value: value,
@@ -284,18 +614,20 @@ export class AwsSsmCredentialStore implements CredentialStore {
284
614
  Overwrite: false,
285
615
  Tags: entries.map(([Key, Value]) => ({ Key, Value })),
286
616
  });
287
- const result = await withPrivateInputFile(input, (path) =>
288
- this.run(["ssm", "put-parameter", "--cli-input-json", `file://${path}`]),
617
+ const result = await withPrivateInputFile(this.stagingRoot, input, (path) =>
618
+ this.run(["ssm", "put-parameter", "--cli-input-json", `file://${path}`], { signal }),
289
619
  );
290
620
  if (result.status === 0) return "created";
621
+ if (signal?.aborted) throw new ImmutableParameterAbortedError();
291
622
 
292
623
  // The CLI can lose the successful response, and a retry then receives
293
624
  // ParameterAlreadyExists. The sign-in-box identity is intentionally
294
625
  // write-only, so authenticate the prior write using metadata only.
295
626
  let observed: Record<string, string>;
296
627
  try {
297
- observed = await this.listTags(name);
628
+ observed = await this.tagsOf(name, signal);
298
629
  } catch {
630
+ if (signal?.aborted) throw new ImmutableParameterAbortedError();
299
631
  throw new ImmutableParameterUnverifiedError();
300
632
  }
301
633
  let missing = false;
@@ -331,15 +663,11 @@ export class AwsSsmCredentialStore implements CredentialStore {
331
663
  if (result.status !== 0) throw failure("add-tags-to-resource", result);
332
664
  }
333
665
 
334
- async get(name: string): Promise<string | null> {
666
+ async get(name: string, options: StoreCallOptions = {}): Promise<string | null> {
335
667
  if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
336
- const result = await this.run([
337
- "ssm",
338
- "get-parameter",
339
- "--name",
340
- name,
341
- "--with-decryption",
342
- ]);
668
+ const result = await this.run(["ssm", "get-parameter", "--name", name, "--with-decryption"], {
669
+ signal: options.signal,
670
+ });
343
671
  if (result.status !== 0) {
344
672
  if (isNotFound(result)) return null;
345
673
  throw failure("get-parameter", result);
@@ -443,16 +771,16 @@ export class AwsSsmCredentialStore implements CredentialStore {
443
771
  );
444
772
  }
445
773
 
446
- async listTags(name: string): Promise<Record<string, string>> {
774
+ async listTags(name: string, options: StoreCallOptions = {}): Promise<Record<string, string>> {
775
+ return this.tagsOf(name, options.signal);
776
+ }
777
+
778
+ private async tagsOf(name: string, signal?: AbortSignal): Promise<Record<string, string>> {
447
779
  if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
448
- const result = await this.run([
449
- "ssm",
450
- "list-tags-for-resource",
451
- "--resource-type",
452
- "Parameter",
453
- "--resource-id",
454
- name,
455
- ]);
780
+ const result = await this.run(
781
+ ["ssm", "list-tags-for-resource", "--resource-type", "Parameter", "--resource-id", name],
782
+ { signal },
783
+ );
456
784
  if (result.status !== 0) throw failure("list-tags-for-resource", result);
457
785
  const parsed = JSON.parse(result.stdout || "{}") as {
458
786
  TagList?: Array<{ Key?: unknown; Value?: unknown }> | null;