@remnic/core 9.7.6 → 9.7.7

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 (40) hide show
  1. package/dist/access-boundary.d.ts +2 -0
  2. package/dist/access-boundary.js +1 -1
  3. package/dist/access-cli.js +7 -7
  4. package/dist/access-http.js +5 -5
  5. package/dist/access-mcp.d.ts +2 -0
  6. package/dist/access-mcp.js +4 -4
  7. package/dist/access-operations-batch.js +2 -2
  8. package/dist/access-operations.js +3 -3
  9. package/dist/{chunk-TBQ4CFIP.js → chunk-57INMZ6F.js} +1 -1
  10. package/dist/chunk-57INMZ6F.js.map +1 -0
  11. package/dist/{chunk-BFCQPJ5B.js → chunk-CHVU4RE5.js} +24 -6
  12. package/dist/chunk-CHVU4RE5.js.map +1 -0
  13. package/dist/{chunk-3CRHW42H.js → chunk-FYKIEOG6.js} +2 -2
  14. package/dist/{chunk-ESE55PZJ.js → chunk-NMMKRVUF.js} +10 -6
  15. package/dist/chunk-NMMKRVUF.js.map +1 -0
  16. package/dist/{chunk-3UPBVNBX.js → chunk-Q6JPMCPO.js} +3 -3
  17. package/dist/{chunk-P2UA6XQG.js → chunk-UHGHTOR5.js} +5 -5
  18. package/dist/chunk-UHGHTOR5.js.map +1 -0
  19. package/dist/{chunk-H67QFYUK.js → chunk-UJBESW6X.js} +918 -146
  20. package/dist/chunk-UJBESW6X.js.map +1 -0
  21. package/dist/cli.js +6 -6
  22. package/dist/connectors/index.d.ts +7 -0
  23. package/dist/connectors/index.js +1 -1
  24. package/dist/index.js +7 -7
  25. package/dist/orchestrator.js +7 -7
  26. package/package.json +2 -2
  27. package/src/access-boundary.ts +2 -0
  28. package/src/access-http.ts +15 -2
  29. package/src/access-mcp-cancellation.test.ts +405 -0
  30. package/src/access-mcp.ts +25 -1
  31. package/src/access-operations-batch.ts +3 -3
  32. package/src/connectors/hermes-shim.ts +523 -0
  33. package/src/connectors/index.ts +769 -15
  34. package/dist/chunk-BFCQPJ5B.js.map +0 -1
  35. package/dist/chunk-ESE55PZJ.js.map +0 -1
  36. package/dist/chunk-H67QFYUK.js.map +0 -1
  37. package/dist/chunk-P2UA6XQG.js.map +0 -1
  38. package/dist/chunk-TBQ4CFIP.js.map +0 -1
  39. /package/dist/{chunk-3CRHW42H.js.map → chunk-FYKIEOG6.js.map} +0 -0
  40. /package/dist/{chunk-3UPBVNBX.js.map → chunk-Q6JPMCPO.js.map} +0 -0
@@ -16,6 +16,17 @@ import { launchProcessSync } from "../runtime/child-process.js";
16
16
  import { mergeEnv, readEnvVar, resolveHomeDir } from "../runtime/env.js";
17
17
  import { expandTildePath } from "../utils/path.js";
18
18
  import { coerceInstallExtension } from "./coerce.js";
19
+ import {
20
+ assertConfigComponentsNotSymlinked,
21
+ hermesShimPath,
22
+ isPlausibleHermesConfigPath,
23
+ materializeHermesShim,
24
+ reconcileHermesShim,
25
+ removeHermesShim,
26
+ resolveHermesRoot,
27
+ sameShimTarget,
28
+ survivingMarkerShims,
29
+ } from "./hermes-shim.js";
19
30
  import { getConnectorsDir, getRegistryPath } from "./paths.js";
20
31
  export { getConnectorsDir, getRegistryPath } from "./paths.js";
21
32
 
@@ -770,10 +781,190 @@ export function installConnector(options: InstallOptions): InstallResult {
770
781
  );
771
782
 
