@moondeck/station 0.0.0-stage → 1.0.0

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 (325) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/meta/_journal.json +223 -0
  36. package/db/schema.ts +850 -0
  37. package/images/standard/Dockerfile +347 -0
  38. package/images/standard/README.md +167 -0
  39. package/images/standard/container/code-server-settings.json +63 -0
  40. package/images/standard/container/corepack-version +1 -0
  41. package/images/standard/container/uai-init +704 -0
  42. package/images/standard/container/uai-materialize-runtimes +1527 -0
  43. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  44. package/images/standard/tool-versions +2 -0
  45. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  46. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  47. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  48. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  49. package/lib/agent-cli.ts +1507 -0
  50. package/lib/agent-errors.ts +22 -0
  51. package/lib/agent.ts +672 -0
  52. package/lib/agents/attach-compatibility.ts +51 -0
  53. package/lib/agents/claude.ts +918 -0
  54. package/lib/agents/codex-token-usage.fixture.json +130 -0
  55. package/lib/agents/codex.ts +1392 -0
  56. package/lib/agents/cursor-period-usage.ts +253 -0
  57. package/lib/agents/cursor.ts +562 -0
  58. package/lib/agents/dispatch.ts +78 -0
  59. package/lib/agents/durable-proc.ts +334 -0
  60. package/lib/agents/factory.ts +57 -0
  61. package/lib/agents/grok.ts +381 -0
  62. package/lib/agents/kimi.ts +329 -0
  63. package/lib/agents/machine-durable.ts +604 -0
  64. package/lib/agents/mock.ts +133 -0
  65. package/lib/agents/mode.ts +64 -0
  66. package/lib/agents/opencode.ts +508 -0
  67. package/lib/agents/output-source.ts +3 -0
  68. package/lib/agents/proc.ts +221 -0
  69. package/lib/agents/rate-limit.ts +337 -0
  70. package/lib/agents/registry.ts +182 -0
  71. package/lib/agents/session-state.ts +197 -0
  72. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  73. package/lib/agents/transport.ts +741 -0
  74. package/lib/agents/types.ts +254 -0
  75. package/lib/agents/uai.ts +106 -0
  76. package/lib/agents/usage.ts +44 -0
  77. package/lib/apple-one-off.ts +277 -0
  78. package/lib/apple-runtime-recycle.ts +237 -0
  79. package/lib/apple-uninstall-teardown.ts +224 -0
  80. package/lib/assignment-evidence.ts +94 -0
  81. package/lib/attachments.ts +48 -0
  82. package/lib/browser-testing.ts +1537 -0
  83. package/lib/capture-flow.ts +332 -0
  84. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  85. package/lib/cloud-credential-host.ts +512 -0
  86. package/lib/cloud-credential-leases.ts +2587 -0
  87. package/lib/cloud-credential-runtime.ts +1357 -0
  88. package/lib/cloud-state.ts +56 -0
  89. package/lib/codex-auth.ts +456 -0
  90. package/lib/command-db.ts +310 -0
  91. package/lib/compose-network-recovery.ts +283 -0
  92. package/lib/connection-secret-redaction.ts +25 -0
  93. package/lib/container-runtime.ts +1453 -0
  94. package/lib/credential-cleanup-inventory.ts +490 -0
  95. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  96. package/lib/credential-consumers/engines.ts +797 -0
  97. package/lib/credential-consumers/files.ts +934 -0
  98. package/lib/credential-consumers/github.ts +702 -0
  99. package/lib/credential-consumers/mcp.ts +389 -0
  100. package/lib/credential-consumers/task-files.ts +127 -0
  101. package/lib/credential-consumers/task-secrets.ts +10 -0
  102. package/lib/credential-store-pickup.ts +149 -0
  103. package/lib/credential-store-scope.ts +112 -0
  104. package/lib/credential-store.ts +932 -0
  105. package/lib/credentials-activation-authority.ts +709 -0
  106. package/lib/credentials-purge.ts +1006 -0
  107. package/lib/custom-connection-capture.ts +395 -0
  108. package/lib/custom-connection-launch.ts +384 -0
  109. package/lib/custom-connection-runtime.ts +141 -0
  110. package/lib/db.ts +80 -0
  111. package/lib/disk-pressure.ts +42 -0
  112. package/lib/docker-exec.ts +156 -0
  113. package/lib/engine-accounts.ts +1970 -0
  114. package/lib/engine-connect.ts +321 -0
  115. package/lib/engine-credential-env.ts +52 -0
  116. package/lib/engine-login.ts +2933 -0
  117. package/lib/engines.ts +1342 -0
  118. package/lib/enrollment-state.ts +251 -0
  119. package/lib/env-file.ts +246 -0
  120. package/lib/env-schema.ts +891 -0
  121. package/lib/env.ts +195 -0
  122. package/lib/fs-aliases.ts +49 -0
  123. package/lib/git-clone-credentials.ts +334 -0
  124. package/lib/git-diff.ts +530 -0
  125. package/lib/git-identity.ts +715 -0
  126. package/lib/github-git-auth.ts +207 -0
  127. package/lib/github-origin.ts +28 -0
  128. package/lib/github-tokens.ts +2439 -0
  129. package/lib/host-cloud-url.ts +62 -0
  130. package/lib/host-config.ts +279 -0
  131. package/lib/host-env.ts +424 -0
  132. package/lib/host-logs.ts +965 -0
  133. package/lib/internal-connections.ts +5 -0
  134. package/lib/keyed-promise-tail.ts +23 -0
  135. package/lib/launch-mode.ts +25 -0
  136. package/lib/lease.ts +239 -0
  137. package/lib/legacy-host.ts +769 -0
  138. package/lib/legacy-runtime-retirement.ts +424 -0
  139. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  140. package/lib/machine-exec.ts +238 -0
  141. package/lib/machine-gateway-tunnel.ts +344 -0
  142. package/lib/machine-keys.ts +65 -0
  143. package/lib/machine-project-env-loader.ts +152 -0
  144. package/lib/machine-provider-aws.ts +923 -0
  145. package/lib/machine-provider-local.ts +338 -0
  146. package/lib/machine-provider.ts +90 -0
  147. package/lib/managed-activation-watcher.ts +72 -0
  148. package/lib/managed-install-owner-watcher.ts +55 -0
  149. package/lib/managed-operation-drain.ts +57 -0
  150. package/lib/managed-runtime.ts +4296 -0
  151. package/lib/managed-update-scheduler.ts +125 -0
  152. package/lib/mcp-capture.ts +865 -0
  153. package/lib/mcp-config-lock.ts +2 -0
  154. package/lib/mcp-connections.ts +959 -0
  155. package/lib/mcp-gateway.ts +2594 -0
  156. package/lib/obs.ts +512 -0
  157. package/lib/openrouter-budget.ts +247 -0
  158. package/lib/orchestrator.ts +11409 -0
  159. package/lib/person-capture.ts +327 -0
  160. package/lib/preview-ports.ts +85 -0
  161. package/lib/preview-sidecar.ts +331 -0
  162. package/lib/project-env-authority.ts +1319 -0
  163. package/lib/project-env-feature.ts +74 -0
  164. package/lib/project-env-legacy-tags.ts +130 -0
  165. package/lib/project-env-loader.ts +242 -0
  166. package/lib/project-env-replica.ts +2094 -0
  167. package/lib/project-env-schema-capture.ts +342 -0
  168. package/lib/project-env-staging.ts +148 -0
  169. package/lib/project-env-task-policy.ts +176 -0
  170. package/lib/project-env-task-replica.ts +1752 -0
  171. package/lib/release-feed.ts +61 -0
  172. package/lib/release-manifest.ts +710 -0
  173. package/lib/release-trust.ts +46 -0
  174. package/lib/repo-clone.ts +38 -0
  175. package/lib/routing/shadow.ts +261 -0
  176. package/lib/runner-boot.ts +10 -0
  177. package/lib/runtime-activation-tail.ts +279 -0
  178. package/lib/runtime-archive.ts +1216 -0
  179. package/lib/runtime-authority.ts +166 -0
  180. package/lib/runtime-guard.ts +36 -0
  181. package/lib/runtime-provider-state.ts +169 -0
  182. package/lib/runtime-reconnect.ts +86 -0
  183. package/lib/runtime-state.ts +1033 -0
  184. package/lib/safe-http.ts +364 -0
  185. package/lib/secrets-proxy/address.ts +95 -0
  186. package/lib/secrets-proxy/audit.ts +30 -0
  187. package/lib/secrets-proxy/ca-store.ts +200 -0
  188. package/lib/secrets-proxy/ca.ts +470 -0
  189. package/lib/secrets-proxy/container-steps.ts +138 -0
  190. package/lib/secrets-proxy/der.ts +273 -0
  191. package/lib/secrets-proxy/engine.ts +1633 -0
  192. package/lib/secrets-proxy/host.ts +93 -0
  193. package/lib/secrets-proxy/hostname.ts +57 -0
  194. package/lib/secrets-proxy/placeholders.ts +196 -0
  195. package/lib/secrets-proxy/policy.ts +150 -0
  196. package/lib/secrets-proxy/readiness.ts +148 -0
  197. package/lib/secrets-proxy/runtime.ts +582 -0
  198. package/lib/secrets-proxy/scrub.ts +559 -0
  199. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  200. package/lib/secrets-proxy/task-scripts.ts +262 -0
  201. package/lib/secrets-proxy/task-trust.ts +605 -0
  202. package/lib/secrets-proxy/trust-watch.ts +80 -0
  203. package/lib/secrets.ts +71 -0
  204. package/lib/shared-files-s3.ts +976 -0
  205. package/lib/shared-files.ts +156 -0
  206. package/lib/sidecar-assign.ts +49 -0
  207. package/lib/sidecar-guard.ts +58 -0
  208. package/lib/sidecar-resume.ts +178 -0
  209. package/lib/sidecar.ts +69 -0
  210. package/lib/signin-box-credentials.ts +465 -0
  211. package/lib/signin-box.ts +114 -0
  212. package/lib/skill-index-example.fixture.ts +56 -0
  213. package/lib/skill-layout.ts +417 -0
  214. package/lib/skills.ts +489 -0
  215. package/lib/ssh.ts +401 -0
  216. package/lib/standard-image-cleanup.ts +117 -0
  217. package/lib/standard-image-pin.ts +207 -0
  218. package/lib/standard-image.ts +1697 -0
  219. package/lib/stopped-task-status-queue.ts +120 -0
  220. package/lib/task-container-cli.ts +269 -0
  221. package/lib/task-credential-cleanup.ts +752 -0
  222. package/lib/task-diff-sandbox.ts +464 -0
  223. package/lib/task-diff.ts +91 -0
  224. package/lib/task-env-forwarder.ts +228 -0
  225. package/lib/task-env-source.ts +249 -0
  226. package/lib/task-environment/anchor.ts +31 -0
  227. package/lib/task-environment/apple-container.ts +860 -0
  228. package/lib/task-environment/docker.ts +1344 -0
  229. package/lib/task-environment/index.ts +466 -0
  230. package/lib/task-environment/legacy-adoption.ts +467 -0
  231. package/lib/task-environment/machine-environment.ts +61 -0
  232. package/lib/task-environment/machine-task-up.ts +1227 -0
  233. package/lib/task-environment/machine.ts +974 -0
  234. package/lib/task-environment/registry.ts +59 -0
  235. package/lib/task-environment/self-identity.ts +20 -0
  236. package/lib/task-environment/self.ts +566 -0
  237. package/lib/task-environment/types.ts +642 -0
  238. package/lib/task-environment/workspace-files.ts +685 -0
  239. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  240. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  241. package/lib/task-files-machine-retirement.ts +116 -0
  242. package/lib/task-files-readiness.ts +127 -0
  243. package/lib/task-gateway-port.ts +22 -0
  244. package/lib/task-gateway-protocol.ts +190 -0
  245. package/lib/task-gateway-recovery.ts +47 -0
  246. package/lib/task-gateway-state.ts +512 -0
  247. package/lib/task-identity.ts +19 -0
  248. package/lib/task-inventory.ts +682 -0
  249. package/lib/task-machine-credentials-activation.ts +95 -0
  250. package/lib/task-machine-swap-safety.ts +729 -0
  251. package/lib/task-person-credentials.ts +76 -0
  252. package/lib/task-project-ops-host.ts +147 -0
  253. package/lib/task-project-ops.ts +1062 -0
  254. package/lib/task-secret-staging.ts +16 -0
  255. package/lib/task-status.ts +46 -0
  256. package/lib/task-token.ts +46 -0
  257. package/lib/token-broker-adopt.ts +344 -0
  258. package/lib/token-broker-lambda.ts +105 -0
  259. package/lib/token-broker-local.ts +392 -0
  260. package/lib/token-broker-revision.ts +310 -0
  261. package/lib/token-broker-ssm.ts +525 -0
  262. package/lib/token-broker.ts +218 -0
  263. package/lib/token-lanes/openai-oauth.ts +123 -0
  264. package/lib/token-lanes/registry.ts +45 -0
  265. package/lib/token-lanes/types.ts +163 -0
  266. package/lib/token-lanes/xai-oauth.ts +165 -0
  267. package/lib/transcript.ts +128 -0
  268. package/lib/tunnel-registry.ts +220 -0
  269. package/lib/tunnel-runtime.ts +235 -0
  270. package/lib/ulid.ts +16 -0
  271. package/lib/user-bus-env.ts +116 -0
  272. package/lib/user-credentials-store.ts +435 -0
  273. package/lib/vnode-pressure.ts +142 -0
  274. package/package.json +131 -4
  275. package/runner/runner.mjs +442 -0
  276. package/scripts/agent/_common.sh +633 -0
  277. package/scripts/agent/env-hook.sh +626 -0
  278. package/scripts/agent/moondeck-cli.sh +5 -0
  279. package/scripts/agent/task-down.sh +291 -0
  280. package/scripts/agent/task-status.sh +70 -0
  281. package/scripts/agent/task-up.sh +3761 -0
  282. package/scripts/agent/uai-env-exec.sh +26 -0
  283. package/scripts/install/darwin.ts +1048 -0
  284. package/scripts/install/linux.ts +958 -0
  285. package/scripts/install/types.ts +78 -0
  286. package/scripts/install/util.ts +254 -0
  287. package/scripts/install/win.ts +143 -0
  288. package/src/activation-respawn.ts +155 -0
  289. package/src/apple-tunnel-route.ts +104 -0
  290. package/src/capture-wire.ts +102 -0
  291. package/src/cli.ts +2583 -0
  292. package/src/cloud-credentials.ts +2144 -0
  293. package/src/cloud-origins.ts +23 -0
  294. package/src/credential-location.ts +248 -0
  295. package/src/credential-wire-validation.ts +25 -0
  296. package/src/custom-connections.ts +237 -0
  297. package/src/engine-account-paths.ts +11 -0
  298. package/src/engine-models.ts +39 -0
  299. package/src/engine-usage.ts +104 -0
  300. package/src/event-outbox.ts +185 -0
  301. package/src/index.ts +2899 -0
  302. package/src/load-env.ts +97 -0
  303. package/src/machine-tunnel-route.ts +156 -0
  304. package/src/main.ts +6349 -0
  305. package/src/never-proxied-domains.ts +31 -0
  306. package/src/outbound-frame.ts +31 -0
  307. package/src/paths.ts +81 -0
  308. package/src/project-env-latest.ts +130 -0
  309. package/src/project-env-policy.ts +396 -0
  310. package/src/project-env-wording.ts +53 -0
  311. package/src/protocol.ts +5545 -0
  312. package/src/runtime-bootstrap.ts +165 -0
  313. package/src/station-identity.ts +96 -0
  314. package/src/task-env-names.ts +28 -0
  315. package/src/task-secrets.ts +75 -0
  316. package/src/tunnel-dial.ts +102 -0
  317. package/src/ui/server.ts +852 -0
  318. package/src/ui/types.ts +173 -0
  319. package/src/workspace-path-policy.ts +41 -0
  320. package/ui/app.js +1102 -0
  321. package/ui/index.html +90 -0
  322. package/ui/style.css +685 -0
  323. package/ui/uai-favicon.svg +21 -0
  324. package/ui/uai-logo-black.svg +9 -0
  325. package/ui/uai-wheel.svg +9 -0
