@runuai/host 0.9.149 → 0.9.151

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  2. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  3. package/db/migrations/meta/_journal.json +14 -0
  4. package/db/schema.ts +188 -0
  5. package/lib/agent-cli.ts +11 -13
  6. package/lib/agent.ts +177 -7
  7. package/lib/agents/durable-proc.ts +56 -44
  8. package/lib/agents/registry.ts +19 -3
  9. package/lib/agents/session-state.ts +23 -10
  10. package/lib/agents/transport.ts +101 -69
  11. package/lib/apple-one-off.ts +277 -0
  12. package/lib/apple-runtime-recycle.ts +4 -3
  13. package/lib/attachments.ts +19 -17
  14. package/lib/capture-flow.ts +319 -0
  15. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  16. package/lib/cloud-credential-host.ts +512 -0
  17. package/lib/cloud-credential-leases.ts +2544 -0
  18. package/lib/cloud-credential-runtime.ts +1035 -0
  19. package/lib/codex-auth.ts +21 -1
  20. package/lib/container-runtime.ts +76 -0
  21. package/lib/credential-cleanup-inventory.ts +405 -0
  22. package/lib/credential-consumers/engine-anchor.ts +1876 -0
  23. package/lib/credential-consumers/engines.ts +773 -0
  24. package/lib/credential-consumers/github.ts +594 -0
  25. package/lib/credential-consumers/mcp.ts +384 -0
  26. package/lib/credential-consumers/task-files.ts +127 -0
  27. package/lib/credential-store-pickup.ts +15 -2
  28. package/lib/credential-store.ts +361 -33
  29. package/lib/credentials-activation-authority.ts +546 -0
  30. package/lib/credentials-purge.ts +1006 -0
  31. package/lib/db.ts +11 -0
  32. package/lib/docker-exec.ts +3 -1
  33. package/lib/engine-accounts.ts +42 -32
  34. package/lib/engine-connect.ts +47 -2
  35. package/lib/engine-credential-env.ts +50 -0
  36. package/lib/engine-login.ts +83 -1
  37. package/lib/env-file.ts +62 -8
  38. package/lib/env.ts +5 -0
  39. package/lib/fs-aliases.ts +49 -0
  40. package/lib/git-clone-credentials.ts +334 -0
  41. package/lib/git-diff.ts +219 -177
  42. package/lib/git-identity.ts +59 -14
  43. package/lib/github-origin.ts +28 -0
  44. package/lib/github-tokens.ts +56 -21
  45. package/lib/host-env.ts +17 -4
  46. package/lib/launch-mode.ts +25 -0
  47. package/lib/legacy-runtime-retirement.ts +388 -0
  48. package/lib/machine-exec.ts +14 -3
  49. package/lib/machine-provider-aws.ts +50 -8
  50. package/lib/machine-provider-local.ts +4 -0
  51. package/lib/machine-provider.ts +4 -0
  52. package/lib/managed-operation-drain.ts +8 -0
  53. package/lib/managed-runtime.ts +371 -7
  54. package/lib/mcp-capture.ts +860 -0
  55. package/lib/mcp-connections.ts +8 -1
  56. package/lib/mcp-gateway.ts +168 -10
  57. package/lib/obs.ts +2 -6
  58. package/lib/orchestrator.ts +1847 -236
  59. package/lib/person-capture.ts +327 -0
  60. package/lib/project-env-legacy-tags.ts +130 -0
  61. package/lib/project-env-task-replica.ts +1752 -0
  62. package/lib/runner-boot.ts +10 -0
  63. package/lib/runtime-activation-tail.ts +50 -3
  64. package/lib/runtime-archive.ts +68 -1
  65. package/lib/runtime-reconnect.ts +86 -0
  66. package/lib/runtime-state.ts +429 -13
  67. package/lib/safe-http.ts +364 -0
  68. package/lib/secrets-proxy/task-trust.ts +34 -3
  69. package/lib/sidecar-resume.ts +18 -5
  70. package/lib/skills.ts +47 -8
  71. package/lib/standard-image.ts +157 -103
  72. package/lib/stopped-task-status-queue.ts +68 -10
  73. package/lib/task-credential-cleanup.ts +525 -0
  74. package/lib/task-diff-sandbox.ts +464 -0
  75. package/lib/task-diff.ts +66 -163
  76. package/lib/task-env-source.ts +68 -14
  77. package/lib/task-environment/anchor.ts +31 -0
  78. package/lib/task-environment/apple-container.ts +69 -28
  79. package/lib/task-environment/docker.ts +101 -20
  80. package/lib/task-environment/machine-environment.ts +61 -0
  81. package/lib/task-environment/machine-task-up.ts +459 -96
  82. package/lib/task-environment/machine.ts +101 -11
  83. package/lib/task-environment/self.ts +108 -50
  84. package/lib/task-environment/types.ts +89 -5
  85. package/lib/task-environment/workspace-files.ts +623 -25
  86. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  87. package/lib/task-gateway-recovery.ts +4 -1
  88. package/lib/task-gateway-state.ts +15 -0
  89. package/lib/task-machine-swap-safety.ts +729 -0
  90. package/lib/token-broker-adopt.ts +344 -0
  91. package/lib/token-broker-lambda.ts +56 -2
  92. package/lib/token-broker-local.ts +28 -4
  93. package/lib/token-broker-revision.ts +310 -0
  94. package/lib/token-broker-ssm.ts +368 -28
  95. package/lib/token-lanes/openai-oauth.ts +7 -3
  96. package/lib/token-lanes/xai-oauth.ts +7 -3
  97. package/lib/transcript.ts +9 -5
  98. package/package.json +6 -2
  99. package/scripts/agent/task-up.sh +242 -100
  100. package/src/cloud-credentials.ts +2399 -0
  101. package/src/event-outbox.ts +8 -6
  102. package/src/index.ts +1386 -875
  103. package/src/main.ts +1062 -140
  104. package/src/outbound-frame.ts +31 -0
  105. package/src/project-env-policy.ts +125 -10
  106. package/src/protocol.ts +699 -23
  107. package/src/ui/server.ts +48 -10
