@caelo-cms/provisioning 0.10.55 → 0.10.57

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 (209) hide show
  1. package/dist/agent-db.js +76 -19
  2. package/dist/cli-routing.js +1 -1
  3. package/dist/cli-routing.js.map +1 -1
  4. package/dist/cli.js +4 -1
  5. package/dist/cli.js.map +1 -1
  6. package/dist/compose.js +5 -0
  7. package/dist/compose.js.map +1 -1
  8. package/dist/converge-checkpoint.d.ts +66 -0
  9. package/dist/converge-checkpoint.d.ts.map +1 -0
  10. package/dist/converge-checkpoint.js +61 -0
  11. package/dist/converge-checkpoint.js.map +1 -0
  12. package/dist/converge-job-gcp.d.ts +46 -0
  13. package/dist/converge-job-gcp.d.ts.map +1 -0
  14. package/dist/converge-job-gcp.js +368 -0
  15. package/dist/converge-job-gcp.js.map +1 -0
  16. package/dist/converge-job-setup.d.ts +62 -0
  17. package/dist/converge-job-setup.d.ts.map +1 -0
  18. package/dist/converge-job-setup.js +228 -0
  19. package/dist/converge-job-setup.js.map +1 -0
  20. package/dist/converge-job.d.ts +141 -0
  21. package/dist/converge-job.d.ts.map +1 -0
  22. package/dist/converge-job.js +325 -0
  23. package/dist/converge-job.js.map +1 -0
  24. package/dist/gcp-names.d.ts +9 -0
  25. package/dist/gcp-names.d.ts.map +1 -1
  26. package/dist/gcp-names.js +13 -0
  27. package/dist/gcp-names.js.map +1 -1
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/infra-contract.d.ts +1 -1
  33. package/dist/infra-contract.d.ts.map +1 -1
  34. package/dist/infra-contract.js +22 -1
  35. package/dist/infra-contract.js.map +1 -1
  36. package/dist/install-job.d.ts +11 -3
  37. package/dist/install-job.d.ts.map +1 -1
  38. package/dist/install-job.js +22 -12
  39. package/dist/install-job.js.map +1 -1
  40. package/dist/lifecycle.d.ts +13 -1
  41. package/dist/lifecycle.d.ts.map +1 -1
  42. package/dist/lifecycle.js +115 -58
  43. package/dist/lifecycle.js.map +1 -1
  44. package/dist/operator-access.d.ts.map +1 -1
  45. package/dist/operator-access.js +13 -3
  46. package/dist/operator-access.js.map +1 -1
  47. package/dist/release-contract.d.ts +3 -2
  48. package/dist/release-contract.d.ts.map +1 -1
  49. package/dist/release-contract.js +5 -2
  50. package/dist/release-contract.js.map +1 -1
  51. package/dist/release-manifest.d.ts +2 -0
  52. package/dist/release-manifest.d.ts.map +1 -1
  53. package/dist/release-manifest.js.map +1 -1
  54. package/dist/remote-mcp-edge.d.ts +114 -0
  55. package/dist/remote-mcp-edge.d.ts.map +1 -0
  56. package/dist/remote-mcp-edge.js +116 -0
  57. package/dist/remote-mcp-edge.js.map +1 -0
  58. package/dist/remote-mcp.d.ts +117 -0
  59. package/dist/remote-mcp.d.ts.map +1 -0
  60. package/dist/remote-mcp.js +379 -0
  61. package/dist/remote-mcp.js.map +1 -0
  62. package/dist/runtime-secrets.d.ts +12 -0
  63. package/dist/runtime-secrets.d.ts.map +1 -1
  64. package/dist/runtime-secrets.js +32 -4
  65. package/dist/runtime-secrets.js.map +1 -1
  66. package/dist/seal-key-rotation.d.ts +38 -0
  67. package/dist/seal-key-rotation.d.ts.map +1 -0
  68. package/dist/seal-key-rotation.js +206 -0
  69. package/dist/seal-key-rotation.js.map +1 -0
  70. package/dist/stack-contract.d.ts +167 -3
  71. package/dist/stack-contract.d.ts.map +1 -1
  72. package/dist/stack-contract.js +171 -3
  73. package/dist/stack-contract.js.map +1 -1
  74. package/dist/stack-converge.d.ts +38 -1
  75. package/dist/stack-converge.d.ts.map +1 -1
  76. package/dist/stack-converge.js +74 -4
  77. package/dist/stack-converge.js.map +1 -1
  78. package/dist/state-lock-lease.d.ts +142 -0
  79. package/dist/state-lock-lease.d.ts.map +1 -0
  80. package/dist/state-lock-lease.js +347 -0
  81. package/dist/state-lock-lease.js.map +1 -0
  82. package/dist/state-store.d.ts +66 -9
  83. package/dist/state-store.d.ts.map +1 -1
  84. package/dist/state-store.js +249 -30
  85. package/dist/state-store.js.map +1 -1
  86. package/dist/upgrade/app-roll.d.ts +36 -7
  87. package/dist/upgrade/app-roll.d.ts.map +1 -1
  88. package/dist/upgrade/app-roll.js +53 -19
  89. package/dist/upgrade/app-roll.js.map +1 -1
  90. package/dist/upgrade/converge.d.ts.map +1 -1
  91. package/dist/upgrade/converge.js +111 -9
  92. package/dist/upgrade/converge.js.map +1 -1
  93. package/dist/upgrade/gcp-lookup.d.ts +27 -10
  94. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  95. package/dist/upgrade/gcp-lookup.js +46 -7
  96. package/dist/upgrade/gcp-lookup.js.map +1 -1
  97. package/dist/upgrade/record-rolled.d.ts +40 -0
  98. package/dist/upgrade/record-rolled.d.ts.map +1 -0
  99. package/dist/upgrade/record-rolled.js +46 -0
  100. package/dist/upgrade/record-rolled.js.map +1 -0
  101. package/dist/upgrade/upgrade.d.ts +45 -1
  102. package/dist/upgrade/upgrade.d.ts.map +1 -1
  103. package/dist/upgrade/upgrade.js +60 -17
  104. package/dist/upgrade/upgrade.js.map +1 -1
  105. package/dist/upgrader-assess.d.ts +15 -1
  106. package/dist/upgrader-assess.d.ts.map +1 -1
  107. package/dist/upgrader-assess.js +36 -6
  108. package/dist/upgrader-assess.js.map +1 -1
  109. package/dist/upgrader-gcp.d.ts +15 -0
  110. package/dist/upgrader-gcp.d.ts.map +1 -1
  111. package/dist/upgrader-gcp.js +42 -7
  112. package/dist/upgrader-gcp.js.map +1 -1
  113. package/dist/upgrader-iam-preflight.d.ts +123 -0
  114. package/dist/upgrader-iam-preflight.d.ts.map +1 -0
  115. package/dist/upgrader-iam-preflight.js +414 -0
  116. package/dist/upgrader-iam-preflight.js.map +1 -0
  117. package/dist/upgrader-job.d.ts +33 -9
  118. package/dist/upgrader-job.d.ts.map +1 -1
  119. package/dist/upgrader-job.js +169 -27
  120. package/dist/upgrader-job.js.map +1 -1
  121. package/dist/upgrader-records.d.ts +58 -0
  122. package/dist/upgrader-records.d.ts.map +1 -0
  123. package/dist/upgrader-records.js +87 -0
  124. package/dist/upgrader-records.js.map +1 -0
  125. package/dist/upgrader-reporter.d.ts +3 -2
  126. package/dist/upgrader-reporter.d.ts.map +1 -1
  127. package/dist/upgrader-reporter.js +10 -2
  128. package/dist/upgrader-reporter.js.map +1 -1
  129. package/dist/upgrader-setup.d.ts +25 -3
  130. package/dist/upgrader-setup.d.ts.map +1 -1
  131. package/dist/upgrader-setup.js +123 -11
  132. package/dist/upgrader-setup.js.map +1 -1
  133. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  134. package/dist/wizards/gcp-cost.js +11 -3
  135. package/dist/wizards/gcp-cost.js.map +1 -1
  136. package/dist/wizards/gcp.d.ts.map +1 -1
  137. package/dist/wizards/gcp.js +99 -4
  138. package/dist/wizards/gcp.js.map +1 -1
  139. package/package.json +2 -2
  140. package/src/agent/run.test.ts +14 -1
  141. package/src/cli-routing.ts +1 -1
  142. package/src/cli.ts +4 -1
  143. package/src/compose.ts +5 -0
  144. package/src/converge-checkpoint.ts +75 -0
  145. package/src/converge-entrypoint.test.ts +213 -0
  146. package/src/converge-job-gcp.test.ts +222 -0
  147. package/src/converge-job-gcp.ts +443 -0
  148. package/src/converge-job-setup.test.ts +152 -0
  149. package/src/converge-job-setup.ts +268 -0
  150. package/src/converge-job.test.ts +486 -0
  151. package/src/converge-job.ts +542 -0
  152. package/src/gateway-env-isolation.test.ts +8 -1
  153. package/src/gcp-names.ts +15 -0
  154. package/src/gcp-url-map.test.ts +9 -1
  155. package/src/index.ts +8 -0
  156. package/src/infra-contract.test.ts +7 -4
  157. package/src/infra-contract.ts +22 -1
  158. package/src/install-job.test.ts +3 -1
  159. package/src/install-job.ts +31 -14
  160. package/src/lifecycle.ts +137 -71
  161. package/src/operator-access.test.ts +14 -0
  162. package/src/operator-access.ts +13 -3
  163. package/src/release-contract.ts +5 -2
  164. package/src/release-manifest.test.ts +42 -0
  165. package/src/release-manifest.ts +2 -0
  166. package/src/remote-mcp-edge.ts +137 -0
  167. package/src/remote-mcp.test.ts +517 -0
  168. package/src/remote-mcp.ts +460 -0
  169. package/src/runtime-secrets.test.ts +31 -6
  170. package/src/runtime-secrets.ts +41 -3
  171. package/src/seal-key-rotation.test.ts +131 -0
  172. package/src/seal-key-rotation.ts +258 -0
  173. package/src/stack-contract-parity.test.ts +20 -3
  174. package/src/stack-contract.test.ts +30 -2
  175. package/src/stack-contract.ts +291 -3
  176. package/src/stack-converge-secrets.test.ts +3 -0
  177. package/src/stack-converge.test.ts +21 -5
  178. package/src/stack-converge.ts +101 -7
  179. package/src/state-attach.test.ts +7 -1
  180. package/src/state-lock-lease.test.ts +302 -0
  181. package/src/state-lock-lease.ts +423 -0
  182. package/src/state-lock.test.ts +568 -0
  183. package/src/state-store.ts +347 -27
  184. package/src/upgrade/app-roll.ts +96 -21
  185. package/src/upgrade/converge.ts +130 -8
  186. package/src/upgrade/gcp-lookup.test.ts +80 -0
  187. package/src/upgrade/gcp-lookup.ts +65 -8
  188. package/src/upgrade/record-rolled.test.ts +105 -0
  189. package/src/upgrade/record-rolled.ts +91 -0
  190. package/src/upgrade/upgrade.test.ts +203 -3
  191. package/src/upgrade/upgrade.ts +120 -24
  192. package/src/upgrader-assess.test.ts +92 -3
  193. package/src/upgrader-assess.ts +58 -9
  194. package/src/upgrader-gcp.ts +47 -8
  195. package/src/upgrader-iam-preflight.test.ts +393 -0
  196. package/src/upgrader-iam-preflight.ts +510 -0
  197. package/src/upgrader-job.test.ts +586 -15
  198. package/src/upgrader-job.ts +237 -53
  199. package/src/upgrader-records.ts +151 -0
  200. package/src/upgrader-reporter.test.ts +6 -0
  201. package/src/upgrader-reporter.ts +7 -2
  202. package/src/upgrader-setup.test.ts +3 -3
  203. package/src/upgrader-setup.ts +128 -11
  204. package/src/wizards/gcp-cost.ts +12 -3
  205. package/src/wizards/gcp.ts +125 -4
  206. package/stacks/aws/index.ts +10 -0
  207. package/stacks/azure/index.ts +9 -0
  208. package/stacks/gcp/index.ts +46 -7
  209. package/stacks/gcp-firebase/index.ts +37 -6