772
783
  if (existing && !options.force) {
784
+ // Hermes backfill (Issue #1929): connectors installed before shim
785
+ // materialization shipped are exactly the installs missing the discovery
786
+ // shim, and rerunning `install` without --force lands here. Reconcile the
787
+ // shim (idempotent, marker-gated, cleans a prior-HERMES_HOME shim) and
788
+ // persist the surviving path so `remove` can clean it later. Best-effort:
789
+ // failures never change the status.
790
+ let backfillNote = "";
791
+ if (options.connectorId === "hermes") {
792
+ try {
793
+ const hermesJsonPath = path.join(getConnectorsDir(), "hermes.json");
794
+ let parsed: Record<string, unknown> | null = null;
795
+ try {
796
+ const parsedRaw: unknown = JSON.parse(fs.readFileSync(hermesJsonPath, "utf8"));
797
+ // Same shape guard as removeConnector (Bugbot on PR #1938, round
798
+ // 14): a non-object payload ([], null, scalar) is not a connector
799
+ // record — writes onto it would not survive JSON.stringify, so the
800
+ // shim must not be moved on its authority. Reconcile without a
801
+ // prior path and skip persistence.
802
+ if (parsedRaw !== null && typeof parsedRaw === "object" && !Array.isArray(parsedRaw)) {
803
+ parsed = parsedRaw as Record<string, unknown>;
804
+ }
805
+ } catch {
806
+ /* connector JSON unreadable — reconcile without a prior path */
807
+ }
808
+ const priorRaw = parsed?.pluginShimPath;
809
+ const priorPersisted = typeof priorRaw === "string" && priorRaw.length > 0 ? priorRaw : null;
810
+ // Gate the whole backfill on the ACTIVE home carrying a remnic:
811
+ // config (Codex P2 on PR #1938, round 19): moving/creating the
812
+ // discovery shim under a home whose config.yaml has no remnic: block
813
+ // would make Hermes discover a provider with no host/token — this
814
+ // path never writes configs or tokens, so only --force can migrate
815
+ // config, token, and shim together.
816
+ const profileRaw = parsed?.profile;
817
+ let currentConfigPath: string | null = null;
818
+ let activeConfigHasBlock = false;
819
+ let configResolutionError: string | null = null;
820
+ try {
821
+ currentConfigPath = hermesConfigPath(
822
+ typeof profileRaw === "string" && profileRaw.length > 0 ? profileRaw : "default",
823
+ );
824
+ activeConfigHasBlock =
825
+ fs.existsSync(currentConfigPath) &&
826
+ /^remnic:/m.test(fs.readFileSync(currentConfigPath, "utf8"));
827
+ } catch (resolveErr) {
828
+ // Surface the REAL failure (symlinked/invalid HERMES_HOME, bad
829
+ // profile) instead of masking it as a missing remnic: block
830
+ // (Bugbot on PR #1938, round 23).
831
+ configResolutionError = resolveErr instanceof Error ? resolveErr.message : String(resolveErr);
832
+ activeConfigHasBlock = false;
833
+ }
834
+ if (!activeConfigHasBlock) {
835
+ backfillNote = configResolutionError
836
+ ? ` Note: could not resolve the active Hermes home (${configResolutionError}) — skipped the shim backfill. ` +
837
+ `Fix the HERMES_HOME/profile configuration and re-run.`
838
+ : " Note: the active Hermes home's config.yaml has no remnic: block — skipped the shim backfill " +
839
+ "so Hermes cannot discover an unconfigured provider. If Hermes was installed under a different " +
840
+ "HERMES_HOME, re-run with --force to rewrite the config (and token) and migrate the shim " +
841
+ "under the current home.";
842
+ return {
843
+ connectorId: options.connectorId,
844
+ status: "already_installed",
845
+ message: `Already installed. Use --force to reinstall.${backfillNote}`,
846
+ };
847
+ }
848
+ const outcome = reconcileHermesShim(priorPersisted);
849
+ if (outcome.notes.length > 0) {
850
+ backfillNote = ` ${outcome.notes.join(" ")}`;
851
+ }
852
+ let configProvenanceAdded = false;
853
+ if (
854
+ parsed !== null &&
855
+ currentConfigPath !== null &&
856
+ (typeof parsed.hermesConfigPath !== "string" || parsed.hermesConfigPath.length === 0)
857
+ ) {
858
+ // Backfill config.yaml provenance too (round 7): the active config
859
+ // carries a remnic: block (gate above), so record where it lives.
860
+ parsed.hermesConfigPath = currentConfigPath;
861
+ configProvenanceAdded = true;
862
+ }
863
+ // Prior-shim provenance parity (round 18): keep tracking any prior
864
+ // marker shim that survives (e.g. its cleanup failed), and clean
865
+ // inherited priors on a confirmed replacement.
866
+ let shimPriorsChanged = false;
867
+ const cleanedInheritedBackfillShims: string[] = [];
868
+ if (parsed !== null) {
869
+ const shimPriorCandidates: string[] = [];
870
+ if (priorPersisted !== null) {
871
+ shimPriorCandidates.push(priorPersisted);
872
+ }
873
+ if (Array.isArray(parsed.priorPluginShimPaths)) {
874
+ for (const entry of parsed.priorPluginShimPaths) {
875
+ if (typeof entry === "string" && entry.length > 0) {
876
+ shimPriorCandidates.push(entry);
877
+ }
878
+ }
879
+ }
880
+ if (outcome.materializedAt !== null && shimPriorCandidates.length > 0) {
881
+ const cleanupTargets = shimPriorCandidates.filter(
882
+ (candidate) => outcome.persistPath === null || !sameShimTarget(candidate, outcome.persistPath),
883
+ );
884
+ if (cleanupTargets.length > 0) {
885
+ try {
886
+ const cleaned = removeHermesShim(cleanupTargets);
887
+ cleanedInheritedBackfillShims.push(...cleaned.removedPaths);
888
+ } catch {
889
+ /* survivors below stay tracked */
890
+ }
891
+ }
892
+ }
893
+ const survivingShims = survivingMarkerShims(shimPriorCandidates, outcome.persistPath);
894
+ const persistedShimPriors = Array.isArray(parsed.priorPluginShimPaths)
895
+ ? parsed.priorPluginShimPaths
896
+ : null;
897
+ shimPriorsChanged =
898
+ persistedShimPriors === null
899
+ ? survivingShims.length > 0
900
+ : persistedShimPriors.length !== survivingShims.length ||
901
+ survivingShims.some((entry, index) => persistedShimPriors[index] !== entry);
902
+ if (survivingShims.length > 0) {
903
+ parsed.priorPluginShimPaths = survivingShims;
904
+ } else {
905
+ delete parsed.priorPluginShimPaths;
906
+ }
907
+ }
908
+ const shimPathChanged =
909
+ parsed !== null && outcome.persistPath !== null && parsed.pluginShimPath !== outcome.persistPath;
910
+ if (parsed !== null && (shimPathChanged || configProvenanceAdded || shimPriorsChanged)) {
911
+ if (outcome.persistPath !== null) {
912
+ parsed.pluginShimPath = outcome.persistPath;
913
+ }
914
+ try {
915
+ writeSecretFileSync(hermesJsonPath, JSON.stringify(parsed, null, 2));
916
+ } catch (persistErr) {
917
+ // Mirror the install-path rollback (Codex P2 on PR #1938, round 5):
918
+ // without the persisted path, the registry cannot clean the shims
919
+ // this reconcile just changed — undo the filesystem effects so the
920
+ // JSON on disk still matches reality. Shim content is
921
+ // deterministic, so a cleaned prior shim regenerates exactly.
922
+ const rollbackFailures: string[] = [];
923
+ if (
924
+ outcome.materializedAt !== null &&
925
+ outcome.createdNew &&
926
+ (priorPersisted === null || !sameShimTarget(outcome.materializedAt, priorPersisted))
927
+ ) {
928
+ try {
929
+ removeHermesShim([outcome.materializedAt]);
930
+ } catch {
931
+ rollbackFailures.push(`remove ${outcome.materializedAt} manually`);
932
+ }
933
+ }
934
+ if (outcome.priorCleanedAt !== null) {
935
+ try {
936
+ materializeHermesShim(outcome.priorCleanedAt);
937
+ } catch {
938
+ rollbackFailures.push(`restore the shim at ${outcome.priorCleanedAt} manually`);
939
+ }
940
+ }
941
+ // Regenerate inherited priors this backfill pass cleaned — parity
942
+ // with the main install rollback (Bugbot on PR #1938, round 19).
943
+ for (const cleanedPath of cleanedInheritedBackfillShims) {
944
+ if (outcome.priorCleanedAt !== null && sameShimTarget(cleanedPath, outcome.priorCleanedAt)) {
945
+ continue;
946
+ }
947
+ try {
948
+ materializeHermesShim(cleanedPath);
949
+ } catch {
950
+ rollbackFailures.push(`restore the shim at ${cleanedPath} manually`);
951
+ }
952
+ }
953
+ const persistMsg = persistErr instanceof Error ? persistErr.message : String(persistErr);
954
+ backfillNote =
955
+ rollbackFailures.length === 0
956
+ ? ` Note: could not persist the shim path (${persistMsg}); shim changes were rolled back.`
957
+ : ` Note: could not persist the shim path (${persistMsg}) and rollback was incomplete — ${rollbackFailures.join("; ")}.`;
958
+ }
959
+ }
960
+ } catch {
961
+ /* best-effort backfill — install status is unchanged */
962
+ }
963
+ }
773
964
  return {
774
965
  connectorId: options.connectorId,
775
966
  status: "already_installed",
776
- message: "Already installed. Use --force to reinstall.",
967
+ message: `Already installed. Use --force to reinstall.${backfillNote}`,
777
968
  };
778
969
  }