@@ -0,0 +1,344 @@
1
+ /**
2
+ * ADR-154, owner answer 3: what Uai Cloud already keeps in Parameter Store
3
+ * under today's layout (ADR-134), its workspaces' AI accounts
4
+ * (`<prefix>/orgs/<org>/engines/<kind>/<id>`), is adopted in place. The cloud
5
+ * lists the accounts through the broker (metadata only: the broker is the
6
+ * one identity that reads those values), opens a revision for each, and
7
+ * names it here. People's MCP connections follow separately, once a fence
8
+ * keeps the v0 task machines that refresh them themselves from doing so.
9
+ *
10
+ * One refresher per grant, always (§7):
11
+ * - before anything is copied, the adoption marker (`<prefix>/adopted/...`) is
12
+ * created under the scan's own lock, so no scan run is refreshing the
13
+ * account meanwhile and none refreshes it again;
14
+ * - the full material becomes the revision's broker-only part and what a
15
+ * task may hold its lease-readable part, both created (never overwritten)
16
+ * with the tags the role policy checks, as a capture's are;
17
+ * - today's parameter then keeps what a task may hold only (the brokered
18
+ * copy): the one refresh token
19
+ * left is the revision's, which only the cloud's scheduler names. Until
20
+ * the fleet audit each refresh of that revision writes the same there,
21
+ * for the runners still reading it (`syncLegacyCopy`).
22
+ *
23
+ * A value that can never be adopted as it is answers `unsupported`, before
24
+ * any marker: it keeps its refresher. A retry after a lost answer finds its
25
+ * own marker and creates, and carries on where it stopped. Nothing here
26
+ * returns or logs material, nor an error's message (only its kind).
27
+ */
28
+
29
+ import {
30
+ brokerErrorKind,
31
+ brokerParameterName,
32
+ DEFAULT_CREDENTIAL_STORE_PREFIX,
33
+ leaseParameterName,
34
+ legacyAdoptedRoot,
35
+ legacyAdoptionMarkerName,
36
+ MAX_LEGACY_ACCOUNTS,
37
+ parseLegacyEngineParameterName,
38
+ parseLegacyParameterName,
39
+ type BrokerAdoptAnswer,
40
+ type BrokerAdoptRequest,
41
+ type BrokerLegacyAccount,
42
+ type BrokerLegacyListAnswer,
43
+ type CredentialLocation,
44
+ type LegacyEngineParameter,
45
+ } from "../src/cloud-credentials";
46
+ import { buildEngineCapture, captureTags, parseCaptureFlow, type EngineCaptureMaterial } from "./capture-flow";
47
+ import type { RevisionStore, StoredParameter } from "./token-broker-revision";
48
+ import { laneFor } from "./token-lanes/registry";
49
+ import type { CredentialFiles, TokenLane } from "./token-lanes/types";
50
+
51
+ /** What adoption needs beyond a refresh: create-only writes, today's
52
+ * accounts and markers, and the scan's lock. */
53
+ export interface AdoptionStore extends RevisionStore {
54
+ /** The exact ADR-134 root this store reads and writes. */
55
+ readonly legacyPrefix: string;
56
+ /**
57
+ * Create a parameter with its identity tags, never overwriting:
58
+ * `matched` when it exists already with exactly these tags (this create,
59
+ * retried). Throws when it exists as anything else.
60
+ */
61
+ create(name: string, value: string, tags: Record<string, string>): Promise<"created" | "matched">;
62
+ /** Every parameter under today's workspace accounts root, decrypted. */
63
+ listLegacy(): Promise<Array<{ name: string; value: string }>>;
64
+ /** Every adoption marker of a workspace account, by the marker's name. */
65
+ listMarkers(): Promise<Array<{ name: string; value: string }>>;
66
+ /** The scan's lock (one run at a time), or null while a run holds it. */
67
+ scanLock(): Promise<(() => Promise<void>) | null>;
68
+ }
69
+
70
+ /** An AI account's value under today's layout, as the shared host wrote it. */
71
+ export interface LegacyEngineValue {
72
+ /** The whole envelope: rewritten with only its files changed. */
73
+ raw: Record<string, unknown>;
74
+ label: string;
75
+ authKind: "env" | "config-dir";
76
+ position: number;
77
+ env: Record<string, string> | null;
78
+ files: CredentialFiles | null;
79
+ }
80
+
81
+ function stringMap(value: unknown): Record<string, string> | null {
82
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
83
+ const out: Record<string, string> = {};
84
+ for (const [key, text] of Object.entries(value as Record<string, unknown>)) {
85
+ if (typeof text !== "string") return null;
86
+ out[key] = text;
87
+ }
88
+ return Object.keys(out).length > 0 ? out : null;
89
+ }
90
+
91
+ function jsonObject(value: string): Record<string, unknown> | null {
92
+ try {
93
+ const parsed: unknown = JSON.parse(value);
94
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /** Today's value for exactly this account, or null. */
101
+ export function parseLegacyEngineValue(value: string, legacy: LegacyEngineParameter): LegacyEngineValue | null {
102
+ const o = jsonObject(value);
103
+ if (
104
+ !o ||
105
+ o.v !== 1 ||
106
+ o.id !== legacy.accountId ||
107
+ o.kind !== legacy.engineKind ||
108
+ o.scope !== `org:${legacy.orgId}` ||
109
+ typeof o.label !== "string" ||
110
+ o.label.trim().length === 0 ||
111
+ (o.authKind !== "env" && o.authKind !== "config-dir")
112
+ ) {
113
+ return null;
114
+ }
115
+ const env = o.authKind === "env" ? stringMap(o.env) : null;
116
+ const files = o.authKind === "config-dir" ? stringMap(o.files) : null;
117
+ if (o.authKind === "env" ? env === null : files === null) return null;
118
+ return {
119
+ raw: o,
120
+ label: o.label.trim(),
121
+ authKind: o.authKind,
122
+ position: typeof o.position === "number" && Number.isSafeInteger(o.position) ? o.position : 0,
123
+ env,
124
+ files,
125
+ };
126
+ }
127
+
128
+ function laneOf(legacy: LegacyEngineParameter, value: LegacyEngineValue): TokenLane | null {
129
+ if (value.authKind !== "config-dir" || !value.files) return null;
130
+ return laneFor(
131
+ { kind: legacy.engineKind, authKind: "config-dir", id: legacy.accountId, scope: "org" },
132
+ value.files,
133
+ );
134
+ }
135
+
136
+ /** Whether an account's material is one a revision can hold, without a
137
+ * revision: the capture's own checks, on a stand-in destination. */
138
+ function engineUnsupported(legacy: LegacyEngineParameter, value: LegacyEngineValue): string | null {
139
+ const lane = laneOf(legacy, value);
140
+ const location: CredentialLocation = {
141
+ class: "engine",
142
+ scope: "org",
143
+ ownerId: legacy.orgId,
144
+ engineKind: legacy.engineKind,
145
+ credentialId: "adoption-check",
146
+ revision: "adoption-check",
147
+ };
148
+ const flow = parseCaptureFlow({
149
+ credentialId: "adoption-check",
150
+ revision: "adoption-check",
151
+ leaseParameter: leaseParameterName(location),
152
+ brokerParameter: lane ? brokerParameterName(location) : null,
153
+ orgId: legacy.orgId,
154
+ userId: null,
155
+ });
156
+ if (!flow) return "destination";
157
+ const built = buildEngineCapture(flow, material(value));
158
+ return "error" in built ? (value.authKind === "env" ? "env_keys" : "files") : null;
159
+ }
160
+
161
+ function material(value: LegacyEngineValue): EngineCaptureMaterial {
162
+ return value.authKind === "env" ? { authKind: "env", env: value.env! } : { authKind: "config-dir", files: value.files! };
163
+ }
164
+
165
+ function legacyNameOfMarker(markerName: string, prefix: string): string | null {
166
+ const adoptedRoot = legacyAdoptedRoot(prefix);
167
+ if (!markerName.startsWith(`${adoptedRoot}/`)) return null;
168
+ const name = `${prefix}${markerName.slice(adoptedRoot.length)}`;
169
+ return parseLegacyParameterName(name, prefix) ? name : null;
170
+ }
171
+
172
+ function parseMarker(value: string): { credentialId: string; revision: string } | null {
173
+ const o = jsonObject(value);
174
+ return typeof o?.credentialId === "string" && typeof o.revision === "string"
175
+ ? { credentialId: o.credentialId, revision: o.revision }
176
+ : null;
177
+ }
178
+
179
+ function sameFiles(a: CredentialFiles, b: CredentialFiles): boolean {
180
+ const keys = Object.keys(a).sort();
181
+ const other = Object.keys(b).sort();
182
+ return keys.length === other.length && keys.every((k, i) => k === other[i] && a[k] === b[k]);
183
+ }
184
+
185
+ const unavailable = (error: unknown): { outcome: "unavailable"; error: string } => ({
186
+ outcome: "unavailable",
187
+ error: typeof error === "string" ? error : brokerErrorKind(error),
188
+ });
189
+
190
+ /**
191
+ * One page of today's workspace AI accounts, metadata only (never a value),
192
+ * in parameter-name order after `after`: each with its lane, the revision it
193
+ * was adopted into once it was, and why it cannot be adopted as it is.
194
+ */
195
+ export async function listLegacyAccounts(store: AdoptionStore, after: string | null = null): Promise<BrokerLegacyListAnswer> {
196
+ try {
197
+ const [values, markers] = await Promise.all([store.listLegacy(), store.listMarkers()]);
198
+ const adopted = new Map<string, { credentialId: string; revision: string }>();
199
+ for (const marker of markers) {
200
+ const name = legacyNameOfMarker(marker.name, store.legacyPrefix);
201
+ const named = parseMarker(marker.value);
202
+ if (name && named) adopted.set(name, named);
203
+ }
204
+ const ordered = values
205
+ .filter(
206
+ ({ name }) =>
207
+ parseLegacyEngineParameterName(name, store.legacyPrefix) !== null && (after === null || name > after),
208
+ )
209
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
210
+ const accounts: BrokerLegacyAccount[] = [];
211
+ let next: string | null = null;
212
+ for (const { name, value } of ordered) {
213
+ if (accounts.length >= MAX_LEGACY_ACCOUNTS) {
214
+ next = accounts.at(-1)!.parameter;
215
+ break;
216
+ }
217
+ const legacy = parseLegacyEngineParameterName(name, store.legacyPrefix)!;
218
+ const parsed = parseLegacyEngineValue(value, legacy);
219
+ accounts.push({
220
+ parameter: name,
221
+ orgId: legacy.orgId,
222
+ engineKind: legacy.engineKind,
223
+ accountId: legacy.accountId,
224
+ label: parsed ? parsed.label.slice(0, 256) : legacy.accountId,
225
+ authKind: parsed?.authKind ?? "config-dir",
226
+ position: parsed?.position ?? 0,
227
+ lane: parsed ? (laneOf(legacy, parsed)?.id ?? null) : null,
228
+ adopted: adopted.get(name) ?? null,
229
+ unsupported: parsed ? engineUnsupported(legacy, parsed) : "value",
230
+ });
231
+ }
232
+ return { outcome: "listed", accounts, next };
233
+ } catch (error) {
234
+ return unavailable(error);
235
+ }
236
+ }
237
+
238
+ /** Adopt one parameter as the revision the cloud named. */
239
+ export async function adoptLegacyAccount(request: BrokerAdoptRequest, store: AdoptionStore): Promise<BrokerAdoptAnswer> {
240
+ const legacy = parseLegacyParameterName(request.legacyParameter, store.legacyPrefix)!;
241
+ let releaseCredential: (() => Promise<void>) | null = null;
242
+ let releaseScan: (() => Promise<void>) | null = null;
243
+ try {
244
+ releaseCredential = await store.lock(request.credentialId);
245
+ if (!releaseCredential) return { outcome: "busy" };
246
+ // No scan run is refreshing today's parameter while it is copied.
247
+ releaseScan = await store.scanLock();
248
+ if (!releaseScan) return { outcome: "busy" };
249
+
250
+ const current = await store.get(request.legacyParameter);
251
+ if (!current) return { outcome: "absent" };
252
+ const flow = parseCaptureFlow({
253
+ credentialId: request.credentialId,
254
+ revision: request.revision,
255
+ leaseParameter: request.leaseParameter,
256
+ brokerParameter: request.brokerParameter,
257
+ orgId: request.orgId,
258
+ userId: request.userId,
259
+ });
260
+ if (!flow) return { outcome: "unsupported", reason: "destination" };
261
+
262
+ // Built and checked before the marker: what cannot be adopted keeps its
263
+ // refresher.
264
+ const value = parseLegacyEngineValue(current.value, legacy);
265
+ if (!value) return { outcome: "unsupported", reason: "value" };
266
+ const parts = buildEngineCapture(flow, material(value));
267
+ if ("error" in parts) return { outcome: "unsupported", reason: value.authKind === "env" ? "env_keys" : "files" };
268
+ // Today's parameter keeps the brokered copy only.
269
+ let rewrite: string | null = null;
270
+ const lane = laneOf(legacy, value);
271
+ if (lane && parts.broker !== null && value.files) {
272
+ const brokered = lane.brokeredCopy(value.files);
273
+ if (!sameFiles(brokered, value.files)) rewrite = JSON.stringify({ ...value.raw, files: brokered });
274
+ }
275
+
276
+ // 1. The marker: from here nothing refreshes today's parameter.
277
+ const markerName = legacyAdoptionMarkerName(request.legacyParameter, store.legacyPrefix);
278
+ const marker = await store.get(markerName);
279
+ if (marker) {
280
+ const named = parseMarker(marker.value);
281
+ if (!named || named.credentialId !== request.credentialId || named.revision !== request.revision) {
282
+ return unavailable("adopted into another revision");
283
+ }
284
+ } else {
285
+ await store.create(
286
+ markerName,
287
+ JSON.stringify({ credentialId: request.credentialId, revision: request.revision }),
288
+ { uai_credential: request.credentialId, uai_revision: request.revision, uai_part: "adopted" },
289
+ );
290
+ }
291
+
292
+ // 2. Both parts, broker-only first, as a capture writes them. A retry
293
+ // matches its own creates and never rebuilds the broker part from what
294
+ // step 3 left in today's parameter.
295
+ if (parts.broker !== null) await store.create(request.brokerParameter!, parts.broker, captureTags(flow, "broker"));
296
+ await store.create(request.leaseParameter, parts.lease, captureTags(flow, "lease"));
297
+
298
+ // 3. Today's parameter keeps what a task may hold only.
299
+ if (rewrite !== null) await store.overwrite(request.legacyParameter, rewrite);
300
+
301
+ const lease = await store.get(request.leaseParameter);
302
+ if (!lease) return unavailable("the lease-readable part is missing");
303
+ return { outcome: "adopted", version: lease.version, expiresAt: parts.expiresAt };
304
+ } catch (error) {
305
+ return unavailable(error);
306
+ } finally {
307
+ if (releaseScan) await releaseScan();
308
+ if (releaseCredential) await releaseCredential();
309
+ }
310
+ }
311
+
312
+ /** Whether today's parameter of an adopted revision now agrees with it. */
313
+ export type LegacySync = "synced" | "behind";
314
+
315
+ /**
316
+ * Until the fleet audit: keep today's parameter of an adopted AI account
317
+ * agreeing with its revision, the brokered copy only, for the runners still
318
+ * reading it. Never creates it, and never fails the refresh it rides on: it
319
+ * says whether the write landed, and the cloud asks again when it did not.
320
+ */
321
+ export async function syncLegacyCopy(
322
+ store: Pick<RevisionStore, "get" | "overwrite">,
323
+ legacyParameter: string,
324
+ lane: TokenLane,
325
+ files: CredentialFiles,
326
+ legacyPrefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
327
+ ): Promise<LegacySync> {
328
+ const legacy = parseLegacyEngineParameterName(legacyParameter, legacyPrefix);
329
+ if (!legacy) return "synced";
330
+ try {
331
+ const current: StoredParameter | null = await store.get(legacyParameter);
332
+ if (!current) return "synced";
333
+ const value = parseLegacyEngineValue(current.value, legacy);
334
+ if (!value || value.authKind !== "config-dir" || !value.files) return "synced";
335
+ const brokered = lane.brokeredCopy(files);
336
+ if (sameFiles(brokered, value.files)) return "synced";
337
+ await store.overwrite(legacyParameter, JSON.stringify({ ...value.raw, files: brokered }));
338
+ return "synced";
339
+ } catch (error) {
340
+ console.warn(`[token-broker] today's copy of ${legacyParameter} was not updated (${brokerErrorKind(error)})`);
341
+ return "behind";
342
+ }
343
+ }
344
+
@@ -5,6 +5,14 @@
5
5
  * (parameter names to refresh now). Returns the cycle summary; logs one
6
6
  * audit line per decision and never a value.
7
7
  *
8
+ * ADR-154 §7: the cloud also invokes it with one revision to refresh
9
+ * (`{ v: 1, credentialId, revision, brokerParameter, leaseParameter, force }`),
10
+ * which never scans and never takes the cycle's lock: one lock per credential.
11
+ * Owner answer 3: `{ v: 1, op: "legacy.list", after }` lists a page of
12
+ * today's workspace accounts (metadata only) and `{ v: 1, op:
13
+ * "legacy.adopt", … }` adopts one account into a revision;
14
+ * an adoption takes its credential's lock and the cycle's.
15
+ *
8
16
  * Lives in lib/ (the package import boundary) and is bundled by
9
17
  * host-agent/scripts/token-broker/build.sh (esbuild, AWS SDK left external —
10
18
  * the Node runtime provides it). Nothing in the host imports it.
@@ -13,7 +21,16 @@
13
21
  import { SSMClient } from "@aws-sdk/client-ssm";
14
22
 
15
23
  import { runBrokerCycle } from "./token-broker";
16
- import { acquireBrokerLock, SsmBrokerStorage } from "./token-broker-ssm";
24
+ import {
25
+ DEFAULT_CREDENTIAL_STORE_PREFIX,
26
+ parseBrokerAdoptRequest,
27
+ parseBrokerLegacyListRequest,
28
+ parseBrokerRevisionRequest,
29
+ parseCredentialStorePrefix,
30
+ } from "../src/cloud-credentials";
31
+ import { adoptLegacyAccount, listLegacyAccounts } from "./token-broker-adopt";
32
+ import { refreshRevision } from "./token-broker-revision";
33
+ import { acquireBrokerLock, SsmAdoptionStore, SsmBrokerStorage, SsmRevisionStore } from "./token-broker-ssm";
17
34
 
18
35
  interface BrokerEvent {
19
36
  names?: unknown;
@@ -22,8 +39,45 @@ interface BrokerEvent {
22
39
  const MAX_FORCED_NAMES = 50;
23
40
 
24
41
  export async function handler(event: BrokerEvent = {}): Promise<Record<string, unknown>> {
25
- const prefix = process.env.UAI_CREDENTIAL_STORE_PREFIX || "/uai";
42
+ const configuredPrefix = process.env.UAI_CREDENTIAL_STORE_PREFIX || DEFAULT_CREDENTIAL_STORE_PREFIX;
43
+ const prefix = parseCredentialStorePrefix(configuredPrefix);
44
+ if (!prefix) throw new Error("invalid UAI_CREDENTIAL_STORE_PREFIX");
26
45
  const kmsKeyId = process.env.UAI_CREDENTIAL_STORE_KMS_KEY || "alias/uai-credentials";
46
+ // ADR-154 §7: the cloud names one revision; the broker refreshes exactly
47
+ // that one and answers for it. Anything shaped like a request but not one
48
+ // is refused, never read as a scan.
49
+ if ((event as { v?: unknown }).v !== undefined) {
50
+ // Owner answer 3: today's workspace accounts, listed (metadata only)
51
+ // and adopted one at a time into the revision the cloud names.
52
+ const op = (event as { op?: unknown }).op;
53
+ if (op === "legacy.list") {
54
+ const list = parseBrokerLegacyListRequest(event, prefix);
55
+ if (!list) return { outcome: "unavailable", error: "malformed request" };
56
+ const listed = await listLegacyAccounts(new SsmAdoptionStore({ client: new SSMClient({}), kmsKeyId, prefix }), list.after);
57
+ console.log(
58
+ `[token-broker] legacy.list ${JSON.stringify({ outcome: listed.outcome, accounts: listed.outcome === "listed" ? listed.accounts.length : 0 })}`,
59
+ );
60
+ return listed;
61
+ }
62
+ if (op === "legacy.adopt") {
63
+ const adopt = parseBrokerAdoptRequest(event, prefix);
64
+ if (!adopt) return { outcome: "unavailable", error: "malformed request" };
65
+ const adopted = await adoptLegacyAccount(adopt, new SsmAdoptionStore({ client: new SSMClient({}), kmsKeyId, prefix }));
66
+ console.log(
67
+ `[token-broker] legacy.adopt ${JSON.stringify({ credentialId: adopt.credentialId, revision: adopt.revision, outcome: adopted.outcome })}`,
68
+ );
69
+ return adopted;
70
+ }
71
+ const request = parseBrokerRevisionRequest(event, prefix);
72
+ if (!request) return { outcome: "unavailable", error: "malformed request" };
73
+ const answer = await refreshRevision(request, new SsmRevisionStore({ client: new SSMClient({}), kmsKeyId }), {
74
+ legacyPrefix: prefix,
75
+ });
76
+ console.log(
77
+ `[token-broker] revision ${JSON.stringify({ credentialId: request.credentialId, revision: request.revision, force: request.force, outcome: answer.outcome })}`,
78
+ );
79
+ return answer;
80
+ }
27
81
  const names = Array.isArray(event.names)
28
82
  ? event.names.filter((n): n is string => typeof n === "string" && n.startsWith(`${prefix}/`)).slice(0, MAX_FORCED_NAMES)
29
83
  : [];
@@ -11,6 +11,11 @@
11
11
  * The loop runs ONLY on owned hosts (`configuredCredentialStore() === null`).
12
12
  * A Uai-operated host must not refresh — the broker Lambda does — it picks
13
13
  * up refreshed parameters instead (credential-store-pickup.ts).
14
+ *
15
+ * ADR-154 §7: and only while the runner is on its own credentials. No runner
16
+ * refreshes once it leaves them: the loop stops, a cycle under way ends, and
17
+ * no reactive request refreshes or invokes the broker Lambda (a leased
18
+ * account's refresh goes through the cloud).
14
19
  */
15
20
 
16
21
  import {
@@ -27,6 +32,7 @@ import { homedir, tmpdir } from "node:os";
27
32
  import { join } from "node:path";
28
33
  import { spawn } from "node:child_process";
29
34
 
35
+ import { onOwnCredentials, whileOnOwnCredentials } from "./cloud-credential-host";
30
36
  import { configuredCredentialStore, credentialStorePrefix } from "./credential-store";
31
37
  import { getDb, schema } from "./db";
32
38
  import {
@@ -212,6 +218,8 @@ function log(line: string): void {
212
218
  */
213
219
  export async function runLocalBrokerCycle(opts: { force?: ReadonlySet<string>; seams?: LocalBrokerSeams } = {}): Promise<void> {
214
220
  const work = async (): Promise<void> => {
221
+ // A cycle queued behind the one leaving waits for refreshes nothing.
222
+ if (!onOwnCredentials()) return;
215
223
  const storage = new LocalBrokerStorage(opts.seams);
216
224
  const refreshedNames = new Set<string>();
217
225
  const summary = await runBrokerCycle({
@@ -244,9 +252,10 @@ export async function runLocalBrokerCycle(opts: { force?: ReadonlySet<string>; s
244
252
  }
245
253
  }
246
254
 
247
- /** Owned hosts only: refresh now, then hourly. Idempotent. */
255
+ /** Owned hosts on their own credentials only: refresh now, then hourly.
256
+ * Idempotent. */
248
257
  export function startTokenBrokerLoop(): void {
249
- if (timer || !ownedHostRefreshes()) return;
258
+ if (timer || !ownedHostRefreshes() || !onOwnCredentials()) return;
250
259
  void runLocalBrokerCycle().catch((err) => log(`cycle failed: ${err instanceof Error ? err.message : String(err)}`));
251
260
  timer = setInterval(() => {
252
261
  void runLocalBrokerCycle().catch((err) => log(`cycle failed: ${err instanceof Error ? err.message : String(err)}`));
@@ -254,9 +263,12 @@ export function startTokenBrokerLoop(): void {
254
263
  timer.unref?.();
255
264
  }
256
265
 
257
- export function stopTokenBrokerLoop(): void {
266
+ /** Stop the loop at once (ADR-154 §7: the runner leaves its own
267
+ * credentials); resolves once a cycle under way has ended. */
268
+ export async function stopTokenBrokerLoop(): Promise<void> {
258
269
  if (timer) clearInterval(timer);
259
270
  timer = null;
271
+ while (running !== null) await running.catch(() => undefined);
260
272
  }
261
273
 
262
274
  /** Whether an engine's error output means its access token is dead now. */
@@ -275,6 +287,18 @@ export async function requestCredentialRefresh(input: {
275
287
  kind: string;
276
288
  accountId: string | null;
277
289
  scope: string;
290
+ }): Promise<boolean> {
291
+ // ADR-154 §7: off its own credentials the runner refreshes nothing and
292
+ // invokes no broker; the cloud refreshes, through the task's lease. A
293
+ // request on them is counted whole (its cycle or its Lambda invocation,
294
+ // and the pickup after): leaving them waits for it.
295
+ return (await whileOnOwnCredentials(() => refreshOnOwnCredentials(input))) ?? false;
296
+ }
297
+
298
+ async function refreshOnOwnCredentials(input: {
299
+ kind: string;
300
+ accountId: string | null;
301
+ scope: string;
278
302
  }): Promise<boolean> {
279
303
  const key = `${input.kind}:${input.accountId ?? "default"}`;
280
304
  const now = Date.now();
@@ -306,7 +330,7 @@ export function setPickupNow(fn: (() => Promise<void>) | null): void {
306
330
  }
307
331
 
308
332
  /** `aws lambda invoke` on the broker function named by UAI_TOKEN_BROKER_FUNCTION. */
309
- export async function invokeBrokerLambda(names: string[]): Promise<boolean> {
333
+ async function invokeBrokerLambda(names: string[]): Promise<boolean> {
310
334
  const fn = env.UAI_TOKEN_BROKER_FUNCTION;
311
335
  const region = env.UAI_AWS_REGION;
312
336
  if (!fn || !region) {