@@ -37,6 +37,7 @@
37
37
  import type { SupportedProvider } from "./adapter.js";
38
38
  import {
39
39
  auditJobResource,
40
+ convergeJobResource,
40
41
  gatewayServiceAccountEmail,
41
42
  gcpBucketName,
42
43
  gcpNamePrefix,
@@ -170,6 +171,13 @@ export const OPERATOR_ACCESS_JOB_ENV_VAR = "CAELO_OPERATOR_ACCESS_JOB";
170
171
  */
171
172
  export const UPGRADER_JOB_ENV_VAR = "CAELO_UPGRADER_JOB";
172
173
 
174
+ /**
175
+ * The admin env var naming the converge job (#819) — what "Install update"
176
+ * starts for a release that changes the cloud setup. `upgrade` leaves it
177
+ * untouched when it could not set the job up.
178
+ */
179
+ export const CONVERGE_JOB_ENV_VAR = "CAELO_CONVERGE_JOB";
180
+
173
181
  /**
174
182
  * #875 — the admin env vars naming the parallel quality-audit job and the
175
183
  * bucket its tasks write results into (admin-core quality/
@@ -251,7 +259,19 @@ export type RuntimeSecret =
251
259
  | "secret-kek"
252
260
  | "internal-secret"
253
261
  | "tool-approval-secret"
254
- | "staging-gate-secret";
262
+ | "staging-gate-secret"
263
+ /**
264
+ * #806 — the Google OAuth client the remote-MCP broker signs users in
265
+ * with: JSON `{"client_id","client_secret"}`, `{}` while remote MCP is not
266
+ * set up. The Owner writes it from Settings → MCP → Remote (the admin may
267
+ * add versions, never read them); the gateway reads it from a file.
268
+ */
269
+ | "mcp-oauth-client"
270
+ /**
271
+ * #806 — the broker's AES-256-GCM key (base64, 32 bytes) that seals its
272
+ * stateless OAuth state, codes and refresh tokens. Gateway only.
273
+ */
274
+ | "mcp-oauth-seal-key";
255
275
 
256
276
  /** The Postgres roles Caelo's services log in as, with their passwords. */
257
277
  export type DatabaseRole = "admin_role" | "public_role";
@@ -285,8 +305,38 @@ export const CLI_GENERATED_SECRETS = [
285
305
  // session cookies, derives the crawler credentials. Read by the admin
286
306
  // (issues) and the gateway (verifies).
287
307
  "staging-gate-secret",
308
+ // #806 — remote MCP. The client starts as the `{}` placeholder (not
309
+ // configured; the Owner fills it under Settings → MCP), the seal key as
310
+ // 32 random bytes in base64 ({@link GENERATED_SECRET_FORMAT}).
311
+ "mcp-oauth-client",
312
+ "mcp-oauth-seal-key",
288
313
  ] as const satisfies readonly RuntimeSecret[];
289
314
 
315
+ /** A CLI-generated secret's first value: random hex, random base64, or a fixed placeholder. */
316
+ export type GeneratedSecretFormat =
317
+ | { readonly kind: "hex32" }
318
+ | { readonly kind: "base64-32" }
319
+ | { readonly kind: "placeholder"; readonly value: string };
320
+
321
+ /**
322
+ * How each CLI-generated secret's first version is made. Every secret not
323
+ * listed is 32 random bytes as hex — the shape the stacks always generated.
324
+ */
325
+ export const GENERATED_SECRET_FORMAT: Readonly<
326
+ Record<(typeof CLI_GENERATED_SECRETS)[number], GeneratedSecretFormat>
327
+ > = {
328
+ "internal-secret": { kind: "hex32" },
329
+ "tool-approval-secret": { kind: "hex32" },
330
+ "public-role-password": { kind: "hex32" },
331
+ "staging-gate-secret": { kind: "hex32" },
332
+ // The gateway's seal key contract (#806): base64 of exactly 32 bytes.
333
+ "mcp-oauth-seal-key": { kind: "base64-32" },
334
+ // "Not configured" until the Owner pastes a client (#806). A version must
335
+ // exist from the start: Cloud Run refuses a revision whose secret volume
336
+ // points at a secret without an enabled version.
337
+ "mcp-oauth-client": { kind: "placeholder", value: "{}" },
338
+ };
339
+
290
340
  /**
291
341
  * Each service's Secret Manager-backed env vars and the secret each reads.
292
342
  * The services' run SAs get `secretAccessor` on exactly these secrets
@@ -316,12 +366,96 @@ export const SERVICE_SECRET_ENV: Readonly<
316
366
  PUBLIC_DATABASE_PASSWORD: "public-role-password",
317
367
  // #650 — verifies staging-host requests (api-gateway staging-proxy.ts).
318
368
  CAELO_STAGING_GATE_SECRET: "staging-gate-secret",
369
+ // #806 — seals the remote-MCP broker's state, codes and refresh tokens.
370
+ CAELO_MCP_OAUTH_SEAL_KEY: "mcp-oauth-seal-key",
319
371
  },
320
372
  };
321
373
 
322
- /** The secrets `service` reads (deduplicated, table order). */
374
+ // ===========================================================================
375
+ // Secret files (#806)
376
+ // ===========================================================================
377
+
378
+ /** Env var naming the file that holds the remote-MCP Google OAuth client. */
379
+ export const MCP_OAUTH_CLIENT_FILE_ENV = "CAELO_MCP_OAUTH_CLIENT_FILE";
380
+
381
+ /**
382
+ * A Secret Manager secret mounted into a service as one file, at `latest`.
383
+ *
384
+ * Why a file and not an env var: Cloud Run resolves a secret env var once,
385
+ * at instance start, but reads a secret volume from Secret Manager each
386
+ * time the file is read ("When reading a volume, Cloud Run always fetches
387
+ * the secret value from the Secret Manager to use the value with the latest
388
+ * version" — cloud.google.com/run/docs/configuring/services/secrets). So the
389
+ * client the Owner saves under Settings → MCP reaches the running gateway
390
+ * (which re-reads it within 60 s) without a new revision, and the admin
391
+ * needs no right to deploy the gateway.
392
+ */
393
+ export interface SecretFileContract {
394
+ readonly secret: RuntimeSecret;
395
+ /** Volume name the stacks give it (gcloud names the ones upgrade adds itself). */
396
+ readonly volumeName: string;
397
+ /** Directory the volume is mounted at; must not exist in the image. */
398
+ readonly mountDir: string;
399
+ readonly fileName: string;
400
+ /** Always `latest`: a pinned version would never see the Owner's new client. */
401
+ readonly version: "latest";
402
+ }
403
+
404
+ /** The gateway's remote-MCP OAuth client file (#806). */
405
+ export const MCP_OAUTH_CLIENT_FILE: SecretFileContract = {
406
+ secret: "mcp-oauth-client",
407
+ volumeName: "mcp-oauth-client",
408
+ mountDir: "/secrets/mcp-oauth-client",
409
+ fileName: "client.json",
410
+ version: "latest",
411
+ };
412
+
413
+ /** Full path of a secret file inside the container. */
414
+ export function secretFilePath(file: SecretFileContract): string {
415
+ return `${file.mountDir}/${file.fileName}`;
416
+ }
417
+
418
+ /** Each service's secret files. The admin has none. */
419
+ export const SERVICE_SECRET_FILES: Readonly<Record<CloudRunSlug, readonly SecretFileContract[]>> = {
420
+ admin: [],
421
+ gateway: [MCP_OAUTH_CLIENT_FILE],
422
+ };
423
+
424
+ /**
425
+ * The secret files of `service` in the shape `gcp.cloudrunv2.Service`
426
+ * takes: `volumes` on the template, `volumeMounts` on the container.
427
+ */
428
+ export function secretFileVolumesTemplate(
429
+ service: CloudRunSlug,
430
+ env: string,
431
+ ): {
432
+ readonly volumes: {
433
+ name: string;
434
+ secret: { secret: string; items: { version: string; path: string }[] };
435
+ }[];
436
+ readonly volumeMounts: { name: string; mountPath: string }[];
437
+ } {
438
+ const files = SERVICE_SECRET_FILES[service];
439
+ return {
440
+ volumes: files.map((f) => ({
441
+ name: f.volumeName,
442
+ secret: {
443
+ secret: gcpSecretId(env, f.secret),
444
+ items: [{ version: f.version, path: f.fileName }],
445
+ },
446
+ })),
447
+ volumeMounts: files.map((f) => ({ name: f.volumeName, mountPath: f.mountDir })),
448
+ };
449
+ }
450
+
451
+ /** The secrets `service` reads, as env or as files (deduplicated, table order). */
323
452
  export function serviceSecrets(service: CloudRunSlug): RuntimeSecret[] {
324
- return [...new Set(Object.values(SERVICE_SECRET_ENV[service]))];
453
+ return [
454
+ ...new Set([
455
+ ...Object.values(SERVICE_SECRET_ENV[service]),
456
+ ...SERVICE_SECRET_FILES[service].map((f) => f.secret),
457
+ ]),
458
+ ];
325
459
  }
326
460
 
327
461
  /**
@@ -411,10 +545,139 @@ export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvV
411
545
  ...commonEnv(inputs),
412
546
  { name: TRUSTED_PROXY_HOPS_ENV, value: String(GATEWAY_TRUSTED_PROXY_HOPS[inputs.provider]) },
413
547
  { name: "PUBLIC_DATABASE_URL", value: inputs.databaseUrls.public },
548
+ // #806 — where the remote-MCP broker reads its Google OAuth client
549
+ // (the secret volume of SERVICE_SECRET_FILES).
550
+ { name: MCP_OAUTH_CLIENT_FILE_ENV, value: secretFilePath(MCP_OAUTH_CLIENT_FILE) },
414
551
  ...secretEnv("gateway", inputs.env),
415
552
  ];
416
553
  }
417
554
 
555
+ /**
556
+ * #806 — the gateway's request timeout, in seconds: Cloud Run's maximum.
557
+ * The gateway proxies remote-MCP requests to the admin, and a `caelo_chat`
558
+ * turn streams for as long as the admin's own chat stream may (the admin
559
+ * runs at 3600 s for the same reason). Visitor endpoints are unaffected: a
560
+ * timeout is a ceiling, not a reservation, and Cloud Run bills request time,
561
+ * not the allowed maximum. On `gcp` the load balancer in front adds no
562
+ * shorter limit — the backend-service timeout does not apply to serverless
563
+ * NEGs, which get a fixed 60 minutes
564
+ * (cloud.google.com/load-balancing/docs/negs/serverless-neg-concepts).
565
+ */
566
+ export const GATEWAY_TIMEOUT_SECONDS = 3600;
567
+
568
+ /** Admin env var naming the Secret Manager secret the Owner writes the OAuth client to. */
569
+ export const MCP_OAUTH_CLIENT_STORE_ENV = "CAELO_MCP_OAUTH_CLIENT_STORE";
570
+
571
+ /** `projects/<project>/secrets/<id>` of the remote-MCP OAuth client secret. */
572
+ export function mcpOAuthClientSecretResource(projectId: string, env: string): string {
573
+ return `projects/${projectId}/secrets/${gcpSecretId(env, "mcp-oauth-client")}`;
574
+ }
575
+
576
+ // ===========================================================================
577
+ // Remote MCP identity env (#806)
578
+ // ===========================================================================
579
+
580
+ /**
581
+ * The remote-MCP env vars that name generated identities of the install:
582
+ * the admin's URL, the gateway's public origin, and the audience of the IAP
583
+ * JWTs the admin verifies.
584
+ *
585
+ * They are not part of {@link adminEnvContract} / {@link gatewayEnvContract}
586
+ * because the stacks cannot declare them: on `gcp-firebase` each service's
587
+ * value names a Cloud Run service Pulumi auto-names (the admin's audience is
588
+ * its own name; the gateway's public URL is its own URL; the admin's URL is
589
+ * a service created after the gateway), and on `gcp` the audience is the
590
+ * numeric id of a backend service created after the admin. A resource
591
+ * cannot take its own (or a later resource's) output as input. So the CLI
592
+ * reads the live identities after deploy and applies these vars with gcloud
593
+ * — the wizard after every `pulumi up`, `upgrade` after every roll
594
+ * (remote-mcp.ts `ensureRemoteMcpIdentityEnv`). A roll that does not set
595
+ * them leaves them as they are (planEnvUpdate never removes a var the
596
+ * contract does not own).
597
+ */
598
+ export interface RemoteMcpIdentity {
599
+ /** Where the gateway proxies `/api/mcp/<surface>` to (IAP in front). */
600
+ readonly adminUrl: string;
601
+ /** The broker's public origin; no trailing slash. */
602
+ readonly mcpPublicUrl: string;
603
+ /** The `aud` of the admin's IAP JWTs. */
604
+ readonly iapAudience: string;
605
+ }
606
+
607
+ /** Env var names of {@link RemoteMcpIdentity}. */
608
+ export const REMOTE_MCP_ENV = {
609
+ adminUrl: "CAELO_ADMIN_URL",
610
+ mcpPublicUrl: "CAELO_MCP_PUBLIC_URL",
611
+ iapAudience: "CAELO_IAP_AUDIENCE",
612
+ } as const;
613
+
614
+ /** What {@link remoteMcpIdentity} derives the identity from — all read from the live install. */
615
+ export type RemoteMcpIdentityInputs =
616
+ | {
617
+ readonly provider: "gcp";
618
+ readonly domain: string;
619
+ readonly projectNumber: string;
620
+ /** Numeric `id` of the admin's IAP-enabled backend service. */
621
+ readonly adminBackendServiceId: string;
622
+ }
623
+ | {
624
+ readonly provider: "gcp-firebase";
625
+ readonly projectNumber: string;
626
+ readonly region: string;
627
+ readonly adminServiceName: string;
628
+ /** The services' `status.url` (`https://<name>-<hash>-<r>.a.run.app`). */
629
+ readonly adminServiceUrl: string;
630
+ readonly gatewayServiceUrl: string;
631
+ };
632
+
633
+ /**
634
+ * The install's remote-MCP identity.
635
+ *
636
+ * - `gcp`: the broker answers on the site's own host through the load
637
+ * balancer (`/api/*` and the `/.well-known/oauth-*` path rules), the admin
638
+ * on `admin.<domain>` behind the LB's IAP; the IAP audience is
639
+ * `/projects/<number>/global/backendServices/<id>`.
640
+ * - `gcp-firebase`: the broker answers on the gateway's own Cloud Run URL
641
+ * (Firebase Hosting rewrites cut requests at 60 s and buffer streams), the
642
+ * admin on its Cloud Run URL behind native IAP — whatever custom domain
643
+ * is mapped to it, IAP and its audience are the service's; the audience
644
+ * is `/projects/<number>/locations/<region>/services/<name>`.
645
+ *
646
+ * Audience formats: cloud.google.com/iap/docs/signed-headers-howto.
647
+ */
648
+ export function remoteMcpIdentity(i: RemoteMcpIdentityInputs): RemoteMcpIdentity {
649
+ const origin = (url: string) => url.replace(/\/+$/, "");
650
+ if (i.provider === "gcp") {
651
+ return {
652
+ adminUrl: `https://admin.${i.domain}`,
653
+ mcpPublicUrl: publicSiteUrl(i.domain),
654
+ iapAudience: `/projects/${i.projectNumber}/global/backendServices/${i.adminBackendServiceId}`,
655
+ };
656
+ }
657
+ return {
658
+ adminUrl: origin(i.adminServiceUrl),
659
+ mcpPublicUrl: origin(i.gatewayServiceUrl),
660
+ iapAudience: `/projects/${i.projectNumber}/locations/${i.region}/services/${i.adminServiceName}`,
661
+ };
662
+ }
663
+
664
+ /** {@link RemoteMcpIdentity} as each service's env vars. */
665
+ export function remoteMcpIdentityEnv(
666
+ id: RemoteMcpIdentity,
667
+ ): Record<CloudRunSlug, CloudRunEnvVar[]> {
668
+ return {
669
+ admin: [
670
+ { name: REMOTE_MCP_ENV.iapAudience, value: id.iapAudience },
671
+ // The setup page shows the redirect URI and the MCP URLs from it.
672
+ { name: REMOTE_MCP_ENV.mcpPublicUrl, value: id.mcpPublicUrl },
673
+ ],
674
+ gateway: [
675
+ { name: REMOTE_MCP_ENV.adminUrl, value: id.adminUrl },
676
+ { name: REMOTE_MCP_ENV.mcpPublicUrl, value: id.mcpPublicUrl },
677
+ ],
678
+ };
679
+ }
680
+
418
681
  /**
419
682
  * The env of the admin service.
420
683
  *
@@ -452,10 +715,16 @@ export function adminEnvContract<V>(inputs: AdminEnvInputs<V>): CloudRunEnvVar<V
452
715
  // #696 — the job "Install update" starts (upgrader-setup.ts); the admin
453
716
  // may only run it, never override it.
454
717
  { name: UPGRADER_JOB_ENV_VAR, value: upgraderJobResource(projectId, inputs.region, env) },
718
+ // #819 — the job "Install update" starts for a release that changes the
719
+ // cloud setup (converge-job-setup.ts); start-only as well.
720
+ { name: CONVERGE_JOB_ENV_VAR, value: convergeJobResource(projectId, inputs.region, env) },
455
721
  // #875 — the job the quality audit fans its Lighthouse runs out to, and
456
722
  // where the tasks write their results (audit-job-setup.ts).
457
723
  { name: AUDIT_JOB_ENV_VAR, value: auditJobResource(projectId, inputs.region, env) },
458
724
  { name: AUDIT_RESULTS_BUCKET_ENV_VAR, value: gcpBucketName(projectId, env, "audit-results") },
725
+ // #806 — where Settings → MCP → Remote writes the Google OAuth client
726
+ // (add-version only, see the run SA's secretVersionAdder invariant).
727
+ { name: MCP_OAUTH_CLIENT_STORE_ENV, value: mcpOAuthClientSecretResource(projectId, env) },
459
728
  ];
460
729
  if (inputs.provider === "gcp") {
461
730
  // v0.2.78 — the GCS StaticPublisher: Stage uploads to staging,
@@ -542,6 +811,7 @@ export interface IamInvariant {
542
811
  }
543
812
 
544
813
  const SECRET_ACCESSOR = "roles/secretmanager.secretAccessor";
814
+ const SECRET_VERSION_ADDER = "roles/secretmanager.secretVersionAdder";
545
815
 
546
816
  /** One `secretAccessor` binding a stack declares for a service's run SA. */
547
817
  export interface RuntimeSecretBinding {
@@ -577,6 +847,23 @@ function secretAccessors(): IamInvariant[] {
577
847
  }));