779
970
 
@@ -1043,6 +1234,23 @@ export function installConnector(options: InstallOptions): InstallResult {
1043
1234
  };
1044
1235
  }
1045
1236
 
1237
+ // When HERMES_HOME is explicitly set to a not-yet-created directory, the
1238
+ // user has stated where Hermes lives — create it so the config write does
1239
+ // not abort while the shim step (recursive mkdir) would have created the
1240
+ // same tree anyway (Bugbot on PR #1938, round 11). Without HERMES_HOME the
1241
+ // missing-profile-dir skip below stays load-bearing: it is the signal
1242
+ // that Hermes is not installed at all.
1243
+ {
1244
+ const envHermesHome = readEnvVar("HERMES_HOME");
1245
+ if (typeof envHermesHome === "string" && envHermesHome.trim().length > 0) {
1246
+ try {
1247
+ fs.mkdirSync(path.dirname(hermesConfigPath(hermesProfile)), { recursive: true });
1248
+ } catch {
1249
+ /* resolution or mkdir failure — the config write below reports it */
1250
+ }
1251
+ }
1252
+ }
1253
+
1046
1254
  // (c) Write config.yaml. If the profile dir does not exist (skipped) or
1047
1255
  // the write throws, abort WITHOUT committing the token or writing connector.json.
1048
1256
  let yamlResult: HermesConfigResult;
@@ -1159,6 +1367,190 @@ export function installConnector(options: InstallOptions): InstallResult {
1159
1367
  };
1160
1368
  }
1161
1369
 
