@runuai/host 0.9.149 → 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.
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/meta/_journal.json +14 -0
- package/db/schema.ts +188 -0
- package/lib/agent-cli.ts +11 -13
- package/lib/agent.ts +177 -7
- package/lib/agents/durable-proc.ts +56 -44
- package/lib/agents/registry.ts +19 -3
- package/lib/agents/session-state.ts +23 -10
- package/lib/agents/transport.ts +101 -69
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +4 -3
- package/lib/attachments.ts +19 -17
- package/lib/capture-flow.ts +319 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2544 -0
- package/lib/cloud-credential-runtime.ts +1035 -0
- package/lib/codex-auth.ts +21 -1
- package/lib/container-runtime.ts +76 -0
- package/lib/credential-cleanup-inventory.ts +405 -0
- package/lib/credential-consumers/engine-anchor.ts +1876 -0
- package/lib/credential-consumers/engines.ts +773 -0
- package/lib/credential-consumers/github.ts +594 -0
- package/lib/credential-consumers/mcp.ts +384 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-store-pickup.ts +15 -2
- package/lib/credential-store.ts +361 -33
- package/lib/credentials-activation-authority.ts +546 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/db.ts +11 -0
- package/lib/docker-exec.ts +3 -1
- package/lib/engine-accounts.ts +42 -32
- package/lib/engine-connect.ts +47 -2
- package/lib/engine-credential-env.ts +50 -0
- package/lib/engine-login.ts +83 -1
- package/lib/env-file.ts +62 -8
- package/lib/env.ts +5 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +219 -177
- package/lib/git-identity.ts +59 -14
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +56 -21
- package/lib/host-env.ts +17 -4
- package/lib/launch-mode.ts +25 -0
- package/lib/legacy-runtime-retirement.ts +388 -0
- package/lib/machine-exec.ts +14 -3
- package/lib/machine-provider-aws.ts +50 -8
- package/lib/machine-provider-local.ts +4 -0
- package/lib/machine-provider.ts +4 -0
- package/lib/managed-operation-drain.ts +8 -0
- package/lib/managed-runtime.ts +371 -7
- package/lib/mcp-capture.ts +860 -0
- package/lib/mcp-connections.ts +8 -1
- package/lib/mcp-gateway.ts +168 -10
- package/lib/obs.ts +2 -6
- package/lib/orchestrator.ts +1847 -236
- package/lib/person-capture.ts +327 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +50 -3
- package/lib/runtime-archive.ts +68 -1
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +429 -13
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/task-trust.ts +34 -3
- package/lib/sidecar-resume.ts +18 -5
- package/lib/skills.ts +47 -8
- package/lib/standard-image.ts +157 -103
- package/lib/stopped-task-status-queue.ts +68 -10
- package/lib/task-credential-cleanup.ts +525 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +66 -163
- package/lib/task-env-source.ts +68 -14
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +69 -28
- package/lib/task-environment/docker.ts +101 -20
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +459 -96
- package/lib/task-environment/machine.ts +101 -11
- package/lib/task-environment/self.ts +108 -50
- package/lib/task-environment/types.ts +89 -5
- package/lib/task-environment/workspace-files.ts +623 -25
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-gateway-recovery.ts +4 -1
- package/lib/task-gateway-state.ts +15 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +56 -2
- package/lib/token-broker-local.ts +28 -4
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +368 -28
- package/lib/token-lanes/openai-oauth.ts +7 -3
- package/lib/token-lanes/xai-oauth.ts +7 -3
- package/lib/transcript.ts +9 -5
- package/package.json +6 -2
- package/scripts/agent/task-up.sh +242 -100
- package/src/cloud-credentials.ts +2399 -0
- package/src/event-outbox.ts +8 -6
- package/src/index.ts +1386 -875
- package/src/main.ts +1062 -140
- package/src/outbound-frame.ts +31 -0
- package/src/project-env-policy.ts +125 -10
- package/src/protocol.ts +699 -23
- package/src/ui/server.ts +48 -10
package/lib/credential-store.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
/**
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
451
|
-
|
|
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;
|