578
848
  }
579
849
 
850
+ /** Bindings both GCP stacks declare beyond the secret accessors and telemetry. */
851
+ const GCP_COMMON: readonly IamInvariant[] = [
852
+ {
853
+ stackResource: "admin-mcp-oauth-client-version-adder",
854
+ // Why the admin may write a secret at all: the Owner pastes the remote-
855
+ // MCP OAuth client under Settings → MCP → Remote, and the admin stores
856
+ // it where the gateway reads it. secretVersionAdder is add-version only
857
+ // (no access, no destroy, no IAM), bound on this one secret only — the
858
+ // admin can replace the client, never read it or any other secret.
859
+ role: SECRET_VERSION_ADDER,
860
+ member: "run-sa",
861
+ target: { kind: "secret", name: "mcp-oauth-client" },
862
+ onFailure: "warn",
863
+ why: "the Owner's remote-MCP setup (Settings → MCP → Remote) stores the OAuth client; without it only that setup fails",
864
+ },
865
+ ];
866
+
580
867
  /** v0.6.6 — a custom runtime SA gets no telemetry roles implicitly. */
581
868
  const TELEMETRY: readonly IamInvariant[] = [
582
869
  {
@@ -743,6 +1030,7 @@ export const RETIRED_IAM_BINDINGS: readonly RetiredIamBinding[] = [
743
1030
  export function stackIamInvariants(provider: GcpProvider): IamInvariant[] {
744
1031
  return [
745
1032
  ...secretAccessors(),
1033
+ ...GCP_COMMON,
746
1034
  ...(provider === "gcp" ? GCP_ONLY : GCP_FIREBASE_ONLY),
747
1035
  ...TELEMETRY,
748
1036
  ];
@@ -342,6 +342,9 @@ describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", (
342
342
  expect(plan.ok && plan.services.gateway.changes.map((c) => c.name).sort()).toEqual([
343
343
  "ADMIN_DATABASE_PASSWORD",
344
344
  "ADMIN_DATABASE_URL",
345
+ // #806 — the remote-MCP broker's client file path and seal key.
346
+ "CAELO_MCP_OAUTH_CLIENT_FILE",
347
+ "CAELO_MCP_OAUTH_SEAL_KEY",
345
348
  // #650 — the staging gate key arrives in the same roll.
346
349
  "CAELO_STAGING_GATE_SECRET",
347
350
  // #845 — and which forwarded address is the visitor.
@@ -312,13 +312,14 @@ describe("planContractEnv", () => {
312
312
  // #650 — the same roll converges the protected staging domain's host
313
313
  // and gate key onto an install that predates it.
314
314
  expect(plan.services.admin.flags).toEqual([
315
- "--update-env-vars=CAELO_SITE_URL=https://acme.com,MEDIA_ROOT_DIR=/app/apps/admin/data/media,CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@acme.iam.gserviceaccount.com,CAELO_OPERATOR_ACCESS_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync,CAELO_UPGRADER_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-upgrader,CAELO_AUDIT_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-audit,CAELO_AUDIT_RESULTS_BUCKET=acme-caelo-production-audit-results,CAELO_STAGING_HOST=staging.acme.com",
315
+ "--update-env-vars=CAELO_SITE_URL=https://acme.com,MEDIA_ROOT_DIR=/app/apps/admin/data/media,CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@acme.iam.gserviceaccount.com,CAELO_OPERATOR_ACCESS_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync,CAELO_UPGRADER_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-upgrader,CAELO_CONVERGE_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-converge,CAELO_AUDIT_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-audit,CAELO_AUDIT_RESULTS_BUCKET=acme-caelo-production-audit-results,CAELO_MCP_OAUTH_CLIENT_STORE=projects/acme/secrets/caelo-production-mcp-oauth-client,CAELO_STAGING_HOST=staging.acme.com",
316
316
  "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
317
317
  ]);
318
318
  expect(plan.services.gateway.flags).toEqual([
319
319
  // The gateway learns which forwarded address is the visitor (#845).
320
- "--update-env-vars=CAELO_TRUSTED_PROXY_HOPS=1",
321
- "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
320
+ // #806 — and where the remote-MCP broker's client file and seal key are.
321
+ "--update-env-vars=CAELO_TRUSTED_PROXY_HOPS=1,CAELO_MCP_OAUTH_CLIENT_FILE=/secrets/mcp-oauth-client/client.json",
322
+ "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest,CAELO_MCP_OAUTH_SEAL_KEY=caelo-production-mcp-oauth-seal-key:latest",
322
323
  ]);
323
324
  });
324
325
 
@@ -474,7 +475,11 @@ describe("ensureStackInvariants", () => {
474
475
  ]),
475
476
  ],
476
477
  "secrets get-iam-policy": [
477
- policy([{ role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA] }]),
478
+ policy([
479
+ { role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA] },
480
+ // #806 — the admin adds remote-MCP OAuth client versions.
481
+ { role: "roles/secretmanager.secretVersionAdder", members: [RUN_SA] },
482
+ ]),
478
483
  ],
479
484
  "storage buckets get-iam-policy": [
480
485
  policy([{ role: "roles/storage.objectAdmin", members: [RUN_SA] }]),
@@ -673,7 +678,10 @@ describe("ensureStackInvariants", () => {
673
678
  ]),
674
679
  ],
675
680
  "secrets get-iam-policy": [
676
- policy([{ role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA] }]),
681
+ policy([
682
+ { role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA] },
683
+ { role: "roles/secretmanager.secretVersionAdder", members: [RUN_SA] },
684
+ ]),
677
685
  ],
678
686
  "storage buckets get-iam-policy": [
679
687
  policy([
@@ -779,6 +787,14 @@ describe("ensureStackInvariants", () => {
779
787
  expect(gatewayGrants).toEqual([
780
788
  `secrets add-iam-policy-binding caelo-production-public-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
781
789
  `secrets add-iam-policy-binding caelo-production-staging-gate-secret --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
790
+ // #806 — the remote-MCP broker's seal key and OAuth client file.
791
+ `secrets add-iam-policy-binding caelo-production-mcp-oauth-seal-key --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
792
+ `secrets add-iam-policy-binding caelo-production-mcp-oauth-client --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
793
+ ]);
794
+ // #806 — the admin may add versions to the OAuth client secret, and to no other.
795
+ const adders = calls.filter((c) => c.includes("roles/secretmanager.secretVersionAdder"));
796
+ expect(adders).toEqual([
797
+ `secrets add-iam-policy-binding caelo-production-mcp-oauth-client --project=acme --member=${RUN_SA} --role=roles/secretmanager.secretVersionAdder --quiet --format=none`,
782
798
  ]);
783
799
  for (const secret of ["internal-secret", "tool-approval-secret", "secret-kek"]) {
784
800
  expect(calls).toContain(
@@ -18,6 +18,9 @@
18
18
  * - {@link planMediaVolume}: the admin's media bucket volume (gen2 + Cloud
19
19
  * Storage volume + mount), added in the same `services update` when
20
20
  * missing.
21
+ * - {@link planSecretFiles} / {@link planRequestTimeout} (#806): the
22
+ * gateway's secret file (the remote-MCP OAuth client) and its request
23
+ * timeout, in the same `services update`.
21
24
  */
22
25
 
23
26
  import { removeDefaultFirewallRules } from "./default-firewall.js";
@@ -43,8 +46,10 @@ import {
43
46
  RETIRED_SERVICE_ENV,
44
47
  type RetiredIamBinding,
45
48
  type RuntimeEnvInputs,
49
+ type SecretFileContract,
46
50
  type ServiceEnvInputs,
47
51
  STATIC_CDN_POLICY,
52
+ secretFilePath,
48
53
  stackIamInvariants,
49
54
  staticBackendBucketPrefix,
50
55
  } from "./stack-contract.js";
@@ -285,6 +290,12 @@ export function planContractEnv(
285
290
  opts: {
286
291
  /** Contract vars to leave exactly as the services have them. */
287
292
  readonly leaveUntouched?: readonly string[];
293
+ /**
294
+ * Vars beyond the contract to set as well (#806: the remote-MCP identity
295
+ * env, resolved by the converge). A name the contract also sets is
296
+ * refused rather than silently doubled.
297
+ */
298
+ readonly extraEnv?: Readonly<Partial<Record<CloudRunSlug, readonly CloudRunEnvVar[]>>>;
288
299
  } = {},
289
300
  ):
290
301
  | {
@@ -320,16 +331,25 @@ export function planContractEnv(
320
331
  inputs = { ...runtime, provider: "gcp" };
321
332
  }
322
333
  const skip = new Set(opts.leaveUntouched ?? []);
323
- const owned = (contract: readonly CloudRunEnvVar[]) => contract.filter((e) => !skip.has(e.name));
324
- const admin = planEnvUpdate(
325
- services.admin.liveEnv,
326
- owned(adminEnvContract(inputs)),
327
- RETIRED_SERVICE_ENV.admin,
328
- );
334
+ const desired = (slug: CloudRunSlug, contract: readonly CloudRunEnvVar[]) => {
335
+ const extra = opts.extraEnv?.[slug] ?? [];
336
+ const clash = extra.find((e) => contract.some((c) => c.name === e.name));
337
+ if (clash) throw new Error(`${slug}: ${clash.name} is both in the contract and extra env`);
338
+ return [...contract, ...extra].filter((e) => !skip.has(e.name));
339
+ };
340
+ let adminDesired: CloudRunEnvVar[];
341
+ let gatewayDesired: CloudRunEnvVar[];
342
+ try {
343
+ adminDesired = desired("admin", adminEnvContract(inputs));
344
+ gatewayDesired = desired("gateway", gatewayEnvContract(runtime));
345
+ } catch (e) {
346
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
347
+ }
348
+ const admin = planEnvUpdate(services.admin.liveEnv, adminDesired, RETIRED_SERVICE_ENV.admin);
329
349
  if (!admin.ok) return { ok: false, error: `admin: ${admin.error}` };
330
350
  const gateway = planEnvUpdate(
331
351
  services.gateway.liveEnv,
332
- owned(gatewayEnvContract(runtime)),
352
+ gatewayDesired,
333
353
  RETIRED_SERVICE_ENV.gateway,
334
354
  );
335
355
  if (!gateway.ok) return { ok: false, error: `gateway: ${gateway.error}` };
@@ -713,6 +733,11 @@ const EXECUTION_ENVIRONMENT_ANNOTATION = "run.googleapis.com/execution-environme
713
733
 
714
734
  interface KnativeVolume {
715
735
  readonly name: string;
736
+ /** A Secret Manager volume (#806 secret files). */
737
+ readonly secret?: {
738
+ readonly secretName?: string;
739
+ readonly items?: readonly { readonly key?: string; readonly path?: string }[];
740
+ };
716
741
  readonly csi?: {
717
742
  readonly driver?: string;
718
743
  readonly readOnly?: boolean;
@@ -843,6 +868,75 @@ export function planMediaVolume(live: LiveVolumes, desired: MediaVolumeContract)
843
868
  return { ok: true, flags, changes };
844
869
  }
845
870
 
871
+ // ===========================================================================
872
+ // Secret files + request timeout (#806)
873
+ // ===========================================================================
874
+
875
+ export type RollAdditionsPlan =
876
+ | { readonly ok: true; readonly flags: string[]; readonly changes: string[] }
877
+ | { readonly ok: false; readonly error: string };
878
+
879
+ /** `projects/<p>/secrets/<id>` or `<id>` → `<id>`. */
880
+ function bareSecretId(name: string | undefined): string {
881
+ return (name ?? "").slice((name ?? "").lastIndexOf("/") + 1);
882
+ }
883
+
884
+ /**
885
+ * The flags that mount each of `files` (stack-contract.ts
886
+ * SERVICE_SECRET_FILES) where the stacks mount it, adding only what is
887
+ * missing — `--update-secrets=<dir>/<file>=<secret>:latest`, the form
888
+ * cloud.google.com/run/docs/configuring/services/secrets documents (gcloud
889
+ * names the volume itself). A file already mounted from the same secret at
890
+ * `latest` is converged whatever its volume is called. Anything else at that
891
+ * directory is refused with what was found: replacing it could feed the
892
+ * gateway a different credential.
893
+ */
894
+ export function planSecretFiles(
895
+ live: LiveVolumes,
896
+ files: readonly SecretFileContract[],
897
+ env: string,
898
+ ): RollAdditionsPlan {
899
+ const flags: string[] = [];
900
+ const changes: string[] = [];
901
+ for (const file of files) {
902
+ const secretId = gcpSecretId(env, file.secret);
903
+ const mount = live.mounts.find((m) => m.mountPath === file.mountDir);
904
+ if (mount) {
905
+ const volume = live.volumes.find((v) => v.name === mount.name);
906
+ const item = volume?.secret?.items?.find((i) => i.path === file.fileName);
907
+ const inShape =
908
+ bareSecretId(volume?.secret?.secretName) === secretId && item?.key === file.version;
909
+ if (inShape) continue;
910
+ return {
911
+ ok: false,
912
+ error: `the service mounts volume "${mount.name}" at ${file.mountDir}, where ${secretId}:${file.version} belongs as ${file.fileName}. Remove it (gcloud run services update <service> --remove-volume-mount=${file.mountDir}) only if nothing else needs it, then re-run upgrade.`,
913
+ };
914
+ }
915
+ flags.push(`--update-secrets=${secretFilePath(file)}=${secretId}:${file.version}`);
916
+ changes.push(`secret file ${secretFilePath(file)}: (none) → ${secretId}:${file.version}`);
917
+ }
918
+ return { ok: true, flags, changes };
919
+ }
920
+
921
+ /** The request timeout of a described service, in seconds (null: Cloud Run's default, 300). */
922
+ export function liveTimeoutSeconds(serviceJson: string): number | null {
923
+ const svc = JSON.parse(serviceJson) as {
924
+ spec?: { template?: { spec?: { timeoutSeconds?: number | string } } };
925
+ };
926
+ const t = svc.spec?.template?.spec?.timeoutSeconds;
927
+ return t === undefined ? null : Number(t);
928
+ }
929
+
930
+ /** `--timeout=<desired>` when the live timeout differs; nothing when it matches. */
931
+ export function planRequestTimeout(live: number | null, desired: number): RollAdditionsPlan {
932
+ if (live === desired) return { ok: true, flags: [], changes: [] };
933
+ return {
934
+ ok: true,
935
+ flags: [`--timeout=${desired}`],
936
+ changes: [`request timeout: ${live === null ? "(default 300)" : live}s → ${desired}s`],
937
+ };
938
+ }
939
+
846
940
  /**
847
941
  * The single `gcloud run services update` that rolls a service to a new
848
942
  * image, its run SA and its env changes, so all land in one new revision.
@@ -186,12 +186,18 @@ describe("state locks", () => {
186
186
  }
187
187
  if (args[1] === "cat")
188
188
  return objects.has(url) ? ok(objects.get(url)) : fail("matched no objects");
189
+ if (args[1] === "objects") {
190
+ const target = args[3] ?? "";
191
+ return objects.has(target)
192
+ ? ok(JSON.stringify({ generation: "1" }))
193
+ : fail("matched no objects");
194
+ }
189
195
  if (args[1] === "rm") return objects.delete(url) ? ok() : fail("matched no objects");
190
196
  return fail("unexpected");
191
197
  };
192
198
  const release = acquireBucketStateLock("acme-site-caelo-ops", "state migrate", run);
193
199
  expect(() => acquireBucketStateLock("acme-site-caelo-ops", "installer pulumi up", run)).toThrow(
194
- /gcloud storage rm gs:\/\/acme-site-caelo-ops\/install\/state.lock/,
200
+ /held by state migrate .*lease runs until/,
195
201
  );
196
202
  release();
197
203
  acquireBucketStateLock("acme-site-caelo-ops", "installer pulumi up", run)();