1370
+ // (d2) Reconcile the on-disk shim BEFORE connector.json is written so the
1371
+ // persisted pluginShimPath always references the shim that actually
1372
+ // survives on disk. Invariant (PR #1938 review, third round): a
1373
+ // Remnic-generated shim on disk is either removed or referenced by the
1374
+ // connector JSON — the registry never silently discards the path of a
1375
+ // shim that is still present (e.g. when materialization at the NEW
1376
+ // HERMES_HOME fails during a force-reinstall).
1377
+ const priorShimPathRaw = savedConnectorConfig.pluginShimPath;
1378
+ const shimOutcome = reconcileHermesShim(
1379
+ typeof priorShimPathRaw === "string" && priorShimPathRaw.length > 0 ? priorShimPathRaw : null,
1380
+ );
1381
+ if (shimOutcome.persistPath !== null) {
1382
+ resolvedConfig.pluginShimPath = shimOutcome.persistPath;
1383
+ }
1384
+ // Prior-shim provenance (Codex P2 on PR #1938, round 18): mirror the
1385
+ // config handling — a prior marker shim whose cleanup failed or was
1386
+ // skipped must stay tracked, or a later remove cannot discover it. On a
1387
+ // confirmed replacement, inherited prior shims are actively cleaned
1388
+ // (marker-gated); whatever still carries the marker afterwards persists
1389
+ // as priorPluginShimPaths in the SAME registry write as everything else.
1390
+ const cleanedInheritedShims: string[] = [];
1391
+ {
1392
+ const shimPriorCandidates: string[] = [];
1393
+ if (typeof priorShimPathRaw === "string" && priorShimPathRaw.length > 0) {
1394
+ shimPriorCandidates.push(priorShimPathRaw);
1395
+ }
1396
+ const inheritedShimPriorsRaw = savedConnectorConfig.priorPluginShimPaths;
1397
+ if (Array.isArray(inheritedShimPriorsRaw)) {
1398
+ for (const entry of inheritedShimPriorsRaw) {
1399
+ if (typeof entry === "string" && entry.length > 0) {
1400
+ shimPriorCandidates.push(entry);
1401
+ }
1402
+ }
1403
+ }
1404
+ if (shimOutcome.materializedAt !== null && shimPriorCandidates.length > 0) {
1405
+ const cleanupTargets = shimPriorCandidates.filter(
1406
+ (candidate) => shimOutcome.persistPath === null || !sameShimTarget(candidate, shimOutcome.persistPath),
1407
+ );
1408
+ if (cleanupTargets.length > 0) {
1409
+ try {
1410
+ const cleaned = removeHermesShim(cleanupTargets);
1411
+ cleanedInheritedShims.push(...cleaned.removedPaths);
1412
+ } catch {
1413
+ /* survivors below stay tracked */
1414
+ }
1415
+ }
1416
+ }
1417
+ const survivingShims = survivingMarkerShims(shimPriorCandidates, shimOutcome.persistPath);
1418
+ if (survivingShims.length > 0) {
1419
+ resolvedConfig.priorPluginShimPaths = survivingShims;
1420
+ } else {
1421
+ delete resolvedConfig.priorPluginShimPaths;
1422
+ }
1423
+ }
1424
+
1425
+ // Persist the config.yaml location used at install time (it honors the
1426
+ // active HERMES_HOME) so remove can clean the exact file even if
1427
+ // HERMES_HOME is unset or changed by then — same provenance rule as
1428
+ // pluginShimPath (Codex P2 on PR #1938, round 6).
1429
+ resolvedConfig.hermesConfigPath = yamlResult.configPath;
1430
+
1431
+ // Prior-config handling (rounds 9-15) runs BEFORE the primary
1432
+ // connector.json write so priorHermesConfigPaths lands atomically in the
1433
+ // SAME write as the rest of the record — a best-effort follow-up write
1434
+ // could fail and leave surviving remnic: blocks untracked (Bugbot on
1435
+ // PR #1938, round 15). Notes are collected here and surfaced after the
1436
+ // write succeeds.
1437
+ const hermesPriorNotes: string[] = [];
1438
+ const priorConfigPathRaw = savedConnectorConfig.hermesConfigPath;
1439
+ const priorConfigPath =
1440
+ typeof priorConfigPathRaw === "string" && priorConfigPathRaw.length > 0 ? priorConfigPathRaw : null;
1441
+ const priorConfigDiffers =
1442
+ priorConfigPath !== null && !sameHermesConfigTarget(priorConfigPath, yamlResult.configPath);
1443
+ // Every prior-config mutation performed before the registry write records
1444
+ // the file's pre-mutation content here, so the connector.json-write
1445
+ // failure path can restore it (Bugbot + Codex P2 on PR #1938, round 16).
1446
+ const hermesPriorConfigMutations: Array<{ mutatedPath: string; priorContent: string }> = [];
1447
+ if (priorConfigDiffers && priorConfigPath !== null && shimOutcome.materializedAt === null) {
1448
+ // Unconfirmed shim move: the prior installation stays the working
1449
+ // fallback. Refresh its config with the ACTIVE token — the install just
1450
+ // rotated the token store, so leaving the old inline token would make
1451
+ // the fallback fail daemon auth (round 13). The upsert preserves the
1452
+ // file's other remnic: sub-keys.
1453
+ let fallbackDetail = "";
1454
+ if (isPlausibleHermesConfigPath(priorConfigPath)) {
1455
+ try {
1456
+ const beforeRefresh = fs.readFileSync(priorConfigPath, "utf8");
1457
+ const refresh = upsertHermesConfigAt(priorConfigPath, {
1458
+ host: hermesHost,
1459
+ port: hermesPort,
1460
+ token: tokenEntry.token,
1461
+ });
1462
+ if (refresh.updated) {
1463
+ hermesPriorConfigMutations.push({ mutatedPath: priorConfigPath, priorContent: beforeRefresh });
1464
+ }
1465
+ fallbackDetail = refresh.updated
1466
+ ? " Its remnic: block was refreshed with the newly-issued token so it keeps working."
1467
+ : ` Note: its token could not be refreshed (${refresh.reason ?? "config not writable"}) — re-run install with HERMES_HOME pointing there to restore daemon auth.`;
1468
+ } catch (refreshErr) {
1469
+ fallbackDetail = ` Note: its token could not be refreshed (${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}) — re-run install with HERMES_HOME pointing there to restore daemon auth.`;
1470
+ }
1471
+ } else {
1472
+ fallbackDetail =
1473
+ " Note: its token was rotated by this install — re-run install with HERMES_HOME pointing there to restore daemon auth.";
1474
+ }
1475
+ hermesPriorNotes.push(
1476
+ `Note: left the prior-install Hermes config in place at ${priorConfigPath} — ` +
1477
+ `no Remnic shim could be written under the current Hermes home, so the prior ` +
1478
+ `installation remains the working fallback.${fallbackDetail} Resolve the shim ` +
1479
+ `collision/failure above and re-run with --force to complete the migration.`,
1480
+ );
1481
+ }
1482
+ // Reconcile prior-config provenance (rounds 12-14): on a CONFIRMED
1483
+ // replacement, actively clean every displaced/inherited prior config; on
1484
+ // an unconfirmed move, preserve them. Every candidate whose remnic: block
1485
+ // still survives afterwards is persisted in priorHermesConfigPaths so a
1486
+ // later `remove` cleans them all; stale entries are dropped.
1487
+ {
1488
+ const candidates: string[] = [];
1489
+ if (priorConfigDiffers && priorConfigPath !== null) {
1490
+ candidates.push(priorConfigPath);
1491
+ }
1492
+ const inheritedArrayRaw = savedConnectorConfig.priorHermesConfigPaths;
1493
+ if (Array.isArray(inheritedArrayRaw)) {
1494
+ for (const entry of inheritedArrayRaw) {
1495
+ if (typeof entry === "string" && entry.length > 0) {
1496
+ candidates.push(entry);
1497
+ }
1498
+ }
1499
+ }
1500
+ // Legacy single-path key from earlier builds of this branch.
1501
+ const inheritedLegacyRaw = savedConnectorConfig.priorHermesConfigPath;
1502
+ if (typeof inheritedLegacyRaw === "string" && inheritedLegacyRaw.length > 0) {
1503
+ candidates.push(inheritedLegacyRaw);
1504
+ }
1505
+ if (shimOutcome.materializedAt !== null) {
1506
+ for (const candidate of candidates) {
1507
+ if (sameHermesConfigTarget(candidate, yamlResult.configPath)) {
1508
+ continue;
1509
+ }
1510
+ if (!isPlausibleHermesConfigPath(candidate)) {
1511
+ continue;
1512
+ }
1513
+ try {
1514
+ const beforeCleanup = fs.readFileSync(candidate, "utf8");
1515
+ const cleanup = removeHermesConfigFile(candidate);
1516
+ if (cleanup.updated) {
1517
+ hermesPriorConfigMutations.push({ mutatedPath: candidate, priorContent: beforeCleanup });
1518
+ hermesPriorNotes.push(`Cleaned remnic: block from prior-install Hermes config: ${candidate}`);
1519
+ }
1520
+ } catch {
1521
+ hermesPriorNotes.push(
1522
+ `Note: could not clean the remnic: block from the prior-install Hermes config at ${candidate} — remove it manually.`,
1523
+ );
1524
+ }
1525
+ }
1526
+ }
1527
+ const survivingPriors: string[] = [];
1528
+ for (const candidate of candidates) {
1529
+ if (sameHermesConfigTarget(candidate, yamlResult.configPath)) {
1530
+ continue;
1531
+ }
1532
+ if (survivingPriors.some((kept) => sameHermesConfigTarget(kept, candidate))) {
1533
+ continue;
1534
+ }
1535
+ let blockSurvives = false;
1536
+ try {
1537
+ blockSurvives = /^remnic:/m.test(fs.readFileSync(candidate, "utf8"));
1538
+ } catch {
1539
+ blockSurvives = false;
1540
+ }
1541
+ if (blockSurvives) {
1542
+ survivingPriors.push(candidate);
1543
+ }
1544
+ }
1545
+ if (survivingPriors.length > 0) {
1546
+ resolvedConfig.priorHermesConfigPaths = survivingPriors;
1547
+ } else {
1548
+ delete resolvedConfig.priorHermesConfigPaths;
1549
+ }
1550
+ // The legacy single-path key never survives a rewrite.
1551
+ delete resolvedConfig.priorHermesConfigPath;
1552
+ }
1553
+
1162
1554
  // (e) Both YAML write and token commit succeeded — now attempt to write connector.json.
1163
1555
  // If this write fails (e.g. connectors dir is not writable), roll back Phase D (token
1164
1556
  // commit) and Phase C (YAML upsert) so no partial-install state is left behind.
@@ -1205,6 +1597,87 @@ export function installConnector(options: InstallOptions): InstallResult {
1205
1597
  } catch (yamlRollbackErr) {
1206
1598
  yamlRollbackMsg = `config.yaml rollback failed: ${yamlRollbackErr instanceof Error ? yamlRollbackErr.message : String(yamlRollbackErr)}`;
1207
1599
  }
