@askexenow/exe-os 0.9.411 → 0.9.413

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.
@@ -28,10 +28,12 @@ import {
28
28
  parseEnv,
29
29
  parseRetainedContainerHealth,
30
30
  patchEnv,
31
+ planLegacyWorkerMigration,
31
32
  printDanglingVolumes,
32
33
  printVolumeCleanupResult,
33
34
  readCurrentStackVersion,
34
35
  readRetainedComposeFiles,
36
+ readStagedComposeTemplate,
35
37
  reconcileStackLockFromContainers,
36
38
  removeDanglingVolumes,
37
39
  reportStackManifestSource,
@@ -50,11 +52,11 @@ import {
50
52
  verifyManifestImagesAvailable,
51
53
  warnUnmanagedManifestServices,
52
54
  withStackUpdateOperationLock
53
- } from "../chunk-WHUQ44F2.js";
55
+ } from "../chunk-RIKFXHLE.js";
54
56
  import "../chunk-EC5H53YD.js";
55
57
  import {
56
58
  runVerifyStack
57
- } from "../chunk-HWP3WCLC.js";
59
+ } from "../chunk-OPG237ZQ.js";
58
60
  import {
59
61
  assertPreflightGate,
60
62
  runPreflightOnManifest
@@ -66,7 +68,7 @@ import {
66
68
  import {
67
69
  logResult,
68
70
  runHealthGate
69
- } from "../chunk-DRHTRVDT.js";
71
+ } from "../chunk-VBYSAQJD.js";
70
72
  import "../chunk-L25OAFOU.js";
71
73
  import "../chunk-RT57D7C4.js";
72
74
  import "../chunk-FTZET5YX.js";
@@ -2325,6 +2327,9 @@ function reportPartialRun(result) {
2325
2327
  }
2326
2328
  async function main(args = process.argv.slice(2)) {
2327
2329
  const opts = parseArgs(args);
2330
+ if (opts.rollback && (opts.check || opts.dryRun || opts.verifyImages)) {
2331
+ throw new Error("Rollback cannot be combined with --check, --dry-run or --verify-images; no rollback was performed.");
2332
+ }
2328
2333
  if (opts.retainCompose && (!opts.targetVersion || !opts.serviceName || !opts.noBootstrap || !opts.skipHardening || opts.configOnly || opts.rollback || opts.resume || opts.addService || opts.removeService || opts.services?.length || opts.cleanupVolumes || opts.reconcileLock || opts.adoptDeployedPins || opts.allowRed || opts.allowUnmanifestedServices || opts.breakGlassReason || opts.strictDigest === false || !opts.verifyManifestSignature)) {
2329
2334
  throw new Error("--retain-compose requires explicit --target, one --service, --no-bootstrap, --skip-hardening, and no selection/config/rollback/cleanup/bypass mode");
2330
2335
  }
@@ -2401,10 +2406,10 @@ async function main(args = process.argv.slice(2)) {
2401
2406
  return;
2402
2407
  }
2403
2408
  let usingPackagedCheckTemplates = false;
2404
- if (opts.check && !opts.noBootstrap && !existsForCli(opts.composeFile) && !existsForCli(opts.envFile)) {
2405
- const packageRoot = path3.resolve(new URL("../..", import.meta.url).pathname);
2406
- opts.composeFile = path3.join(packageRoot, "deploy", "compose", "docker-compose.yml");
2407
- opts.envFile = path3.join(packageRoot, "deploy", "compose", ".env.customer.example");
2409
+ if ((opts.check || opts.dryRun || opts.verifyImages || !opts.yes) && !opts.noBootstrap && !existsForCli(opts.composeFile) && !existsForCli(opts.envFile)) {
2410
+ const packaged = readStagedComposeTemplate(opts, "");
2411
+ opts.composeFile = packaged.templatePath;
2412
+ opts.envFile = path3.join(path3.dirname(packaged.templatePath), ".env.customer.example");
2408
2413
  opts.noBootstrap = true;
2409
2414
  usingPackagedCheckTemplates = true;
2410
2415
  }
@@ -2418,7 +2423,7 @@ async function main(args = process.argv.slice(2)) {
2418
2423
  return;
2419
2424
  }
2420
2425
  let hostReport;
2421
- if (!opts.noBootstrap) {
2426
+ if (!opts.noBootstrap && opts.yes && !opts.check && !opts.dryRun && !opts.verifyImages) {
2422
2427
  hostReport = bootstrapStackHost({ ...opts, installDocker: opts.yes, domain: opts.domain, adminEmail: opts.adminEmail });
2423
2428
  printHostReport(hostReport);
2424
2429
  if (!opts.check && !opts.dryRun) assertHostReadyForApply(hostReport);
@@ -2440,6 +2445,7 @@ async function main(args = process.argv.slice(2)) {
2440
2445
  );
2441
2446
  reportStackManifestSource(manifestLoad);
2442
2447
  const manifest = manifestLoad.manifest;
2448
+ let previewServiceSelection;
2443
2449
  if (opts.addService || opts.removeService) {
2444
2450
  const effectiveVersion = opts.targetVersion ?? manifest.latest;
2445
2451
  const manifestRelease = manifest.stacks[effectiveVersion];
@@ -2463,15 +2469,20 @@ async function main(args = process.argv.slice(2)) {
2463
2469
  currentServices.delete(opts.removeService);
2464
2470
  console.log(`Removed service: ${opts.removeService}`);
2465
2471
  }
2466
- saveServiceSelection(opts.envFile, [...currentServices]);
2467
- console.log(`Saved service selection: ${[...currentServices].join(", ")}`);
2468
- if (!opts.yes && !opts.check) {
2472
+ if (opts.check || opts.dryRun || opts.verifyImages) {
2473
+ previewServiceSelection = runScopedServiceSelection([...currentServices]);
2474
+ console.log(`Planned service selection (not saved): ${[...currentServices].join(", ")}`);
2475
+ } else {
2476
+ saveServiceSelection(opts.envFile, [...currentServices]);
2477
+ console.log(`Saved service selection: ${[...currentServices].join(", ")}`);
2478
+ }
2479
+ if (!opts.yes && !opts.check && !opts.dryRun && !opts.verifyImages) {
2469
2480
  console.log("Service selection updated. Re-run with --yes to apply.");
2470
2481
  return;
2471
2482
  }
2472
2483
  }
2473
2484
  const isRunScoped = Boolean(opts.services && opts.services.length > 0);
2474
- const serviceSelection = isRunScoped ? runScopedServiceSelection(opts.services) : loadServiceSelection(opts.envFile);
2485
+ const serviceSelection = isRunScoped ? runScopedServiceSelection(opts.services) : previewServiceSelection ?? loadServiceSelection(opts.envFile);
2475
2486
  if (isRunScoped) {
2476
2487
  console.log(
2477
2488
  `Service scope for THIS RUN ONLY: ${opts.services.join(", ")} (the managed set in ${serviceSelectionPath(opts.envFile)} is unchanged)`
@@ -2533,7 +2544,22 @@ async function main(args = process.argv.slice(2)) {
2533
2544
  }
2534
2545
  assertDeploymentScopeAllowed(plan, opts.deploymentPersona);
2535
2546
  const plannedEnvRaw = patchEnv(envRaw, Object.fromEntries(plan.changes.map((c) => [c.key, c.after])));
2536
- const composeRawForGate = readFileSync3(opts.composeFile, "utf8");
2547
+ const currentComposeRaw = readFileSync3(opts.composeFile, "utf8");
2548
+ const hostState = listHostComposeServices(composeArgsForStack(opts.composeFile, opts.envFile));
2549
+ const overrideFile = path3.join(path3.dirname(opts.composeFile), "docker-compose.override.yml");
2550
+ const workerMigration = opts.retainCompose ? { raw: currentComposeRaw, changed: false } : planLegacyWorkerMigration({
2551
+ composeRaw: currentComposeRaw,
2552
+ targetComposeRaw: () => {
2553
+ const staged = readStagedComposeTemplate(opts, currentComposeRaw);
2554
+ return staged.templateFound ? staged.raw : null;
2555
+ },
2556
+ env: new Map(["COMPOSE_PROFILES", "EXE_OS_WORKER_IMAGE_TAG"].map((key) => [key, readKeyFromEnvFile(opts.envFile, key) ?? ""])),
2557
+ hostServices: hostState.services,
2558
+ hostServicesUnavailable: hostState.unavailable,
2559
+ overrideRaw: existsSync3(overrideFile) ? readFileSync3(overrideFile, "utf8") : null
2560
+ });
2561
+ const composeRawForGate = workerMigration.raw;
2562
+ if (workerMigration.changed) console.log("Planned configuration migration: clear the unused legacy embeddings-worker image default (no image will be deployed).");
2537
2563
  assertProductionDeployGate(plan, plannedEnvRaw, composeRawForGate, {
2538
2564
  breakGlassReason: opts.breakGlassReason,
2539
2565
  breakGlassAuditFile: opts.breakGlassAuditFile,
@@ -2541,7 +2567,6 @@ async function main(args = process.argv.slice(2)) {
2541
2567
  envFile: opts.envFile
2542
2568
  });
2543
2569
  {
2544
- const hostState = listHostComposeServices(composeArgsForStack(opts.composeFile, opts.envFile));
2545
2570
  assertSignedManifestDeliveryGate({
2546
2571
  release: manifest.stacks[plan.targetVersion],
2547
2572
  targetVersion: plan.targetVersion,
@@ -7,7 +7,7 @@ import {
7
7
  checkSsoSessionContract,
8
8
  parseComposeServiceNames,
9
9
  runVerifyStack
10
- } from "../chunk-HWP3WCLC.js";
10
+ } from "../chunk-OPG237ZQ.js";
11
11
  import "../chunk-RT57D7C4.js";
12
12
  import "../chunk-FTZET5YX.js";
13
13
  import "../chunk-6Y4B3QF6.js";
@@ -8,7 +8,7 @@ import {
8
8
  logResult,
9
9
  main,
10
10
  runHealthGate
11
- } from "../chunk-DRHTRVDT.js";
11
+ } from "../chunk-VBYSAQJD.js";
12
12
  import "../chunk-MLKGABMK.js";
13
13
  export {
14
14
  checkCRM,
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.9.411",
3
- "commit": "7831446d8e",
4
- "buildTime": "2026-10-12T18:45:23.972Z"
2
+ "version": "0.9.413",
3
+ "commit": "cf66dffc1f",
4
+ "buildTime": "2026-10-14T21:38:04.474Z"
5
5
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 2,
3
- "generated_at": "2026-10-12T18:45:24.278Z",
3
+ "generated_at": "2026-10-14T21:38:04.732Z",
4
4
  "files": [
5
5
  {
6
6
  "path": "active-agent-3YCLS7GB.js",
@@ -179,7 +179,7 @@
179
179
  },
180
180
  {
181
181
  "path": "bin/cli.js",
182
- "sha256": "f6ccf8070a3151f8f5d1b70827b7b6fe311c64e030e3f4992a91a349fdf6b67e",
182
+ "sha256": "ecc4054b893c658689be2664e4106425497519d98d1b8ab220f05a87a88d21a0",
183
183
  "bytes": 69826
184
184
  },
185
185
  {
@@ -529,7 +529,7 @@
529
529
  },
530
530
  {
531
531
  "path": "bin/registry-proxy.js",
532
- "sha256": "49967e4dacf4c85b79e9595907a0052455eac8e4562dea589ebd4a4ecbbcf438",
532
+ "sha256": "6246f910ac5dc7ee8e424276768ce69834acc34cb1ded671b67e14402449aca9",
533
533
  "bytes": 1885
534
534
  },
535
535
  {
@@ -554,8 +554,8 @@
554
554
  },
555
555
  {
556
556
  "path": "bin/stack-update.js",
557
- "sha256": "8905263c3a6033dbdc7f2168104e7348325273e5d8ad3bdc3218746806ff9fdc",
558
- "bytes": 133983
557
+ "sha256": "d99802bc590930932894066d40e57bdd844e200a07f4f09834795f9dcb623bcc",
558
+ "bytes": 135594
559
559
  },
560
560
  {
561
561
  "path": "bin/update.js",
@@ -564,7 +564,7 @@
564
564
  },
565
565
  {
566
566
  "path": "bin/verify-stack.js",
567
- "sha256": "243b78602db4556a9ffa28a532cc025f0901d13bf2d21d7ddc5c3ece5458a24c",
567
+ "sha256": "eb579bed1ecc499124d9b64ee92f12e39402faa459370d48bb8e1468c123e11b",
568
568
  "bytes": 588
569
569
  },
570
570
  {
@@ -574,7 +574,7 @@
574
574
  },
575
575
  {
576
576
  "path": "bin/vps-health-gate.js",
577
- "sha256": "174a06829ed463c162e055d9a4fe10d940cc26dffc9fcf5ff7d9915a00c2d488",
577
+ "sha256": "c94514d9a30b1996dfd90b7f55ef7472e8b3fe02106306e39bcb985504d61329",
578
578
  "bytes": 333
579
579
  },
580
580
  {
@@ -624,7 +624,7 @@
624
624
  },
625
625
  {
626
626
  "path": "build-info.json",
627
- "sha256": "dc744cb25ca287542b6f1c267113db20272b234bc0059d866bb675a50dd6a4c4",
627
+ "sha256": "ae2c18364e569176d61a4ca37007c3b167bcd86cbb5dcf15b40a35ccc933486f",
628
628
  "bytes": 96
629
629
  },
630
630
  {
@@ -787,11 +787,6 @@
787
787
  "sha256": "29a6acbbaa627ae4d7852dd16a33f341809f3d82940b40c67c35d39d99fdf2c4",
788
788
  "bytes": 1090
789
789
  },
790
- {
791
- "path": "chunk-54STF27U.js",
792
- "sha256": "6247938bc9df3b4c7c59de0cd0e8524952728545a5e28c22e3b53723966beec6",
793
- "bytes": 44776
794
- },
795
790
  {
796
791
  "path": "chunk-557C2IGL.js",
797
792
  "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
@@ -802,6 +797,11 @@
802
797
  "sha256": "be62387389c3e7572937bbb4dd16d7b1b7d8412519da9e5184970e0e2903dc41",
803
798
  "bytes": 22775
804
799
  },
800
+ {
801
+ "path": "chunk-5DLZJ3MY.js",
802
+ "sha256": "92f2a77f33866d1a5948b5977783676a45820ba6b44908b873776912e0001380",
803
+ "bytes": 3766
804
+ },
805
805
  {
806
806
  "path": "chunk-5DMAMQNU.js",
807
807
  "sha256": "19a9706b415bb25b177a37e1192d9df7d2cfc60cf3c0d997fc5d87c5001e59b1",
@@ -1132,11 +1132,6 @@
1132
1132
  "sha256": "72d6557a872c40e78d45a4e3adc793ac24d437e567b133b701f04203aea5a859",
1133
1133
  "bytes": 12949
1134
1134
  },
1135
- {
1136
- "path": "chunk-DRHTRVDT.js",
1137
- "sha256": "d3452468dcb0a27330ff6807ccbad6f957a6427a099d9bd2a867743d34b10258",
1138
- "bytes": 7587
1139
- },
1140
1135
  {
1141
1136
  "path": "chunk-DRTJCG6B.js",
1142
1137
  "sha256": "cf3ce4cf6c9cdba17a8a5e3d9f0c78ae42f1cb72f1a06608cc8da1fb27f06a76",
@@ -1342,11 +1337,6 @@
1342
1337
  "sha256": "a73bf84b9ddbd1b17f015dfb536fa2c87a05759bd7f8a33ec99bf37435ecdcdf",
1343
1338
  "bytes": 37638
1344
1339
  },
1345
- {
1346
- "path": "chunk-HWP3WCLC.js",
1347
- "sha256": "c6bd6a6824ccad9848e5dd323301b8bfe09222bc4f02516f220588bf5e359447",
1348
- "bytes": 39986
1349
- },
1350
1340
  {
1351
1341
  "path": "chunk-IC2AONBH.js",
1352
1342
  "sha256": "a36b4578fc5a22358234f1268894f828ba992cc68300413d4c2ac09d5b6a78e5",
@@ -1642,6 +1632,11 @@
1642
1632
  "sha256": "49c15064d81c686c9f3e36c08b8f49c41380ee87ed91a2c87b5280584ba61f45",
1643
1633
  "bytes": 15398
1644
1634
  },
1635
+ {
1636
+ "path": "chunk-OPG237ZQ.js",
1637
+ "sha256": "bb7a843a54a6f1593d03b51729042108856b25e48fa7e555e234cb89f3deaee7",
1638
+ "bytes": 45241
1639
+ },
1645
1640
  {
1646
1641
  "path": "chunk-ORCCI2VV.js",
1647
1642
  "sha256": "a37bac62b7a21c982e0b3452ecedc0d6701215fece2336e45834cf1ba7f6fc03",
@@ -1777,6 +1772,11 @@
1777
1772
  "sha256": "c20bef017d05b8cc9c956d0a506eff0c31b14c3c1befdacf31e7f4fa008fbbdb",
1778
1773
  "bytes": 1549
1779
1774
  },
1775
+ {
1776
+ "path": "chunk-RIKFXHLE.js",
1777
+ "sha256": "ec22ba327e38c90d0f3bf3211f2b789a5addb0e378a0a0112ee36457540a09dd",
1778
+ "bytes": 286000
1779
+ },
1780
1780
  {
1781
1781
  "path": "chunk-RJ67XAJU.js",
1782
1782
  "sha256": "48fb50b39022099eec6ec9ab7183d5d4e67b7f777d2eb3c2db1ea75af89c404a",
@@ -1942,6 +1942,11 @@
1942
1942
  "sha256": "2177b5e9109243a74fbaf9d67367db6a7b029b9aa7347cc232e3e354ad6c8768",
1943
1943
  "bytes": 7289
1944
1944
  },
1945
+ {
1946
+ "path": "chunk-VBYSAQJD.js",
1947
+ "sha256": "dd5853a20a5092445dc86cc33fdc0e8f45777980fd98dd56c8fbea4d0a7e84a1",
1948
+ "bytes": 7587
1949
+ },
1945
1950
  {
1946
1951
  "path": "chunk-VGMACSEV.js",
1947
1952
  "sha256": "4a51d1fb50f1389160ec2d06ebeb702976a647212f7731ab58f562de3f53f215",
@@ -1967,6 +1972,11 @@
1967
1972
  "sha256": "5b4f1603c33ffbae21010d78cca899592e9f46576fa96be79fa044354506b221",
1968
1973
  "bytes": 19195
1969
1974
  },
1975
+ {
1976
+ "path": "chunk-W3ABSZ6G.js",
1977
+ "sha256": "6380ccbf98ed850ed45daff314a6d85607e246ab333502d969e1d885e2da7b11",
1978
+ "bytes": 44776
1979
+ },
1970
1980
  {
1971
1981
  "path": "chunk-W4H2MZAS.js",
1972
1982
  "sha256": "cce67b1fec0b0c19a2099d1d8ae4f10a1097f59d70317570efa85d04400ed6f5",
@@ -1982,21 +1992,11 @@
1982
1992
  "sha256": "1b9948f2debbef6df967904c57797a46739dfbffe17493f3e5786d6e05d71701",
1983
1993
  "bytes": 50904
1984
1994
  },
1985
- {
1986
- "path": "chunk-WC44PNZU.js",
1987
- "sha256": "ddaee681898a3a9899fb8344b85f05f47fe2b2d7b8f48308e494b63e042c5052",
1988
- "bytes": 3766
1989
- },
1990
1995
  {
1991
1996
  "path": "chunk-WCGNCH3Q.js",
1992
1997
  "sha256": "4a905397d309c73ed4ad5d1ec488309541be3eec2fc6021d275f433ca92cc7a9",
1993
1998
  "bytes": 2265
1994
1999
  },
1995
- {
1996
- "path": "chunk-WHUQ44F2.js",
1997
- "sha256": "25b05355f18e18f5f5c80dc465fce50869af66825e6ca7be14ed595d9926ffd6",
1998
- "bytes": 275037
1999
- },
2000
2000
  {
2001
2001
  "path": "chunk-WJWRQQMS.js",
2002
2002
  "sha256": "288c40cefdb07850759e0d9bb48d5fd7b3e8bdff9e128b44780c626b0e0ef2a9",
@@ -2604,7 +2604,7 @@
2604
2604
  },
2605
2605
  {
2606
2606
  "path": "hooks/manifest.json",
2607
- "sha256": "9d55042634eb4e0f419e855ab60be3b39974d3f09e4d7b1173e875f334cd9bed",
2607
+ "sha256": "6e4d658cb0eb906d2ea17974d9448cb11a0e1b3ed45fd4fcd92c9480ba1469f2",
2608
2608
  "bytes": 1841
2609
2609
  },
2610
2610
  {
@@ -2919,7 +2919,7 @@
2919
2919
  },
2920
2920
  {
2921
2921
  "path": "lib/registry-proxy.js",
2922
- "sha256": "32257fcbf92713bc59bec9f2c6189cdc264feae5f68ec96fc443a5897d2e439b",
2922
+ "sha256": "8c19b7462382a7b98887843ce2bb5f19b1b6e236819c95841f4b26ad4924d460",
2923
2923
  "bytes": 749
2924
2924
  },
2925
2925
  {
@@ -3513,9 +3513,9 @@
3513
3513
  "bytes": 111
3514
3514
  },
3515
3515
  {
3516
- "path": "stack-generate-manifest-SU6OFFY7.js",
3517
- "sha256": "eb2109ae5b9b0d493e808b4c81486fd2de39e84d429b50ab59c0ace1d76173aa",
3518
- "bytes": 56960
3516
+ "path": "stack-generate-manifest-MVJDTU4F.js",
3517
+ "sha256": "08e90e042fb2c3a3891b667d0e5edd757ae308ee56835c70b502b2a2bd8973a5",
3518
+ "bytes": 54817
3519
3519
  },
3520
3520
  {
3521
3521
  "path": "stack-init-5VKL5PL5.js",
@@ -3523,19 +3523,19 @@
3523
3523
  "bytes": 4761
3524
3524
  },
3525
3525
  {
3526
- "path": "stack-promote-MJF436JD.js",
3527
- "sha256": "45324465991756db5a4bef259f52cb5d3f3a03423c2e78067a38fdab3095dea5",
3526
+ "path": "stack-promote-BMGEY6SQ.js",
3527
+ "sha256": "77f706cfcd451b7a82cbfbe7cb9b1e884aee6f1f6824046bc244fb6d753bf945",
3528
3528
  "bytes": 7425
3529
3529
  },
3530
3530
  {
3531
- "path": "stack-release-S37R57XC.js",
3532
- "sha256": "8bdcd6d6a27d5e234da655acb201f4763dc7bdaf80d13323b135731b621cad3a",
3533
- "bytes": 117404
3531
+ "path": "stack-release-64UJ6Y4W.js",
3532
+ "sha256": "736e14e7a747a039b8464fd322f967b4a4eb026d77191ec558bc2ce97449dc3a",
3533
+ "bytes": 115927
3534
3534
  },
3535
3535
  {
3536
- "path": "stack-update-XXDRKGGR.js",
3537
- "sha256": "a76298354a9acefc167ab902853c0104606353d1d4e749442ce53dffd76f7a21",
3538
- "bytes": 8231
3536
+ "path": "stack-update-EBATTQPP.js",
3537
+ "sha256": "48c4711a26259d079b26e7d675d3c086f1daadba511e13c4efddfe6211e80f44",
3538
+ "bytes": 8289
3539
3539
  },
3540
3540
  {
3541
3541
  "path": "stalled-dispatch-escalation-MHLYH2PT.js",
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  canonicalizeStackManifest
3
- } from "./chunk-WHUQ44F2.js";
3
+ } from "./chunk-RIKFXHLE.js";
4
4
 
5
5
  // src/lib/stack-manifest-write.ts
6
6
  import { readFileSync, statSync, writeFileSync } from "fs";
@@ -13,7 +13,6 @@ import {
13
13
 
14
14
  // src/bin/verify-stack.ts
15
15
  import { execSync } from "child_process";
16
- import { createHash } from "crypto";
17
16
  import { readFileSync, existsSync } from "fs";
18
17
  import http from "http";
19
18
  import path from "path";
@@ -36,6 +35,18 @@ function evaluateProduct(probes) {
36
35
  const layer = gatingLayerOf(probes);
37
36
  const findings = [];
38
37
  const at = (p) => `landed on ${p.finalHost} (status ${p.status}, ${p.hops} hop(s))`;
38
+ for (const [scenario, probe] of Object.entries({
39
+ anonymous: probes.anonymous,
40
+ "forged-session": probes.forgedSession,
41
+ "sentinel-only": probes.sentinelOnly,
42
+ "real-session": probes.realSession
43
+ })) {
44
+ if (probe?.admissionError) findings.push({
45
+ scenario,
46
+ severity: "violation",
47
+ detail: probe.admissionError
48
+ });
49
+ }
39
50
  if (layer === "client-gated") {
40
51
  findings.push({
41
52
  scenario: "anonymous",
@@ -68,11 +79,12 @@ function evaluateProduct(probes) {
68
79
  }
69
80
  const real = probes.realSession;
70
81
  if (real) {
71
- if (real.hops > MAX_SSO_HANDOFF_HOPS) {
82
+ const limit = probes.app === "exe-erp" && real.verifiedNativeSession ? 4 : MAX_SSO_HANDOFF_HOPS;
83
+ if (real.hops > limit) {
72
84
  findings.push({
73
85
  scenario: "real-session",
74
86
  severity: "violation",
75
- detail: `a real apex session took ${real.hops} redirect(s) reaching ${probes.host}, over the contract's ${MAX_SSO_HANDOFF_HOPS} \u2014 ${at(real)}. An unbounded hop count here is the CRM livelock (bug 83ba9546 regression): the edge and the app are disagreeing about the handoff and bouncing the user between them.`
87
+ detail: `a real apex session took ${real.hops} redirect(s) reaching ${probes.host}, over the contract's ${limit} \u2014 ${at(real)}. An unbounded hop count here is the CRM livelock (bug 83ba9546 regression): the edge and the app are disagreeing about the handoff and bouncing the user between them.`
76
88
  });
77
89
  } else if (real.finalHost !== probes.host) {
78
90
  findings.push({
@@ -135,6 +147,130 @@ function parseApexCookies(setCookieLines) {
135
147
  return jar;
136
148
  }
137
149
 
150
+ // src/lib/sso-admission-probe.ts
151
+ import { createHash } from "crypto";
152
+ var CALLBACK = "/api/method/erpnext.exe_auth.api.gotrue_login_callback";
153
+ var START = "/api/method/erpnext.exe_auth.api.gotrue_login_start";
154
+ var loopback = (u) => u.hostname === "127.0.0.1" || u.hostname === "localhost";
155
+ async function probeAdmission(url, jar, timeoutMs, options) {
156
+ const app = new URL(url), auth = new URL(options.authUrl);
157
+ let current = app, hops = 0, resumed = false, started = false;
158
+ const local = {};
159
+ let actorEmail;
160
+ const safe = (u) => !u.username && !u.password && (u.origin === app.origin || u.origin === auth.origin) && (u.protocol === "https:" || u.protocol === "http:" && loopback(u));
161
+ const headers = (u, apexOnly = false) => ({
162
+ accept: "text/html,application/xhtml+xml",
163
+ "user-agent": "exe-os-verify-stack/sso-session-contract",
164
+ cookie: cookieHeader({
165
+ ...jar,
166
+ ...u.origin === app.origin && !apexOnly ? local : {}
167
+ })
168
+ });
169
+ const result = (status, body, admissionError) => ({
170
+ finalHost: current.host,
171
+ hops,
172
+ status,
173
+ sawPasswordField: htmlHasPasswordField(body),
174
+ bodyFingerprint: createHash("sha256").update(body).digest("hex"),
175
+ ...admissionError ? { admissionError } : {},
176
+ ...resumed && actorEmail && !admissionError ? { verifiedNativeSession: true } : {}
177
+ });
178
+ for (; ; ) {
179
+ if (!safe(current))
180
+ return result(0, "", "SSO redirect left the trusted HTTPS origins");
181
+ const res = await fetch(current, {
182
+ headers: headers(current),
183
+ redirect: "manual",
184
+ signal: AbortSignal.timeout(timeoutMs)
185
+ });
186
+ if (options.service === "exe-erp" && current.origin === app.origin) {
187
+ for (const line of res.headers.getSetCookie()) {
188
+ const [pair = "", ...attrs] = line.split(";");
189
+ const eq = pair.indexOf("="), name = pair.slice(0, eq).trim();
190
+ if (eq < 1 || !["sid", "exe_sso_state"].includes(name)) continue;
191
+ const domain = attrs.find((a) => /^\s*domain=/i.test(a))?.split("=")[1]?.trim().replace(/^\./, "");
192
+ if (domain && domain !== app.hostname) continue;
193
+ local[name] = pair.slice(eq + 1).trim();
194
+ }
195
+ if (current.pathname === START) started = true;
196
+ }
197
+ if (res.status >= 300 && res.status < 400 && res.headers.get("location")) {
198
+ current = new URL(res.headers.get("location"), current);
199
+ if (++hops < 6) continue;
200
+ return result(res.status, "", "SSO redirect budget exhausted");
201
+ }
202
+ if (options.service === "exe-erp" && options.email && jar.exe_sess && !resumed && started && current.origin === auth.origin && current.pathname === "/login" && res.ok && current.searchParams.has("redirect")) {
203
+ const callback = new URL(current.searchParams.get("redirect"), current);
204
+ if (!safe(callback) || callback.origin !== app.origin || callback.pathname !== CALLBACK || !local.exe_sso_state || callback.searchParams.get("state") !== local.exe_sso_state || [...callback.searchParams.keys()].some((k) => k !== "state")) {
205
+ return result(
206
+ res.status,
207
+ "",
208
+ "ERP callback origin, path or state did not match its initiating cookie"
209
+ );
210
+ }
211
+ const me = await fetch(new URL("/auth/me", auth), {
212
+ headers: headers(auth),
213
+ redirect: "manual",
214
+ signal: AbortSignal.timeout(timeoutMs)
215
+ });
216
+ const actor = me.ok ? await me.json() : null;
217
+ if (!actor || actor.ok !== true || typeof actor.sub !== "string" || !actor.sub || typeof actor.email !== "string" || !options.email || actor.email.toLowerCase() !== options.email.toLowerCase()) {
218
+ return result(
219
+ me.status,
220
+ "",
221
+ "Auth did not validate the expected SSO identity"
222
+ );
223
+ }
224
+ actorEmail = actor.email;
225
+ resumed = true;
226
+ current = callback;
227
+ if (++hops < 6) continue;
228
+ return result(0, "", "SSO redirect budget exhausted");
229
+ }
230
+ const body = await res.text();
231
+ if (options.email && !res.ok) {
232
+ return result(res.status, body, "Real-session product request was not successful");
233
+ }
234
+ if (resumed && current.origin === app.origin && res.ok) {
235
+ const identity = await fetch(
236
+ new URL("/api/method/frappe.auth.get_logged_user", app),
237
+ {
238
+ headers: headers(app),
239
+ redirect: "manual",
240
+ signal: AbortSignal.timeout(timeoutMs)
241
+ }
242
+ );
243
+ const user = identity.ok ? await identity.json() : null;
244
+ if (!local.sid || local.sid === "Guest" || !user || user.message !== actorEmail) {
245
+ return result(
246
+ identity.status,
247
+ body,
248
+ "ERP native session did not match the validated Auth identity"
249
+ );
250
+ }
251
+ const control = await fetch(new URL("/api/method/frappe.auth.get_logged_user", app), {
252
+ headers: headers(app, true),
253
+ redirect: "manual",
254
+ signal: AbortSignal.timeout(timeoutMs)
255
+ });
256
+ const guest = control.ok ? await control.json() : null;
257
+ const location = control.headers.get("location");
258
+ const refusal = location ? new URL(location, app) : null;
259
+ const refused = control.status >= 300 && control.status < 400 && refusal && safe(refusal) && (refusal.origin === auth.origin || refusal.origin === app.origin && [START, "/login"].includes(refusal.pathname));
260
+ if (!(guest?.message === "Guest" || [401, 403].includes(control.status) || refused)) {
261
+ return result(control.status, body, "ERP identity was not independently bound to its native session");
262
+ }
263
+ } else if (resumed) {
264
+ return result(
265
+ res.status,
266
+ body,
267
+ "ERP callback did not finish on the expected product"
268
+ );
269
+ }
270
+ return result(res.status, body);
271
+ }
272
+ }
273
+
138
274
  // src/bin/verify-stack.ts
139
275
  var DEFAULT_COMPOSE = "/opt/exe-stack/docker-compose.yml";
140
276
  var DEFAULT_ENV = "/opt/exe-stack/.env";
@@ -518,57 +654,11 @@ async function checkSsoCookieContract(deployed, urlOverrides) {
518
654
  message: `${Object.keys(urls).length} product(s) free of retired SSO cookie names`
519
655
  };
520
656
  }
521
- var MAX_PROBE_HOPS = 6;
522
657
  var SSO_ADMISSION_APPS = {
523
658
  "exe-crm": "crm",
524
659
  "exe-erp": "erp",
525
660
  "exe-wiki": "wiki"
526
661
  };
527
- async function probeAdmission(url, jar, timeoutMs) {
528
- let current = url;
529
- let hops = 0;
530
- const headers = {
531
- // Some edges vary their gate on Accept; ask for a document like a browser does.
532
- accept: "text/html,application/xhtml+xml",
533
- "user-agent": "exe-os-verify-stack/sso-session-contract"
534
- };
535
- const cookie = cookieHeader(jar);
536
- if (cookie) headers.cookie = cookie;
537
- for (; ; ) {
538
- const res = await fetch(current, {
539
- headers,
540
- redirect: "manual",
541
- signal: AbortSignal.timeout(timeoutMs)
542
- });
543
- if (res.status >= 300 && res.status < 400) {
544
- const location = res.headers.get("location");
545
- if (location) {
546
- hops += 1;
547
- current = new URL(location, current).toString();
548
- if (hops < MAX_PROBE_HOPS) continue;
549
- }
550
- return {
551
- finalHost: new URL(current).host,
552
- hops,
553
- status: res.status,
554
- sawPasswordField: false,
555
- bodyFingerprint: `redirect:${res.status}:${location ?? ""}`
556
- };
557
- }
558
- let body = "";
559
- try {
560
- body = await res.text();
561
- } catch {
562
- }
563
- return {
564
- finalHost: new URL(current).host,
565
- hops,
566
- status: res.status,
567
- sawPasswordField: htmlHasPasswordField(body),
568
- bodyFingerprint: createHash("sha256").update(body).digest("hex")
569
- };
570
- }
571
- }
572
662
  async function loginForApexCookies(authUrl, email, password, timeoutMs) {
573
663
  let res;
574
664
  try {
@@ -642,8 +732,22 @@ async function checkSsoSessionContract(env, deployed, overrides) {
642
732
  const settled = await Promise.all(
643
733
  pairs.map(async (pair) => {
644
734
  try {
645
- return { pair, probe: await probeAdmission(pair.target.baseUrl, pair.jar, CHECK_TIMEOUT_MS * 2) };
735
+ return { pair, probe: await probeAdmission(pair.target.baseUrl, pair.jar, CHECK_TIMEOUT_MS * 2, {
736
+ authUrl,
737
+ service: pair.target.service,
738
+ email: pair.scenario === "real-session" ? email : void 0
739
+ }) };
646
740
  } catch (err) {
741
+ if (pair.scenario === "real-session") {
742
+ return { pair, probe: {
743
+ finalHost: pair.target.host,
744
+ hops: 0,
745
+ status: 0,
746
+ sawPasswordField: false,
747
+ bodyFingerprint: "",
748
+ admissionError: "Real-session admission probe could not complete safely"
749
+ } };
750
+ }
647
751
  return { pair, error: err instanceof Error ? err.message : String(err) };
648
752
  }
649
753
  })