@moondeck/station 0.0.1 → 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 (326) 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 +115 -8
  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
  326. package/bin/moondeck-station.mjs +0 -3
@@ -0,0 +1,525 @@
1
+ /**
2
+ * Parameter Store adapter for the broker core (ADR-135) — the storage the
3
+ * broker Lambda runs against. Reads the ADR-134 engine-account parameters
4
+ * under `<prefix>/orgs` and `<prefix>/hosts`, hands each to the core as a
5
+ * credential, and writes refreshed material + the `refresh` block back with
6
+ * `Overwrite`. Only the Lambda imports this module: it depends on the AWS
7
+ * SDK the Lambda runtime provides, which the host package does not ship.
8
+ */
9
+
10
+ import {
11
+ GetParameterCommand,
12
+ GetParameterHistoryCommand,
13
+ GetParametersByPathCommand,
14
+ ListTagsForResourceCommand,
15
+ PutParameterCommand,
16
+ SSMClient,
17
+ } from "@aws-sdk/client-ssm";
18
+
19
+ import {
20
+ BROKER_PARAMETER_ROOT,
21
+ idPathSegment,
22
+ legacyAdoptedRoot,
23
+ legacyEngineParameterRoot,
24
+ } from "../src/cloud-credentials";
25
+ import type { BrokerCredential, BrokerStorage } from "./token-broker";
26
+ import type { AdoptionStore } from "./token-broker-adopt";
27
+ import type { RevisionStore, StoredParameter } from "./token-broker-revision";
28
+ import { refreshMetaFrom, type CredentialFiles, type RefreshMeta } from "./token-lanes/types";
29
+
30
+ export interface SsmBrokerStorageOptions {
31
+ prefix: string;
32
+ kmsKeyId: string;
33
+ client?: SSMClient;
34
+ region?: string;
35
+ }
36
+
37
+ interface StoredValue {
38
+ raw: Record<string, unknown>;
39
+ version: number | undefined;
40
+ }
41
+
42
+ /** `<prefix>/orgs/<o>/engines/<kind>/<id>` and the corresponding host path. */
43
+ export function engineParameterHeader(
44
+ name: string,
45
+ prefix: string,
46
+ ): { kind: string; id: string; scope: string } | null {
47
+ if (!name.startsWith(`${prefix}/`)) return null;
48
+ const parts = name.slice(prefix.length + 1).split("/");
49
+ if (parts.length !== 5 || parts[2] !== "engines") return null;
50
+ const [owner, ownerId, , kind, id] = parts as [string, string, string, string, string];
51
+ if (owner === "orgs") return { kind, id, scope: `org:${ownerId}` };
52
+ if (owner === "hosts") return { kind, id, scope: "host" };
53
+ return null;
54
+ }
55
+
56
+ export class SsmBrokerStorage implements BrokerStorage {
57
+ private readonly client: SSMClient;
58
+ private readonly values = new Map<string, StoredValue>();
59
+
60
+ constructor(private readonly opts: SsmBrokerStorageOptions) {
61
+ this.client = opts.client ?? new SSMClient(opts.region ? { region: opts.region } : {});
62
+ }
63
+
64
+ async list(): Promise<BrokerCredential[]> {
65
+ // ADR-154, owner answer 3: an adopted account has one refresher, the
66
+ // cloud's scheduler, never this scan. Markers that cannot be listed stop
67
+ // the run rather than risk a second refresher.
68
+ const adopted = await adoptedLegacyNames(this.client, this.opts.prefix);
69
+ const out: BrokerCredential[] = [];
70
+ for (const path of [`${this.opts.prefix}/orgs`, `${this.opts.prefix}/hosts`]) {
71
+ let nextToken: string | undefined;
72
+ do {
73
+ const page = await this.client.send(
74
+ new GetParametersByPathCommand({
75
+ Path: path,
76
+ Recursive: true,
77
+ WithDecryption: true,
78
+ NextToken: nextToken,
79
+ }),
80
+ );
81
+ for (const p of page.Parameters ?? []) {
82
+ if (!p.Name || typeof p.Value !== "string") continue;
83
+ if (adopted.has(p.Name)) continue;
84
+ const header = engineParameterHeader(p.Name, this.opts.prefix);
85
+ if (!header) continue;
86
+ let raw: unknown;
87
+ try {
88
+ raw = JSON.parse(p.Value);
89
+ } catch {
90
+ continue;
91
+ }
92
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
93
+ const value = raw as Record<string, unknown>;
94
+ if (value.v !== 1 || value.authKind !== "config-dir") continue;
95
+ const files = value.files;
96
+ if (!files || typeof files !== "object" || Array.isArray(files)) continue;
97
+ const materials: CredentialFiles = {};
98
+ for (const [k, v] of Object.entries(files as Record<string, unknown>)) {
99
+ if (typeof v === "string") materials[k] = v;
100
+ }
101
+ this.values.set(p.Name, { raw: value, version: p.Version });
102
+ out.push({
103
+ name: p.Name,
104
+ header: { kind: header.kind, authKind: "config-dir", id: header.id, scope: header.scope },
105
+ files: materials,
106
+ refresh: refreshMetaFrom(value.refresh),
107
+ });
108
+ }
109
+ nextToken = page.NextToken;
110
+ } while (nextToken);
111
+ }
112
+ return out;
113
+ }
114
+
115
+ async save(
116
+ credential: BrokerCredential,
117
+ files: CredentialFiles | null,
118
+ refresh: RefreshMeta,
119
+ ): Promise<void> {
120
+ const stored = this.values.get(credential.name);
121
+ const raw = stored?.raw ?? { v: 1 };
122
+ const next = { ...raw, refresh, ...(files ? { files } : {}) };
123
+ await this.client.send(
124
+ new PutParameterCommand({
125
+ Name: credential.name,
126
+ Value: JSON.stringify(next),
127
+ Type: "SecureString",
128
+ KeyId: this.opts.kmsKeyId,
129
+ Tier: "Advanced",
130
+ Overwrite: true,
131
+ }),
132
+ );
133
+ this.values.set(credential.name, { raw: next, version: undefined });
134
+ }
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Mutual exclusion between runs. Reserved concurrency 1 is the first line
139
+ // (deploy-aws.sh sets it when the account's limit allows); this lock is the
140
+ // one that always holds. A lock is one parameter that is never deleted: AWS
141
+ // asks for 30 seconds between deleting a parameter and creating one of the
142
+ // same name (DeleteParameter), and adoptions take the scan lock one after
143
+ // another. Free, it holds RELEASED_LOCK; held, its holder's
144
+ // `<time>:<nonce>`. A holder that died keeps it at most LOCK_STALE_MS; a
145
+ // later run takes it over.
146
+ // ---------------------------------------------------------------------------
147
+
148
+ export const LOCK_STALE_MS = 5 * 60_000;
149
+ /** A holder frees the lock only this far inside the stale window: past it,
150
+ * a run that found the lock stale may hold it now. The broker's 120-second
151
+ * Lambda timeout (deploy-aws.sh) keeps every holder well inside. */
152
+ const LOCK_RELEASE_MARGIN_MS = 60_000;
153
+ /** A free lock: time zero, which every reader takes for stale (the build
154
+ * before this one too, which deleted the parameter instead). */
155
+ export const RELEASED_LOCK = "0:released";
156
+
157
+ interface LockToken {
158
+ heldAt: number;
159
+ /** The SSM version this claimant had to create. Null for the previous
160
+ * build's `<time>:<nonce>` tokens and recovery fences, which stay
161
+ * conservatively held until released or stale. */
162
+ expectedVersion: number | null;
163
+ }
164
+
165
+ function lockToken(value: string | undefined): LockToken | null {
166
+ if (typeof value !== "string") return null;
167
+ const parts = value.split(":");
168
+ const [heldAtText, expectedText] = parts;
169
+ const heldAt = Number(heldAtText);
170
+ if (!Number.isSafeInteger(heldAt) || heldAt <= 0) return null;
171
+ const expectedVersion = Number(expectedText);
172
+ return {
173
+ heldAt,
174
+ expectedVersion:
175
+ parts.length === 3 && Number.isSafeInteger(expectedVersion) && expectedVersion > 0
176
+ ? expectedVersion
177
+ : null,
178
+ };
179
+ }
180
+
181
+ /** A contender can land after the elected holder has already released. Its
182
+ * token is not a lock (its returned SSM version missed the version it named),
183
+ * but it may also have overwritten a newer holder that is still working.
184
+ * Replay every intervening version: an elected token opens a hold, RELEASED
185
+ * closes it, and a losing token changes neither. Only a complete chain ending
186
+ * with no open hold proves that the current false token is free. */
187
+ async function holderBeforeFalseToken(
188
+ client: SSMClient,
189
+ name: string,
190
+ expectedVersion: number,
191
+ currentVersion: number,
192
+ ): Promise<LockToken | null | undefined> {
193
+ let nextToken: string | undefined;
194
+ const history = new Map<number, string>();
195
+ try {
196
+ do {
197
+ const page = await client.send(
198
+ new GetParameterHistoryCommand({
199
+ Name: name,
200
+ WithDecryption: false,
201
+ MaxResults: 50,
202
+ NextToken: nextToken,
203
+ }),
204
+ );
205
+ for (const item of page.Parameters ?? []) {
206
+ if (
207
+ item.Version !== undefined &&
208
+ item.Version >= expectedVersion &&
209
+ item.Version < currentVersion &&
210
+ item.Value !== undefined
211
+ ) {
212
+ history.set(item.Version, item.Value);
213
+ }
214
+ }
215
+ nextToken = page.NextToken;
216
+ } while (nextToken);
217
+ } catch {
218
+ // Missing IAM or a transient history read fails closed. The false token
219
+ // becomes stale normally; it must never be mistaken for a free live lock.
220
+ }
221
+ let holder: LockToken | null = null;
222
+ for (let version = expectedVersion; version < currentVersion; version += 1) {
223
+ const value = history.get(version);
224
+ // A missing/expired history entry cannot prove the holder is gone.
225
+ if (value === undefined) return undefined;
226
+ if (value === RELEASED_LOCK) {
227
+ holder = null;
228
+ continue;
229
+ }
230
+ const claim = lockToken(value);
231
+ // The previous build's two-field token won its own election; for a new
232
+ // token, only the exact SSM version it named did. Losing writes do not
233
+ // erase an already-open hold.
234
+ if (claim && (claim.expectedVersion === null || claim.expectedVersion === version)) {
235
+ holder = claim;
236
+ }
237
+ }
238
+ return holder;
239
+ }
240
+
241
+ function lockWriteConflict(error: unknown): boolean {
242
+ const name = (error as { name?: string }).name;
243
+ return name === "ParameterAlreadyExists" || name === "TooManyUpdates";
244
+ }
245
+
246
+ /** History can age out (SSM retains 100 versions) or be temporarily
247
+ * unavailable. Replace an unprovable false token with a fresh fence and admit
248
+ * nobody for one stale window. Any hidden holder predates this write and is
249
+ * therefore stale when the fence is; a normal holder release may free it
250
+ * sooner. This is deliberately not an elected holder and never returns a
251
+ * release callback. */
252
+ async function installRecoveryFence(
253
+ client: SSMClient,
254
+ name: string,
255
+ now: () => number,
256
+ ): Promise<void> {
257
+ const token = `${now()}:recovery:${Math.random().toString(36).slice(2)}`;
258
+ try {
259
+ await client.send(
260
+ new PutParameterCommand({ Name: name, Value: token, Type: "String", Overwrite: true }),
261
+ );
262
+ } catch (error) {
263
+ if (!lockWriteConflict(error)) throw error;
264
+ }
265
+ }
266
+
267
+ export function brokerLockName(prefix: string): string {
268
+ return `${prefix}/locks/token-broker`;
269
+ }
270
+
271
+ export interface BrokerLockOptions {
272
+ client: SSMClient;
273
+ prefix: string;
274
+ now?: () => number;
275
+ staleMs?: number;
276
+ }
277
+
278
+ /** Try to take the lock. Returns a release function, or null when another
279
+ * run holds a fresh one. */
280
+ export async function acquireBrokerLock(opts: BrokerLockOptions): Promise<(() => Promise<void>) | null> {
281
+ return acquireParameterLock({ ...opts, name: brokerLockName(opts.prefix) });
282
+ }
283
+
284
+ /** The same lock, at any parameter name. */
285
+ export async function acquireParameterLock(opts: {
286
+ client: SSMClient;
287
+ name: string;
288
+ now?: () => number;
289
+ staleMs?: number;
290
+ }): Promise<(() => Promise<void>) | null> {
291
+ const name = opts.name;
292
+ const now = opts.now ?? Date.now;
293
+ const staleMs = opts.staleMs ?? LOCK_STALE_MS;
294
+ let current: { Parameter?: { Value?: string; Version?: number } } | null;
295
+ try {
296
+ current = await opts.client.send(new GetParameterCommand({ Name: name }));
297
+ } catch (err) {
298
+ if ((err as { name?: string }).name !== "ParameterNotFound") throw err;
299
+ current = null;
300
+ }
301
+ if (current !== null) {
302
+ const claim = lockToken(current.Parameter?.Value);
303
+ const currentVersion = current.Parameter?.Version;
304
+ if (claim) {
305
+ const isElected =
306
+ claim.expectedVersion === null ||
307
+ currentVersion === undefined ||
308
+ claim.expectedVersion === currentVersion;
309
+ if (isElected) {
310
+ if (now() - claim.heldAt < staleMs) return null;
311
+ } else {
312
+ // Versions only rise. A token naming a future version is malformed,
313
+ // not evidence that a previous holder released. Fence recovery rather
314
+ // than leaving the malformed lineage wedged forever.
315
+ if (claim.expectedVersion! > currentVersion) {
316
+ await installRecoveryFence(opts.client, name, now);
317
+ return null;
318
+ }
319
+ // Replay a mismatched token even when that token's own timestamp is
320
+ // stale: it may have landed late over a much newer, live holder.
321
+ const holder = await holderBeforeFalseToken(
322
+ opts.client,
323
+ name,
324
+ claim.expectedVersion!,
325
+ currentVersion,
326
+ );
327
+ if (holder === undefined) {
328
+ await installRecoveryFence(opts.client, name, now);
329
+ return null;
330
+ }
331
+ if (holder !== null && now() - holder.heldAt < staleMs) {
332
+ return null;
333
+ }
334
+ }
335
+ }
336
+ }
337
+ // Timed at the write, so a run that lost the election below leaves a token
338
+ // no older than ours.
339
+ const takenAt = now();
340
+ const read = current?.Parameter?.Version;
341
+ const expectedVersion = current === null ? 1 : read === undefined ? null : read + 1;
342
+ if (expectedVersion === null) return null;
343
+ const token = `${takenAt}:${expectedVersion}:${Math.random().toString(36).slice(2)}`;
344
+ if (current === null) {
345
+ // Never taken (or deleted by the build before this one): created once.
346
+ try {
347
+ await opts.client.send(new PutParameterCommand({ Name: name, Value: token, Type: "String", Overwrite: false }));
348
+ } catch (err) {
349
+ if (lockWriteConflict(err)) return null;
350
+ throw err;
351
+ }
352
+ } else {
353
+ /**
354
+ * SSM has no conditional overwrite, but it numbers every write: ours is
355
+ * the lock only if it is the very next version after the one we read
356
+ * free or stale. Of runs that read the same version, exactly one gets
357
+ * the next; the others may leave their tokens in the value after it, but
358
+ * a token is only written over a lock read free or stale, so none of
359
+ * them holds it meanwhile.
360
+ */
361
+ let written: { Version?: number };
362
+ try {
363
+ written = await opts.client.send(
364
+ new PutParameterCommand({ Name: name, Value: token, Type: "String", Overwrite: true }),
365
+ );
366
+ } catch (error) {
367
+ if (lockWriteConflict(error)) return null;
368
+ throw error;
369
+ }
370
+ if (written.Version !== expectedVersion) return null;
371
+ }
372
+ return async () => {
373
+ // Inside the hold no other run can hold the lock, whatever token a run
374
+ // that lost the election left in the value: free it. Late in it, a run
375
+ // that found it stale may hold it already; leave it, stale anyway.
376
+ if (now() - takenAt >= Math.max(0, staleMs - LOCK_RELEASE_MARGIN_MS)) return;
377
+ try {
378
+ await opts.client.send(
379
+ new PutParameterCommand({ Name: name, Value: RELEASED_LOCK, Type: "String", Overwrite: true }),
380
+ );
381
+ } catch {
382
+ /* the stale window covers a failed release */
383
+ }
384
+ };
385
+ }
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // ADR-154 §7: one named revision, for the exact-name entry point
389
+ // ---------------------------------------------------------------------------
390
+
391
+ /** One credential's lock: one invoke at a time per credential, whichever
392
+ * revision it names, since an invoke the cloud gave up on may still run. */
393
+ export function revisionLockName(credentialId: string): string {
394
+ return `${BROKER_PARAMETER_ROOT}/locks/${idPathSegment(credentialId)}`;
395
+ }
396
+
397
+ export class SsmRevisionStore implements RevisionStore {
398
+ constructor(
399
+ private readonly opts: { client: SSMClient; kmsKeyId: string; now?: () => number },
400
+ ) {}
401
+
402
+ async get(name: string): Promise<StoredParameter | null> {
403
+ try {
404
+ const out = await this.opts.client.send(new GetParameterCommand({ Name: name, WithDecryption: true }));
405
+ const parameter = out.Parameter;
406
+ if (parameter?.Value === undefined || parameter.Version === undefined) return null;
407
+ return { value: parameter.Value, version: parameter.Version };
408
+ } catch (err) {
409
+ if ((err as { name?: string }).name === "ParameterNotFound") return null;
410
+ throw err;
411
+ }
412
+ }
413
+
414
+ async overwrite(name: string, value: string): Promise<number> {
415
+ const out = await this.opts.client.send(
416
+ new PutParameterCommand({
417
+ Name: name,
418
+ Value: value,
419
+ Type: "SecureString",
420
+ KeyId: this.opts.kmsKeyId,
421
+ Overwrite: true,
422
+ }),
423
+ );
424
+ if (out.Version === undefined) throw new Error("PutParameter returned no version");
425
+ return out.Version;
426
+ }
427
+
428
+ lock(credentialId: string): Promise<(() => Promise<void>) | null> {
429
+ return acquireParameterLock({
430
+ client: this.opts.client,
431
+ name: revisionLockName(credentialId),
432
+ now: this.opts.now,
433
+ });
434
+ }
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // ADR-154, owner answer 3: adoption of today's workspace AI accounts
439
+ // ---------------------------------------------------------------------------
440
+
441
+ async function parametersUnder(
442
+ client: SSMClient,
443
+ path: string,
444
+ decrypt: boolean,
445
+ ): Promise<Array<{ name: string; value: string }>> {
446
+ const out: Array<{ name: string; value: string }> = [];
447
+ let nextToken: string | undefined;
448
+ do {
449
+ const page = await client.send(
450
+ new GetParametersByPathCommand({ Path: path, Recursive: true, WithDecryption: decrypt, NextToken: nextToken }),
451
+ );
452
+ for (const p of page.Parameters ?? []) {
453
+ if (p.Name && typeof p.Value === "string") out.push({ name: p.Name, value: p.Value });
454
+ }
455
+ nextToken = page.NextToken;
456
+ } while (nextToken);
457
+ return out;
458
+ }
459
+
460
+ /** The legacy parameter names (under `prefix`) an adoption marker names. */
461
+ export async function adoptedLegacyNames(client: SSMClient, prefix: string): Promise<Set<string>> {
462
+ const names = new Set<string>();
463
+ const adoptedRoot = legacyAdoptedRoot(prefix);
464
+ for (const marker of await parametersUnder(client, `${adoptedRoot}/orgs`, false)) {
465
+ names.add(`${prefix}${marker.name.slice(adoptedRoot.length)}`);
466
+ }
467
+ return names;
468
+ }
469
+
470
+ export class SsmAdoptionStore extends SsmRevisionStore implements AdoptionStore {
471
+ constructor(
472
+ private readonly adoption: { client: SSMClient; kmsKeyId: string; prefix: string; now?: () => number },
473
+ ) {
474
+ super(adoption);
475
+ }
476
+
477
+ get legacyPrefix(): string {
478
+ return this.adoption.prefix;
479
+ }
480
+
481
+ async create(name: string, value: string, tags: Record<string, string>): Promise<"created" | "matched"> {
482
+ const entries = Object.entries(tags);
483
+ const marker = name.startsWith(`${legacyAdoptedRoot(this.adoption.prefix)}/`);
484
+ try {
485
+ await this.adoption.client.send(
486
+ new PutParameterCommand({
487
+ Name: name,
488
+ Value: value,
489
+ // A marker names ids only; every part is a secret.
490
+ Type: marker ? "String" : "SecureString",
491
+ ...(marker ? {} : { KeyId: this.adoption.kmsKeyId }),
492
+ Tier: marker ? "Standard" : "Advanced",
493
+ Overwrite: false,
494
+ Tags: entries.map(([Key, Value]) => ({ Key, Value })),
495
+ }),
496
+ );
497
+ return "created";
498
+ } catch (error) {
499
+ if ((error as { name?: string }).name !== "ParameterAlreadyExists") throw error;
500
+ }
501
+ // Exists: this create's retry only when its identity tags all match.
502
+ const listed = await this.adoption.client.send(
503
+ new ListTagsForResourceCommand({ ResourceType: "Parameter", ResourceId: name }),
504
+ );
505
+ const observed = new Map((listed.TagList ?? []).map((tag) => [tag.Key, tag.Value]));
506
+ for (const [key, expected] of entries) {
507
+ const actual = observed.get(key);
508
+ if (actual === undefined) throw new Error(`${name} exists and its tags are not visible yet`);
509
+ if (actual !== expected) throw new Error(`${name} exists as another credential's`);
510
+ }
511
+ return "matched";
512
+ }
513
+
514
+ listLegacy(): Promise<Array<{ name: string; value: string }>> {
515
+ return parametersUnder(this.adoption.client, legacyEngineParameterRoot(this.adoption.prefix), true);
516
+ }
517
+
518
+ listMarkers(): Promise<Array<{ name: string; value: string }>> {
519
+ return parametersUnder(this.adoption.client, `${legacyAdoptedRoot(this.adoption.prefix)}/orgs`, false);
520
+ }
521
+
522
+ scanLock(): Promise<(() => Promise<void>) | null> {
523
+ return acquireBrokerLock({ client: this.adoption.client, prefix: this.adoption.prefix, now: this.adoption.now });
524
+ }
525
+ }