1600
+ // Roll back the (d2) shim reconciliation: without a persisted
1601
+ // pluginShimPath the registry cannot clean these effects later
1602
+ // (Bugbot + Codex P2 on PR #1938, round 4). The shim content is
1603
+ // deterministic, so a cleaned prior shim can be regenerated exactly.
1604
+ let shimRollbackMsg = "no shim changes to roll back";
1605
+ const shimRollbackFailures: string[] = [];
1606
+ const priorPersisted =
1607
+ typeof priorShimPathRaw === "string" && priorShimPathRaw.length > 0 ? priorShimPathRaw : null;
1608
+ if (
1609
+ shimOutcome.materializedAt !== null &&
1610
+ shimOutcome.createdNew &&
1611
+ (priorPersisted === null || !sameShimTarget(shimOutcome.materializedAt, priorPersisted))
1612
+ ) {
1613
+ // Newly created at a location the registry never tracked — delete it
1614
+ // (marker-gated). A shim that pre-existed this install (createdNew ===
1615
+ // false) is left in place: it was functional before and our overwrite
1616
+ // is byte-identical deterministic content (Codex P2, round 7). Path
1617
+ // comparison is by resolved file identity, not string equality.
1618
+ try {
1619
+ removeHermesShim([shimOutcome.materializedAt]);
1620
+ } catch (err) {
1621
+ shimRollbackFailures.push(
1622
+ `could not remove the newly-written shim at ${shimOutcome.materializedAt} (${err instanceof Error ? err.message : String(err)})`,
1623
+ );
1624
+ }
1625
+ }
1626
+ if (shimOutcome.priorCleanedAt !== null) {
1627
+ try {
1628
+ materializeHermesShim(shimOutcome.priorCleanedAt);
1629
+ } catch (err) {
1630
+ shimRollbackFailures.push(
1631
+ `could not restore the prior shim at ${shimOutcome.priorCleanedAt} (${err instanceof Error ? err.message : String(err)})`,
1632
+ );
1633
+ }
1634
+ }
1635
+ // Regenerate inherited prior shims the (d2) provenance pass cleaned —
1636
+ // content is deterministic, so restoration is exact (round 18).
1637
+ for (const cleanedPath of cleanedInheritedShims) {
1638
+ if (shimOutcome.priorCleanedAt !== null && sameShimTarget(cleanedPath, shimOutcome.priorCleanedAt)) {
1639
+ continue;
1640
+ }
1641
+ try {
1642
+ materializeHermesShim(cleanedPath);
1643
+ } catch (err) {
1644
+ shimRollbackFailures.push(
1645
+ `could not restore the prior shim at ${cleanedPath} (${err instanceof Error ? err.message : String(err)})`,
1646
+ );
1647
+ }
1648
+ }
1649
+ if (
1650
+ shimOutcome.materializedAt !== null ||
1651
+ shimOutcome.priorCleanedAt !== null ||
1652
+ cleanedInheritedShims.length > 0
1653
+ ) {
1654
+ shimRollbackMsg =
1655
+ shimRollbackFailures.length === 0
1656
+ ? "plugin shim changes rolled back"
1657
+ : `plugin shim rollback incomplete: ${shimRollbackFailures.join("; ")}`;
1658
+ }
1659
+ // Roll back the (d2) prior-config mutations from their recorded
1660
+ // pre-mutation content: a stripped old-home remnic: block or a
1661
+ // token-refreshed fallback must revert alongside tokens.json, or the
1662
+ // previously working install is left broken (Bugbot + Codex P2 on
1663
+ // PR #1938, round 16). Restore in reverse mutation order.
1664
+ let priorConfigRollbackMsg = "no prior-config changes to roll back";
1665
+ if (hermesPriorConfigMutations.length > 0) {
1666
+ const priorConfigRollbackFailures: string[] = [];
1667
+ for (const mutation of [...hermesPriorConfigMutations].reverse()) {
1668
+ try {
1669
+ writeSecretFileSync(mutation.mutatedPath, mutation.priorContent);
1670
+ } catch (restoreErr) {
1671
+ priorConfigRollbackFailures.push(
1672
+ `could not restore ${mutation.mutatedPath} (${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)})`,
1673
+ );
1674
+ }
1675
+ }
1676
+ priorConfigRollbackMsg =
1677
+ priorConfigRollbackFailures.length === 0
1678
+ ? "prior-install config(s) restored"
1679
+ : `prior-install config rollback incomplete: ${priorConfigRollbackFailures.join("; ")}`;
1680
+ }
1208
1681
  const urgentSuffix = tokenRollbackFailed
1209
1682
  ? ` tokens.json may be in an inconsistent state — manually restore hermes token with 'remnic token generate hermes'.`
1210
1683
  : "";
@@ -1214,7 +1687,7 @@ export function installConnector(options: InstallOptions): InstallResult {
1214
1687
  message:
1215
1688
  `Hermes install aborted: connector config write failed — ` +
1216
1689
  `connector directory may not be writable. ` +
1217
- `Rollback: ${tokenRollbackMsg}; ${yamlRollbackMsg}.` +
1690
+ `Rollback: ${tokenRollbackMsg}; ${yamlRollbackMsg}; ${shimRollbackMsg}; ${priorConfigRollbackMsg}.` +
1218
1691
  `${urgentSuffix} Resolve the permission issue, then reinstall.`,
1219
1692
  };
1220
1693
  }
