@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.
- package/dist/access-boundary.d.ts +2 -0
- package/dist/access-boundary.js +1 -1
- package/dist/access-cli.js +7 -7
- package/dist/access-http.js +5 -5
- package/dist/access-mcp.d.ts +2 -0
- package/dist/access-mcp.js +4 -4
- package/dist/access-operations-batch.js +2 -2
- package/dist/access-operations.js +3 -3
- package/dist/{chunk-TBQ4CFIP.js → chunk-57INMZ6F.js} +1 -1
- package/dist/chunk-57INMZ6F.js.map +1 -0
- package/dist/{chunk-BFCQPJ5B.js → chunk-CHVU4RE5.js} +24 -6
- package/dist/chunk-CHVU4RE5.js.map +1 -0
- package/dist/{chunk-3CRHW42H.js → chunk-FYKIEOG6.js} +2 -2
- package/dist/{chunk-ESE55PZJ.js → chunk-NMMKRVUF.js} +10 -6
- package/dist/chunk-NMMKRVUF.js.map +1 -0
- package/dist/{chunk-3UPBVNBX.js → chunk-Q6JPMCPO.js} +3 -3
- package/dist/{chunk-P2UA6XQG.js → chunk-UHGHTOR5.js} +5 -5
- package/dist/chunk-UHGHTOR5.js.map +1 -0
- package/dist/{chunk-H67QFYUK.js → chunk-UJBESW6X.js} +918 -146
- package/dist/chunk-UJBESW6X.js.map +1 -0
- package/dist/cli.js +6 -6
- package/dist/connectors/index.d.ts +7 -0
- package/dist/connectors/index.js +1 -1
- package/dist/index.js +7 -7
- package/dist/orchestrator.js +7 -7
- package/package.json +2 -2
- package/src/access-boundary.ts +2 -0
- package/src/access-http.ts +15 -2
- package/src/access-mcp-cancellation.test.ts +405 -0
- package/src/access-mcp.ts +25 -1
- package/src/access-operations-batch.ts +3 -3
- package/src/connectors/hermes-shim.ts +523 -0
- package/src/connectors/index.ts +769 -15
- package/dist/chunk-BFCQPJ5B.js.map +0 -1
- package/dist/chunk-ESE55PZJ.js.map +0 -1
- package/dist/chunk-H67QFYUK.js.map +0 -1
- package/dist/chunk-P2UA6XQG.js.map +0 -1
- package/dist/chunk-TBQ4CFIP.js.map +0 -1
- /package/dist/{chunk-3CRHW42H.js.map → chunk-FYKIEOG6.js.map} +0 -0
- /package/dist/{chunk-3UPBVNBX.js.map → chunk-Q6JPMCPO.js.map} +0 -0
package/src/connectors/index.ts
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
1920
|
-
// is
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
2354
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|