@runuai/host 0.9.150 → 0.9.152

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 +1880 -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 +1837 -224
  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 +650 -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 +1061 -139
  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
@@ -36,6 +36,8 @@ import {
36
36
  BUNDLED_RUNTIME_CAPABILITY,
37
37
  BUNDLED_RUNTIME_ROOT,
38
38
  MANAGED_RUNTIME_CAPABILITY,
39
+ MANAGED_RUNTIME_CREDENTIALS,
40
+ MANAGED_RUNTIME_CREDENTIALS_VERSION,
39
41
  extractVerifiedRuntimeArchive,
40
42
  readVerifiedArtifactFile,
41
43
  type ResolvedRuntimeDescriptor,
@@ -43,6 +45,7 @@ import {
43
45
  type RuntimeCapabilityDescriptorV3,
44
46
  type RuntimeAssetManifest,
45
47
  validateManagedRuntimeCapabilityDescriptor,
48
+ validateManagedRuntimeCredentialsDescriptor,
46
49
  validateRuntimeAssetManifest,
47
50
  validateRuntimeCapabilityDescriptor,
48
51
  validateRuntimeDescriptor,
@@ -77,6 +80,10 @@ const APPLE_CONTAINER_PLUGINS = Object.freeze([
77
80
  "machine-apiserver",
78
81
  ]);
79
82
 
83
+ /** ADR-154 §13: the activation authority's record, in the runner's data
84
+ * directory (lib/credentials-activation-authority.ts). */
85
+ export const CREDENTIALS_ACTIVATION_RECORD = "credentials-activation.json";
86
+
80
87
  export interface ManagedRuntimePaths {
81
88
  readonly home: string;
82
89
  readonly root: string;
@@ -93,6 +100,17 @@ export interface ManagedRuntimePaths {
93
100
  * task state. It lives outside version generations so pruning/rollback
94
101
  * cannot erase the evidence an older runtime must honor. */
95
102
  readonly minimumRuntimeCapability: string;
103
+ /** ADR-154 §13's second, independent floor, claimed when the runner's
104
+ * credentials move to the cloud. The runtime capability floor cannot mark
105
+ * that cutover (see MANAGED_RUNTIME_CREDENTIALS), and like it this lives
106
+ * outside every generation, beside it in the data directory, so it holds on
107
+ * a runner installed through npm too. */
108
+ readonly minimumCredentialsCapability: string;
109
+ /** ADR-154 §13's activation record, beside the runner's database: once it
110
+ * exists the runner has begun its switch to cloud credentials, which no
111
+ * release built before the cutover may undo, so it counts as a credentials
112
+ * floor of 1 even before that floor's own file is written. */
113
+ readonly credentialsActivationRecord: string;
96
114
  /** Older Phase-B builds may have claimed provider state before publishing
97
115
  * the durable capability marker. This preserved marker implies floor 2. */
98
116
  readonly runtimeProviderState: string;
@@ -295,6 +313,11 @@ export function managedRuntimePaths(home?: string): ManagedRuntimePaths {
295
313
  configuredUaiHome,
296
314
  ".minimum-runtime-capability.json",
297
315
  ),
316
+ minimumCredentialsCapability: path.join(
317
+ configuredUaiHome,
318
+ ".minimum-credentials-capability.json",
319
+ ),
320
+ credentialsActivationRecord: path.join(configuredDataDir, CREDENTIALS_ACTIVATION_RECORD),
298
321
  runtimeProviderState: path.join(configuredDataDir, "container-runtime-provider.json"),
299
322
  // The lock must not live inside `root`: uninstall atomically renames that
300
323
  // whole tree, and moving the lock would let another process create a new
@@ -464,6 +487,7 @@ export async function installManagedRuntimeArchive(
464
487
  nativePlatform,
465
488
  );
466
489
  await assertRuntimeMeetsDurableCapabilityFloor(paths, currentDescriptor);
490
+ await assertRuntimeMeetsDurableCredentialsFloor(paths, currentDescriptor);
467
491
  if (
468
492
  installIntent?.serviceOwnership === "legacy" &&
469
493
  !(await options.canActivate!({ legacyConversion: true }))
@@ -501,10 +525,12 @@ export async function installManagedRuntimeArchive(
501
525
  version: options.version,
502
526
  platform: options.platform,
503
527
  });
504
- await assertRuntimeMeetsDurableCapabilityFloor(paths, {
528
+ const stagedDescriptor: ResolvedRuntimeDescriptor = {
505
529
  ...inspected.descriptor,
506
530
  ...inspected.capabilities,
507
- });
531
+ };
532
+ await assertRuntimeMeetsDurableCapabilityFloor(paths, stagedDescriptor);
533
+ await assertRuntimeMeetsDurableCredentialsFloor(paths, stagedDescriptor);
508
534
  if (
509
535
  options.canActivate &&
510
536
  !(await options.canActivate(activationContext))
@@ -545,6 +571,7 @@ export async function installManagedRuntimeArchive(
545
571
  options.platform,
546
572
  );
547
573
  await assertRuntimeMeetsDurableCapabilityFloor(paths, targetDescriptor);
574
+ await assertRuntimeMeetsDurableCredentialsFloor(paths, targetDescriptor);
548
575
  } else {
549
576
  await rename(staged, target);
550
577
  await syncDirectory(paths.runtime);
@@ -716,6 +743,7 @@ export async function rollbackManagedRuntime(
716
743
  platform,
717
744
  );
718
745
  await assertRuntimeMeetsDurableCapabilityFloor(paths, targetDescriptor);
746
+ await assertRuntimeMeetsDurableCredentialsFloor(paths, targetDescriptor);
719
747
  if (canActivate && !(await canActivate())) {
720
748
  throw new Error("refusing to roll back while host tasks are active");
721
749
  }
@@ -810,7 +838,7 @@ async function persistManagedRuntimeCapabilityFloor(
810
838
  // owned file before making the floor durable, so standalone recovery can
811
839
  // recognize the new cross-platform capability marker immediately.
812
840
  const recoveryEntrypoint = await assertRecoveryEntrypointReplaceable(paths);
813
- if (recoveryEntrypoint === "legacy-v1") {
841
+ if (recoveryEntrypoint === "legacy-v1" || recoveryEntrypoint === "legacy-v2") {
814
842
  await ensureRecoveryEntrypoint(paths);
815
843
  }
816
844
  await mkdir(path.dirname(paths.minimumRuntimeCapability), {
@@ -825,6 +853,178 @@ async function persistManagedRuntimeCapabilityFloor(
825
853
  });
826
854
  }
827
855
 
856
+ /**
857
+ * ADR-154 §13's local commit: claim the credentials floor. From then on this
858
+ * release and every later one refuse to update to, roll back to, or boot a
859
+ * generation built before the cloud-only credentials cutover. It only rises,
860
+ * is written under the managed-operation lock, and is independent of the
861
+ * runtime capability floor, which cannot rise to mark the cutover.
862
+ *
863
+ * On a runner installed through npm there is no managed runtime: the floor is
864
+ * only persisted, nothing reads it yet, and the managed tree is not created.
865
+ *
866
+ * It refuses, and the activation authority retries, while an installation
867
+ * intent, an activation marker, a rollback journal or an uninstall marker is
868
+ * present, and while the selected generation itself predates the floor. Each
869
+ * of those means the generation this runner boots next may not be the one
870
+ * claiming, or is about to be removed, which is the window the runtime
871
+ * capability claim closes for the same reason. Refusing is safe because the
872
+ * local commit precedes the cloud commit: a failed claim leaves the runner on
873
+ * v0 with its stores intact. Standalone recovery (revision 3) enforces it
874
+ * too (see `recoveryEntrypointContents`).
875
+ */
876
+ export async function claimCredentialsCapabilityFloor(
877
+ minimumCredentialsCapability: number = MANAGED_RUNTIME_CREDENTIALS_VERSION,
878
+ home?: string,
879
+ ): Promise<void> {
880
+ assertCredentialsCapabilityFloor(minimumCredentialsCapability);
881
+ const paths = managedRuntimePaths(home);
882
+ await withManagedOperationLockWaiting(paths, async () => {
883
+ await assertNoManagedInstallIntent(paths, "credentials capability claim");
884
+ if (
885
+ pathExistsSync(paths.activationPending) ||
886
+ pathExistsSync(paths.rollbackJournal)
887
+ ) {
888
+ throw new Error("managed runtime activation is already in progress");
889
+ }
890
+ if (pathExistsSync(paths.maintenancePending)) {
891
+ throw new Error("managed runtime maintenance is already in progress");
892
+ }
893
+ const install = await managedInstallState(paths);
894
+ if (install.kind === "damaged") throw new Error(install.detail);
895
+ if (install.kind === "selected") {
896
+ const selectedCapability = await resolveInstalledCredentialsCapability(
897
+ await realpath(path.join(paths.runtime, install.version)),
898
+ );
899
+ if (selectedCapability < minimumCredentialsCapability) {
900
+ throw new Error(
901
+ `refusing to claim credentials capability ${minimumCredentialsCapability}; the selected managed runtime ${install.version} carries credentials capability ${selectedCapability}`,
902
+ );
903
+ }
904
+ // Standalone recovery must refuse what this floor refuses before the
905
+ // floor counts.
906
+ await ensureCurrentRecoveryEntrypoint(paths);
907
+ }
908
+ await mkdir(path.dirname(paths.minimumCredentialsCapability), {
909
+ recursive: true,
910
+ mode: 0o700,
911
+ });
912
+ const existing = await readMinimumCredentialsCapabilityFile(paths);
913
+ if (existing !== null && existing >= minimumCredentialsCapability) return;
914
+ await writeAtomicJson(paths.minimumCredentialsCapability, {
915
+ schemaVersion: 1,
916
+ minimumCredentialsCapability,
917
+ });
918
+ });
919
+ }
920
+
921
+ /** Whether the credentials floor's file exists, valid or not: a runner
922
+ * that claimed it has begun its switch, whatever its activation record
923
+ * says (ADR-154 §13). */
924
+ export function credentialsCapabilityFloorExists(home?: string): boolean {
925
+ return pathExistsSync(managedRuntimePaths(home).minimumCredentialsCapability);
926
+ }
927
+
928
+ /**
929
+ * ADR-154 §13, the runner's local commit: from its record on, the switch
930
+ * never goes back, so no release built before the cutover may boot here
931
+ * again. Under the managed-operation lock, which every install, update and
932
+ * rollback holds from its checks to its link commit, so none crosses this:
933
+ * nothing is being installed, activated, rolled back or uninstalled; the
934
+ * selected generation carries the credentials marker, so the next boot is a
935
+ * release that reads the record; standalone recovery reads it too (its
936
+ * entrypoint upgraded); then `record()` writes the record, still under the
937
+ * lock. An update or rollback after it finds the record a credentials floor.
938
+ * A managed tree that is damaged (its current link dangling or missing
939
+ * beside other managed state) fails closed. A runner with no managed
940
+ * install (an npm one) has none of this: `record()` alone.
941
+ *
942
+ * Standalone recovery takes no lock: it reads the record when it starts,
943
+ * then hands the command to the previous generation's own launcher, which
944
+ * does not. The upgrade here fences that launcher's rollback: every managed
945
+ * release before the cutover rolls back under this lock only, and before
946
+ * its journal or any link refuses a recovery entrypoint other than its own
947
+ * revisions'. So such a rollback either took the lock first, and this finds
948
+ * its generation selected, or comes after the upgrade and refuses.
949
+ */
950
+ export async function commitCredentialsSwitchLocally(record: () => void, home?: string): Promise<void> {
951
+ const paths = managedRuntimePaths(home);
952
+ await withManagedOperationLockWaiting(paths, async () => {
953
+ await assertNoManagedInstallIntent(paths, "the switch to cloud credentials");
954
+ const install = await managedInstallState(paths);
955
+ if (install.kind === "damaged") throw new Error(install.detail);
956
+ if (install.kind === "selected") {
957
+ if (pathExistsSync(paths.activationPending) || pathExistsSync(paths.rollbackJournal)) {
958
+ throw new Error("managed runtime activation is already in progress");
959
+ }
960
+ if (pathExistsSync(paths.maintenancePending)) {
961
+ throw new Error("managed runtime maintenance is already in progress");
962
+ }
963
+ const capability = await resolveInstalledCredentialsCapability(
964
+ await realpath(path.join(paths.runtime, install.version)),
965
+ );
966
+ if (capability < MANAGED_RUNTIME_CREDENTIALS_VERSION) {
967
+ throw new Error(
968
+ `the selected managed runtime ${install.version} was built before the switch to cloud credentials`,
969
+ );
970
+ }
971
+ await ensureCurrentRecoveryEntrypoint(paths);
972
+ }
973
+ record();
974
+ });
975
+ }
976
+
977
+ type ManagedInstallState =
978
+ | { kind: "absent" }
979
+ | { kind: "selected"; version: string }
980
+ | { kind: "damaged"; detail: string };
981
+
982
+ /** A managed install, if any, and the generation it boots next. A missing or
983
+ * dangling current link is no npm install when other managed state (the
984
+ * runtime directory, a previous link, the recovery entrypoint) is there:
985
+ * that tree is damaged, and nothing may be decided from it. */
986
+ async function managedInstallState(paths: ManagedRuntimePaths): Promise<ManagedInstallState> {
987
+ let currentLink = true;
988
+ try {
989
+ await lstat(paths.current);
990
+ } catch (error) {
991
+ if (!isMissing(error)) throw error;
992
+ currentLink = false;
993
+ }
994
+ if (currentLink) {
995
+ const version = await readVersionLink(paths.current, paths.runtime);
996
+ return version === null
997
+ ? { kind: "damaged", detail: "the managed runtime's current link leads to nothing; repair it first" }
998
+ : { kind: "selected", version };
999
+ }
1000
+ for (const [state, what] of [
1001
+ [paths.runtime, "runtime directory"],
1002
+ [paths.previous, "previous runtime link"],
1003
+ [paths.recoveryEntrypoint, "recovery entrypoint"],
1004
+ ] as const) {
1005
+ try {
1006
+ await lstat(state);
1007
+ return { kind: "damaged", detail: `the managed runtime has a ${what} but no current runtime; repair it first` };
1008
+ } catch (error) {
1009
+ if (!isMissing(error)) throw error;
1010
+ }
1011
+ }
1012
+ return { kind: "absent" };
1013
+ }
1014
+
1015
+ /** The caller holds the operation lock and the runtime is managed. */
1016
+ async function ensureCurrentRecoveryEntrypoint(paths: ManagedRuntimePaths): Promise<void> {
1017
+ if ((await assertRecoveryEntrypointReplaceable(paths)) !== "current") {
1018
+ await ensureRecoveryEntrypoint(paths);
1019
+ }
1020
+ }
1021
+
1022
+ function assertCredentialsCapabilityFloor(minimum: number): void {
1023
+ if (!Number.isSafeInteger(minimum) || minimum < 1) {
1024
+ throw new Error("managed runtime credentials floor is invalid");
1025
+ }
1026
+ }
1027
+
828
1028
  /** True while `current` has changed but the supervisor has not exec'd it yet. */
829
1029
  export function managedRuntimeActivationPending(home?: string): boolean {
830
1030
  return pathExistsSync(managedRuntimePaths(home).activationPending);
@@ -1122,6 +1322,7 @@ async function reconcileManagedRuntimeSelectionLocked(
1122
1322
  selectedVersion,
1123
1323
  );
1124
1324
  await assertRuntimeMeetsDurableCapabilityFloor(paths, selectedDescriptor);
1325
+ await assertRuntimeMeetsDurableCredentialsFloor(paths, selectedDescriptor);
1125
1326
  }
1126
1327
  const persistAcknowledgedCapabilityFloor = async (): Promise<void> => {
1127
1328
  if (capabilityAcknowledgement === null) return;
@@ -1978,7 +2179,13 @@ async function resolveInstalledRuntimeCapabilities(
1978
2179
  root,
1979
2180
  platformCapabilities.capabilitySchemaVersion,
1980
2181
  );
1981
- return { ...platformCapabilities, runtimeCapabilityVersion };
2182
+ const credentialsCapabilityVersion =
2183
+ await resolveInstalledCredentialsCapability(root);
2184
+ return {
2185
+ ...platformCapabilities,
2186
+ runtimeCapabilityVersion,
2187
+ credentialsCapabilityVersion,
2188
+ };
1982
2189
  }
1983
2190
 
1984
2191
  async function resolveInstalledPlatformCapabilities(
@@ -2051,6 +2258,39 @@ async function resolveInstalledManagedRuntimeCapability(
2051
2258
  .capabilitySchemaVersion;
2052
2259
  }
2053
2260
 
2261
+ /** `root` must already be the generation's real path. */
2262
+ async function resolveInstalledCredentialsCapability(
2263
+ root: string,
2264
+ ): Promise<number> {
2265
+ const descriptorPath = path.join(
2266
+ root,
2267
+ ...MANAGED_RUNTIME_CREDENTIALS.split("/"),
2268
+ );
2269
+ let metadata;
2270
+ try {
2271
+ metadata = await lstat(descriptorPath);
2272
+ } catch (error) {
2273
+ if (isMissing(error)) return 0;
2274
+ throw error;
2275
+ }
2276
+ if (
2277
+ !metadata.isFile() ||
2278
+ metadata.isSymbolicLink() ||
2279
+ metadata.size > MAX_OPERATION_LOCK_BYTES ||
2280
+ !isInside(root, await realpath(descriptorPath))
2281
+ ) {
2282
+ throw new Error("managed runtime credentials descriptor is invalid");
2283
+ }
2284
+ let raw: unknown;
2285
+ try {
2286
+ raw = JSON.parse(await readFile(descriptorPath, "utf8"));
2287
+ } catch {
2288
+ throw new Error("managed runtime credentials descriptor is invalid JSON");
2289
+ }
2290
+ return validateManagedRuntimeCredentialsDescriptor(raw)
2291
+ .credentialsCapabilityVersion;
2292
+ }
2293
+
2054
2294
  async function validateInstalledRuntimeAssets(
2055
2295
  root: string,
2056
2296
  capabilities: RuntimeCapabilityDescriptorV3,
@@ -2646,7 +2886,7 @@ async function ensureRecoveryEntrypoint(paths: ManagedRuntimePaths): Promise<voi
2646
2886
  }
2647
2887
  }
2648
2888
 
2649
- type RecoveryEntrypointState = "missing" | "legacy-v1" | "current";
2889
+ type RecoveryEntrypointState = "missing" | "legacy-v1" | "legacy-v2" | "current";
2650
2890
 
2651
2891
  /** Classify only entrypoints this release owns exactly. */
2652
2892
  async function assertRecoveryEntrypointReplaceable(
@@ -2669,6 +2909,9 @@ async function assertRecoveryEntrypointReplaceable(
2669
2909
  if (existing.equals(Buffer.from(recoveryEntrypointContents(paths, 1)))) {
2670
2910
  return "legacy-v1";
2671
2911
  }
2912
+ if (existing.equals(Buffer.from(recoveryEntrypointContents(paths, 2)))) {
2913
+ return "legacy-v2";
2914
+ }
2672
2915
  throw new Error(
2673
2916
  `refusing to replace modified host recovery entrypoint at ${paths.recoveryEntrypoint}`,
2674
2917
  );
@@ -2678,13 +2921,57 @@ async function assertRecoveryEntrypointReplaceable(
2678
2921
  }
2679
2922
  }
2680
2923
 
2924
+ /**
2925
+ * The standalone recovery shell. Revision 3 enforces ADR-154's credentials
2926
+ * floor too: once the activation record or the credentials floor exists, a
2927
+ * previous generation without the credentials marker (built before the
2928
+ * cutover) is refused, so recovery cannot undo a switch to cloud credentials,
2929
+ * even between the runner's local commit and the cloud's. Any change to these
2930
+ * bytes makes a deployed entrypoint "modified", so each revision is a new one
2931
+ * that this release's floors upgrade the older owned revisions to before they
2932
+ * count (commitCredentialsSwitchLocally, claimCredentialsCapabilityFloor).
2933
+ */
2681
2934
  function recoveryEntrypointContents(
2682
2935
  paths: ManagedRuntimePaths,
2683
- revision: 1 | 2 = 2,
2936
+ revision: 1 | 2 | 3 = 3,
2684
2937
  ): string {
2685
2938
  const runtime = shellSingleQuote(paths.runtime);
2686
2939
  const capabilityFloor = shellSingleQuote(paths.minimumRuntimeCapability);
2687
2940
  const providerState = shellSingleQuote(paths.runtimeProviderState);
2941
+ const credentialsGuard =
2942
+ revision < 3
2943
+ ? ""
2944
+ : `credentials_floor=${shellSingleQuote(paths.minimumCredentialsCapability)}
2945
+ activation_record=${shellSingleQuote(paths.credentialsActivationRecord)}
2946
+ required_credentials=0
2947
+ if [ -e "$credentials_floor" ] || [ -L "$credentials_floor" ]; then
2948
+ if [ ! -f "$credentials_floor" ] || [ -L "$credentials_floor" ]; then
2949
+ fail "the credentials floor is invalid"
2950
+ fi
2951
+ required_credentials=$(LC_ALL=C tr -d '[:space:]' < "$credentials_floor" |
2952
+ sed -n 's/^{"schemaVersion":1,"minimumCredentialsCapability":\\([1-9][0-9]*\\)}$/\\1/p')
2953
+ case $required_credentials in
2954
+ '' | *[!0-9]*) fail "the credentials floor is invalid" ;;
2955
+ esac
2956
+ fi
2957
+ if [ "$required_credentials" -lt 1 ] && { [ -e "$activation_record" ] || [ -L "$activation_record" ]; }; then
2958
+ required_credentials=1
2959
+ fi
2960
+ if [ "$required_credentials" -ge 1 ]; then
2961
+ credentials=$generation/agent/managed-runtime-credentials.json
2962
+ if [ ! -f "$credentials" ] || [ -L "$credentials" ]; then
2963
+ fail "the previous runtime predates this host's switch to cloud credentials"
2964
+ fi
2965
+ generation_credentials=$(LC_ALL=C tr -d '[:space:]' < "$credentials" |
2966
+ sed -n 's/^{"credentialsCapabilityVersion":\\([1-9][0-9]*\\)}$/\\1/p')
2967
+ case $generation_credentials in
2968
+ '' | *[!0-9]*) fail "the previous runtime credentials descriptor is invalid" ;;
2969
+ esac
2970
+ if [ "$generation_credentials" -lt "$required_credentials" ]; then
2971
+ fail "the previous runtime predates this host's switch to cloud credentials"
2972
+ fi
2973
+ fi
2974
+ `;
2688
2975
  const capabilityProbe =
2689
2976
  revision === 1
2690
2977
  ? ` capability=$generation/agent/bundled-runtime/capability.json
@@ -2800,7 +3087,7 @@ ${capabilityProbe} case $generation_capability in
2800
3087
  fail "the previous runtime capability $generation_capability is below this host's floor $required_floor"
2801
3088
  fi
2802
3089
  fi
2803
- launcher_dir=$generation/bin
3090
+ ${credentialsGuard}launcher_dir=$generation/bin
2804
3091
  if [ ! -d "$launcher_dir" ] || [ -L "$launcher_dir" ]; then
2805
3092
  fail "the previous managed runtime launcher directory is invalid"
2806
3093
  fi
@@ -2813,6 +3100,14 @@ exec "$launcher" "$@"
2813
3100
  `;
2814
3101
  }
2815
3102
 
3103
+ /** Tests only: an exact revision's bytes, as it was or is deployed. */
3104
+ export function recoveryEntrypointContentsForTests(
3105
+ paths: ManagedRuntimePaths,
3106
+ revision: 1 | 2 | 3,
3107
+ ): string {
3108
+ return recoveryEntrypointContents(paths, revision);
3109
+ }
3110
+
2816
3111
  function shellSingleQuote(value: string): string {
2817
3112
  return `'${value.split("'").join(`'"'"'`)}'`;
2818
3113
  }
@@ -2936,6 +3231,7 @@ async function recoverRollback(
2936
3231
  journal.version,
2937
3232
  );
2938
3233
  await assertRuntimeMeetsDurableCapabilityFloor(paths, targetDescriptor);
3234
+ await assertRuntimeMeetsDurableCredentialsFloor(paths, targetDescriptor);
2939
3235
  await atomicVersionLink(paths.current, journal.version);
2940
3236
  await atomicVersionLink(paths.previous, journal.fromVersion);
2941
3237
  await writeRollbackUpdateHold(paths, journal.fromVersion, journal.version);
@@ -2955,6 +3251,74 @@ async function assertRuntimeMeetsDurableCapabilityFloor(
2955
3251
  }
2956
3252
  }
2957
3253
 
3254
+ /** Before the switch every generation passes, exactly as before the cutover. */
3255
+ async function assertRuntimeMeetsDurableCredentialsFloor(
3256
+ paths: ManagedRuntimePaths,
3257
+ descriptor: ResolvedRuntimeDescriptor,
3258
+ ): Promise<void> {
3259
+ const minimum = await requiredCredentialsCapability(paths);
3260
+ if (minimum !== null && descriptor.credentialsCapabilityVersion < minimum) {
3261
+ throw new Error(
3262
+ `refusing runtime ${descriptor.hostVersion} with credentials capability ${descriptor.credentialsCapabilityVersion}; this host requires credentials capability ${minimum} since its credentials moved to the cloud`,
3263
+ );
3264
+ }
3265
+ }
3266
+
3267
+ /** The credentials floor in force: its file's, and at least 1 once the
3268
+ * activation record exists (the switch has begun). */
3269
+ async function requiredCredentialsCapability(paths: ManagedRuntimePaths): Promise<number | null> {
3270
+ const floor = await readMinimumCredentialsCapabilityFile(paths);
3271
+ let recorded = false;
3272
+ try {
3273
+ await lstat(paths.credentialsActivationRecord);
3274
+ recorded = true;
3275
+ } catch (error) {
3276
+ if (!isMissing(error)) throw error;
3277
+ }
3278
+ if (!recorded) return floor;
3279
+ return Math.max(floor ?? 0, 1);
3280
+ }
3281
+
3282
+ async function readMinimumCredentialsCapabilityFile(
3283
+ paths: ManagedRuntimePaths,
3284
+ ): Promise<number | null> {
3285
+ let bytes: string;
3286
+ try {
3287
+ const stat = await lstat(paths.minimumCredentialsCapability);
3288
+ if (
3289
+ !stat.isFile() ||
3290
+ stat.isSymbolicLink() ||
3291
+ stat.size > MAX_OPERATION_LOCK_BYTES
3292
+ ) {
3293
+ throw new Error("managed runtime credentials floor is invalid");
3294
+ }
3295
+ bytes = await readFile(paths.minimumCredentialsCapability, "utf8");
3296
+ } catch (error) {
3297
+ if (isMissing(error)) return null;
3298
+ throw error;
3299
+ }
3300
+ let raw: unknown;
3301
+ try {
3302
+ raw = JSON.parse(bytes);
3303
+ } catch {
3304
+ throw new Error("managed runtime credentials floor is invalid");
3305
+ }
3306
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
3307
+ throw new Error("managed runtime credentials floor is invalid");
3308
+ }
3309
+ const marker = raw as Record<string, unknown>;
3310
+ if (
3311
+ Object.keys(marker).sort().join("\0") !==
3312
+ ["minimumCredentialsCapability", "schemaVersion"].join("\0") ||
3313
+ marker.schemaVersion !== 1 ||
3314
+ !Number.isSafeInteger(marker.minimumCredentialsCapability) ||
3315
+ Number(marker.minimumCredentialsCapability) < 1
3316
+ ) {
3317
+ throw new Error("managed runtime credentials floor is invalid");
3318
+ }
3319
+ return Number(marker.minimumCredentialsCapability);
3320
+ }
3321
+
2958
3322
  async function readMinimumRuntimeCapability(
2959
3323
  paths: ManagedRuntimePaths,
2960
3324
  ): Promise<number | null> {