@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,2399 @@
1
+ /**
2
+ * ADR-154 — the shared contract for cloud-only credentials.
3
+ *
4
+ * Every repository env value, AI account, MCP connection, GitHub sign-in and
5
+ * SSH key has one copy, in Parameter Store. A runner (owned or task machine)
6
+ * reads what one live task needs under that task's lease and keeps it in
7
+ * memory only. This module is what the cloud and the runner agree on:
8
+ *
9
+ * - the parameter layout: lease-readable copies under `/uai/lease`, the
10
+ * broker-only OAuth material under the disjoint root `/uai-broker`, each
11
+ * credential's material in immutable revisions;
12
+ * - the v1 grant: the task's credential manifest (names and versions, never
13
+ * values) and the STS sessions that read it;
14
+ * - the frames that renew, narrow, refresh and activate it.
15
+ *
16
+ * It holds no I/O and imports nothing, so the cloud, the runner and the
17
+ * sign-in box can all load it. Every parser is exact: unknown keys, wrong
18
+ * types and out-of-bound sizes are refused, never dropped or repaired.
19
+ */
20
+
21
+ /** Advertised by a runner that reads credentials only through v1 leases. */
22
+ export const HOST_CLOUD_CREDENTIALS_FEATURE = "host-cloud-credentials-v1";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Parameter layout
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** What a task may receive: a static key, or an OAuth login's brokered copy. */
29
+ export const LEASE_PARAMETER_ROOT = "/uai/lease";
30
+ /**
31
+ * The full OAuth material only the broker reads. Outside `/uai` altogether:
32
+ * `GetParametersByPath` returns every descendant of a path its caller may
33
+ * read, even one a Deny names, so containment comes from the root alone.
34
+ */
35
+ export const BROKER_PARAMETER_ROOT = "/uai-broker";
36
+
37
+ export type CredentialClass = "engine" | "mcp" | "github" | "ssh";
38
+ export type CredentialScope = "org" | "user";
39
+
40
+ /** Where one revision of one credential lives. */
41
+ export interface CredentialLocation {
42
+ class: CredentialClass;
43
+ /** `github` and `ssh` are always `user`: one per person. */
44
+ scope: CredentialScope;
45
+ /** The org id for `org` scope, the user id for `user` scope. */
46
+ ownerId: string;
47
+ /** `engine` only: the engine kind (`codex`, `claude`, …). */
48
+ engineKind?: string;
49
+ /** `engine` and `mcp` only: the credential's stable id. */
50
+ credentialId?: string;
51
+ /** One immutable revision of the material. */
52
+ revision: string;
53
+ }
54
+
55
+ /** SSM allows `[A-Za-z0-9_.-]` in a path segment. An id that already fits,
56
+ * starts with a letter or digit and has no dot is used as it is; any other
57
+ * id is `-` followed by the hex of its UTF-8 bytes. No plain id starts with
58
+ * `-`, so the mapping is injective. Legacy workspace ids
59
+ * (`personal:<user>`) take the second form. */
60
+ const PLAIN_ID_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
61
+ const ENCODED_ID_SEGMENT = /^-(?:[0-9a-f]{2})+$/;
62
+ const ENGINE_KIND = /^[a-z][a-z0-9-]{0,31}$/;
63
+ export const MAX_CREDENTIAL_ID_CHARS = 128;
64
+ /** SSM's limit for a parameter name, its ARN included. */
65
+ export const MAX_PARAMETER_NAME_CHARS = 1011;
66
+
67
+ function isIdValue(value: unknown): value is string {
68
+ return (
69
+ typeof value === "string" &&
70
+ value.length > 0 &&
71
+ value.length <= MAX_CREDENTIAL_ID_CHARS &&
72
+ !/[\u0000-\u001f\u007f]/.test(value)
73
+ );
74
+ }
75
+
76
+ function utf8Hex(value: string): string {
77
+ let out = "";
78
+ for (const byte of new TextEncoder().encode(value)) {
79
+ out += byte.toString(16).padStart(2, "0");
80
+ }
81
+ return out;
82
+ }
83
+
84
+ function fromUtf8Hex(hex: string): string | null {
85
+ const bytes = new Uint8Array(hex.length / 2);
86
+ for (let i = 0; i < bytes.length; i++) {
87
+ bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
88
+ }
89
+ try {
90
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /** The path segment for an id (see `PLAIN_ID_SEGMENT`). Throws on an id no
97
+ * segment may carry: empty, too long, or containing control characters. */
98
+ export function idPathSegment(id: string): string {
99
+ if (!isIdValue(id)) throw new Error("invalid credential path id");
100
+ return PLAIN_ID_SEGMENT.test(id) ? id : `-${utf8Hex(id)}`;
101
+ }
102
+
103
+ /** Inverse of `idPathSegment`; null for a segment it never produces. */
104
+ export function parseIdPathSegment(segment: string): string | null {
105
+ if (PLAIN_ID_SEGMENT.test(segment)) {
106
+ return segment.length <= MAX_CREDENTIAL_ID_CHARS ? segment : null;
107
+ }
108
+ if (!ENCODED_ID_SEGMENT.test(segment)) return null;
109
+ const id = fromUtf8Hex(segment.slice(1));
110
+ if (id === null || !isIdValue(id) || PLAIN_ID_SEGMENT.test(id)) return null;
111
+ return id;
112
+ }
113
+
114
+ export function isEngineKind(value: unknown): value is string {
115
+ return typeof value === "string" && ENGINE_KIND.test(value);
116
+ }
117
+
118
+ function locationSegments(location: CredentialLocation): string[] {
119
+ const owner = location.scope === "org" ? "orgs" : "users";
120
+ const segments = [owner, idPathSegment(location.ownerId)];
121
+ switch (location.class) {
122
+ case "engine": {
123
+ if (!isEngineKind(location.engineKind)) {
124
+ throw new Error("invalid engine kind");
125
+ }
126
+ if (location.credentialId === undefined) {
127
+ throw new Error("an engine credential needs its id");
128
+ }
129
+ segments.push(
130
+ "engines",
131
+ location.engineKind,
132
+ idPathSegment(location.credentialId),
133
+ );
134
+ break;
135
+ }
136
+ case "mcp": {
137
+ if (location.engineKind !== undefined) {
138
+ throw new Error("an MCP credential has no engine kind");
139
+ }
140
+ if (location.credentialId === undefined) {
141
+ throw new Error("an MCP credential needs its id");
142
+ }
143
+ segments.push("mcp", idPathSegment(location.credentialId));
144
+ break;
145
+ }
146
+ case "github":
147
+ case "ssh": {
148
+ if (location.scope !== "user") {
149
+ throw new Error(`${location.class} credentials belong to a user`);
150
+ }
151
+ if (
152
+ location.engineKind !== undefined ||
153
+ location.credentialId !== undefined
154
+ ) {
155
+ throw new Error(`${location.class} has one credential per person`);
156
+ }
157
+ segments.push(location.class);
158
+ break;
159
+ }
160
+ default:
161
+ throw new Error("invalid credential class");
162
+ }
163
+ segments.push(idPathSegment(location.revision));
164
+ return segments;
165
+ }
166
+
167
+ function nameUnder(root: string, location: CredentialLocation): string {
168
+ const name = `${root}/${locationSegments(location).join("/")}`;
169
+ if (name.length > MAX_PARAMETER_NAME_CHARS) {
170
+ throw new Error("credential parameter name too long");
171
+ }
172
+ return name;
173
+ }
174
+
175
+ /** The lease-readable parameter of one revision. */
176
+ export function leaseParameterName(location: CredentialLocation): string {
177
+ return nameUnder(LEASE_PARAMETER_ROOT, location);
178
+ }
179
+
180
+ /** The broker-only parameter of one revision of an OAuth credential. GitHub
181
+ * tokens and SSH keys are static and have none. */
182
+ export function brokerParameterName(location: CredentialLocation): string {
183
+ if (location.class === "github" || location.class === "ssh") {
184
+ throw new Error(`${location.class} credentials have no broker part`);
185
+ }
186
+ return nameUnder(BROKER_PARAMETER_ROOT, location);
187
+ }
188
+
189
+ function parseUnder(root: string, name: string): CredentialLocation | null {
190
+ if (
191
+ typeof name !== "string" ||
192
+ name.length > MAX_PARAMETER_NAME_CHARS ||
193
+ !name.startsWith(`${root}/`)
194
+ ) {
195
+ return null;
196
+ }
197
+ const parts = name.slice(root.length + 1).split("/");
198
+ const [ownerKind, ownerSegment] = parts;
199
+ const scope: CredentialScope | null =
200
+ ownerKind === "orgs" ? "org" : ownerKind === "users" ? "user" : null;
201
+ if (scope === null || ownerSegment === undefined) return null;
202
+ const ownerId = parseIdPathSegment(ownerSegment);
203
+ if (ownerId === null) return null;
204
+ const revisionOf = (segment: string | undefined): string | null =>
205
+ segment === undefined ? null : parseIdPathSegment(segment);
206
+
207
+ if (parts[2] === "engines" && parts.length === 6) {
208
+ const credentialId = parseIdPathSegment(parts[4]!);
209
+ const revision = revisionOf(parts[5]);
210
+ if (!isEngineKind(parts[3]) || credentialId === null || revision === null) {
211
+ return null;
212
+ }
213
+ return {
214
+ class: "engine",
215
+ scope,
216
+ ownerId,
217
+ engineKind: parts[3],
218
+ credentialId,
219
+ revision,
220
+ };
221
+ }
222
+ if (parts[2] === "mcp" && parts.length === 5) {
223
+ const credentialId = parseIdPathSegment(parts[3]!);
224
+ const revision = revisionOf(parts[4]);
225
+ if (credentialId === null || revision === null) return null;
226
+ return { class: "mcp", scope, ownerId, credentialId, revision };
227
+ }
228
+ if (
229
+ (parts[2] === "github" || parts[2] === "ssh") &&
230
+ parts.length === 4 &&
231
+ scope === "user"
232
+ ) {
233
+ const revision = revisionOf(parts[3]);
234
+ if (revision === null) return null;
235
+ return { class: parts[2], scope, ownerId, revision };
236
+ }
237
+ return null;
238
+ }
239
+
240
+ export function parseLeaseParameterName(
241
+ name: string,
242
+ ): CredentialLocation | null {
243
+ return parseUnder(LEASE_PARAMETER_ROOT, name);
244
+ }
245
+
246
+ export function parseBrokerParameterName(
247
+ name: string,
248
+ ): CredentialLocation | null {
249
+ const location = parseUnder(BROKER_PARAMETER_ROOT, name);
250
+ return location && (location.class === "engine" || location.class === "mcp")
251
+ ? location
252
+ : null;
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Lease-readable values
257
+ // ---------------------------------------------------------------------------
258
+
259
+ /** An Advanced-tier parameter holds 8 KiB; every envelope must fit. */
260
+ export const MAX_CREDENTIAL_VALUE_BYTES = 8 * 1024;
261
+
262
+ /** The env vars an env-token engine's account may set, per engine kind. A
263
+ * value naming any other variable is refused, so a stored account can never
264
+ * set `LD_PRELOAD` or `PATH` on an agent's spawn. */
265
+ export const ENGINE_ENV_CREDENTIAL_KEYS: Readonly<Record<string, readonly string[]>> = {
266
+ claude: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
267
+ cursor: ["CURSOR_API_KEY"],
268
+ grok: ["XAI_API_KEY"],
269
+ };
270
+
271
+ /** The files a config-directory engine's account may carry, per engine kind:
272
+ * only what the lane owns, written into the engine's home, nothing else. */
273
+ export const ENGINE_CREDENTIAL_FILES: Readonly<Record<string, readonly string[]>> = {
274
+ codex: ["auth.json"],
275
+ grok: ["auth.json"],
276
+ opencode: ["auth.json"],
277
+ };
278
+
279
+ interface LeasedValueBase {
280
+ v: 1;
281
+ part: "lease";
282
+ /** The parameter this value belongs to, which the reader checks: a value
283
+ * copied to another credential's or revision's parameter is refused. */
284
+ location: CredentialLocation;
285
+ }
286
+
287
+ export type LeasedValue =
288
+ | (LeasedValueBase & { class: "engine"; authKind: "env"; env: Record<string, string> })
289
+ | (LeasedValueBase & {
290
+ class: "engine";
291
+ authKind: "config-dir";
292
+ /** The brokered copy: access token in, refresh token replaced by the
293
+ * broker's placeholder. */
294
+ files: Record<string, string>;
295
+ expiresAt: number | null;
296
+ })
297
+ | (LeasedValueBase & { class: "mcp"; authKind: "token"; header: { name: string; value: string } })
298
+ | (LeasedValueBase & {
299
+ class: "mcp";
300
+ authKind: "oauth";
301
+ accessToken: string;
302
+ tokenType: "Bearer";
303
+ expiresAt: number | null;
304
+ })
305
+ | (LeasedValueBase & { class: "github"; token: string })
306
+ | (LeasedValueBase & { class: "ssh"; privateKey: string; publicKey: string });
307
+
308
+ const HEADER_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/;
309
+ /** Hop-by-hop and routing headers a stored connection may not set. */
310
+ const FORBIDDEN_HEADER_NAMES = new Set([
311
+ "connection",
312
+ "content-length",
313
+ "host",
314
+ "keep-alive",
315
+ "proxy-authorization",
316
+ "te",
317
+ "trailer",
318
+ "transfer-encoding",
319
+ "upgrade",
320
+ ]);
321
+
322
+ function sameLocation(a: CredentialLocation, b: CredentialLocation): boolean {
323
+ return (
324
+ a.class === b.class &&
325
+ a.scope === b.scope &&
326
+ a.ownerId === b.ownerId &&
327
+ a.engineKind === b.engineKind &&
328
+ a.credentialId === b.credentialId &&
329
+ a.revision === b.revision
330
+ );
331
+ }
332
+
333
+ function parseLocationField(value: unknown): CredentialLocation | null {
334
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
335
+ const o = value as WireObject;
336
+ const keys = Object.keys(o);
337
+ const allowed = new Set(["class", "scope", "ownerId", "engineKind", "credentialId", "revision"]);
338
+ if (keys.some((key) => !allowed.has(key))) return null;
339
+ if (
340
+ (o.class !== "engine" && o.class !== "mcp" && o.class !== "github" && o.class !== "ssh") ||
341
+ !isScope(o.scope) ||
342
+ !isIdValue(o.ownerId) ||
343
+ !isIdValue(o.revision) ||
344
+ !(o.engineKind === undefined || isEngineKind(o.engineKind)) ||
345
+ !(o.credentialId === undefined || isIdValue(o.credentialId))
346
+ ) {
347
+ return null;
348
+ }
349
+ const location: CredentialLocation = {
350
+ class: o.class,
351
+ scope: o.scope,
352
+ ownerId: o.ownerId,
353
+ ...(o.engineKind !== undefined ? { engineKind: o.engineKind as string } : {}),
354
+ ...(o.credentialId !== undefined ? { credentialId: o.credentialId as string } : {}),
355
+ revision: o.revision,
356
+ };
357
+ try {
358
+ leaseParameterName(location);
359
+ } catch {
360
+ return null;
361
+ }
362
+ return location;
363
+ }
364
+
365
+ function parseStringMap(
366
+ value: unknown,
367
+ allowedKeys: readonly string[],
368
+ maxChars: number,
369
+ ): Record<string, string> | null {
370
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
371
+ const entries = Object.entries(value as WireObject);
372
+ if (entries.length === 0) return null;
373
+ const out: Record<string, string> = {};
374
+ for (const [key, item] of entries) {
375
+ if (!allowedKeys.includes(key) || !isBoundedString(item, maxChars)) return null;
376
+ out[key] = item;
377
+ }
378
+ return out;
379
+ }
380
+
381
+ /** The JSON a writer stores in a lease-readable parameter. */
382
+ export function serializeLeasedValue(value: LeasedValue): string {
383
+ const json = canonicalJson(value);
384
+ if (new TextEncoder().encode(json).length > MAX_CREDENTIAL_VALUE_BYTES) {
385
+ throw new Error("credential value too large for one parameter");
386
+ }
387
+ return json;
388
+ }
389
+
390
+ /**
391
+ * A lease-readable value, read from the parameter at `expected`, or null.
392
+ * Exact like every parser here, and bound to its parameter: the envelope's
393
+ * location must be the one the reader asked for, so a value copied into
394
+ * another credential's or revision's parameter is refused, not used.
395
+ */
396
+ export function parseLeasedValue(
397
+ raw: string,
398
+ expected: CredentialLocation,
399
+ ): LeasedValue | null {
400
+ if (
401
+ typeof raw !== "string" ||
402
+ new TextEncoder().encode(raw).length > MAX_CREDENTIAL_VALUE_BYTES
403
+ ) {
404
+ return null;
405
+ }
406
+ let parsed: unknown;
407
+ try {
408
+ parsed = JSON.parse(raw);
409
+ } catch {
410
+ return null;
411
+ }
412
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
413
+ const o = parsed as WireObject;
414
+ if (o.v !== 1 || o.part !== "lease") return null;
415
+ const location = parseLocationField(o.location);
416
+ if (!location || !sameLocation(location, expected) || o.class !== location.class) {
417
+ return null;
418
+ }
419
+ const base = { v: 1 as const, part: "lease" as const, location };
420
+ switch (location.class) {
421
+ case "engine": {
422
+ const kind = location.engineKind!;
423
+ if (o.authKind === "env") {
424
+ const env = exactObject(o, ["v", "part", "location", "class", "authKind", "env"])
425
+ ? parseStringMap(o.env, ENGINE_ENV_CREDENTIAL_KEYS[kind] ?? [], 4096)
426
+ : null;
427
+ return env ? { ...base, class: "engine", authKind: "env", env } : null;
428
+ }
429
+ if (o.authKind === "config-dir") {
430
+ if (
431
+ !exactObject(o, ["v", "part", "location", "class", "authKind", "files", "expiresAt"]) ||
432
+ !(o.expiresAt === null || isEpochMs(o.expiresAt))
433
+ ) {
434
+ return null;
435
+ }
436
+ const files = parseStringMap(o.files, ENGINE_CREDENTIAL_FILES[kind] ?? [], MAX_CREDENTIAL_VALUE_BYTES);
437
+ return files
438
+ ? { ...base, class: "engine", authKind: "config-dir", files, expiresAt: o.expiresAt }
439
+ : null;
440
+ }
441
+ return null;
442
+ }
443
+ case "mcp": {
444
+ if (o.authKind === "token") {
445
+ const header = exactObject(o.header, ["name", "value"]);
446
+ if (
447
+ !exactObject(o, ["v", "part", "location", "class", "authKind", "header"]) ||
448
+ !header ||
449
+ typeof header.name !== "string" ||
450
+ !HEADER_NAME.test(header.name) ||
451
+ FORBIDDEN_HEADER_NAMES.has(header.name.toLowerCase()) ||
452
+ !isBoundedString(header.value, 4096) ||
453
+ /[\r\n]/.test(header.value)
454
+ ) {
455
+ return null;
456
+ }
457
+ return {
458
+ ...base,
459
+ class: "mcp",
460
+ authKind: "token",
461
+ header: { name: header.name, value: header.value },
462
+ };
463
+ }
464
+ if (o.authKind === "oauth") {
465
+ if (
466
+ !exactObject(o, ["v", "part", "location", "class", "authKind", "accessToken", "tokenType", "expiresAt"]) ||
467
+ !isBoundedString(o.accessToken, 4096) ||
468
+ /[\r\n]/.test(o.accessToken) ||
469
+ o.tokenType !== "Bearer" ||
470
+ !(o.expiresAt === null || isEpochMs(o.expiresAt))
471
+ ) {
472
+ return null;
473
+ }
474
+ return {
475
+ ...base,
476
+ class: "mcp",
477
+ authKind: "oauth",
478
+ accessToken: o.accessToken,
479
+ tokenType: "Bearer",
480
+ expiresAt: o.expiresAt,
481
+ };
482
+ }
483
+ return null;
484
+ }
485
+ case "github":
486
+ return exactObject(o, ["v", "part", "location", "class", "token"]) &&
487
+ isBoundedString(o.token, 512) &&
488
+ !/\s/.test(o.token)
489
+ ? { ...base, class: "github", token: o.token }
490
+ : null;
491
+ case "ssh":
492
+ return exactObject(o, ["v", "part", "location", "class", "privateKey", "publicKey"]) &&
493
+ isBoundedString(o.privateKey, 4096) &&
494
+ isBoundedString(o.publicKey, 1024)
495
+ ? { ...base, class: "ssh", privateKey: o.privateKey, publicKey: o.publicKey }
496
+ : null;
497
+ }
498
+ }
499
+
500
+ // ---------------------------------------------------------------------------
501
+ // Capture flows (cloud ↔ sign-in box)
502
+ // ---------------------------------------------------------------------------
503
+
504
+ /** Advertised by a sign-in box that writes captures as ADR-154 revisions. */
505
+ export const SIGNIN_BOX_CAPTURE_FLOWS_FEATURE = "signin-box-capture-flows-v1";
506
+
507
+ /**
508
+ * What the cloud tells the sign-in box to write for one capture: the
509
+ * revision's parameters (the box never chooses a name) and the ids it tags
510
+ * them with. The box checks every field against the others before it writes.
511
+ */
512
+ export interface CaptureFlowWire {
513
+ credentialId: string;
514
+ revision: string;
515
+ leaseParameter: string;
516
+ /** Null for a static credential. */
517
+ brokerParameter: string | null;
518
+ /** The workspace tag: required except for GitHub and SSH. */
519
+ orgId: string | null;
520
+ /** The person, for a personal credential. */
521
+ userId: string | null;
522
+ }
523
+
524
+ /** What the box answers once both parts are written: never material. */
525
+ export interface CaptureResultWire {
526
+ credentialId: string;
527
+ revision: string;
528
+ /** The lease-readable parameter's Parameter Store version. */
529
+ leaseVersion: number;
530
+ /** The access token's expiry when the box could read it. */
531
+ expiresAt: number | null;
532
+ }
533
+
534
+ /**
535
+ * A capture flow, rebuilt and checked, with the location its parameters name,
536
+ * or null. The two parameters must name the same credential and revision under
537
+ * their own roots, and the tags must be the parameter's owner: a workspace
538
+ * credential's `orgId` is its owner and it has no `userId`; a personal AI
539
+ * account or MCP connection names both its person and its workspace; GitHub
540
+ * and SSH name their person alone.
541
+ */
542
+ export function parseCaptureFlowWire(
543
+ value: unknown,
544
+ ): (CaptureFlowWire & { location: CredentialLocation }) | null {
545
+ const o = exactObject(value, [
546
+ "credentialId",
547
+ "revision",
548
+ "leaseParameter",
549
+ "brokerParameter",
550
+ "orgId",
551
+ "userId",
552
+ ]);
553
+ if (
554
+ !o ||
555
+ !isIdValue(o.credentialId) ||
556
+ !isIdValue(o.revision) ||
557
+ typeof o.leaseParameter !== "string" ||
558
+ !(o.brokerParameter === null || typeof o.brokerParameter === "string") ||
559
+ !(o.orgId === null || (isIdValue(o.orgId) && isTagValue(o.orgId))) ||
560
+ !(o.userId === null || (isIdValue(o.userId) && isTagValue(o.userId)))
561
+ ) {
562
+ return null;
563
+ }
564
+ const location = parseLeaseParameterName(o.leaseParameter);
565
+ if (!location || location.revision !== o.revision) return null;
566
+ const isPerson = location.class === "github" || location.class === "ssh";
567
+ if (!isPerson && location.credentialId !== o.credentialId) return null;
568
+ if (o.brokerParameter !== null) {
569
+ const broker = parseBrokerParameterName(o.brokerParameter);
570
+ if (!broker || !sameLocation(broker, location)) return null;
571
+ }
572
+ if (isPerson && o.brokerParameter !== null) return null;
573
+ const ownerOk =
574
+ location.scope === "org"
575
+ ? o.orgId === location.ownerId && o.userId === null
576
+ : o.userId === location.ownerId && (isPerson ? o.orgId === null : o.orgId !== null);
577
+ if (!ownerOk) return null;
578
+ return {
579
+ credentialId: o.credentialId,
580
+ revision: o.revision,
581
+ leaseParameter: o.leaseParameter,
582
+ brokerParameter: o.brokerParameter,
583
+ orgId: o.orgId,
584
+ userId: o.userId,
585
+ location,
586
+ };
587
+ }
588
+
589
+ export function parseCaptureResultWire(value: unknown): CaptureResultWire | null {
590
+ const o = exactObject(value, ["credentialId", "revision", "leaseVersion", "expiresAt"]);
591
+ return o &&
592
+ isIdValue(o.credentialId) &&
593
+ isIdValue(o.revision) &&
594
+ isPositiveInteger(o.leaseVersion) &&
595
+ (o.expiresAt === null || isEpochMs(o.expiresAt))
596
+ ? {
597
+ credentialId: o.credentialId,
598
+ revision: o.revision,
599
+ leaseVersion: o.leaseVersion,
600
+ expiresAt: o.expiresAt,
601
+ }
602
+ : null;
603
+ }
604
+
605
+ // ---------------------------------------------------------------------------
606
+ // Session tags
607
+ // ---------------------------------------------------------------------------
608
+
609
+ /**
610
+ * Every v1 `credentials` session carries `lease=v1`, and every role statement
611
+ * that grants `/uai/lease` requires it, so the statements v1 adds never widen
612
+ * a v0 session (which carries no such tag).
613
+ */
614
+ export const LEASE_V1_SESSION_TAG = { key: "lease", value: "v1" } as const;
615
+ /** The session tag the role policy puts into `/uai/lease/orgs/${…}` paths:
616
+ * the workspace id as `idPathSegment` writes it. `org` keeps the raw id,
617
+ * which resource-tag conditions compare. */
618
+ export const ORG_PATH_SESSION_TAG_KEY = "orgseg";
619
+
620
+ const TAG_VALUE = /^[\p{L}\p{Z}\p{N}_.:/=+\-@]{1,256}$/u;
621
+
622
+ /** STS session tag and SSM resource tag values: the same grammar, refused
623
+ * rather than rewritten, so a principal tag and a resource tag written for
624
+ * one id always compare equal. */
625
+ export function isTagValue(value: unknown): value is string {
626
+ return typeof value === "string" && TAG_VALUE.test(value);
627
+ }
628
+
629
+ // ---------------------------------------------------------------------------
630
+ // The v1 grant and its manifest
631
+ // ---------------------------------------------------------------------------
632
+
633
+ export const MAX_LEASE_SESSIONS = 5;
634
+ export const MAX_LEASE_CREDENTIAL_SESSIONS = 4;
635
+ export const MAX_MANIFEST_PROJECTS = 32;
636
+ export const MAX_MANIFEST_ENGINES = 64;
637
+ export const MAX_MANIFEST_MCP = 64;
638
+ export const MAX_MANIFEST_LABEL_CHARS = 64;
639
+ export const MAX_MANIFEST_URL_CHARS = 2048;
640
+ export const MAX_MANIFEST_SLUG_CHARS = 64;
641
+ export const MAX_LEASE_ERROR_CHARS = 512;
642
+ const MAX_LEASE_OP_ID_CHARS = 128;
643
+ const MAX_SESSION_SECRET_CHARS = 4096;
644
+ const MAX_REGION_CHARS = 32;
645
+ const MAX_BUCKET_CHARS = 63;
646
+ const MAX_PREFIX_CHARS = 512;
647
+ const DIGEST = /^[0-9a-f]{64}$/;
648
+ const OP_ID = /^[A-Za-z0-9_-]+$/;
649
+
650
+ export type LeaseSessionPurpose = "files" | "credentials";
651
+
652
+ /** One STS session. A task machine's grant has exactly one `files` session,
653
+ * which only Mountpoint uses; an owned runner's has none. */
654
+ export interface LeaseSession {
655
+ id: string;
656
+ purpose: LeaseSessionPurpose;
657
+ accessKeyId: string;
658
+ secretAccessKey: string;
659
+ sessionToken: string;
660
+ /** Epoch ms. */
661
+ expiresAt: number;
662
+ }
663
+
664
+ export interface LeaseManifestProject {
665
+ projectId: string;
666
+ /** The session whose policy names this project's env values. */
667
+ sessionId: string;
668
+ }
669
+
670
+ export interface LeaseManifestEngine {
671
+ credentialId: string;
672
+ revision: string;
673
+ /** The revision's lease-readable parameter. */
674
+ parameter: string;
675
+ /** Its Parameter Store version as the cloud last recorded it. */
676
+ version: number;
677
+ expiresAt: number | null;
678
+ sessionId: string;
679
+ engineKind: string;
680
+ scope: CredentialScope;
681
+ label: string;
682
+ authKind: "env" | "config-dir";
683
+ /** Position within its scope; the list itself is already in try order. */
684
+ position: number;
685
+ }
686
+
687
+ export interface LeaseManifestMcp {
688
+ credentialId: string;
689
+ slug: string;
690
+ url: string;
691
+ scope: CredentialScope;
692
+ authKind: "oauth" | "token" | "none";
693
+ /** Null exactly when `authKind` is `none`: nothing secret to read. */
694
+ revision: string | null;
695
+ parameter: string | null;
696
+ version: number | null;
697
+ expiresAt: number | null;
698
+ sessionId: string | null;
699
+ }
700
+
701
+ export interface LeaseManifestStatic {
702
+ credentialId: string;
703
+ revision: string;
704
+ parameter: string;
705
+ version: number;
706
+ sessionId: string;
707
+ }
708
+
709
+ /** What a task may read, by name and version — never a value. */
710
+ export interface CredentialManifest {
711
+ /** The task's projects, lead first. */
712
+ projects: LeaseManifestProject[];
713
+ /** Per engine kind: the owner's personal accounts, then the workspace's. */
714
+ engines: LeaseManifestEngine[];
715
+ mcp: LeaseManifestMcp[];
716
+ github: LeaseManifestStatic | null;
717
+ ssh: LeaseManifestStatic | null;
718
+ }
719
+
720
+ export interface LeaseFiles {
721
+ bucket: string;
722
+ orgPrefix: string | null;
723
+ userPrefix: string;
724
+ }
725
+
726
+ /** ADR-154 §4: the grant a v1 runner installs for one live task. */
727
+ export interface TaskLeaseV1 {
728
+ v: 1;
729
+ leaseId: string;
730
+ taskId: string;
731
+ orgId: string;
732
+ userId: string;
733
+ /** The task's run epoch: advanced by the cloud at every start, resume and
734
+ * wake. With the runner the task is assigned to, it is the assignment a
735
+ * grant, a replica frame and the cleanup inventory are fenced to. */
736
+ runEpoch: number;
737
+ /** Only grows per task. */
738
+ manifestRevision: number;
739
+ /** sha256 (lowercase hex) of `canonicalManifestJson(manifest)`. */
740
+ manifestDigest: string;
741
+ region: string;
742
+ sessions: LeaseSession[];
743
+ /** Present exactly when a `files` session is. */
744
+ files: LeaseFiles | null;
745
+ manifest: CredentialManifest;
746
+ }
747
+
748
+ /**
749
+ * A task command's run epoch (`TaskCommandTask.runEpoch`): undefined when
750
+ * absent, since a cloud that predates ADR-154 sends none. A present value that
751
+ * is not a positive safe integer throws; it is never read as absent.
752
+ */
753
+ export function runEpochFromWire(
754
+ value: unknown,
755
+ field = "runEpoch",
756
+ ): number | undefined {
757
+ if (value === undefined) return undefined;
758
+ if (!isPositiveInteger(value)) throw new Error(`invalid ${field}`);
759
+ return value;
760
+ }
761
+
762
+ /** A grant expires with its earliest session. */
763
+ export function taskLeaseV1ExpiresAt(lease: TaskLeaseV1): number {
764
+ return Math.min(...lease.sessions.map((session) => session.expiresAt));
765
+ }
766
+
767
+ /**
768
+ * The bytes `manifestDigest` covers: JSON with every object's keys sorted,
769
+ * arrays in order, no whitespace. Callers hash it with their own crypto, so
770
+ * this module stays loadable everywhere.
771
+ */
772
+ export function canonicalManifestJson(manifest: CredentialManifest): string {
773
+ return canonicalJson(manifest);
774
+ }
775
+
776
+ function canonicalJson(value: unknown): string {
777
+ if (value === null || typeof value !== "object") {
778
+ return JSON.stringify(value);
779
+ }
780
+ if (Array.isArray(value)) {
781
+ return `[${value.map(canonicalJson).join(",")}]`;
782
+ }
783
+ const record = value as Record<string, unknown>;
784
+ const keys = Object.keys(record)
785
+ .filter((key) => record[key] !== undefined)
786
+ .sort();
787
+ return `{${keys
788
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
789
+ .join(",")}}`;
790
+ }
791
+
792
+ // ---------------------------------------------------------------------------
793
+ // Frames
794
+ // ---------------------------------------------------------------------------
795
+
796
+ /** Runner → cloud: a v1 lease request for one exact task run. */
797
+ export interface LeaseRequestV1Frame {
798
+ kind: "lease.request";
799
+ opId: string;
800
+ taskId: string;
801
+ runEpoch: number;
802
+ }
803
+
804
+ /** Cloud → runner: a v1 grant answering `lease.request`. A separate kind from
805
+ * ADR-133's `lease.grant`, so a runner that predates v1 drops it unread. */
806
+ export interface LeaseGrantV1Frame {
807
+ kind: "lease.grant.v1";
808
+ opId: string;
809
+ lease: TaskLeaseV1;
810
+ }
811
+
812
+ /** Cloud → runner: STS or the network failed; ask again later. Not a denial:
813
+ * the runner keeps an unexpired grant that is not below `minRevision`. */
814
+ export interface LeaseUnavailableFrame {
815
+ kind: "lease.unavailable";
816
+ opId: string;
817
+ error: string;
818
+ }
819
+
820
+ /**
821
+ * Cloud → runner: the task's credentials changed. Renew until a grant at or
822
+ * above `wantedRevision` is installed. With `minRevision`, grants below it are
823
+ * unusable at once and `removed` names the credentials that left, whose
824
+ * cleanup runs before the next grant is installed.
825
+ */
826
+ export interface LeaseStaleFrame {
827
+ kind: "lease.stale";
828
+ taskId: string;
829
+ runEpoch: number;
830
+ wantedRevision: number;
831
+ minRevision: number | null;
832
+ removed: string[];
833
+ }
834
+
835
+ /** Cloud → runner: a credential the task's manifest names has a new version
836
+ * of its lease-readable parameter (a broker refresh). */
837
+ export interface CredentialChangedFrame {
838
+ kind: "credential.changed";
839
+ taskId: string;
840
+ runEpoch: number;
841
+ credentialId: string;
842
+ revision: string;
843
+ version: number;
844
+ }
845
+
846
+ /** Runner → cloud: a credential-invalid signal or an MCP 401. */
847
+ export interface CredentialRefreshFrame {
848
+ kind: "credential.refresh";
849
+ opId: string;
850
+ taskId: string;
851
+ runEpoch: number;
852
+ credentialId: string;
853
+ revision: string;
854
+ }
855
+
856
+ export type CredentialRefreshOutcome =
857
+ | "refreshed"
858
+ | "fresh"
859
+ | "needs_sign_in"
860
+ | "busy"
861
+ | "unavailable";
862
+
863
+ /** Cloud → runner: the broker's typed answer, never material. `version` and
864
+ * `expiresAt` accompany `refreshed` and `fresh` only. */
865
+ export interface CredentialRefreshResultFrame {
866
+ kind: "credential.refresh.result";
867
+ opId: string;
868
+ outcome: CredentialRefreshOutcome;
869
+ version: number | null;
870
+ expiresAt: number | null;
871
+ }
872
+
873
+ export type CredentialsActivationPhase = "prepare" | "commit";
874
+
875
+ /** Runner → cloud: ADR-154 §13's prepare and commit steps. */
876
+ export interface CredentialsActivateFrame {
877
+ kind: "credentials.activate";
878
+ opId: string;
879
+ phase: CredentialsActivationPhase;
880
+ }
881
+
882
+ export interface CredentialsActivateResultFrame {
883
+ kind: "credentials.activate.result";
884
+ opId: string;
885
+ phase: CredentialsActivationPhase;
886
+ ok: boolean;
887
+ error: string | null;
888
+ }
889
+
890
+ /** Runner → cloud: the purge is complete; the runner now advertises v1. */
891
+ export interface CredentialsActivatedFrame {
892
+ kind: "credentials.activated";
893
+ opId: string;
894
+ }
895
+
896
+ // ---------------------------------------------------------------------------
897
+ // Parsers
898
+ // ---------------------------------------------------------------------------
899
+
900
+ type WireObject = Record<string, unknown>;
901
+
902
+ function exactObject(
903
+ value: unknown,
904
+ required: readonly string[],
905
+ ): WireObject | null {
906
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
907
+ const object = value as WireObject;
908
+ const keys = Object.keys(object);
909
+ if (
910
+ keys.length !== required.length ||
911
+ required.some((key) => !Object.hasOwn(object, key))
912
+ ) {
913
+ return null;
914
+ }
915
+ return object;
916
+ }
917
+
918
+ function isBoundedString(value: unknown, max: number): value is string {
919
+ return typeof value === "string" && value.length > 0 && value.length <= max;
920
+ }
921
+
922
+ function isOpId(value: unknown): value is string {
923
+ return isBoundedString(value, MAX_LEASE_OP_ID_CHARS) && OP_ID.test(value);
924
+ }
925
+
926
+ function isPositiveInteger(value: unknown): value is number {
927
+ return Number.isSafeInteger(value) && (value as number) > 0;
928
+ }
929
+
930
+ function isNonNegativeInteger(value: unknown): value is number {
931
+ return Number.isSafeInteger(value) && (value as number) >= 0;
932
+ }
933
+
934
+ function isEpochMs(value: unknown): value is number {
935
+ return isPositiveInteger(value);
936
+ }
937
+
938
+ function isScope(value: unknown): value is CredentialScope {
939
+ return value === "org" || value === "user";
940
+ }
941
+
942
+ function uniqueBy<T>(items: readonly T[], key: (item: T) => string): boolean {
943
+ return new Set(items.map(key)).size === items.length;
944
+ }
945
+
946
+ function parseSession(value: unknown): LeaseSession | null {
947
+ const o = exactObject(value, [
948
+ "id",
949
+ "purpose",
950
+ "accessKeyId",
951
+ "secretAccessKey",
952
+ "sessionToken",
953
+ "expiresAt",
954
+ ]);
955
+ if (
956
+ !o ||
957
+ !isOpId(o.id) ||
958
+ (o.purpose !== "files" && o.purpose !== "credentials") ||
959
+ !isBoundedString(o.accessKeyId, 128) ||
960
+ !isBoundedString(o.secretAccessKey, MAX_SESSION_SECRET_CHARS) ||
961
+ !isBoundedString(o.sessionToken, MAX_SESSION_SECRET_CHARS) ||
962
+ !isEpochMs(o.expiresAt)
963
+ ) {
964
+ return null;
965
+ }
966
+ return {
967
+ id: o.id,
968
+ purpose: o.purpose,
969
+ accessKeyId: o.accessKeyId,
970
+ secretAccessKey: o.secretAccessKey,
971
+ sessionToken: o.sessionToken,
972
+ expiresAt: o.expiresAt,
973
+ };
974
+ }
975
+
976
+ /** The owner a lease parameter must belong to, for a grant's org and user. */
977
+ function belongsTo(
978
+ location: CredentialLocation,
979
+ grant: { orgId: string; userId: string },
980
+ ): boolean {
981
+ return location.scope === "org"
982
+ ? location.ownerId === grant.orgId
983
+ : location.ownerId === grant.userId;
984
+ }
985
+
986
+ function parseEngine(
987
+ value: unknown,
988
+ grant: { orgId: string; userId: string },
989
+ credentialSessions: ReadonlySet<string>,
990
+ ): LeaseManifestEngine | null {
991
+ const o = exactObject(value, [
992
+ "credentialId",
993
+ "revision",
994
+ "parameter",
995
+ "version",
996
+ "expiresAt",
997
+ "sessionId",
998
+ "engineKind",
999
+ "scope",
1000
+ "label",
1001
+ "authKind",
1002
+ "position",
1003
+ ]);
1004
+ if (
1005
+ !o ||
1006
+ !isIdValue(o.credentialId) ||
1007
+ !isIdValue(o.revision) ||
1008
+ typeof o.parameter !== "string" ||
1009
+ !isPositiveInteger(o.version) ||
1010
+ !(o.expiresAt === null || isEpochMs(o.expiresAt)) ||
1011
+ typeof o.sessionId !== "string" ||
1012
+ !credentialSessions.has(o.sessionId) ||
1013
+ !isEngineKind(o.engineKind) ||
1014
+ !isScope(o.scope) ||
1015
+ !isBoundedString(o.label, MAX_MANIFEST_LABEL_CHARS) ||
1016
+ (o.authKind !== "env" && o.authKind !== "config-dir") ||
1017
+ !isNonNegativeInteger(o.position)
1018
+ ) {
1019
+ return null;
1020
+ }
1021
+ const location = parseLeaseParameterName(o.parameter);
1022
+ if (
1023
+ !location ||
1024
+ location.class !== "engine" ||
1025
+ location.scope !== o.scope ||
1026
+ location.engineKind !== o.engineKind ||
1027
+ location.credentialId !== o.credentialId ||
1028
+ location.revision !== o.revision ||
1029
+ !belongsTo(location, grant)
1030
+ ) {
1031
+ return null;
1032
+ }
1033
+ return {
1034
+ credentialId: o.credentialId,
1035
+ revision: o.revision,
1036
+ parameter: o.parameter,
1037
+ version: o.version,
1038
+ expiresAt: o.expiresAt,
1039
+ sessionId: o.sessionId,
1040
+ engineKind: o.engineKind,
1041
+ scope: o.scope,
1042
+ label: o.label,
1043
+ authKind: o.authKind,
1044
+ position: o.position,
1045
+ };
1046
+ }
1047
+
1048
+ function isMcpUrl(value: unknown): value is string {
1049
+ if (!isBoundedString(value, MAX_MANIFEST_URL_CHARS)) return false;
1050
+ try {
1051
+ const url = new URL(value);
1052
+ return url.protocol === "https:" && url.username === "" && url.password === "";
1053
+ } catch {
1054
+ return false;
1055
+ }
1056
+ }
1057
+
1058
+ function parseMcp(
1059
+ value: unknown,
1060
+ grant: { orgId: string; userId: string },
1061
+ credentialSessions: ReadonlySet<string>,
1062
+ ): LeaseManifestMcp | null {
1063
+ const o = exactObject(value, [
1064
+ "credentialId",
1065
+ "slug",
1066
+ "url",
1067
+ "scope",
1068
+ "authKind",
1069
+ "revision",
1070
+ "parameter",
1071
+ "version",
1072
+ "expiresAt",
1073
+ "sessionId",
1074
+ ]);
1075
+ if (
1076
+ !o ||
1077
+ !isIdValue(o.credentialId) ||
1078
+ !isBoundedString(o.slug, MAX_MANIFEST_SLUG_CHARS) ||
1079
+ !/^[a-z0-9][a-z0-9-]*$/.test(o.slug) ||
1080
+ !isMcpUrl(o.url) ||
1081
+ !isScope(o.scope) ||
1082
+ (o.authKind !== "oauth" && o.authKind !== "token" && o.authKind !== "none")
1083
+ ) {
1084
+ return null;
1085
+ }
1086
+ const authKind: LeaseManifestMcp["authKind"] = o.authKind;
1087
+ const base = {
1088
+ credentialId: o.credentialId,
1089
+ slug: o.slug,
1090
+ url: o.url,
1091
+ scope: o.scope,
1092
+ authKind,
1093
+ };
1094
+ if (authKind === "none") {
1095
+ return o.revision === null &&
1096
+ o.parameter === null &&
1097
+ o.version === null &&
1098
+ o.expiresAt === null &&
1099
+ o.sessionId === null
1100
+ ? {
1101
+ ...base,
1102
+ revision: null,
1103
+ parameter: null,
1104
+ version: null,
1105
+ expiresAt: null,
1106
+ sessionId: null,
1107
+ }
1108
+ : null;
1109
+ }
1110
+ if (
1111
+ !isIdValue(o.revision) ||
1112
+ typeof o.parameter !== "string" ||
1113
+ !isPositiveInteger(o.version) ||
1114
+ !(o.expiresAt === null || isEpochMs(o.expiresAt)) ||
1115
+ typeof o.sessionId !== "string" ||
1116
+ !credentialSessions.has(o.sessionId)
1117
+ ) {
1118
+ return null;
1119
+ }
1120
+ const location = parseLeaseParameterName(o.parameter);
1121
+ if (
1122
+ !location ||
1123
+ location.class !== "mcp" ||
1124
+ location.scope !== o.scope ||
1125
+ location.credentialId !== o.credentialId ||
1126
+ location.revision !== o.revision ||
1127
+ !belongsTo(location, grant)
1128
+ ) {
1129
+ return null;
1130
+ }
1131
+ return {
1132
+ ...base,
1133
+ revision: o.revision,
1134
+ parameter: o.parameter,
1135
+ version: o.version,
1136
+ expiresAt: o.expiresAt,
1137
+ sessionId: o.sessionId,
1138
+ };
1139
+ }
1140
+
1141
+ function parseStatic(
1142
+ value: unknown,
1143
+ credentialClass: "github" | "ssh",
1144
+ grant: { orgId: string; userId: string },
1145
+ credentialSessions: ReadonlySet<string>,
1146
+ ): LeaseManifestStatic | null {
1147
+ const o = exactObject(value, [
1148
+ "credentialId",
1149
+ "revision",
1150
+ "parameter",
1151
+ "version",
1152
+ "sessionId",
1153
+ ]);
1154
+ if (
1155
+ !o ||
1156
+ !isIdValue(o.credentialId) ||
1157
+ !isIdValue(o.revision) ||
1158
+ typeof o.parameter !== "string" ||
1159
+ !isPositiveInteger(o.version) ||
1160
+ typeof o.sessionId !== "string" ||
1161
+ !credentialSessions.has(o.sessionId)
1162
+ ) {
1163
+ return null;
1164
+ }
1165
+ const location = parseLeaseParameterName(o.parameter);
1166
+ if (
1167
+ !location ||
1168
+ location.class !== credentialClass ||
1169
+ location.revision !== o.revision ||
1170
+ location.ownerId !== grant.userId
1171
+ ) {
1172
+ return null;
1173
+ }
1174
+ return {
1175
+ credentialId: o.credentialId,
1176
+ revision: o.revision,
1177
+ parameter: o.parameter,
1178
+ version: o.version,
1179
+ sessionId: o.sessionId,
1180
+ };
1181
+ }
1182
+
1183
+ function parseManifest(
1184
+ value: unknown,
1185
+ grant: { orgId: string; userId: string },
1186
+ credentialSessions: ReadonlySet<string>,
1187
+ ): CredentialManifest | null {
1188
+ const o = exactObject(value, ["projects", "engines", "mcp", "github", "ssh"]);
1189
+ if (
1190
+ !o ||
1191
+ !Array.isArray(o.projects) ||
1192
+ o.projects.length > MAX_MANIFEST_PROJECTS ||
1193
+ !Array.isArray(o.engines) ||
1194
+ o.engines.length > MAX_MANIFEST_ENGINES ||
1195
+ !Array.isArray(o.mcp) ||
1196
+ o.mcp.length > MAX_MANIFEST_MCP
1197
+ ) {
1198
+ return null;
1199
+ }
1200
+ const projects: LeaseManifestProject[] = [];
1201
+ for (const item of o.projects) {
1202
+ const p = exactObject(item, ["projectId", "sessionId"]);
1203
+ if (
1204
+ !p ||
1205
+ !isIdValue(p.projectId) ||
1206
+ typeof p.sessionId !== "string" ||
1207
+ !credentialSessions.has(p.sessionId)
1208
+ ) {
1209
+ return null;
1210
+ }
1211
+ projects.push({ projectId: p.projectId, sessionId: p.sessionId });
1212
+ }
1213
+ const engines: LeaseManifestEngine[] = [];
1214
+ for (const item of o.engines) {
1215
+ const engine = parseEngine(item, grant, credentialSessions);
1216
+ if (!engine) return null;
1217
+ engines.push(engine);
1218
+ }
1219
+ const mcp: LeaseManifestMcp[] = [];
1220
+ for (const item of o.mcp) {
1221
+ const connection = parseMcp(item, grant, credentialSessions);
1222
+ if (!connection) return null;
1223
+ mcp.push(connection);
1224
+ }
1225
+ const github =
1226
+ o.github === null
1227
+ ? null
1228
+ : parseStatic(o.github, "github", grant, credentialSessions);
1229
+ const ssh =
1230
+ o.ssh === null ? null : parseStatic(o.ssh, "ssh", grant, credentialSessions);
1231
+ if ((o.github !== null && !github) || (o.ssh !== null && !ssh)) return null;
1232
+ if (
1233
+ !uniqueBy(projects, (p) => p.projectId) ||
1234
+ !uniqueBy(engines, (e) => e.credentialId) ||
1235
+ !uniqueBy(mcp, (m) => m.credentialId) ||
1236
+ !uniqueBy(mcp, (m) => m.slug)
1237
+ ) {
1238
+ return null;
1239
+ }
1240
+ return { projects, engines, mcp, github, ssh };
1241
+ }
1242
+
1243
+ function parseFiles(value: unknown): LeaseFiles | null {
1244
+ const o = exactObject(value, ["bucket", "orgPrefix", "userPrefix"]);
1245
+ if (
1246
+ !o ||
1247
+ !isBoundedString(o.bucket, MAX_BUCKET_CHARS) ||
1248
+ !(o.orgPrefix === null || isBoundedString(o.orgPrefix, MAX_PREFIX_CHARS)) ||
1249
+ !isBoundedString(o.userPrefix, MAX_PREFIX_CHARS)
1250
+ ) {
1251
+ return null;
1252
+ }
1253
+ return { bucket: o.bucket, orgPrefix: o.orgPrefix, userPrefix: o.userPrefix };
1254
+ }
1255
+
1256
+ /**
1257
+ * The exact v1 grant, rebuilt field by field, or null. Beyond shapes it
1258
+ * checks the grant is self-consistent: one to four `credentials` sessions
1259
+ * and at most one `files` session, `files` present exactly with that
1260
+ * session, unique session ids, every manifest entry read by a `credentials`
1261
+ * session, and every parameter a lease-readable name owned by the grant's
1262
+ * workspace or user.
1263
+ */
1264
+ export function parseTaskLeaseV1(value: unknown): TaskLeaseV1 | null {
1265
+ const o = exactObject(value, [
1266
+ "v",
1267
+ "leaseId",
1268
+ "taskId",
1269
+ "orgId",
1270
+ "userId",
1271
+ "runEpoch",
1272
+ "manifestRevision",
1273
+ "manifestDigest",
1274
+ "region",
1275
+ "sessions",
1276
+ "files",
1277
+ "manifest",
1278
+ ]);
1279
+ if (
1280
+ !o ||
1281
+ o.v !== 1 ||
1282
+ !isOpId(o.leaseId) ||
1283
+ !isIdValue(o.taskId) ||
1284
+ !isIdValue(o.orgId) ||
1285
+ !isIdValue(o.userId) ||
1286
+ !isPositiveInteger(o.runEpoch) ||
1287
+ !isPositiveInteger(o.manifestRevision) ||
1288
+ typeof o.manifestDigest !== "string" ||
1289
+ !DIGEST.test(o.manifestDigest) ||
1290
+ !isBoundedString(o.region, MAX_REGION_CHARS) ||
1291
+ !Array.isArray(o.sessions) ||
1292
+ o.sessions.length === 0 ||
1293
+ o.sessions.length > MAX_LEASE_SESSIONS
1294
+ ) {
1295
+ return null;
1296
+ }
1297
+ const sessions: LeaseSession[] = [];
1298
+ for (const item of o.sessions) {
1299
+ const session = parseSession(item);
1300
+ if (!session) return null;
1301
+ sessions.push(session);
1302
+ }
1303
+ const credentialSessions = new Set(
1304
+ sessions.filter((s) => s.purpose === "credentials").map((s) => s.id),
1305
+ );
1306
+ const filesSessions = sessions.filter((s) => s.purpose === "files").length;
1307
+ if (
1308
+ !uniqueBy(sessions, (s) => s.id) ||
1309
+ credentialSessions.size === 0 ||
1310
+ credentialSessions.size > MAX_LEASE_CREDENTIAL_SESSIONS ||
1311
+ filesSessions > 1 ||
1312
+ (filesSessions === 1) !== (o.files !== null)
1313
+ ) {
1314
+ return null;
1315
+ }
1316
+ const files = o.files === null ? null : parseFiles(o.files);
1317
+ if (o.files !== null && !files) return null;
1318
+ const grant = { orgId: o.orgId, userId: o.userId };
1319
+ const manifest = parseManifest(o.manifest, grant, credentialSessions);
1320
+ if (!manifest) return null;
1321
+ return {
1322
+ v: 1,
1323
+ leaseId: o.leaseId,
1324
+ taskId: o.taskId,
1325
+ orgId: o.orgId,
1326
+ userId: o.userId,
1327
+ runEpoch: o.runEpoch,
1328
+ manifestRevision: o.manifestRevision,
1329
+ manifestDigest: o.manifestDigest,
1330
+ region: o.region,
1331
+ sessions,
1332
+ files,
1333
+ manifest,
1334
+ };
1335
+ }
1336
+
1337
+ export function parseLeaseGrantV1Frame(value: unknown): LeaseGrantV1Frame | null {
1338
+ const o = exactObject(value, ["kind", "opId", "lease"]);
1339
+ if (!o || o.kind !== "lease.grant.v1" || !isOpId(o.opId)) return null;
1340
+ const lease = parseTaskLeaseV1(o.lease);
1341
+ return lease ? { kind: "lease.grant.v1", opId: o.opId, lease } : null;
1342
+ }
1343
+
1344
+ export function parseLeaseRequestV1Frame(
1345
+ value: unknown,
1346
+ ): LeaseRequestV1Frame | null {
1347
+ const o = exactObject(value, ["kind", "opId", "taskId", "runEpoch"]);
1348
+ return o &&
1349
+ o.kind === "lease.request" &&
1350
+ isOpId(o.opId) &&
1351
+ isIdValue(o.taskId) &&
1352
+ isPositiveInteger(o.runEpoch)
1353
+ ? {
1354
+ kind: "lease.request",
1355
+ opId: o.opId,
1356
+ taskId: o.taskId,
1357
+ runEpoch: o.runEpoch,
1358
+ }
1359
+ : null;
1360
+ }
1361
+
1362
+ export function parseLeaseUnavailableFrame(
1363
+ value: unknown,
1364
+ ): LeaseUnavailableFrame | null {
1365
+ const o = exactObject(value, ["kind", "opId", "error"]);
1366
+ return o &&
1367
+ o.kind === "lease.unavailable" &&
1368
+ isOpId(o.opId) &&
1369
+ isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)
1370
+ ? { kind: "lease.unavailable", opId: o.opId, error: o.error }
1371
+ : null;
1372
+ }
1373
+
1374
+ export function parseLeaseStaleFrame(value: unknown): LeaseStaleFrame | null {
1375
+ const o = exactObject(value, [
1376
+ "kind",
1377
+ "taskId",
1378
+ "runEpoch",
1379
+ "wantedRevision",
1380
+ "minRevision",
1381
+ "removed",
1382
+ ]);
1383
+ if (
1384
+ !o ||
1385
+ o.kind !== "lease.stale" ||
1386
+ !isIdValue(o.taskId) ||
1387
+ !isPositiveInteger(o.runEpoch) ||
1388
+ !isPositiveInteger(o.wantedRevision) ||
1389
+ !(o.minRevision === null || isPositiveInteger(o.minRevision)) ||
1390
+ !Array.isArray(o.removed) ||
1391
+ o.removed.length > MAX_MANIFEST_ENGINES + MAX_MANIFEST_MCP + 2 ||
1392
+ !o.removed.every(isIdValue) ||
1393
+ new Set(o.removed).size !== o.removed.length
1394
+ ) {
1395
+ return null;
1396
+ }
1397
+ // Removing a credential is what makes older grants unusable, so the two
1398
+ // travel together: `removed` without `minRevision` (or a floor above the
1399
+ // revision the runner is told to want) is refused.
1400
+ if (o.minRevision === null && o.removed.length > 0) return null;
1401
+ if (o.minRevision !== null && o.minRevision > o.wantedRevision) return null;
1402
+ return {
1403
+ kind: "lease.stale",
1404
+ taskId: o.taskId,
1405
+ runEpoch: o.runEpoch,
1406
+ wantedRevision: o.wantedRevision,
1407
+ minRevision: o.minRevision,
1408
+ removed: [...(o.removed as string[])],
1409
+ };
1410
+ }
1411
+
1412
+ export function parseCredentialChangedFrame(
1413
+ value: unknown,
1414
+ ): CredentialChangedFrame | null {
1415
+ const o = exactObject(value, [
1416
+ "kind",
1417
+ "taskId",
1418
+ "runEpoch",
1419
+ "credentialId",
1420
+ "revision",
1421
+ "version",
1422
+ ]);
1423
+ return o &&
1424
+ o.kind === "credential.changed" &&
1425
+ isIdValue(o.taskId) &&
1426
+ isPositiveInteger(o.runEpoch) &&
1427
+ isIdValue(o.credentialId) &&
1428
+ isIdValue(o.revision) &&
1429
+ isPositiveInteger(o.version)
1430
+ ? {
1431
+ kind: "credential.changed",
1432
+ taskId: o.taskId,
1433
+ runEpoch: o.runEpoch,
1434
+ credentialId: o.credentialId,
1435
+ revision: o.revision,
1436
+ version: o.version,
1437
+ }
1438
+ : null;
1439
+ }
1440
+
1441
+ export function parseCredentialRefreshFrame(
1442
+ value: unknown,
1443
+ ): CredentialRefreshFrame | null {
1444
+ const o = exactObject(value, [
1445
+ "kind",
1446
+ "opId",
1447
+ "taskId",
1448
+ "runEpoch",
1449
+ "credentialId",
1450
+ "revision",
1451
+ ]);
1452
+ return o &&
1453
+ o.kind === "credential.refresh" &&
1454
+ isOpId(o.opId) &&
1455
+ isIdValue(o.taskId) &&
1456
+ isPositiveInteger(o.runEpoch) &&
1457
+ isIdValue(o.credentialId) &&
1458
+ isIdValue(o.revision)
1459
+ ? {
1460
+ kind: "credential.refresh",
1461
+ opId: o.opId,
1462
+ taskId: o.taskId,
1463
+ runEpoch: o.runEpoch,
1464
+ credentialId: o.credentialId,
1465
+ revision: o.revision,
1466
+ }
1467
+ : null;
1468
+ }
1469
+
1470
+ const REFRESH_OUTCOMES: readonly CredentialRefreshOutcome[] = [
1471
+ "refreshed",
1472
+ "fresh",
1473
+ "needs_sign_in",
1474
+ "busy",
1475
+ "unavailable",
1476
+ ];
1477
+
1478
+ export function parseCredentialRefreshResultFrame(
1479
+ value: unknown,
1480
+ ): CredentialRefreshResultFrame | null {
1481
+ const o = exactObject(value, [
1482
+ "kind",
1483
+ "opId",
1484
+ "outcome",
1485
+ "version",
1486
+ "expiresAt",
1487
+ ]);
1488
+ if (
1489
+ !o ||
1490
+ o.kind !== "credential.refresh.result" ||
1491
+ !isOpId(o.opId) ||
1492
+ !(REFRESH_OUTCOMES as readonly unknown[]).includes(o.outcome)
1493
+ ) {
1494
+ return null;
1495
+ }
1496
+ const outcome = o.outcome as CredentialRefreshOutcome;
1497
+ const carriesVersion = outcome === "refreshed" || outcome === "fresh";
1498
+ if (carriesVersion) {
1499
+ if (
1500
+ !isPositiveInteger(o.version) ||
1501
+ !(o.expiresAt === null || isEpochMs(o.expiresAt))
1502
+ ) {
1503
+ return null;
1504
+ }
1505
+ } else if (o.version !== null || o.expiresAt !== null) {
1506
+ return null;
1507
+ }
1508
+ return {
1509
+ kind: "credential.refresh.result",
1510
+ opId: o.opId,
1511
+ outcome,
1512
+ version: o.version as number | null,
1513
+ expiresAt: o.expiresAt as number | null,
1514
+ };
1515
+ }
1516
+
1517
+ function isPhase(value: unknown): value is CredentialsActivationPhase {
1518
+ return value === "prepare" || value === "commit";
1519
+ }
1520
+
1521
+ export function parseCredentialsActivateFrame(
1522
+ value: unknown,
1523
+ ): CredentialsActivateFrame | null {
1524
+ const o = exactObject(value, ["kind", "opId", "phase"]);
1525
+ return o &&
1526
+ o.kind === "credentials.activate" &&
1527
+ isOpId(o.opId) &&
1528
+ isPhase(o.phase)
1529
+ ? { kind: "credentials.activate", opId: o.opId, phase: o.phase }
1530
+ : null;
1531
+ }
1532
+
1533
+ export function parseCredentialsActivateResultFrame(
1534
+ value: unknown,
1535
+ ): CredentialsActivateResultFrame | null {
1536
+ const o = exactObject(value, ["kind", "opId", "phase", "ok", "error"]);
1537
+ if (
1538
+ !o ||
1539
+ o.kind !== "credentials.activate.result" ||
1540
+ !isOpId(o.opId) ||
1541
+ !isPhase(o.phase) ||
1542
+ typeof o.ok !== "boolean"
1543
+ ) {
1544
+ return null;
1545
+ }
1546
+ if (o.ok ? o.error !== null : !isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)) {
1547
+ return null;
1548
+ }
1549
+ return {
1550
+ kind: "credentials.activate.result",
1551
+ opId: o.opId,
1552
+ phase: o.phase,
1553
+ ok: o.ok,
1554
+ error: o.error as string | null,
1555
+ };
1556
+ }
1557
+
1558
+ export function parseCredentialsActivatedFrame(
1559
+ value: unknown,
1560
+ ): CredentialsActivatedFrame | null {
1561
+ const o = exactObject(value, ["kind", "opId"]);
1562
+ return o && o.kind === "credentials.activated" && isOpId(o.opId)
1563
+ ? { kind: "credentials.activated", opId: o.opId }
1564
+ : null;
1565
+ }
1566
+
1567
+ // ---------------------------------------------------------------------------
1568
+ // ADR-154 §7: the broker's exact-name entry point
1569
+ // ---------------------------------------------------------------------------
1570
+
1571
+ const HOUR_MS = 60 * 60_000;
1572
+
1573
+ /**
1574
+ * When the cloud's scheduler names a lane's revision to the broker: this
1575
+ * long before its access token's known expiry, or on this cadence when the
1576
+ * expiry cannot be read. The broker's lanes keep the same numbers; the broker
1577
+ * decides from the material itself and answers `fresh` when not due.
1578
+ */
1579
+ export const CREDENTIAL_LANE_SCHEDULES: Readonly<
1580
+ Record<string, { leadMs: number; everyMs?: number }>
1581
+ > = {
1582
+ "openai-oauth": { leadMs: 48 * HOUR_MS },
1583
+ "xai-oauth": { leadMs: 3 * HOUR_MS, everyMs: 4 * HOUR_MS },
1584
+ // ADR-154 §11: MCP access tokens are often an hour long; one that names no
1585
+ // expiry is refreshed on a cadence below that.
1586
+ "mcp-oauth": { leadMs: 10 * 60_000, everyMs: 45 * 60_000 },
1587
+ };
1588
+
1589
+ /** Cloud → broker: refresh exactly this revision of this credential. */
1590
+ export interface BrokerRevisionRequest {
1591
+ v: 1;
1592
+ credentialId: string;
1593
+ revision: string;
1594
+ brokerParameter: string;
1595
+ leaseParameter: string;
1596
+ /** A runner reported the credential invalid: refresh even if not due. */
1597
+ force: boolean;
1598
+ /**
1599
+ * An adopted revision only (owner answer 3): today's parameter it was
1600
+ * adopted from, which the broker keeps agreeing with the revision (the
1601
+ * brokered copy, never the refresh token) for the runners still reading
1602
+ * it, until the fleet audit. Never created when it is gone.
1603
+ */
1604
+ legacyParameter?: string;
1605
+ }
1606
+
1607
+ /** Broker → cloud: the answer for that one credential, never material.
1608
+ * For an adopted revision (a request with `legacyParameter`), `legacy`
1609
+ * says whether today's parameter now agrees with it: `behind` when its
1610
+ * write did not land, which the cloud asks the broker to repair soon. */
1611
+ export type BrokerRevisionAnswer =
1612
+ | { outcome: "refreshed" | "fresh"; version: number; expiresAt: number | null; legacy?: "synced" | "behind" }
1613
+ | { outcome: "needs_sign_in" }
1614
+ | { outcome: "busy" }
1615
+ | { outcome: "unavailable"; error: string };
1616
+
1617
+ /** The request, exactly, or null: both parameters must name the same
1618
+ * revision of the same credential, of a class the broker refreshes. */
1619
+ export function parseBrokerRevisionRequest(
1620
+ value: unknown,
1621
+ legacyPrefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
1622
+ ): BrokerRevisionRequest | null {
1623
+ const fields = ["v", "credentialId", "revision", "brokerParameter", "leaseParameter", "force"];
1624
+ const o = exactObject(value, fields) ?? exactObject(value, [...fields, "legacyParameter"]);
1625
+ if (
1626
+ !o ||
1627
+ o.v !== 1 ||
1628
+ !isIdValue(o.credentialId) ||
1629
+ !isIdValue(o.revision) ||
1630
+ typeof o.brokerParameter !== "string" ||
1631
+ typeof o.leaseParameter !== "string" ||
1632
+ typeof o.force !== "boolean"
1633
+ ) {
1634
+ return null;
1635
+ }
1636
+ const broker = parseBrokerParameterName(o.brokerParameter);
1637
+ const lease = parseLeaseParameterName(o.leaseParameter);
1638
+ if (
1639
+ !broker ||
1640
+ !lease ||
1641
+ !sameLocation(broker, lease) ||
1642
+ (broker.class !== "engine" && broker.class !== "mcp") ||
1643
+ broker.credentialId !== o.credentialId ||
1644
+ broker.revision !== o.revision
1645
+ ) {
1646
+ return null;
1647
+ }
1648
+ if (o.legacyParameter !== undefined && !adoptedFrom(o.legacyParameter, broker, legacyPrefix)) return null;
1649
+ return {
1650
+ v: 1,
1651
+ credentialId: o.credentialId,
1652
+ revision: o.revision,
1653
+ brokerParameter: o.brokerParameter,
1654
+ leaseParameter: o.leaseParameter,
1655
+ force: o.force,
1656
+ ...(o.legacyParameter !== undefined ? { legacyParameter: o.legacyParameter as string } : {}),
1657
+ };
1658
+ }
1659
+
1660
+ /** The broker's answer, exactly, or null. */
1661
+ export function parseBrokerRevisionAnswer(value: unknown): BrokerRevisionAnswer | null {
1662
+ const outcome = (value as { outcome?: unknown } | null)?.outcome;
1663
+ if (outcome === "refreshed" || outcome === "fresh") {
1664
+ const fields = ["outcome", "version", "expiresAt"];
1665
+ const o = exactObject(value, fields) ?? exactObject(value, [...fields, "legacy"]);
1666
+ if (
1667
+ !o ||
1668
+ !isPositiveInteger(o.version) ||
1669
+ !(o.expiresAt === null || isEpochMs(o.expiresAt)) ||
1670
+ !(o.legacy === undefined || o.legacy === "synced" || o.legacy === "behind")
1671
+ ) {
1672
+ return null;
1673
+ }
1674
+ return {
1675
+ outcome,
1676
+ version: o.version,
1677
+ expiresAt: o.expiresAt,
1678
+ ...(o.legacy !== undefined ? { legacy: o.legacy as "synced" | "behind" } : {}),
1679
+ };
1680
+ }
1681
+ if (outcome === "needs_sign_in" || outcome === "busy") {
1682
+ return exactObject(value, ["outcome"]) ? { outcome } : null;
1683
+ }
1684
+ if (outcome === "unavailable") {
1685
+ const o = exactObject(value, ["outcome", "error"]);
1686
+ return o && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)
1687
+ ? { outcome, error: o.error }
1688
+ : null;
1689
+ }
1690
+ return null;
1691
+ }
1692
+
1693
+ /**
1694
+ * What a broker answer may say about a failure: the error's kind (an AWS or
1695
+ * Node error's name), never its message, which can quote a value a request
1696
+ * carried.
1697
+ */
1698
+ export function brokerErrorKind(error: unknown): string {
1699
+ const name = error instanceof Error ? error.name : "";
1700
+ return /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(name) ? name : "Error";
1701
+ }
1702
+
1703
+ // ---------------------------------------------------------------------------
1704
+ // ADR-154, owner answer 3: Uai Cloud's workspace AI accounts adopted in place
1705
+ // ---------------------------------------------------------------------------
1706
+
1707
+ /** ADR-134's default root. Installed Uai-operated tiers may configure a
1708
+ * different root; the cloud and broker must agree on the exact value. */
1709
+ export const DEFAULT_CREDENTIAL_STORE_PREFIX = "/uai";
1710
+ const CREDENTIAL_STORE_PREFIX = /^\/[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/;
1711
+
1712
+ /** A credential-store root in the same form accepted by the host setting. */
1713
+ export function parseCredentialStorePrefix(value: unknown): string | null {
1714
+ return typeof value === "string" && CREDENTIAL_STORE_PREFIX.test(value) ? value : null;
1715
+ }
1716
+
1717
+ function credentialStoreRoot(prefix: string, suffix: string): string {
1718
+ const parsed = parseCredentialStorePrefix(prefix);
1719
+ if (!parsed) throw new Error("invalid credential-store prefix");
1720
+ return `${parsed}/${suffix}`;
1721
+ }
1722
+
1723
+ /** Today's layout (ADR-134) for workspace AI accounts. */
1724
+ export function legacyEngineParameterRoot(prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX): string {
1725
+ return credentialStoreRoot(prefix, "orgs");
1726
+ }
1727
+
1728
+ /** Where the broker records adoption, with the legacy path below it. */
1729
+ export function legacyAdoptedRoot(prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX): string {
1730
+ return credentialStoreRoot(prefix, "adopted");
1731
+ }
1732
+
1733
+ /** Default-root aliases retained for callers describing the standard layout. */
1734
+ export const LEGACY_ENGINE_PARAMETER_ROOT = legacyEngineParameterRoot();
1735
+ export const LEGACY_ADOPTED_ROOT = legacyAdoptedRoot();
1736
+ /** At most this many accounts in one listing page. */
1737
+ export const MAX_LEGACY_ACCOUNTS = 500;
1738
+ const MAX_LEGACY_LABEL_CHARS = 256;
1739
+ /** ADR-134's segment rule, as its ids are actually minted (no dots). */
1740
+ const LEGACY_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
1741
+
1742
+ /** A parameter under today's layout the cloud adopts: a workspace AI
1743
+ * account. People's MCP connections wait for a fence against the v0 task
1744
+ * machines that refresh them themselves. */
1745
+ export type LegacyParameter = { kind: "engine"; orgId: string; engineKind: string; accountId: string };
1746
+
1747
+ export type LegacyEngineParameter = Extract<LegacyParameter, { kind: "engine" }>;
1748
+
1749
+ /** A parameter under today's layout the cloud adopts, or null. */
1750
+ export function parseLegacyParameterName(
1751
+ name: unknown,
1752
+ prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
1753
+ ): LegacyParameter | null {
1754
+ const parsedPrefix = parseCredentialStorePrefix(prefix);
1755
+ if (typeof name !== "string" || !parsedPrefix) return null;
1756
+ const engineRoot = `${parsedPrefix}/orgs`;
1757
+ if (name.startsWith(`${engineRoot}/`)) {
1758
+ const parts = name.slice(engineRoot.length + 1).split("/");
1759
+ if (parts.length !== 4 || parts[1] !== "engines") return null;
1760
+ const [orgId, , engineKind, accountId] = parts as [string, string, string, string];
1761
+ if (!LEGACY_SEGMENT.test(orgId) || !isEngineKind(engineKind) || !LEGACY_SEGMENT.test(accountId)) return null;
1762
+ return { kind: "engine", orgId, engineKind, accountId };
1763
+ }
1764
+ return null;
1765
+ }
1766
+
1767
+ /** A workspace AI account's parameter under today's layout, or null. */
1768
+ export function parseLegacyEngineParameterName(
1769
+ name: unknown,
1770
+ prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
1771
+ ): LegacyEngineParameter | null {
1772
+ const legacy = parseLegacyParameterName(name, prefix);
1773
+ return legacy?.kind === "engine" ? legacy : null;
1774
+ }
1775
+
1776
+ /** The adoption marker for a legacy parameter: the same path under that
1777
+ * store's adopted root (`<prefix>/adopted/orgs/…`). */
1778
+ export function legacyAdoptionMarkerName(
1779
+ legacyParameter: string,
1780
+ prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
1781
+ ): string {
1782
+ if (!parseLegacyParameterName(legacyParameter, prefix)) {
1783
+ throw new Error("not a legacy parameter the cloud adopts");
1784
+ }
1785
+ return `${legacyAdoptedRoot(prefix)}${legacyParameter.slice(prefix.length)}`;
1786
+ }
1787
+
1788
+ /** A parameter under today's layout may be adopted into (and be kept
1789
+ * agreeing with) exactly this credential's location: a workspace AI account
1790
+ * into that workspace's account of the same kind. */
1791
+ function adoptedFrom(
1792
+ legacyParameter: unknown,
1793
+ location: CredentialLocation,
1794
+ prefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
1795
+ ): boolean {
1796
+ const legacy = parseLegacyParameterName(legacyParameter, prefix);
1797
+ if (legacy?.kind === "engine") {
1798
+ return (
1799
+ location.class === "engine" &&
1800
+ location.scope === "org" &&
1801
+ location.ownerId === legacy.orgId &&
1802
+ location.engineKind === legacy.engineKind
1803
+ );
1804
+ }
1805
+ return false;
1806
+ }
1807
+
1808
+ /** Cloud → broker: one page of today's workspace AI accounts, metadata
1809
+ * only, by parameter name, after `after` (null: from the first). */
1810
+ export interface BrokerLegacyListRequest {
1811
+ v: 1;
1812
+ op: "legacy.list";
1813
+ after: string | null;
1814
+ }
1815
+
1816
+ export interface BrokerLegacyAccount {
1817
+ parameter: string;
1818
+ orgId: string;
1819
+ engineKind: string;
1820
+ accountId: string;
1821
+ label: string;
1822
+ authKind: "env" | "config-dir";
1823
+ position: number;
1824
+ /** The lane that refreshes it; null for a key that does not rotate. */
1825
+ lane: string | null;
1826
+ /** The revision it was adopted into, once it was. */
1827
+ adopted: { credentialId: string; revision: string } | null;
1828
+ /** Why it cannot be adopted as it is (a fixed reason code), or null. */
1829
+ unsupported: string | null;
1830
+ }
1831
+
1832
+ export type BrokerLegacyListAnswer =
1833
+ /** `next`: the name to list after for the following page, or null. */
1834
+ | { outcome: "listed"; accounts: BrokerLegacyAccount[]; next: string | null }
1835
+ | { outcome: "unavailable"; error: string };
1836
+
1837
+ /** Cloud → broker: adopt one parameter under today's layout as this
1838
+ * revision, which the cloud has opened for it in `orgId` (and for `userId`,
1839
+ * a person's). */
1840
+ export interface BrokerAdoptRequest {
1841
+ v: 1;
1842
+ op: "legacy.adopt";
1843
+ legacyParameter: string;
1844
+ credentialId: string;
1845
+ revision: string;
1846
+ leaseParameter: string;
1847
+ /** The broker-only part, exactly when the material rotates. */
1848
+ brokerParameter: string | null;
1849
+ orgId: string;
1850
+ userId: string | null;
1851
+ }
1852
+
1853
+ export type BrokerAdoptAnswer =
1854
+ /** Both parts exist; `version` is the lease-readable part's. */
1855
+ | { outcome: "adopted"; version: number; expiresAt: number | null }
1856
+ /** Today's parameter is gone: there is nothing to adopt. */
1857
+ | { outcome: "absent" }
1858
+ /** It cannot be adopted as it is, ever (a fixed reason code). */
1859
+ | { outcome: "unsupported"; reason: string }
1860
+ | { outcome: "busy" }
1861
+ | { outcome: "unavailable"; error: string };
1862
+
1863
+ const isReasonCode = (value: unknown): value is string =>
1864
+ typeof value === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(value);
1865
+
1866
+ export function parseBrokerLegacyListRequest(
1867
+ value: unknown,
1868
+ legacyPrefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
1869
+ ): BrokerLegacyListRequest | null {
1870
+ const o = exactObject(value, ["v", "op", "after"]);
1871
+ if (!o || o.v !== 1 || o.op !== "legacy.list") return null;
1872
+ if (o.after !== null && !parseLegacyEngineParameterName(o.after, legacyPrefix)) return null;
1873
+ return { v: 1, op: "legacy.list", after: o.after as string | null };
1874
+ }
1875
+
1876
+ export function parseBrokerAdoptRequest(
1877
+ value: unknown,
1878
+ legacyPrefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
1879
+ ): BrokerAdoptRequest | null {
1880
+ const o = exactObject(value, [
1881
+ "v",
1882
+ "op",
1883
+ "legacyParameter",
1884
+ "credentialId",
1885
+ "revision",
1886
+ "leaseParameter",
1887
+ "brokerParameter",
1888
+ "orgId",
1889
+ "userId",
1890
+ ]);
1891
+ if (
1892
+ !o ||
1893
+ o.v !== 1 ||
1894
+ o.op !== "legacy.adopt" ||
1895
+ typeof o.legacyParameter !== "string" ||
1896
+ !isIdValue(o.credentialId) ||
1897
+ !isIdValue(o.revision) ||
1898
+ typeof o.leaseParameter !== "string" ||
1899
+ (o.brokerParameter !== null && typeof o.brokerParameter !== "string") ||
1900
+ !isIdValue(o.orgId) ||
1901
+ (o.userId !== null && !isIdValue(o.userId))
1902
+ ) {
1903
+ return null;
1904
+ }
1905
+ const lease = parseLeaseParameterName(o.leaseParameter);
1906
+ if (
1907
+ !lease ||
1908
+ !adoptedFrom(o.legacyParameter, lease, legacyPrefix) ||
1909
+ lease.credentialId !== o.credentialId ||
1910
+ lease.revision !== o.revision ||
1911
+ // The flow's owners: a workspace's account is the workspace's alone; a
1912
+ // person's connection is theirs, in a workspace.
1913
+ (lease.scope === "org" ? o.userId !== null || o.orgId !== lease.ownerId : o.userId !== lease.ownerId)
1914
+ ) {
1915
+ return null;
1916
+ }
1917
+ if (o.brokerParameter !== null) {
1918
+ const broker = parseBrokerParameterName(o.brokerParameter);
1919
+ if (!broker || !sameLocation(broker, lease)) return null;
1920
+ }
1921
+ return {
1922
+ v: 1,
1923
+ op: "legacy.adopt",
1924
+ legacyParameter: o.legacyParameter,
1925
+ credentialId: o.credentialId,
1926
+ revision: o.revision,
1927
+ leaseParameter: o.leaseParameter,
1928
+ brokerParameter: o.brokerParameter as string | null,
1929
+ orgId: o.orgId,
1930
+ userId: o.userId as string | null,
1931
+ };
1932
+ }
1933
+
1934
+ function parseLegacyAccount(
1935
+ value: unknown,
1936
+ legacyPrefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
1937
+ ): BrokerLegacyAccount | null {
1938
+ const o = exactObject(value, [
1939
+ "parameter",
1940
+ "orgId",
1941
+ "engineKind",
1942
+ "accountId",
1943
+ "label",
1944
+ "authKind",
1945
+ "position",
1946
+ "lane",
1947
+ "adopted",
1948
+ "unsupported",
1949
+ ]);
1950
+ const legacy = o ? parseLegacyEngineParameterName(o.parameter, legacyPrefix) : null;
1951
+ if (
1952
+ !o ||
1953
+ !legacy ||
1954
+ legacy.orgId !== o.orgId ||
1955
+ legacy.engineKind !== o.engineKind ||
1956
+ legacy.accountId !== o.accountId ||
1957
+ !isBoundedString(o.label, MAX_LEGACY_LABEL_CHARS) ||
1958
+ (o.authKind !== "env" && o.authKind !== "config-dir") ||
1959
+ typeof o.position !== "number" ||
1960
+ !Number.isSafeInteger(o.position) ||
1961
+ (o.lane !== null && !isBoundedString(o.lane, 64)) ||
1962
+ (o.unsupported !== null && !isReasonCode(o.unsupported))
1963
+ ) {
1964
+ return null;
1965
+ }
1966
+ let adopted: BrokerLegacyAccount["adopted"] = null;
1967
+ if (o.adopted !== null) {
1968
+ const a = exactObject(o.adopted, ["credentialId", "revision"]);
1969
+ if (!a || !isIdValue(a.credentialId) || !isIdValue(a.revision)) return null;
1970
+ adopted = { credentialId: a.credentialId, revision: a.revision };
1971
+ }
1972
+ return {
1973
+ parameter: o.parameter as string,
1974
+ orgId: legacy.orgId,
1975
+ engineKind: legacy.engineKind,
1976
+ accountId: legacy.accountId,
1977
+ label: o.label,
1978
+ authKind: o.authKind,
1979
+ position: o.position,
1980
+ lane: o.lane as string | null,
1981
+ adopted,
1982
+ unsupported: o.unsupported as string | null,
1983
+ };
1984
+ }
1985
+
1986
+ /** The broker's listing page, exactly, or null. */
1987
+ export function parseBrokerLegacyListAnswer(
1988
+ value: unknown,
1989
+ legacyPrefix: string = DEFAULT_CREDENTIAL_STORE_PREFIX,
1990
+ ): BrokerLegacyListAnswer | null {
1991
+ const outcome = (value as { outcome?: unknown } | null)?.outcome;
1992
+ if (outcome === "listed") {
1993
+ const o = exactObject(value, ["outcome", "accounts", "next"]);
1994
+ if (!o || !Array.isArray(o.accounts) || o.accounts.length > MAX_LEGACY_ACCOUNTS) return null;
1995
+ if (o.next !== null && !parseLegacyEngineParameterName(o.next, legacyPrefix)) return null;
1996
+ const accounts: BrokerLegacyAccount[] = [];
1997
+ for (const entry of o.accounts) {
1998
+ const account = parseLegacyAccount(entry, legacyPrefix);
1999
+ if (!account) return null;
2000
+ accounts.push(account);
2001
+ }
2002
+ return { outcome, accounts, next: o.next as string | null };
2003
+ }
2004
+ if (outcome === "unavailable") {
2005
+ const o = exactObject(value, ["outcome", "error"]);
2006
+ return o && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS) ? { outcome, error: o.error } : null;
2007
+ }
2008
+ return null;
2009
+ }
2010
+
2011
+ /** The broker's answer to an adoption, exactly, or null. */
2012
+ export function parseBrokerAdoptAnswer(value: unknown): BrokerAdoptAnswer | null {
2013
+ const outcome = (value as { outcome?: unknown } | null)?.outcome;
2014
+ if (outcome === "adopted") {
2015
+ const o = exactObject(value, ["outcome", "version", "expiresAt"]);
2016
+ return o && isPositiveInteger(o.version) && (o.expiresAt === null || isEpochMs(o.expiresAt))
2017
+ ? { outcome, version: o.version, expiresAt: o.expiresAt }
2018
+ : null;
2019
+ }
2020
+ if (outcome === "absent" || outcome === "busy") {
2021
+ return exactObject(value, ["outcome"]) ? { outcome } : null;
2022
+ }
2023
+ if (outcome === "unsupported") {
2024
+ const o = exactObject(value, ["outcome", "reason"]);
2025
+ return o && isReasonCode(o.reason) ? { outcome, reason: o.reason } : null;
2026
+ }
2027
+ if (outcome === "unavailable") {
2028
+ const o = exactObject(value, ["outcome", "error"]);
2029
+ return o && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS) ? { outcome, error: o.error } : null;
2030
+ }
2031
+ return null;
2032
+ }
2033
+
2034
+ // ---------------------------------------------------------------------------
2035
+ // ADR-154 §11: an MCP connection's handshake on the sign-in box
2036
+ // ---------------------------------------------------------------------------
2037
+
2038
+ /** Advertised by a sign-in box that runs MCP handshakes as capture flows. */
2039
+ export const SIGNIN_BOX_MCP_CAPTURE_FEATURE = "signin-box-mcp-capture-v1";
2040
+
2041
+ const MAX_MCP_URL_CHARS = 2048;
2042
+ const MAX_MCP_STATE_CHARS = 512;
2043
+ const MAX_MCP_CODE_CHARS = 4096;
2044
+ const MAX_MCP_CLIENT_CHARS = 1024;
2045
+ const MAX_MCP_SCOPES = 64;
2046
+
2047
+ /** Cloud → box: probe an MCP server for a connection being added. */
2048
+ export interface McpCaptureProbeFrame {
2049
+ kind: "mcp.capture.probe";
2050
+ opId: string;
2051
+ flow: CaptureFlowWire;
2052
+ url: string;
2053
+ /** A static header the server takes, when the person gave one. */
2054
+ header: { name: string; value: string } | null;
2055
+ /** A client the person (or the catalog) brings, when the authorization
2056
+ * server cannot register one. */
2057
+ clientId: string | null;
2058
+ clientSecret: string | null;
2059
+ redirectUri: string;
2060
+ state: string;
2061
+ /** The scopes to ask for (a catalog entry's); null asks for none and
2062
+ * takes the authorization server's default. Never its whole
2063
+ * `scopes_supported`, which is a capability list, not a need. */
2064
+ scopes: string[] | null;
2065
+ }
2066
+
2067
+ /** Cloud → box: the authorization server redirected back. */
2068
+ export interface McpCaptureCompleteFrame {
2069
+ kind: "mcp.capture.complete";
2070
+ opId: string;
2071
+ flow: CaptureFlowWire;
2072
+ code: string;
2073
+ state: string;
2074
+ /** RFC 9207: the issuer the redirect named, when it named one. */
2075
+ iss: string | null;
2076
+ }
2077
+
2078
+ /** Cloud → box: the cloud ended this sign-in (expired, cancelled): forget its
2079
+ * verifier and client secret now. No answer. */
2080
+ export interface McpCaptureCancelFrame {
2081
+ kind: "mcp.capture.cancel";
2082
+ opId: string;
2083
+ flow: CaptureFlowWire;
2084
+ state: string;
2085
+ }
2086
+
2087
+ export type McpCaptureOutcome = "none" | "token" | "auth_required" | "connected" | "error";
2088
+
2089
+ /** Box → cloud: what the handshake came to. `capture` is the revision it
2090
+ * wrote (`token`, `connected`); `authorizeUrl` where the person signs in
2091
+ * (`auth_required`); `error` why it failed. Never material. */
2092
+ export interface McpCaptureAckFrame {
2093
+ kind: "mcp.capture.ack";
2094
+ opId: string;
2095
+ outcome: McpCaptureOutcome;
2096
+ capture: CaptureResultWire | null;
2097
+ authorizeUrl: string | null;
2098
+ scopes: string[] | null;
2099
+ error: string | null;
2100
+ }
2101
+
2102
+ function isHttpsUrl(value: unknown): value is string {
2103
+ if (!isBoundedString(value, MAX_MCP_URL_CHARS)) return false;
2104
+ try {
2105
+ const url = new URL(value);
2106
+ return url.protocol === "https:" && url.username === "" && url.password === "" && url.hostname !== "";
2107
+ } catch {
2108
+ return false;
2109
+ }
2110
+ }
2111
+
2112
+ function isMcpFlow(value: unknown): value is CaptureFlowWire {
2113
+ const flow = parseCaptureFlowWire(value);
2114
+ return flow !== null && flow.location.class === "mcp";
2115
+ }
2116
+
2117
+ function isOptionalClient(value: unknown): value is string | null {
2118
+ return value === null || isBoundedString(value, MAX_MCP_CLIENT_CHARS);
2119
+ }
2120
+
2121
+ /** RFC 6749 scope tokens: printable ASCII but space, `"` and `\\`. */
2122
+ const SCOPE_TOKEN = /^[\x21\x23-\x5B\x5D-\x7E]{1,128}$/;
2123
+
2124
+ export function isScopeList(value: unknown): value is string[] {
2125
+ return (
2126
+ Array.isArray(value) &&
2127
+ value.length <= MAX_MCP_SCOPES &&
2128
+ value.every((scope) => typeof scope === "string" && SCOPE_TOKEN.test(scope))
2129
+ );
2130
+ }
2131
+
2132
+ export function parseMcpCaptureProbeFrame(value: unknown): McpCaptureProbeFrame | null {
2133
+ const o = exactObject(value, [
2134
+ "kind",
2135
+ "opId",
2136
+ "flow",
2137
+ "url",
2138
+ "header",
2139
+ "clientId",
2140
+ "clientSecret",
2141
+ "redirectUri",
2142
+ "state",
2143
+ "scopes",
2144
+ ]);
2145
+ if (
2146
+ !o ||
2147
+ o.kind !== "mcp.capture.probe" ||
2148
+ !isOpId(o.opId) ||
2149
+ !isMcpFlow(o.flow) ||
2150
+ !isHttpsUrl(o.url) ||
2151
+ !isOptionalClient(o.clientId) ||
2152
+ !isOptionalClient(o.clientSecret) ||
2153
+ !isHttpsUrl(o.redirectUri) ||
2154
+ !isBoundedString(o.state, MAX_MCP_STATE_CHARS) ||
2155
+ !(o.scopes === null || isScopeList(o.scopes))
2156
+ ) {
2157
+ return null;
2158
+ }
2159
+ let header: { name: string; value: string } | null = null;
2160
+ if (o.header !== null) {
2161
+ const h = exactObject(o.header, ["name", "value"]);
2162
+ if (
2163
+ !h ||
2164
+ typeof h.name !== "string" ||
2165
+ !HEADER_NAME.test(h.name) ||
2166
+ FORBIDDEN_HEADER_NAMES.has(h.name.toLowerCase()) ||
2167
+ !isBoundedString(h.value, 4096) ||
2168
+ /[\r\n]/.test(h.value)
2169
+ ) {
2170
+ return null;
2171
+ }
2172
+ header = { name: h.name, value: h.value };
2173
+ }
2174
+ return {
2175
+ kind: "mcp.capture.probe",
2176
+ opId: o.opId,
2177
+ flow: o.flow as CaptureFlowWire,
2178
+ url: o.url,
2179
+ header,
2180
+ clientId: o.clientId,
2181
+ clientSecret: o.clientSecret,
2182
+ redirectUri: o.redirectUri,
2183
+ state: o.state,
2184
+ scopes: o.scopes === null ? null : [...(o.scopes as string[])],
2185
+ };
2186
+ }
2187
+
2188
+ export function parseMcpCaptureCompleteFrame(value: unknown): McpCaptureCompleteFrame | null {
2189
+ const o = exactObject(value, ["kind", "opId", "flow", "code", "state", "iss"]);
2190
+ return o &&
2191
+ o.kind === "mcp.capture.complete" &&
2192
+ isOpId(o.opId) &&
2193
+ isMcpFlow(o.flow) &&
2194
+ isBoundedString(o.code, MAX_MCP_CODE_CHARS) &&
2195
+ isBoundedString(o.state, MAX_MCP_STATE_CHARS) &&
2196
+ (o.iss === null || isBoundedString(o.iss, MAX_MCP_URL_CHARS))
2197
+ ? {
2198
+ kind: "mcp.capture.complete",
2199
+ opId: o.opId,
2200
+ flow: o.flow as CaptureFlowWire,
2201
+ code: o.code,
2202
+ state: o.state,
2203
+ iss: o.iss,
2204
+ }
2205
+ : null;
2206
+ }
2207
+
2208
+ export function parseMcpCaptureCancelFrame(value: unknown): McpCaptureCancelFrame | null {
2209
+ const o = exactObject(value, ["kind", "opId", "flow", "state"]);
2210
+ return o &&
2211
+ o.kind === "mcp.capture.cancel" &&
2212
+ isOpId(o.opId) &&
2213
+ isMcpFlow(o.flow) &&
2214
+ isBoundedString(o.state, MAX_MCP_STATE_CHARS)
2215
+ ? { kind: "mcp.capture.cancel", opId: o.opId, flow: o.flow as CaptureFlowWire, state: o.state }
2216
+ : null;
2217
+ }
2218
+
2219
+ export function parseMcpCaptureAckFrame(value: unknown): McpCaptureAckFrame | null {
2220
+ const o = exactObject(value, ["kind", "opId", "outcome", "capture", "authorizeUrl", "scopes", "error"]);
2221
+ if (!o || o.kind !== "mcp.capture.ack" || !isOpId(o.opId)) return null;
2222
+ const outcome = o.outcome;
2223
+ const capture = o.capture === null ? null : parseCaptureResultWire(o.capture);
2224
+ if (o.capture !== null && !capture) return null;
2225
+ const scopes = o.scopes === null ? null : isScopeList(o.scopes) ? [...o.scopes] : undefined;
2226
+ if (scopes === undefined) return null;
2227
+ const base = { kind: "mcp.capture.ack" as const, opId: o.opId, scopes };
2228
+ switch (outcome) {
2229
+ case "none":
2230
+ return capture === null && o.authorizeUrl === null && o.error === null
2231
+ ? { ...base, outcome, capture: null, authorizeUrl: null, error: null }
2232
+ : null;
2233
+ case "token":
2234
+ case "connected":
2235
+ return capture !== null && o.authorizeUrl === null && o.error === null
2236
+ ? { ...base, outcome, capture, authorizeUrl: null, error: null }
2237
+ : null;
2238
+ case "auth_required":
2239
+ return capture === null && isHttpsUrl(o.authorizeUrl) && o.error === null
2240
+ ? { ...base, outcome, capture: null, authorizeUrl: o.authorizeUrl, error: null }
2241
+ : null;
2242
+ case "error":
2243
+ return capture === null && o.authorizeUrl === null && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)
2244
+ ? { ...base, outcome, capture: null, authorizeUrl: null, error: o.error }
2245
+ : null;
2246
+ default:
2247
+ return null;
2248
+ }
2249
+ }
2250
+
2251
+ // ---------------------------------------------------------------------------
2252
+ // GitHub sign-ins and SSH keys on the sign-in box (ADR-154 §12)
2253
+ // ---------------------------------------------------------------------------
2254
+
2255
+ /** Advertised by a sign-in box that writes GitHub and SSH revisions, lists a
2256
+ * person's GitHub installations from the revision the cloud names, and
2257
+ * revokes a retired GitHub token. */
2258
+ export const SIGNIN_BOX_PERSON_CREDENTIALS_FEATURE = "signin-box-person-credentials-v1";
2259
+
2260
+ const MAX_GITHUB_TOKEN_CHARS = 512;
2261
+ const MAX_GITHUB_CLIENT_ID_CHARS = 128;
2262
+ const MAX_GITHUB_CLIENT_SECRET_CHARS = 256;
2263
+ export const MAX_SSH_PUBLIC_KEY_CHARS = 1024;
2264
+
2265
+ /** Cloud → box: write the GitHub user token the cloud's callback obtained as
2266
+ * this revision. This frame is the token's only trip past the cloud. */
2267
+ export interface GithubCaptureFrame {
2268
+ kind: "github.capture";
2269
+ opId: string;
2270
+ flow: CaptureFlowWire;
2271
+ token: string;
2272
+ }
2273
+
2274
+ /** Cloud → box: mint an SSH key as this revision. The private half goes to
2275
+ * the revision's parameter and nowhere else. */
2276
+ export interface SshMintFrame {
2277
+ kind: "ssh.mint";
2278
+ opId: string;
2279
+ flow: CaptureFlowWire;
2280
+ }
2281
+
2282
+ /** Box → cloud: the revision written (with an SSH key's public half), or why
2283
+ * not. Never material. */
2284
+ export interface PersonCaptureAckFrame {
2285
+ kind: "person.capture.ack";
2286
+ opId: string;
2287
+ capture: CaptureResultWire | null;
2288
+ publicKey: string | null;
2289
+ error: string | null;
2290
+ }
2291
+
2292
+ /** Cloud → box: revoke a retired GitHub revision's token at GitHub. The App's
2293
+ * client id and secret ride the frame; the box keeps neither. */
2294
+ export interface GithubRevokeFrame {
2295
+ kind: "github.revoke";
2296
+ opId: string;
2297
+ /** The retired revision's lease-readable parameter. */
2298
+ parameter: string;
2299
+ clientId: string;
2300
+ clientSecret: string;
2301
+ }
2302
+
2303
+ /** `revoked`: GitHub confirmed; `absent`: the token, or its parameter, is
2304
+ * gone already; `retry`: ask again later. */
2305
+ export type GithubRevokeOutcome = "revoked" | "absent" | "retry";
2306
+
2307
+ export interface GithubRevokeAckFrame {
2308
+ kind: "github.revoke.ack";
2309
+ opId: string;
2310
+ outcome: GithubRevokeOutcome;
2311
+ error: string | null;
2312
+ }
2313
+
2314
+ function isPersonFlow(value: unknown, credentialClass: "github" | "ssh"): value is CaptureFlowWire {
2315
+ const flow = parseCaptureFlowWire(value);
2316
+ return flow !== null && flow.location.class === credentialClass;
2317
+ }
2318
+
2319
+ /** A GitHub token as the leased value holds it: bounded, one word. */
2320
+ export function isGithubToken(value: unknown): value is string {
2321
+ return isBoundedString(value, MAX_GITHUB_TOKEN_CHARS) && !/\s/.test(value);
2322
+ }
2323
+
2324
+ export function parseGithubCaptureFrame(value: unknown): GithubCaptureFrame | null {
2325
+ const o = exactObject(value, ["kind", "opId", "flow", "token"]);
2326
+ return o &&
2327
+ o.kind === "github.capture" &&
2328
+ isOpId(o.opId) &&
2329
+ isPersonFlow(o.flow, "github") &&
2330
+ isGithubToken(o.token)
2331
+ ? { kind: "github.capture", opId: o.opId, flow: o.flow as CaptureFlowWire, token: o.token }
2332
+ : null;
2333
+ }
2334
+
2335
+ export function parseSshMintFrame(value: unknown): SshMintFrame | null {
2336
+ const o = exactObject(value, ["kind", "opId", "flow"]);
2337
+ return o && o.kind === "ssh.mint" && isOpId(o.opId) && isPersonFlow(o.flow, "ssh")
2338
+ ? { kind: "ssh.mint", opId: o.opId, flow: o.flow as CaptureFlowWire }
2339
+ : null;
2340
+ }
2341
+
2342
+ export function parsePersonCaptureAckFrame(value: unknown): PersonCaptureAckFrame | null {
2343
+ const o = exactObject(value, ["kind", "opId", "capture", "publicKey", "error"]);
2344
+ if (!o || o.kind !== "person.capture.ack" || !isOpId(o.opId)) return null;
2345
+ const capture = o.capture === null ? null : parseCaptureResultWire(o.capture);
2346
+ if (o.capture !== null && !capture) return null;
2347
+ if (!(o.publicKey === null || (isBoundedString(o.publicKey, MAX_SSH_PUBLIC_KEY_CHARS) && !/[\r\n]/.test(o.publicKey)))) {
2348
+ return null;
2349
+ }
2350
+ if (capture !== null) {
2351
+ return o.error === null
2352
+ ? { kind: "person.capture.ack", opId: o.opId, capture, publicKey: o.publicKey, error: null }
2353
+ : null;
2354
+ }
2355
+ return o.publicKey === null && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)
2356
+ ? { kind: "person.capture.ack", opId: o.opId, capture: null, publicKey: null, error: o.error }
2357
+ : null;
2358
+ }
2359
+
2360
+ export function parseGithubRevokeFrame(value: unknown): GithubRevokeFrame | null {
2361
+ const o = exactObject(value, ["kind", "opId", "parameter", "clientId", "clientSecret"]);
2362
+ if (
2363
+ !o ||
2364
+ o.kind !== "github.revoke" ||
2365
+ !isOpId(o.opId) ||
2366
+ typeof o.parameter !== "string" ||
2367
+ parseLeaseParameterName(o.parameter)?.class !== "github" ||
2368
+ !isBoundedString(o.clientId, MAX_GITHUB_CLIENT_ID_CHARS) ||
2369
+ !isBoundedString(o.clientSecret, MAX_GITHUB_CLIENT_SECRET_CHARS)
2370
+ ) {
2371
+ return null;
2372
+ }
2373
+ return {
2374
+ kind: "github.revoke",
2375
+ opId: o.opId,
2376
+ parameter: o.parameter,
2377
+ clientId: o.clientId,
2378
+ clientSecret: o.clientSecret,
2379
+ };
2380
+ }
2381
+
2382
+ export function parseGithubRevokeAckFrame(value: unknown): GithubRevokeAckFrame | null {
2383
+ const o = exactObject(value, ["kind", "opId", "outcome", "error"]);
2384
+ if (!o || o.kind !== "github.revoke.ack" || !isOpId(o.opId)) return null;
2385
+ if (o.outcome === "revoked" || o.outcome === "absent") {
2386
+ return o.error === null ? { kind: "github.revoke.ack", opId: o.opId, outcome: o.outcome, error: null } : null;
2387
+ }
2388
+ return o.outcome === "retry" && isBoundedString(o.error, MAX_LEASE_ERROR_CHARS)
2389
+ ? { kind: "github.revoke.ack", opId: o.opId, outcome: "retry", error: o.error }
2390
+ : null;
2391
+ }
2392
+
2393
+ /** A GitHub revision the cloud names for the repository picker: it must be
2394
+ * that person's own. */
2395
+ export function isPersonGithubParameter(parameter: unknown, userId: string): parameter is string {
2396
+ if (typeof parameter !== "string") return false;
2397
+ const location = parseLeaseParameterName(parameter);
2398
+ return location !== null && location.class === "github" && location.ownerId === userId;
2399
+ }