@@ -0,0 +1,932 @@
1
+ /**
2
+ * Credential store (ADR-134) — where a Uai-operated host keeps tenant
3
+ * credentials: AWS Systems Manager Parameter Store, one SecureString per
4
+ * credential, encrypted with the `alias/uai-credentials` KMS key, read and
5
+ * written with the host's own IAM role through the `aws` CLI the host already
6
+ * uses for machines (ADR-121).
7
+ *
8
+ * Owned hosts never construct one of these: `configuredCredentialStore()`
9
+ * returns null unless `UAI_CREDENTIAL_STORE=aws-ssm`, and every caller treats
10
+ * null as "credentials stay on this machine".
11
+ *
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 dir and hands the CLI
14
+ * `--cli-input-json file://<path>`, removed as soon as the CLI exits. (Not
15
+ * `file:///dev/stdin`: the CLI opens the file twice and a pipe reads empty the
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.
23
+ */
24
+
25
+ import { spawn } from "node:child_process";
26
+ import {
27
+ lstatSync,
28
+ mkdtempSync,
29
+ readdirSync,
30
+ rmSync,
31
+ statfsSync,
32
+ statSync,
33
+ writeFileSync,
34
+ type Stats,
35
+ } from "node:fs";
36
+ import { tmpdir } from "node:os";
37
+ import { join, resolve } from "node:path";
38
+
39
+ import { env } from "./env";
40
+ import { assertNoAliases } from "./fs-aliases";
41
+ import { leaseAwsEnv } from "./lease";
42
+
43
+ const DEFAULT_TIMEOUT_MS = 30_000;
44
+
45
+ /** Parameter names: absolute, slash-separated, the SSM-legal character set. */
46
+ const PARAMETER_NAME = /^\/[A-Za-z0-9_.\-]+(?:\/[A-Za-z0-9_.\-]+)*$/;
47
+ /** A single path segment (an org id, a host id, an engine kind, an account id). */
48
+ const PATH_SEGMENT = /^[A-Za-z0-9_.\-]+$/;
49
+
50
+ export interface StoredParameter {
51
+ name: string;
52
+ value: string;
53
+ }
54
+
55
+ export interface StoredParameterVersion extends StoredParameter {
56
+ version: number;
57
+ }
58
+
59
+ export type ImmutableParameterPutResult = "created" | "matched";
60
+
61
+ /** The immutable name exists, but its authenticated identity is different. */
62
+ export class ImmutableParameterConflictError extends Error {
63
+ constructor(message = "immutable parameter identity conflicts with the existing value") {
64
+ super(message);
65
+ this.name = "ImmutableParameterConflictError";
66
+ }
67
+ }
68
+
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
+
93
+ export class ImmutableParameterUnverifiedError extends Error {
94
+ constructor(message = "immutable parameter create could not be verified") {
95
+ super(message);
96
+ this.name = "ImmutableParameterUnverifiedError";
97
+ }
98
+ }
99
+
100
+ export interface CredentialStore {
101
+ readonly kind: "aws-ssm";
102
+ /** Create or overwrite one credential. Throws on failure. */
103
+ put(name: string, value: string): Promise<void>;
104
+ /**
105
+ * Create one immutable SecureString with its identity tags in the same AWS
106
+ * request. A retry never overwrites or reads the value: metadata proves that
107
+ * an already-existing name is the exact prior write.
108
+ *
109
+ * Optional for custom/older stores. Callers that require create-once
110
+ * semantics must fail closed when it is absent.
111
+ */
112
+ putImmutable?(
113
+ name: string,
114
+ value: string,
115
+ tags: Record<string, string>,
116
+ options?: ImmutablePutOptions,
117
+ ): Promise<ImmutableParameterPutResult>;
118
+ /** The credential's plaintext, or null when it does not exist. */
119
+ get(name: string, options?: StoreCallOptions): Promise<string | null>;
120
+ /**
121
+ * One exact immutable parameter version. Project-env replica migration
122
+ * uses this for legacy value references: reading the latest value and then
123
+ * assuming its version would allow a late old writer to substitute a new
124
+ * secret for the head the cloud actually named.
125
+ *
126
+ * Optional for older/custom stores. Callers that require a pinned read
127
+ * must fail closed when it is absent.
128
+ */
129
+ getVersion?(name: string, version: number): Promise<StoredParameterVersion | null>;
130
+ /** Idempotent: a missing parameter is not an error. */
131
+ delete(name: string): Promise<void>;
132
+ /** Every credential under `path` (recursive), decrypted. Throws on failure. */
133
+ list(path: string): Promise<StoredParameter[]>;
134
+ /**
135
+ * Every credential under `path` read by NAME: names from
136
+ * `describe-parameters` (allowed on `*`, never returns values), then one
137
+ * `get-parameter` each, decrypted; a name the identity may not read is
138
+ * skipped. This is how a task machine's lease reads `/uai/projects/<id>`:
139
+ * its policy conditions on the parameter's `org` tag, and
140
+ * `get-parameters-by-path` is authorised against the PATH resource, which
141
+ * carries no tags, so it is denied even when every parameter under it is
142
+ * tagged (live 2026-09-10, smoke 12).
143
+ */
144
+ listByName(path: string): Promise<StoredParameter[]>;
145
+ /** Set tags on an existing parameter (a second call, because put-parameter
146
+ * with Overwrite cannot carry tags). Throws on failure — including when
147
+ * the identity lacks `ssm:AddTagsToResource`, which older host policies do. */
148
+ tag(name: string, tags: Record<string, string>): Promise<void>;
149
+ /** The tags on one parameter. Metadata only: `ssm:ListTagsForResource`
150
+ * reveals no value and needs no KMS grant — the sign-in box (ADR-136)
151
+ * rebuilds an account's label from here without ever reading the secret. */
152
+ listTags(name: string, options?: StoreCallOptions): Promise<Record<string, string>>;
153
+ /** Every parameter NAME under `path` (recursive), no values — the
154
+ * `describe-parameters` listing that is allowed on `*` and never decrypts. */
155
+ describeNames(path: string): Promise<string[]>;
156
+ /** Names and versions under `path` (recursive), NO decryption — the cheap
157
+ * poll a Uai-operated host runs to notice a broker refresh. */
158
+ listVersions(path: string): Promise<Array<{ name: string; version: number }>>;
159
+ }
160
+
161
+ export interface CliResult {
162
+ status: number | null;
163
+ stdout: string;
164
+ stderr: string;
165
+ }
166
+
167
+ /** `aws --region <r> --output json <args>`. A signal kills the process. */
168
+ export type AwsCliRunner = (args: string[], options?: { signal?: AbortSignal }) => Promise<CliResult>;
169
+
170
+ export function isParameterName(name: string): boolean {
171
+ return PARAMETER_NAME.test(name);
172
+ }
173
+
174
+ export function isParameterPathSegment(segment: string): boolean {
175
+ return PATH_SEGMENT.test(segment);
176
+ }
177
+
178
+ /** Join path segments under `prefix`, refusing anything SSM would reject or
179
+ * that could escape its intended subtree (an empty segment, a `/` inside). */
180
+ export function parameterName(prefix: string, ...segments: string[]): string {
181
+ for (const segment of segments) {
182
+ if (!isParameterPathSegment(segment)) {
183
+ throw new Error(`invalid credential-store path segment: ${JSON.stringify(segment)}`);
184
+ }
185
+ }
186
+ const name = `${prefix}/${segments.join("/")}`;
187
+ if (!isParameterName(name)) {
188
+ throw new Error(`invalid credential-store parameter name: ${JSON.stringify(name)}`);
189
+ }
190
+ return name;
191
+ }
192
+
193
+ export function defaultAwsCliRunner(region: string): AwsCliRunner {
194
+ return (args, options = {}) =>
195
+ new Promise<CliResult>((resolve) => {
196
+ let stdout = "";
197
+ let stderr = "";
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
+ }
204
+ const child = spawn("aws", ["--region", region, "--output", "json", ...args], {
205
+ stdio: ["ignore", "pipe", "pipe"],
206
+ // ADR-133: a task machine acts under its lease (empty elsewhere — an
207
+ // owned or Uai-operated host keeps its own instance role).
208
+ env: { ...process.env, ...leaseAwsEnv() },
209
+ });
210
+ const settle = (status: number | null): void => {
211
+ if (settled) return;
212
+ settled = true;
213
+ clearTimeout(timer);
214
+ signal?.removeEventListener("abort", abort);
215
+ resolve({ status, stdout, stderr });
216
+ };
217
+ const timer = setTimeout(() => {
218
+ child.kill("SIGKILL");
219
+ const grace = setTimeout(() => settle(null), 2_000);
220
+ grace.unref?.();
221
+ }, DEFAULT_TIMEOUT_MS);
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 });
230
+ child.stdout?.setEncoding("utf8");
231
+ child.stderr?.setEncoding("utf8");
232
+ child.stdout?.on("data", (chunk: string) => (stdout += chunk));
233
+ child.stderr?.on("data", (chunk: string) => (stderr += chunk));
234
+ child.once("error", () => settle(null));
235
+ child.once("close", (code) => settle(code));
236
+ });
237
+ }
238
+
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. */
445
+ async function withPrivateInputFile<T>(
446
+ root: string | null,
447
+ content: string,
448
+ work: (path: string) => Promise<T>,
449
+ ): Promise<T> {
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));
454
+ try {
455
+ const path = join(dir, "input.json");
456
+ writeFileSync(path, content, { mode: 0o600 });
457
+ return await work(path);
458
+ } finally {
459
+ rmSync(dir, { recursive: true, force: true });
460
+ stagedInUse.delete(resolve(dir));
461
+ }
462
+ }
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
+
473
+ function isNotFound(result: CliResult): boolean {
474
+ return /ParameterNotFound/.test(result.stderr);
475
+ }
476
+
477
+ function isAccessDenied(result: CliResult): boolean {
478
+ return /AccessDenied/.test(result.stderr);
479
+ }
480
+
481
+ function checkedTagEntries(tags: Record<string, string>): Array<[string, string]> {
482
+ const entries = Object.entries(tags);
483
+ for (const [key, value] of entries) {
484
+ if (
485
+ !/^[A-Za-z0-9_.:/=+@-]{1,128}$/.test(key) ||
486
+ !/^[A-Za-z0-9_.:/=+@ -]{0,256}$/.test(value)
487
+ ) {
488
+ throw new Error(`invalid tag ${key}`);
489
+ }
490
+ }
491
+ return entries;
492
+ }
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
+
508
+ /** Summarize a CLI failure WITHOUT echoing stdout (which could carry a value). */
509
+ function failure(op: string, result: CliResult): Error {
510
+ const line =
511
+ result.stderr
512
+ .split("\n")
513
+ .map((l) => l.trim())
514
+ .find((l) => l.length > 0) ?? (result.status === null ? "spawn failed or timed out" : "");
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";
536
+ }
537
+
538
+ export interface AwsSsmCredentialStoreOptions {
539
+ region: string;
540
+ kmsKeyId: string;
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;
548
+ }
549
+
550
+ export class AwsSsmCredentialStore implements CredentialStore {
551
+ readonly kind = "aws-ssm" as const;
552
+ private readonly run: AwsCliRunner;
553
+ private readonly kmsKeyId: string;
554
+
555
+ private readonly stagingRoot: string | null;
556
+
557
+ constructor(opts: AwsSsmCredentialStoreOptions) {
558
+ this.run = opts.runner ?? defaultAwsCliRunner(opts.region);
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
+ }
574
+ }
575
+
576
+ async put(name: string, value: string): Promise<void> {
577
+ if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
578
+ // Tier Advanced: a Codex auth.json is ~4 KB, past the 4 KB standard cap.
579
+ // Tags cannot ride along with Overwrite, and the path already encodes
580
+ // ownership, so none are set.
581
+ const input = JSON.stringify({
582
+ Name: name,
583
+ Value: value,
584
+ Type: "SecureString",
585
+ KeyId: this.kmsKeyId,
586
+ Tier: "Advanced",
587
+ Overwrite: true,
588
+ });
589
+ const result = await withPrivateInputFile(this.stagingRoot, input, (path) =>
590
+ this.run(["ssm", "put-parameter", "--cli-input-json", `file://${path}`]),
591
+ );
592
+ if (result.status !== 0) throw failure("put-parameter", result);
593
+ }
594
+
595
+ async putImmutable(
596
+ name: string,
597
+ value: string,
598
+ tags: Record<string, string>,
599
+ options: ImmutablePutOptions = {},
600
+ ): Promise<ImmutableParameterPutResult> {
601
+ if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
602
+ const entries = checkedTagEntries(tags);
603
+ if (entries.length === 0) {
604
+ throw new Error("immutable parameter identity tags are required");
605
+ }
606
+ const { signal } = options;
607
+ if (signal?.aborted) throw new ImmutableParameterAbortedError();
608
+ const input = JSON.stringify({
609
+ Name: name,
610
+ Value: value,
611
+ Type: "SecureString",
612
+ KeyId: this.kmsKeyId,
613
+ Tier: "Advanced",
614
+ Overwrite: false,
615
+ Tags: entries.map(([Key, Value]) => ({ Key, Value })),
616
+ });
617
+ const result = await withPrivateInputFile(this.stagingRoot, input, (path) =>
618
+ this.run(["ssm", "put-parameter", "--cli-input-json", `file://${path}`], { signal }),
619
+ );
620
+ if (result.status === 0) return "created";
621
+ if (signal?.aborted) throw new ImmutableParameterAbortedError();
622
+
623
+ // The CLI can lose the successful response, and a retry then receives
624
+ // ParameterAlreadyExists. The sign-in-box identity is intentionally
625
+ // write-only, so authenticate the prior write using metadata only.
626
+ let observed: Record<string, string>;
627
+ try {
628
+ observed = await this.tagsOf(name, signal);
629
+ } catch {
630
+ if (signal?.aborted) throw new ImmutableParameterAbortedError();
631
+ throw new ImmutableParameterUnverifiedError();
632
+ }
633
+ let missing = false;
634
+ for (const [key, expected] of entries) {
635
+ const actual = observed[key];
636
+ if (actual === undefined) {
637
+ missing = true;
638
+ } else if (actual !== expected) {
639
+ throw new ImmutableParameterConflictError();
640
+ }
641
+ }
642
+ if (!missing) return "matched";
643
+
644
+ // Tags are eventually consistent with the create. Missing/partial tags
645
+ // are therefore retryable, never permission to add tags or overwrite.
646
+ throw new ImmutableParameterUnverifiedError();
647
+ }
648
+
649
+ async tag(name: string, tags: Record<string, string>): Promise<void> {
650
+ if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
651
+ const entries = checkedTagEntries(tags);
652
+ if (entries.length === 0) return;
653
+ const result = await this.run([
654
+ "ssm",
655
+ "add-tags-to-resource",
656
+ "--resource-type",
657
+ "Parameter",
658
+ "--resource-id",
659
+ name,
660
+ "--tags",
661
+ ...entries.map(([key, value]) => `Key=${key},Value=${value}`),
662
+ ]);
663
+ if (result.status !== 0) throw failure("add-tags-to-resource", result);
664
+ }
665
+
666
+ async get(name: string, options: StoreCallOptions = {}): Promise<string | null> {
667
+ if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
668
+ const result = await this.run(["ssm", "get-parameter", "--name", name, "--with-decryption"], {
669
+ signal: options.signal,
670
+ });
671
+ if (result.status !== 0) {
672
+ if (isNotFound(result)) return null;
673
+ throw failure("get-parameter", result);
674
+ }
675
+ const parsed = JSON.parse(result.stdout) as { Parameter?: { Value?: unknown } };
676
+ const value = parsed.Parameter?.Value;
677
+ if (typeof value !== "string") throw new Error("aws ssm get-parameter: malformed response");
678
+ return value;
679
+ }
680
+
681
+ async getVersion(
682
+ name: string,
683
+ version: number,
684
+ ): Promise<StoredParameterVersion | null> {
685
+ if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
686
+ if (!Number.isSafeInteger(version) || version < 1) {
687
+ throw new Error(`invalid parameter version: ${version}`);
688
+ }
689
+ const result = await this.run([
690
+ "ssm",
691
+ "get-parameter",
692
+ "--name",
693
+ `${name}:${version}`,
694
+ "--with-decryption",
695
+ ]);
696
+ if (result.status !== 0) {
697
+ if (isNotFound(result)) return null;
698
+ throw failure("get-parameter", result);
699
+ }
700
+ const parameter = (JSON.parse(result.stdout) as {
701
+ Parameter?: { Name?: unknown; Value?: unknown; Version?: unknown };
702
+ }).Parameter;
703
+ if (
704
+ parameter === undefined ||
705
+ parameter.Name !== name ||
706
+ typeof parameter.Value !== "string" ||
707
+ parameter.Version !== version
708
+ ) {
709
+ throw new Error("aws ssm get-parameter: malformed versioned response");
710
+ }
711
+ return { name, value: parameter.Value, version };
712
+ }
713
+
714
+ async delete(name: string): Promise<void> {
715
+ if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
716
+ const result = await this.run(["ssm", "delete-parameter", "--name", name]);
717
+ if (result.status !== 0 && !isNotFound(result)) {
718
+ throw failure("delete-parameter", result);
719
+ }
720
+ }
721
+
722
+ async list(path: string): Promise<StoredParameter[]> {
723
+ if (!isParameterName(path)) throw new Error(`invalid parameter path: ${path}`);
724
+ const out: StoredParameter[] = [];
725
+ let nextToken: string | undefined;
726
+ // The CLI auto-paginates JSON output, but a response may still carry a
727
+ // NextToken when the page cap is hit; follow it either way.
728
+ for (let page = 0; page < 100; page++) {
729
+ const result = await this.run([
730
+ "ssm",
731
+ "get-parameters-by-path",
732
+ "--path",
733
+ path,
734
+ "--recursive",
735
+ "--with-decryption",
736
+ ...(nextToken ? ["--next-token", nextToken] : []),
737
+ ]);
738
+ if (result.status !== 0) throw failure("get-parameters-by-path", result);
739
+ const parsed = JSON.parse(result.stdout) as {
740
+ Parameters?: Array<{ Name?: unknown; Value?: unknown }>;
741
+ NextToken?: unknown;
742
+ };
743
+ for (const p of parsed.Parameters ?? []) {
744
+ if (typeof p.Name === "string" && typeof p.Value === "string") {
745
+ out.push({ name: p.Name, value: p.Value });
746
+ }
747
+ }
748
+ if (typeof parsed.NextToken === "string" && parsed.NextToken) {
749
+ nextToken = parsed.NextToken;
750
+ } else {
751
+ break;
752
+ }
753
+ }
754
+ return out;
755
+ }
756
+
757
+ async describeNames(path: string): Promise<string[]> {
758
+ if (!isParameterName(path)) throw new Error(`invalid parameter path: ${path}`);
759
+ const described = await this.run([
760
+ "ssm",
761
+ "describe-parameters",
762
+ "--parameter-filters",
763
+ `Key=Path,Option=Recursive,Values=${path}`,
764
+ "--query",
765
+ "Parameters[].Name",
766
+ ]);
767
+ if (described.status !== 0) throw failure("describe-parameters", described);
768
+ const parsed: unknown = JSON.parse(described.stdout || "[]");
769
+ return (Array.isArray(parsed) ? parsed : []).filter(
770
+ (name): name is string => typeof name === "string" && name.startsWith(`${path}/`),
771
+ );
772
+ }
773
+
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>> {
779
+ if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
780
+ const result = await this.run(
781
+ ["ssm", "list-tags-for-resource", "--resource-type", "Parameter", "--resource-id", name],
782
+ { signal },
783
+ );
784
+ if (result.status !== 0) throw failure("list-tags-for-resource", result);
785
+ const parsed = JSON.parse(result.stdout || "{}") as {
786
+ TagList?: Array<{ Key?: unknown; Value?: unknown }> | null;
787
+ };
788
+ const out: Record<string, string> = {};
789
+ for (const tag of parsed.TagList ?? []) {
790
+ if (typeof tag.Key === "string" && typeof tag.Value === "string") out[tag.Key] = tag.Value;
791
+ }
792
+ return out;
793
+ }
794
+
795
+ async listByName(path: string): Promise<StoredParameter[]> {
796
+ const names = await this.describeNames(path);
797
+ const out: StoredParameter[] = [];
798
+ for (const name of names) {
799
+ const result = await this.run(["ssm", "get-parameter", "--name", name, "--with-decryption"]);
800
+ if (result.status !== 0) {
801
+ // Not tagged for this identity (or gone between the two calls):
802
+ // invisible, not an error — the listing is what the lease may read.
803
+ if (isAccessDenied(result) || isNotFound(result)) continue;
804
+ throw failure("get-parameter", result);
805
+ }
806
+ const value = (JSON.parse(result.stdout) as { Parameter?: { Value?: unknown } }).Parameter?.Value;
807
+ if (typeof value === "string") out.push({ name, value });
808
+ }
809
+ return out;
810
+ }
811
+
812
+ async listVersions(path: string): Promise<Array<{ name: string; version: number }>> {
813
+ if (!isParameterName(path)) throw new Error(`invalid parameter path: ${path}`);
814
+ const out: Array<{ name: string; version: number }> = [];
815
+ let nextToken: string | undefined;
816
+ for (let page = 0; page < 100; page++) {
817
+ const result = await this.run([
818
+ "ssm",
819
+ "get-parameters-by-path",
820
+ "--path",
821
+ path,
822
+ "--recursive",
823
+ "--query",
824
+ "{Parameters: Parameters[].{Name: Name, Version: Version}, NextToken: NextToken}",
825
+ ...(nextToken ? ["--next-token", nextToken] : []),
826
+ ]);
827
+ if (result.status !== 0) throw failure("get-parameters-by-path", result);
828
+ const parsed = JSON.parse(result.stdout) as {
829
+ Parameters?: Array<{ Name?: unknown; Version?: unknown }> | null;
830
+ NextToken?: unknown;
831
+ };
832
+ for (const p of parsed.Parameters ?? []) {
833
+ if (typeof p.Name === "string" && typeof p.Version === "number") {
834
+ out.push({ name: p.Name, version: p.Version });
835
+ }
836
+ }
837
+ if (typeof parsed.NextToken === "string" && parsed.NextToken) nextToken = parsed.NextToken;
838
+ else break;
839
+ }
840
+ return out;
841
+ }
842
+ }
843
+
844
+ // (listVersions lives on the class below.)
845
+
846
+ // ---------------------------------------------------------------------------
847
+ // Write queue shared by every mirrored table (engine accounts, GitHub tokens,
848
+ // SSH keys, MCP connections). Mirrors are async and best effort: a store
849
+ // outage delays a mirror, never a task-up or a UI action.
850
+ // ---------------------------------------------------------------------------
851
+
852
+ const inflight = new Set<Promise<void>>();
853
+
854
+ /** Run `work` against the configured store off the caller's path; a no-op on
855
+ * owned hosts. Failures are logged (never the value) and never thrown. */
856
+ export function queueStoreWrite(
857
+ what: string,
858
+ work: (store: CredentialStore) => Promise<void>,
859
+ ): void {
860
+ const store = configuredCredentialStore();
861
+ if (!store) return;
862
+ const p: Promise<void> = work(store)
863
+ .catch((err: unknown) => {
864
+ console.warn(
865
+ `[credential-store] ${what} failed: ${
866
+ err instanceof Error ? err.message : String(err)
867
+ }`,
868
+ );
869
+ })
870
+ .finally(() => {
871
+ inflight.delete(p);
872
+ });
873
+ inflight.add(p);
874
+ }
875
+
876
+ /** Tests / shutdown: wait for every queued mirror or delete to settle. */
877
+ export async function settleStoreWrites(): Promise<void> {
878
+ while (inflight.size > 0) await Promise.allSettled([...inflight]);
879
+ }
880
+
881
+ /** `<prefix>/users/<userId>/<…>` — the per-user subtree (GitHub token, SSH
882
+ * key, MCP connections). The STS `user` session tag scopes it later. */
883
+ export function userParameterName(userId: string, ...segments: string[]): string {
884
+ return parameterName(credentialStorePrefix(), "users", userId, ...segments);
885
+ }
886
+
887
+ // ---------------------------------------------------------------------------
888
+ // Process-wide configuration.
889
+ // ---------------------------------------------------------------------------
890
+
891
+ let override: CredentialStore | null | undefined;
892
+ let configured: CredentialStore | null | undefined;
893
+ let warnedMissingRegion = false;
894
+
895
+ /** Tests: pin the store (`null` = behave as an owned host); `undefined` resets. */
896
+ export function setCredentialStoreOverride(store: CredentialStore | null | undefined): void {
897
+ override = store;
898
+ }
899
+
900
+ /** The parameter-name prefix every stored credential lives under. */
901
+ export function credentialStorePrefix(): string {
902
+ return env.UAI_CREDENTIAL_STORE_PREFIX;
903
+ }
904
+
905
+ /**
906
+ * The store this host mirrors to, or null on an owned host (the default).
907
+ * Memoized: the env does not change while the process runs.
908
+ */
909
+ export function configuredCredentialStore(): CredentialStore | null {
910
+ if (override !== undefined) return override;
911
+ if (configured !== undefined) return configured;
912
+ if (env.UAI_CREDENTIAL_STORE !== "aws-ssm") {
913
+ configured = null;
914
+ return configured;
915
+ }
916
+ const region = env.UAI_AWS_REGION;
917
+ if (!region) {
918
+ if (!warnedMissingRegion) {
919
+ warnedMissingRegion = true;
920
+ console.warn(
921
+ "[credential-store] UAI_CREDENTIAL_STORE=aws-ssm needs UAI_AWS_REGION; credentials stay local until it is set",
922
+ );
923
+ }
924
+ configured = null;
925
+ return configured;
926
+ }
927
+ configured = new AwsSsmCredentialStore({
928
+ region,
929
+ kmsKeyId: env.UAI_CREDENTIAL_STORE_KMS_KEY,
930
+ });
931
+ return configured;
932
+ }