@@ -1222,6 +1695,18 @@ export function installConnector(options: InstallOptions): InstallResult {
1222
1695
  const notes: string[] = [];
1223
1696
  notes.push(`Updated Hermes config: ${yamlResult.configPath}`);
1224
1697
 
1698
+ // (g) Report the shim reconciliation performed in (d2) — materialization
1699
+ // itself already ran before connector.json was written (Issue #1929).
1700
+ notes.push(...shimOutcome.notes);
1701
+ // Prior-config handling ran in (d2) before the connector.json write so its
1702
+ // provenance landed atomically in the primary record; surface its notes.
1703
+ notes.push(...hermesPriorNotes);
1704
+ // Provider activation is a manual, non-destructive step: we never edit the
1705
+ // exclusive `memory.provider` slot in the user's Hermes config.yaml.
1706
+ notes.push(
1707
+ "Next step: set `memory.provider: remnic` (and `memory_enabled: true`) in your Hermes config.yaml to activate the provider.",
1708
+ );
1709
+
1225
1710
  // If a migrated default-profile install now writes to Hermes' root config,
1226
1711
  // remove stale Remnic credentials from the legacy default profile file too.
1227
1712
  if (hermesProfile === "default") {
@@ -1667,6 +2152,71 @@ export function removeConnector(connectorId: string): RemoveResult {
1667
2152
  }
1668
2153
  }
1669
2154
 
2155
+ // For hermes, read the shim and config.yaml paths persisted at install time
2156
+ // BEFORE the connector JSON is deleted, so removal targets the files written
2157
+ // under the HERMES_HOME that was active during install (Codex P2 on PR #1938).
2158
+ let savedHermesShimPath: string | null = null;
2159
+ let savedHermesConfigPath: string | null = null;
2160
+ const savedPriorHermesConfigPaths: string[] = [];
2161
+ const savedPriorHermesShimPaths: string[] = [];
2162
+ if (connectorId === "hermes" && fs.existsSync(configPath)) {
2163
+ try {
2164
+ const parsedRaw: unknown = JSON.parse(fs.readFileSync(configPath, "utf8"));
2165
+ // A syntactically valid but non-object payload ([], null, "x", 5) is as
2166
+ // untrustworthy as unparseable JSON — fail closed like the catch below
2167
+ // (Codex P2 on PR #1938, round 10). A plain object that merely LACKS
2168
+ // provenance fields is a legitimate pre-#1929 install and must still be
2169
+ // removable (cleanup then uses the environment-resolved paths).
2170
+ if (parsedRaw === null || typeof parsedRaw !== "object" || Array.isArray(parsedRaw)) {
2171
+ throw new Error("hermes.json is not a JSON object");
2172
+ }
2173
+ const parsed = parsedRaw as Record<string, unknown>;
2174
+ if (typeof parsed.pluginShimPath === "string" && parsed.pluginShimPath.length > 0) {
2175
+ savedHermesShimPath = parsed.pluginShimPath;
2176
+ }
2177
+ // Prior-shim provenance (round 18): shims whose cleanup failed on an
2178
+ // earlier install move remain tracked here.
2179
+ if (Array.isArray(parsed.priorPluginShimPaths)) {
2180
+ for (const entry of parsed.priorPluginShimPaths) {
2181
+ if (typeof entry === "string" && entry.length > 0) {
2182
+ savedPriorHermesShimPaths.push(entry);
2183
+ }
2184
+ }
2185
+ }
2186
+ if (typeof parsed.hermesConfigPath === "string" && parsed.hermesConfigPath.length > 0) {
2187
+ savedHermesConfigPath = parsed.hermesConfigPath;
2188
+ }
2189
+ // Array provenance (round 13) plus the legacy single-path key from
2190
+ // earlier builds of this branch.
2191
+ if (Array.isArray(parsed.priorHermesConfigPaths)) {
2192
+ for (const entry of parsed.priorHermesConfigPaths) {
2193
+ if (typeof entry === "string" && entry.length > 0) {
2194
+ savedPriorHermesConfigPaths.push(entry);
2195
+ }
2196
+ }
2197
+ }
2198
+ if (typeof parsed.priorHermesConfigPath === "string" && parsed.priorHermesConfigPath.length > 0) {
2199
+ savedPriorHermesConfigPaths.push(parsed.priorHermesConfigPath);
2200
+ }
2201
+ } catch {
2202
+ // Fail closed, mirroring the codex-cli provenance guard: a malformed
2203
+ // hermes.json is the only record of the install-time shim location.
2204
+ // Proceeding would delete the registry entry while a Remnic-generated
2205
+ // shim under a since-changed HERMES_HOME stays discoverable forever
2206
+ // (Codex P2 on PR #1938).
2207
+ return {
2208
+ connectorId,
2209
+ configPath,
2210
+ status: "error",
2211
+ message:
2212
+ `Removal aborted: ${configPath} is malformed and cannot be parsed. ` +
2213
+ `It records where the Hermes plugin shim was installed; removing the connector without it ` +
2214
+ `could orphan the shim. Fix or delete the file (and remove any ` +
2215
+ `plugins/remnic/__init__.py under your Hermes home manually), then re-run removal.`,
2216
+ };
2217
+ }
2218
+ }
2219
+
1670
2220
  if (!fs.existsSync(configPath)) {
1671
2221
  // Best-effort: revoke any orphan token that may have survived a prior partial
1672
2222
  // cleanup (e.g. connector JSON deleted manually or XDG_CONFIG_HOME change).
@@ -1803,6 +2353,80 @@ export function removeConnector(connectorId: string): RemoveResult {
1803
2353
  }
1804
2354
  }
1805
2355
 
2356
+ // Hermes-specific preflight (Codex P2 on PR #1938, round 15): verify every
2357
+ // remnic:-bearing config target is writable BEFORE any destructive step.
2358
+ // The registry unlink and token revocation must precede the actual yaml
2359
+ // cleanup (the unlink-failure contract keeps the install fully functional),
2360
+ // but once the registry is gone a cleanup failure would strand the old
2361
+ // home's block with no provenance for a retry — so refuse up front while
2362
+ // the registry, provenance, and token are all still intact.
2363
+ if (connectorId === "hermes") {
2364
+ const preflightCandidates: string[] = [];
2365
+ try {
2366
+ preflightCandidates.push(...hermesConfigCleanupPaths(storedProfile));
2367
+ } catch {
2368
+ /* current-environment resolution failed — persisted paths still checked */
2369
+ }
2370
+ // Persisted provenance is filtered through the SAME guard cleanup uses
2371
+ // (Bugbot on PR #1938, round 17): a tampered/implausible path that
2372
+ // removeHermesConfig would never touch must not be able to block removal.
2373
+ if (savedHermesConfigPath !== null && isPlausibleHermesConfigPath(savedHermesConfigPath)) {
2374
+ preflightCandidates.push(savedHermesConfigPath);
2375
+ }
2376
+ preflightCandidates.push(...savedPriorHermesConfigPaths.filter(isPlausibleHermesConfigPath));
2377
+ // De-duplicate by resolved file identity first (matching cleanup): in the
2378
+ // legacy alias layout profiles/default/config.yaml symlinks to the root
2379
+ // config — the alias collapses onto its non-symlinked representative and
2380
+ // must not trip the component guard below.
2381
+ const uniqueCandidates: string[] = [];
2382
+ for (const candidate of preflightCandidates) {
2383
+ if (!uniqueCandidates.some((kept) => sameHermesConfigTarget(kept, candidate))) {
2384
+ uniqueCandidates.push(candidate);
2385
+ }
2386
+ }
2387
+ const blocked: string[] = [];
2388
+ for (const candidate of uniqueCandidates) {
2389
+ let needsCleanup = false;
2390
+ try {
2391
+ needsCleanup = /^remnic:/m.test(fs.readFileSync(candidate, "utf8"));
2392
+ } catch {
2393
+ needsCleanup = false; // missing/unreadable — cleanup will skip it
2394
+ }
2395
+ if (!needsCleanup) {
2396
+ continue;
2397
+ }
2398
+ // Cleanup refuses symlinked components (round 19); a target that would
2399
+ // be skipped that way must abort HERE, while registry/provenance/token
2400
+ // are intact, instead of surfacing as a post-unlink note that strands
2401
+ // the block without provenance (Codex P2 on PR #1938, round 22).
2402
+ try {
2403
+ assertConfigComponentsNotSymlinked(candidate);
2404
+ } catch {
2405
+ blocked.push(`${candidate} (symlinked path component)`);
2406
+ continue;
2407
+ }
2408
+ try {
2409
+ // writeSecretFileSync rewrites via temp file + rename, so BOTH the
2410
+ // file and its directory must be writable.
2411
+ fs.accessSync(candidate, fs.constants.W_OK);
2412
+ fs.accessSync(path.dirname(candidate), fs.constants.W_OK);
2413
+ } catch {
2414
+ blocked.push(`${candidate} (not writable)`);
2415
+ }
2416
+ }
2417
+ if (blocked.length > 0) {
2418
+ return {
2419
+ connectorId,
2420
+ configPath,
2421
+ status: "error",
2422
+ message:
2423
+ `Hermes remove aborted: the following config.yaml path(s) hold a remnic: block but cannot be cleaned: ` +
2424
+ `${blocked.join(", ")}. The connector registry entry, provenance, and token were left untouched — ` +
2425
+ `fix the permissions or resolve the symlink and re-run removal.`,
2426
+ };
2427
+ }
2428
+ }
2429
+
1806
2430
  // Delete the connector config file AFTER extension removal (Finding 5): if
1807
2431
  // extension removal throws, we do not reach here and the config is preserved.
1808
2432
  // Token revocation and YAML cleanup only happen after the file is gone so
@@ -1915,12 +2539,51 @@ export function removeConnector(connectorId: string): RemoveResult {
1915
2539
  };
1916
2540
  }
1917
2541
 
1918
- // Hermes-specific: strip the remnic: block from config.yaml.
1919
- // Only attempted after successful file removal so that config.yaml cleanup
1920
- // is consistent with the connector JSON state.
2542
+ // Hermes-specific: strip the remnic: block from config.yaml (the writability
2543
+ // preflight above already verified every remnic:-bearing target, so a
2544
+ // partial failure here is a rare TOCTOU race — the error branch below stays
2545
+ // as the backstop).
1921
2546
  if (connectorId === "hermes") {
2547
+ // Remove the Hermes plugin-directory shim (Issue #1929) FIRST — it is
2548
+ // independent of config.yaml cleanup, and the yaml partial-failure branch
2549
+ // below returns early. Running the shim cleanup after that return would
2550
+ // leave a Remnic-generated shim discoverable while the registry entry and
2551
+ // token are already gone (Bugbot on PR #1938, round 5). removeHermesShim
2552
+ // deletes candidates ONLY when they carry our generated marker, then
2553
+ // rmdir's the now-empty plugins/remnic/ dir. Never rm -rf; never touch a
2554
+ // user-authored __init__.py. Candidates: the path persisted at install
2555
+ // time plus the path resolved from the current environment, so a
2556
+ // HERMES_HOME change between install and remove cannot orphan the shim.
2557
+ // Best-effort: a failure here never aborts remove.
2558
+ try {
2559
+ const shimCandidates: string[] = [];
2560
+ if (savedHermesShimPath !== null) {
2561
+ shimCandidates.push(savedHermesShimPath);
2562
+ }
2563
+ shimCandidates.push(...savedPriorHermesShimPaths);
2564
+ try {
2565
+ shimCandidates.push(hermesShimPath());
2566
+ } catch {
2567
+ /* HERMES_HOME unresolved in the current environment — persisted path only */
2568
+ }
2569
+ const shimResult = removeHermesShim(shimCandidates);
2570
+ if (shimResult.notes.length > 0) {
2571
+ notes.push(shimResult.notes.join("; "));
2572
+ }
2573
+ } catch (shimErr) {
2574
+ notes.push(
2575
+ `Hermes plugin shim cleanup skipped: ${shimErr instanceof Error ? shimErr.message : String(shimErr)}`,
2576
+ );
2577
+ }
2578
+
1922
2579
  try {
1923
- const yamlResult = removeHermesConfig({ profile: storedProfile });
2580
+ const yamlResult = removeHermesConfig({
2581
+ profile: storedProfile,
2582
+ extraConfigPaths: [
2583
+ ...(savedHermesConfigPath !== null ? [savedHermesConfigPath] : []),
2584
+ ...savedPriorHermesConfigPaths,
2585
+ ],
2586
+ });
1924
2587
  if (yamlResult.updated) {
1925
2588
  notes.push(`Removed remnic: block from Hermes config: ${yamlResult.configPath}`);
1926
2589
  } else if (yamlResult.reason?.startsWith("Hermes config cleanup partially failed:")) {
@@ -1928,6 +2591,7 @@ export function removeConnector(connectorId: string): RemoveResult {
1928
2591
  ? "the connector registry config was deleted and the token was revoked"
1929
2592
  : "the connector registry config was deleted but TOKEN REVOCATION ALSO FAILED — " +
1930
2593
  "inspect ~/.remnic/tokens.json and revoke manually";
2594
+ const shimSuffix = notes.length > 0 ? ` Completed cleanup: ${notes.join("; ")}.` : "";
1931
2595
  return {
1932
2596
  connectorId,
1933
2597
  configPath,
@@ -1935,7 +2599,8 @@ export function removeConnector(connectorId: string): RemoveResult {
1935
2599
  message:
1936
2600
  `Hermes remove partially succeeded: ${tokenStatus}, but ${yamlResult.reason}. ` +
1937
2601
  `Updated paths: ${yamlResult.configPath}. Manually remove any stale remnic: ` +
1938
- `block and token material from the failed Hermes config path.`,
2602
+ `block and token material from the failed Hermes config path.` +
2603
+ shimSuffix,
1939
2604
  };
1940
2605
  } else if (yamlResult.skipped) {
1941
2606
  notes.push(`Hermes config cleanup skipped: ${yamlResult.reason}`);
@@ -2005,7 +2670,12 @@ function sanitizeHermesProfile(profile: string): string {
2005
2670
 
2006
2671
  function hermesConfigPath(profile: string): string {
2007
2672
  const safeProfile = sanitizeHermesProfile(profile);
2008
- const hermesRoot = path.resolve(resolveHomeDir(), ".hermes");
2673
+ // Root at the SAME Hermes home the shim uses (honors HERMES_HOME, and the
2674
+ // platform-native default). Upstream get_hermes_home() reads HERMES_HOME and
2675
+ // the config loader resolves config.yaml relative to it, so writing the
2676
+ // remnic: block under ~/.hermes while HERMES_HOME points elsewhere would be
2677
+ // silently ignored by Hermes (Codex P2 on PR #1938, round 5).
2678
+ const hermesRoot = resolveHermesRoot();
2009
2679
  const rootConfigPath = path.join(hermesRoot, "config.yaml");
2010
2680
  const profilesRoot = path.join(hermesRoot, "profiles");
2011
2681
  if (safeProfile === "default") {
@@ -2054,8 +2724,7 @@ function sameHermesConfigTarget(leftPath: string, rightPath: string): boolean {
2054
2724
  }
2055
2725
 
2056
2726
  function hermesDefaultProfileConfigPath(): string {
2057
- const hermesRoot = path.resolve(resolveHomeDir(), ".hermes");
2058
- return path.join(hermesRoot, "profiles", "default", "config.yaml");
2727
+ return path.join(resolveHermesRoot(), "profiles", "default", "config.yaml");
2059
2728
  }
2060
2729
 
2061
2730
  function hermesConfigCleanupPaths(profile: string): string[] {
@@ -2067,6 +2736,7 @@ function hermesConfigCleanupPaths(profile: string): string[] {
2067
2736
  return [...new Set([cfgPath, hermesDefaultProfileConfigPath()])];
2068
2737
  }
2069
2738
 
2739
+
2070
2740
  /**
2071
2741
  * Validate a Hermes host string before interpolating it into YAML.
2072
2742
  *
@@ -2214,7 +2884,22 @@ export function upsertHermesConfig(opts: {
2214
2884
  port: number;
2215
2885
  token: string;
2216
2886
  }): HermesConfigResult {
2217
- const cfgPath = hermesConfigPath(opts.profile);
2887
+ return upsertHermesConfigAt(hermesConfigPath(opts.profile), opts);
2888
+ }
2889
+
2890
+ /**
2891
+ * Path-addressed variant of {@link upsertHermesConfig}: writes/updates the
2892
+ * `remnic:` block in the config.yaml at `cfgPath`. Used both for the
2893
+ * profile-resolved primary config and for refreshing a preserved
2894
+ * prior-install config with the active token (PR #1938, round 13).
2895
+ */
2896
+ function upsertHermesConfigAt(
2897
+ cfgPath: string,
2898
+ opts: { host: string; port: number; token: string },
2899
+ ): HermesConfigResult {
2900
+ // Symlinked components below the Hermes root must not redirect the
2901
+ // token-bearing write (Codex P1 on PR #1938, round 19).
2902
+ assertConfigComponentsNotSymlinked(cfgPath);
2218
2903
  const profileDir = path.dirname(cfgPath);
2219
2904
 
2220
2905
  // YAML-injection guard: validate scalar values before interpolating them
@@ -2349,9 +3034,58 @@ export function upsertHermesConfig(opts: {
2349
3034
  /**
2350
3035
  * Remove the `remnic:` block from a Hermes profile config.yaml.
2351
3036
  * Idempotent — if the block is absent, returns skipped.
3037
+ *
3038
+ * `extraConfigPaths` carries the config.yaml location persisted in the
3039
+ * connector JSON at install time, so a HERMES_HOME change between install and
3040
+ * remove cannot leave the old home's remnic: block (with its daemon token)
3041
+ * behind (Codex P2 on PR #1938, round 6). Extra candidates are shape-guarded
3042
+ * and de-duplicated against the environment-resolved paths.
2352
3043
  */
2353
- export function removeHermesConfig(opts: { profile: string }): HermesConfigResult {
2354
- const cfgPaths = hermesConfigCleanupPaths(opts.profile);
3044
+ export function removeHermesConfig(opts: {
3045
+ profile: string;
3046
+ extraConfigPaths?: readonly string[];
3047
+ }): HermesConfigResult {
3048
+ // Environment-resolved candidates can fail (e.g. HERMES_HOME points at a
3049
+ // regular file at remove time). That must not abort cleanup of the VALID
3050
+ // persisted install-time path — seed from whatever resolves and continue
3051
+ // (Codex P2 on PR #1938, round 8).
3052
+ let envCfgPaths: string[];
3053
+ let envResolutionError: string | null = null;
3054
+ try {
3055
+ envCfgPaths = [...hermesConfigCleanupPaths(opts.profile)];
3056
+ } catch (err) {
3057
+ envCfgPaths = [];
3058
+ envResolutionError = err instanceof Error ? err.message : String(err);
3059
+ }
3060
+ // De-duplicate ALL candidates by resolved file identity, not string
3061
+ // equality: in the legacy layout profiles/default/config.yaml is commonly a
3062
+ // SYMLINK to the root config.yaml — cleaning the real file and then hitting
3063
+ // the symlinked alias would trip the component guard and report a phantom
3064
+ // partial failure (Bugbot on PR #1938, round 21).
3065
+ const cfgPaths: string[] = [];
3066
+ for (const candidate of envCfgPaths) {
3067
+ if (!cfgPaths.some((existing) => sameHermesConfigTarget(existing, candidate))) {
3068
+ cfgPaths.push(candidate);
3069
+ }
3070
+ }
3071
+ for (const extra of opts.extraConfigPaths ?? []) {
3072
+ if (
3073
+ isPlausibleHermesConfigPath(extra) &&
3074
+ !cfgPaths.some((existing) => sameHermesConfigTarget(existing, extra))
3075
+ ) {
3076
+ cfgPaths.push(extra);
3077
+ }
3078
+ }
3079
+ if (cfgPaths.length === 0) {
3080
+ return {
3081
+ updated: false,
3082
+ skipped: true,
3083
+ reason: envResolutionError
3084
+ ? `Hermes config cleanup failed: ${envResolutionError}`
3085
+ : "Hermes config.yaml not found",
3086
+ configPath: "<unresolved>",
3087
+ };
3088
+ }
2355
3089
  const results = cfgPaths.map((cfgPath) => {
2356
3090
  try {
2357
3091
  return removeHermesConfigFile(cfgPath);
@@ -2391,15 +3125,35 @@ export function removeHermesConfig(opts: { profile: string }): HermesConfigResul
2391
3125
  }
2392
3126
 
2393
3127
  const existingWithoutBlock = results.find((result) => result.reason !== "Hermes config.yaml not found");
2394
- return existingWithoutBlock ?? results[0] ?? {
3128
+ const fallbackResult = existingWithoutBlock ?? results[0];
3129
+ if (fallbackResult) {
3130
+ return fallbackResult;
3131
+ }
3132
+ return {
2395
3133
  updated: false,
2396
3134
  skipped: true,
2397
3135
  reason: "Hermes config.yaml not found",
2398
- configPath: hermesConfigPath(opts.profile),
3136
+ configPath: cfgPaths[0] ?? "<unresolved>",
2399
3137
  };
2400
3138
  }
2401
3139
 
2402
3140
  function removeHermesConfigFile(cfgPath: string): HermesConfigResult {
3141
+ // Symlinked components below the Hermes root must not redirect the cleanup
3142
+ // rewrite (Codex P1 on PR #1938, round 19). A symlinked candidate is either
3143
+ // an alias of another candidate (already de-duplicated by file identity) or
3144
+ // a redirect we refuse to follow — report it as SKIPPED, not as a cleanup
3145
+ // failure, so it cannot manufacture a phantom partial-failure abort
3146
+ // (Bugbot on PR #1938, round 21).
3147
+ try {
3148
+ assertConfigComponentsNotSymlinked(cfgPath);
3149
+ } catch (err) {
3150
+ return {
3151
+ updated: false,
3152
+ skipped: true,
3153
+ reason: `Hermes config cleanup skipped: ${err instanceof Error ? err.message : String(err)}`,
3154
+ configPath: cfgPath,
3155
+ };
3156
+ }
2403
3157
  if (!fs.existsSync(cfgPath)) {
2404
3158
  return {
2405
3159
  updated: false,