@remnic/core 9.7.5 → 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
package/src/access-mcp.ts CHANGED
@@ -40,6 +40,7 @@ import { expandTildePath } from "./utils/path.js";
40
40
 
41
41
  import { applyToolOutputSchemas } from "./access-mcp-output-schemas.js";
42
42
  import { MCP_READ_ONLY_TOOL_SUFFIXES } from "./mcp-read-only-tools.js";
43
+ import { abortError, isAbortError } from "./abort-error.js";
43
44
  type JsonRpcId = string | number | null;
44
45
 
45
46
  type JsonRpcRequest = {
@@ -49,6 +50,12 @@ type JsonRpcRequest = {
49
50
  params?: Record<string, unknown>;
50
51
  };
51
52
 
53
+ function throwMcpAbort(signal: AbortSignal | undefined, message: string): void {
54
+ if (!signal?.aborted) return;
55
+ if (isAbortError(signal.reason)) throw signal.reason;
56
+ throw abortError(message);
57
+ }
58
+
52
59
  type McpRequestOptions = {
53
60
  principalOverride?: string;
54
61
  namespaceOverride?: string;
@@ -67,6 +74,8 @@ type McpRequestOptions = {
67
74
  * the operation context so write handlers stamp it onto frontmatter.
68
75
  */
69
76
  sourceConnector?: string;
77
+ /** HTTP request lifetime; absent for the standalone stdio transport. */
78
+ abortSignal?: AbortSignal;
70
79
  };
71
80
 
72
81
  type McpTool = {
@@ -2645,6 +2654,10 @@ export class EngramMcpServer {
2645
2654
  ...(options?.namespaceOverride ? { namespace: options.namespaceOverride } : {}),
2646
2655
  ...(options?.sessionKeyOverride ? { sessionKey: options.sessionKeyOverride } : {}),
2647
2656
  };
2657
+ // Abort before dispatch so a disconnected request never starts work.
2658
+ // Once a mutating tool has returned, cancellation is deferred to the
2659
+ // HTTP transport so it can account for the committed write first.
2660
+ throwMcpAbort(options?.abortSignal, "MCP request aborted before operation start");
2648
2661
  const result = await this.callTool(
2649
2662
  name,
2650
2663
  argumentsObject,
@@ -2652,8 +2665,12 @@ export class EngramMcpServer {
2652
2665
  options?.sessionId,
2653
2666
  mcpScope,
2654
2667
  options?.enforceWriteQuota,
2655
- options?.sourceConnector
2668
+ options?.sourceConnector,
2669
+ options?.abortSignal,
2656
2670
  );
2671
+ if (isReadOnlyToolName(name)) {
2672
+ throwMcpAbort(options?.abortSignal, "MCP request aborted before response");
2673
+ }
2657
2674
  return {
2658
2675
  jsonrpc: "2.0",
2659
2676
  id,
@@ -2664,6 +2681,9 @@ export class EngramMcpServer {
2664
2681
  },
2665
2682
  };
2666
2683
  } catch (err) {
2684
+ // Cancellation is transport control flow, not a JSON-RPC tool error.
2685
+ // Preserve the original AbortError so HTTP can silently end a dead socket.
2686
+ if (isAbortError(err)) throw err;
2667
2687
  const message = err instanceof Error ? err.message : String(err);
2668
2688
  return {
2669
2689
  jsonrpc: "2.0",
@@ -2862,6 +2882,7 @@ export class EngramMcpServer {
2862
2882
  scope?: { namespace?: string; sessionKey?: string },
2863
2883
  enforceWriteQuota?: () => void | Promise<void>,
2864
2884
  sourceConnector?: string,
2885
+ abortSignal?: AbortSignal,
2865
2886
  ): Promise<unknown> {
2866
2887
  const migrated = MCP_MIGRATED_OPERATIONS[toLegacyToolName(name)];
2867
2888
  if (!migrated) {
@@ -2941,7 +2962,9 @@ export class EngramMcpServer {
2941
2962
  const result = (await op.run(envelope, {
2942
2963
  service: this.service,
2943
2964
  authenticatedPrincipal: effectivePrincipal,
2965
+ ...(abortSignal ? { abortSignal } : {}),
2944
2966
  })) as { result: unknown };
2967
+ throwMcpAbort(abortSignal, "MCP recall aborted before postprocessing");
2945
2968
  const response = result.result as Record<string, unknown>;
2946
2969
  if (this.shouldEmitCitations(mcpSessionId)) {
2947
2970
  const citations = this.buildRecallCitations(response as unknown as EngramAccessRecallResponse);
@@ -2961,6 +2984,7 @@ export class EngramMcpServer {
2961
2984
  authenticatedPrincipal: effectivePrincipal,
2962
2985
  ...(enforceWriteQuota ? { hooks: { enforceWriteQuota } } : {}),
2963
2986
  ...(sourceConnector ? { sourceConnector } : {}),
2987
+ ...(abortSignal ? { abortSignal } : {}),
2964
2988
  })) as { result: unknown };
2965
2989
  return output.result;
2966
2990
  }
@@ -83,7 +83,7 @@ defineOperation({ name: "recall", description: "Semantic recall.", schema: stric
83
83
  if (input.tags !== undefined) { if (!Array.isArray(input.tags) || !input.tags.every((t) => typeof t === "string")) throw new EngramAccessInputError("tags must be an array of strings"); tags = input.tags; }
84
84
  let tagMatch: "any" | "all" | undefined;
85
85
  if (input.tagMatch !== undefined) { if (input.tagMatch !== "any" && input.tagMatch !== "all") throw new EngramAccessInputError("tagMatch must be one of: any, all"); tagMatch = input.tagMatch; }
86
- const result = await ctx.service.recall({ query: typeof input.query === "string" ? input.query : "", sessionKey: optStr(input.sessionKey), authenticatedPrincipal: ctx.authenticatedPrincipal, namespace: optStr(input.namespace), topK: optNum(input.topK), mode: optStr(input.mode) as RecallPlanMode | "auto" | undefined, includeDebug: input.includeDebug === true, disclosure, cwd: optStr(input.cwd), projectTag: optStr(input.projectTag), asOf: optStr(input.asOf), ...(tags ? { tags } : {}), ...(tagMatch ? { tagMatch } : {}) });
86
+ const result = await ctx.service.recall({ query: typeof input.query === "string" ? input.query : "", sessionKey: optStr(input.sessionKey), authenticatedPrincipal: ctx.authenticatedPrincipal, namespace: optStr(input.namespace), topK: optNum(input.topK), mode: optStr(input.mode) as RecallPlanMode | "auto" | undefined, includeDebug: input.includeDebug === true, disclosure, cwd: optStr(input.cwd), projectTag: optStr(input.projectTag), asOf: optStr(input.asOf), ...(tags ? { tags } : {}), ...(tagMatch ? { tagMatch } : {}), ...(ctx.abortSignal ? { abortSignal: ctx.abortSignal } : {}) });
87
87
  return { result };
88
88
  },
89
89
  });
@@ -110,7 +110,7 @@ defineOperation({ name: "recall_xray", description: "X-ray recall.", schema: str
110
110
  let budget: number | undefined;
111
111
  if (input.budget !== undefined) { const p = typeof input.budget === "number" ? input.budget : typeof input.budget === "string" ? Number(input.budget) : undefined; if (p === undefined || !Number.isFinite(p) || p <= 0 || !Number.isInteger(p)) throw new EngramAccessInputError("recall_xray: budget expects a positive integer"); budget = p; }
112
112
  const dr = optStr(input.disclosure);
113
- return { result: await ctx.service.recallXray({ query: defStr(input.query, ""), sessionKey: optStr(input.sessionKey), namespace: optStr(input.namespace), budget, authenticatedPrincipal: ctx.authenticatedPrincipal, ...(dr && dr !== "" ? { disclosure: dr as RecallDisclosure } : {}) }) };
113
+ return { result: await ctx.service.recallXray({ query: defStr(input.query, ""), sessionKey: optStr(input.sessionKey), namespace: optStr(input.namespace), budget, authenticatedPrincipal: ctx.authenticatedPrincipal, ...(dr && dr !== "" ? { disclosure: dr as RecallDisclosure } : {}), ...(ctx.abortSignal ? { abortSignal: ctx.abortSignal } : {}) }) };
114
114
  },
115
115
  });
116
116
 
@@ -133,7 +133,7 @@ defineOperation({ name: "chatgpt_memory_inspector", description: "Memory inspect
133
133
  if (input.currentContextScopes !== undefined) ii.currentContextScopes = input.currentContextScopes as string[];
134
134
  if (input.allowUnverifiedPreview !== undefined) ii.allowUnverifiedPreview = input.allowUnverifiedPreview as boolean;
135
135
  const rsk = ii.sessionKey ?? (ctx.authenticatedPrincipal ? "remnic:chatgpt-memory-inspector:" + randomUUID() : undefined);
136
- const xr = await ctx.service.recallXray({ query: ii.query, sessionKey: rsk, namespace: ii.namespace, currentContextScopes: ii.currentContextScopes, authenticatedPrincipal: ctx.authenticatedPrincipal, mode: "full", disclosure: "chunk", includeRecall: true });
136
+ const xr = await ctx.service.recallXray({ query: ii.query, sessionKey: rsk, namespace: ii.namespace, currentContextScopes: ii.currentContextScopes, authenticatedPrincipal: ctx.authenticatedPrincipal, mode: "full", disclosure: "chunk", includeRecall: true, ...(ctx.abortSignal ? { abortSignal: ctx.abortSignal } : {}) });
137
137
  const x = xr.snapshotFound === true ? (xr.snapshot ?? null) : null;
138
138
  const r = xr.recall ?? { query: ii.query, namespace: ii.namespace ?? x?.namespace ?? "global", context: "", count: 0, memoryIds: [], results: [], fallbackUsed: false, sourcesUsed: [], disclosure: "chunk" as const };
139
139
  const ac = await ctx.service.actionConfidence(buildChatGptMemoryInspectorActionRequest(ii, r, x));
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Hermes plugin-directory shim lifecycle (Issue #1929).
3
+ *
4
+ * Hermes Agent discovers memory providers by scanning
5
+ * `$HERMES_HOME/plugins/<name>/__init__.py` — NOT pip metadata and NOT any
6
+ * plugin.yaml. The discovery heuristic reads the first 8KB of that file and
7
+ * keeps the directory only when the source text contains the literal string
8
+ * `register_memory_provider` or `MemoryProvider`. A bare `pip install
9
+ * remnic-hermes` therefore leaves the provider invisible until the CLI
10
+ * materializes this directory shim. The loader then calls the module's
11
+ * `register(collector)`; `remnic_hermes.register()` (>= 1.0.5) loads Hermes
12
+ * config itself when the collector exposes no `.config`.
13
+ *
14
+ * Activation still requires the user to set `memory.provider: remnic` in their
15
+ * Hermes config.yaml — an exclusive slot the installer never touches
16
+ * programmatically.
17
+ *
18
+ * This module isolates every Hermes-discovery-contract detail (home
19
+ * resolution, discovery heuristic, shim content, marker provenance) so the
20
+ * generic connector registry in `index.ts` only orchestrates. Verified against
21
+ * upstream `NousResearch/hermes-agent` @ `53adb3f`
22
+ * (`plugins/memory/__init__.py`, `hermes_constants.py`).
23
+ */
24
+
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+
28
+ import { readEnvVar, resolveHomeDir } from "../runtime/env.js";
29
+ import { expandTildePath } from "../utils/path.js";
30
+
31
+ /**
32
+ * Stable marker embedded in the generated shim's docstring. `remove` uses it to
33
+ * distinguish a Remnic-authored shim from a user-authored `__init__.py` so we
34
+ * never delete a file we did not write.
35
+ */
36
+ export const HERMES_SHIM_MARKER = "generated by `remnic connectors install hermes`";
37
+
38
+ /**
39
+ * Resolve the Hermes home directory. Honors `HERMES_HOME` (tilde-expanded and
40
+ * resolved to an absolute path) when set; otherwise falls back to Hermes'
41
+ * platform-native default, mirroring upstream `_get_platform_default_hermes_home`
42
+ * in `hermes_constants.py`: `%LOCALAPPDATA%\hermes` on Windows (with
43
+ * `~/AppData/Local/hermes` when LOCALAPPDATA is unset), `~/.hermes` elsewhere.
44
+ * If `HERMES_HOME` points at an existing non-directory path it is a
45
+ * misconfiguration — throw rather than attempt to create a `plugins/` subtree
46
+ * under a regular file.
47
+ */
48
+ export function resolveHermesRoot(): string {
49
+ const envHome = readEnvVar("HERMES_HOME");
50
+ if (typeof envHome === "string" && envHome.trim().length > 0) {
51
+ const expanded = path.resolve(expandTildePath(envHome.trim()));
52
+ // lstat (no symlink following): an externally supplied HERMES_HOME that is
53
+ // a symlink could redirect config/shim writes and deletions to an
54
+ // arbitrary directory — reject symlinked roots outright, matching the
55
+ // repository's symlink-traversal guard for directory scans (Codex P1 on
56
+ // PR #1938, round 15). A non-directory (regular file) root is likewise a
57
+ // misconfiguration.
58
+ try {
59
+ const stat = fs.lstatSync(expanded);
60
+ if (stat.isSymbolicLink()) {
61
+ throw new Error(`HERMES_HOME must not be a symbolic link: ${expanded}`);
62
+ }
63
+ if (!stat.isDirectory()) {
64
+ throw new Error(`HERMES_HOME is not a directory: ${expanded}`);
65
+ }
66
+ } catch (err) {
67
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
68
+ throw err;
69
+ }
70
+ // Does not exist yet — acceptable; install creates it.
71
+ }
72
+ return expanded;
73
+ }
74
+ if (process.platform === "win32") {
75
+ const localAppData = (readEnvVar("LOCALAPPDATA") ?? "").trim();
76
+ const base = localAppData.length > 0 ? path.resolve(localAppData) : path.join(resolveHomeDir(), "AppData", "Local");
77
+ return resolveDefaultRoot(path.join(base, "hermes"));
78
+ }
79
+ return resolveDefaultRoot(path.resolve(resolveHomeDir(), ".hermes"));
80
+ }
81
+
82
+ /**
83
+ * Normalize the platform-default Hermes root. Unlike an explicitly supplied
84
+ * `HERMES_HOME` (external input — symlinks rejected outright above), the
85
+ * default `~/.hermes` / `%LOCALAPPDATA%\hermes` is commonly a symlink under
86
+ * dotfile managers, and Hermes itself follows it. Resolving to the realpath
87
+ * (rather than rejecting) removes the divergence a symlinked root could
88
+ * introduce: every subsequent read/write/remove derives from the SAME
89
+ * resolved base, and the component-level symlink guard still protects the
90
+ * subtree below it (Codex P1 on PR #1938, round 17).
91
+ */
92
+ function resolveDefaultRoot(candidate: string): string {
93
+ try {
94
+ return fs.realpathSync.native(candidate);
95
+ } catch {
96
+ return candidate; // does not exist yet — created on demand
97
+ }
98
+ }
99
+
100
+ export function hermesShimPath(): string {
101
+ return path.join(resolveHermesRoot(), "plugins", "remnic", "__init__.py");
102
+ }
103
+
104
+ /**
105
+ * Atomically write a plain (non-secret) file with 0o644 permissions. The shim
106
+ * carries no credentials, so unlike `writeSecretFileSync` it is world-readable.
107
+ * Writing to a temp file then renaming into place guarantees a mid-write failure
108
+ * cannot truncate an existing shim (AGENTS.md: never destroy old state before
109
+ * the new state is confirmed).
110
+ */
111
+ function writePlainFileAtomicSync(filePath: string, data: string): void {
112
+ const dir = path.dirname(filePath);
113
+ const base = path.basename(filePath);
114
+ const tmpPath = path.join(dir, `.${base}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
115
+ let wroteTemp = false;
116
+ try {
117
+ fs.writeFileSync(tmpPath, data, { mode: 0o644, flag: "wx" });
118
+ wroteTemp = true;
119
+ fs.renameSync(tmpPath, filePath);
120
+ try {
121
+ fs.chmodSync(filePath, 0o644);
122
+ } catch {
123
+ /* best-effort on non-POSIX filesystems */
124
+ }
125
+ } catch (err) {
126
+ if (wroteTemp) {
127
+ try {
128
+ fs.unlinkSync(tmpPath);
129
+ } catch {
130
+ /* best-effort temp cleanup */
131
+ }
132
+ }
133
+ throw err;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Reject symlinked path components between the Hermes root and the shim file
139
+ * (`plugins/`, `plugins/remnic/`, and the `__init__.py` itself). The root-only
140
+ * lstat in `resolveHermesRoot` does not cover these; a symlinked component
141
+ * would let mkdir/write/unlink escape the selected Hermes home (Codex P1 on
142
+ * PR #1938, round 16). Missing components are fine — they get created.
143
+ */
144
+ function assertShimComponentsNotSymlinked(shimPath: string): void {
145
+ const remnicDir = path.dirname(shimPath);
146
+ const pluginsDir = path.dirname(remnicDir);
147
+ for (const component of [pluginsDir, remnicDir, shimPath]) {
148
+ let isLink = false;
149
+ try {
150
+ isLink = fs.lstatSync(component).isSymbolicLink();
151
+ } catch {
152
+ continue; // does not exist yet
153
+ }
154
+ if (isLink) {
155
+ throw new Error(`refusing to operate through a symbolic link: ${component}`);
156
+ }
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Materialize the Hermes plugin-directory shim. Idempotent:
162
+ * - A shim already carrying our marker is overwritten (reinstall).
163
+ * - A shim WITHOUT our marker is user-authored → left untouched
164
+ * (`wrote: false`) so callers never treat the collision as a materialized
165
+ * replacement.
166
+ * - Otherwise the parent dirs are created and the shim is written.
167
+ * Returns a human-readable note plus whether the shim was actually written.
168
+ * Throws only on a real filesystem failure; the caller wraps this so install
169
+ * never fails on the shim.
170
+ */
171
+ export function materializeHermesShim(shimPath: string): { note: string; wrote: boolean } {
172
+ assertShimComponentsNotSymlinked(shimPath);
173
+ if (fs.existsSync(shimPath)) {
174
+ let existing: string;
175
+ try {
176
+ existing = fs.readFileSync(shimPath, "utf8");
177
+ } catch (readErr) {
178
+ // An unreadable existing file cannot be classified as ours vs
179
+ // user-authored. Treating it as user-authored would silently skip a
180
+ // reinstall of OUR marker shim (Bugbot on PR #1938); surface the read
181
+ // failure instead so the caller emits the manual hint.
182
+ throw new Error(
183
+ `cannot read existing shim at ${shimPath}: ${readErr instanceof Error ? readErr.message : String(readErr)}`
184
+ );
185
+ }
186
+ if (!existing.includes(HERMES_SHIM_MARKER)) {
187
+ // Collision with a user-authored file: nothing was written. Callers must
188
+ // treat this as an UNCONFIRMED replacement (wrote: false) — never as a
189
+ // materialized shim (Codex P2 on PR #1938, round 8).
190
+ return {
191
+ note: `Hermes plugin shim already exists and was NOT generated by Remnic — left untouched: ${shimPath}. If the provider is not discovered, ensure that file imports remnic_hermes.register AND contains the literal text register_memory_provider (or MemoryProvider) — Hermes' discovery text-scan skips the directory without it.`,
192
+ wrote: false,
193
+ };
194
+ }
195
+ }
196
+ fs.mkdirSync(path.dirname(shimPath), { recursive: true });
197
+ const content = [
198
+ `"""Remnic memory provider shim for Hermes Agent (${HERMES_SHIM_MARKER}).`,
199
+ "",
200
+ "Hermes memory-provider discovery calls register(collector);",
201
+ "collector.register_memory_provider() receives the provider.",
202
+ `"""`,
203
+ "",
204
+ "from remnic_hermes import register # noqa: F401 (register() loads Hermes config itself)",
205
+ "",
206
+ ].join("\n");
207
+ writePlainFileAtomicSync(shimPath, content);
208
+ return { note: `Materialized Hermes plugin shim: ${shimPath}`, wrote: true };
209
+ }
210
+
211
+ /**
212
+ * Reconcile the on-disk Remnic shim with the shim location resolved from the
213
+ * CURRENT environment. One routine for both install paths (fresh/force install
214
+ * and the already_installed backfill) so they enforce the same invariant: a
215
+ * Remnic-generated shim on disk is either removed or referenced by the
216
+ * returned `persistPath` — the caller must persist that path in the connector
217
+ * JSON and never silently discard the location of a shim that survives on
218
+ * disk.
219
+ *
220
+ * Behavior:
221
+ * - Resolves the target path (`hermesShimPath()`), materializes the shim
222
+ * there, THEN removes a marker shim at `priorPersistedPath` when it differs
223
+ * (never destroy old state before the new state is confirmed).
224
+ * - On resolution/materialization failure: emits a manual-creation hint with
225
+ * the exact required contents (including the `register_memory_provider`
226
+ * discovery literal) and returns the PRIOR path as `persistPath`, so the
227
+ * registry keeps tracking the shim that is still on disk.
228
+ * - Never throws.
229
+ */
230
+ export function reconcileHermesShim(priorPersistedPath: string | null): {
231
+ notes: string[];
232
+ persistPath: string | null;
233
+ /** Path where a shim was (re)written by this reconcile, or null. */
234
+ materializedAt: string | null;
235
+ /** True when the materialized shim did NOT exist before this reconcile. */
236
+ createdNew: boolean;
237
+ /** Prior-install shim path this reconcile actually deleted, or null. */
238
+ priorCleanedAt: string | null;
239
+ } {
240
+ const notes: string[] = [];
241
+ let target: string | null = null;
242
+ let materialized = false;
243
+ let createdNew = false;
244
+ try {
245
+ target = hermesShimPath();
246
+ // Record create-vs-overwrite BEFORE writing: a rollback must never delete
247
+ // a shim that pre-existed this reconcile (e.g. connector JSON deleted
248
+ // manually while the marker shim survived) — overwriting our own
249
+ // deterministic content is a no-op, but deleting it would break a
250
+ // functional install (Codex P2 on PR #1938, round 7).
251
+ const existedBefore = fs.existsSync(target);
252
+ const result = materializeHermesShim(target);
253
+ notes.push(result.note);
254
+ // A collision with a user-authored file writes nothing: treat it as an
255
+ // unconfirmed replacement so the prior generated shim is NOT cleaned and
256
+ // the registry keeps pointing at the shim that actually works
257
+ // (Codex P2 on PR #1938, round 8).
258
+ materialized = result.wrote;
259
+ createdNew = result.wrote && !existedBefore;
260
+ } catch (shimErr) {
261
+ const shimPathHint = target ?? "<hermesRoot>/plugins/remnic/__init__.py";
262
+ // No shell one-liner here: the path may contain characters that break
263
+ // quoting, and the shim content MUST include the literal text
264
+ // `register_memory_provider` (or `MemoryProvider`) or Hermes' discovery
265
+ // text-scan skips the directory (PR #1938 review).
266
+ notes.push(
267
+ `Note: could not materialize the Hermes plugin shim (${shimErr instanceof Error ? shimErr.message : String(shimErr)}). Create ${shimPathHint} manually with exactly these two lines:\n """Remnic memory provider shim. Calls collector.register_memory_provider()."""\n from remnic_hermes import register`
268
+ );
269
+ }
270
+ if (!materialized || target === null) {
271
+ // Nothing new confirmed on disk — keep tracking ONLY what the registry
272
+ // already pointed at. Never persist the unconfirmed target: provenance
273
+ // must reference a Remnic shim that actually exists (Bugbot on PR #1938,
274
+ // round 8).
275
+ return {
276
+ notes,
277
+ persistPath: priorPersistedPath,
278
+ materializedAt: null,
279
+ createdNew: false,
280
+ priorCleanedAt: null,
281
+ };
282
+ }
283
+ const confirmedTarget: string = target;
284
+ let priorCleanedAt: string | null = null;
285
+ if (priorPersistedPath !== null && !sameShimTarget(priorPersistedPath, confirmedTarget)) {
286
+ // The prior install's shim lives elsewhere (HERMES_HOME changed). Compare
287
+ // by resolved file identity, not string equality — two spellings of the
288
+ // same directory (symlinks, case-insensitive volumes, tilde vs absolute)
289
+ // must not delete the shim that was just written (Bugbot on PR #1938,
290
+ // round 7). Clean it now that the replacement is confirmed; marker-gating
291
+ // inside removeHermesShim protects user-authored files.
292
+ try {
293
+ const stale = removeHermesShim([priorPersistedPath]);
294
+ if (stale.notes.length > 0) {
295
+ notes.push(`Cleaned prior-install shim: ${stale.notes.join("; ")}`);
296
+ }
297
+ priorCleanedAt = stale.removedPaths.includes(priorPersistedPath) ? priorPersistedPath : null;
298
+ } catch {
299
+ notes.push(
300
+ `Note: could not clean the prior-install Hermes plugin shim at ${priorPersistedPath} — remove it manually if present.`
301
+ );
302
+ }
303
+ }
304
+ return { notes, persistPath: target, materializedAt: target, createdNew, priorCleanedAt };
305
+ }
306
+
307
+ /**
308
+ * Compare two shim paths by resolved file identity (realpath when available),
309
+ * so symlinked, tilde-expanded, or differently-cased spellings of the same
310
+ * location are treated as equal.
311
+ */
312
+ export function sameShimTarget(leftPath: string, rightPath: string): boolean {
313
+ return resolveShimTarget(leftPath) === resolveShimTarget(rightPath);
314
+ }
315
+
316
+ function resolveShimTarget(candidate: string): string {
317
+ try {
318
+ return fs.realpathSync.native(candidate);
319
+ } catch {
320
+ return path.resolve(candidate);
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Remove the Hermes plugin-directory shim ONLY when it carries our generated
326
+ * marker (never delete a user-authored `__init__.py`). Accepts every candidate
327
+ * location — the path persisted in connector.json at install time plus the
328
+ * path resolved from the CURRENT environment — so a HERMES_HOME change between
329
+ * install and remove cannot orphan the generated shim (Codex P2 on PR #1938).
330
+ * After deleting, attempts to remove the now-empty `plugins/remnic/` directory
331
+ * with rmdir — which fails harmlessly if the directory is non-empty or
332
+ * missing. Never rm -rf. Returns the paths actually deleted plus
333
+ * human-readable notes (empty when there was nothing of ours to act on).
334
+ */
335
+ export function removeHermesShim(candidatePaths: readonly string[]): {
336
+ removedPaths: string[];
337
+ notes: string[];
338
+ } {
339
+ const notes: string[] = [];
340
+ const removedPaths: string[] = [];
341
+ for (const shimPath of new Set(candidatePaths)) {
342
+ // Only ever touch a path with the exact generated-shim shape. The
343
+ // persisted candidate comes from connector.json, which is on-disk state —
344
+ // do not let a tampered value point this cleanup at an arbitrary file.
345
+ if (!isPlausibleHermesShimPath(shimPath)) {
346
+ continue;
347
+ }
348
+ try {
349
+ assertShimComponentsNotSymlinked(shimPath);
350
+ } catch {
351
+ notes.push(`Hermes plugin shim left untouched (symlinked path component): ${shimPath}`);
352
+ continue;
353
+ }
354
+ if (!fs.existsSync(shimPath)) {
355
+ continue;
356
+ }
357
+ let content: string;
358
+ try {
359
+ content = fs.readFileSync(shimPath, "utf8");
360
+ } catch {
361
+ // Fail safe: an unreadable file cannot be verified as ours — never
362
+ // delete what we cannot positively identify.
363
+ notes.push(`Hermes plugin shim left untouched (unreadable): ${shimPath}`);
364
+ continue;
365
+ }
366
+ if (!content.includes(HERMES_SHIM_MARKER)) {
367
+ notes.push(`Hermes plugin shim left untouched (not Remnic-generated): ${shimPath}`);
368
+ continue;
369
+ }
370
+ try {
371
+ fs.unlinkSync(shimPath);
372
+ } catch (unlinkErr) {
373
+ // One stale/unwritable candidate must not abort cleanup of the rest
374
+ // (Codex P2 on PR #1938, round 20). The failed path stays on disk and,
375
+ // still carrying the marker, remains tracked by the provenance
376
+ // reconciliation for a later retry.
377
+ notes.push(
378
+ `Hermes plugin shim could not be removed (${unlinkErr instanceof Error ? unlinkErr.message : String(unlinkErr)}): ${shimPath} — remove it manually or re-run after fixing permissions.`,
379
+ );
380
+ continue;
381
+ }
382
+ removedPaths.push(shimPath);
383
+ try {
384
+ fs.rmdirSync(path.dirname(shimPath));
385
+ } catch {
386
+ /* directory non-empty or already gone — leave it in place */
387
+ }
388
+ notes.push(`Removed Hermes plugin shim: ${shimPath}`);
389
+ }
390
+ return { removedPaths, notes };
391
+ }
392
+
393
+ /**
394
+ * Shape guard for shim paths read back from connector.json: absolute, named
395
+ * `__init__.py`, and living under a `plugins/remnic/` directory.
396
+ */
397
+ function isPlausibleHermesShimPath(candidate: string): boolean {
398
+ if (typeof candidate !== "string" || candidate.length === 0 || !path.isAbsolute(candidate)) {
399
+ return false;
400
+ }
401
+ const dir = path.dirname(candidate);
402
+ return (
403
+ path.basename(candidate) === "__init__.py" &&
404
+ path.basename(dir) === "remnic" &&
405
+ path.basename(path.dirname(dir)) === "plugins"
406
+ );
407
+ }
408
+
409
+ /**
410
+ * Shape guard for config.yaml paths read back from connector.json: absolute,
411
+ * named `config.yaml`, and living in a directory that looks like a Hermes
412
+ * home or Hermes profile — the home directory is named `.hermes`/`hermes`,
413
+ * the path sits under a `profiles/` directory, or the directory carries a
414
+ * Hermes-layout sibling (`plugins/` or `profiles/`). A tampered connector
415
+ * JSON must not be able to point remnic:-block cleanup at an arbitrary
416
+ * config.yaml elsewhere on disk (Codex P2 on PR #1938, round 7).
417
+ */
418
+ export function isPlausibleHermesConfigPath(candidate: string): boolean {
419
+ if (typeof candidate !== "string" || candidate.length === 0 || !path.isAbsolute(candidate)) {
420
+ return false;
421
+ }
422
+ if (path.basename(candidate) !== "config.yaml") {
423
+ return false;
424
+ }
425
+ const dir = path.dirname(candidate);
426
+ const dirName = path.basename(dir).toLowerCase();
427
+ if (dirName === ".hermes" || dirName === "hermes") {
428
+ return true;
429
+ }
430
+ if (path.basename(path.dirname(dir)) === "profiles") {
431
+ return true;
432
+ }
433
+ if (
434
+ ["plugins", "profiles"].some((sibling) => {
435
+ try {
436
+ return fs.statSync(path.join(dir, sibling)).isDirectory();
437
+ } catch {
438
+ return false;
439
+ }
440
+ })
441
+ ) {
442
+ return true;
443
+ }
444
+ // Last resort for custom HERMES_HOME dirs with an arbitrary basename and no
445
+ // Hermes-layout siblings (e.g. shim materialization failed so plugins/ was
446
+ // never created): accept the path when the file itself currently carries a
447
+ // top-level remnic: block — cleanup only ever strips that block, so the
448
+ // content check bounds what a tampered path could affect (Bugbot on
449
+ // PR #1938, round 9).
450
+ try {
451
+ const stat = fs.statSync(candidate);
452
+ if (!stat.isFile() || stat.size > 1024 * 1024) {
453
+ return false;
454
+ }
455
+ return /^remnic:/m.test(fs.readFileSync(candidate, "utf8"));
456
+ } catch {
457
+ return false;
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Return every candidate that still holds a Remnic marker shim on disk —
463
+ * shape-guarded, file-identity de-duplicated, and excluding `excludeTarget`
464
+ * (the currently-tracked shim). Callers persist the result as
465
+ * `priorPluginShimPaths` so a shim whose cleanup failed is never orphaned:
466
+ * a later install or remove keeps targeting it (Codex P2 on PR #1938,
467
+ * round 18).
468
+ */
469
+ export function survivingMarkerShims(
470
+ candidates: readonly string[],
471
+ excludeTarget: string | null,
472
+ ): string[] {
473
+ const survivors: string[] = [];
474
+ for (const candidate of new Set(candidates)) {
475
+ if (!isPlausibleHermesShimPath(candidate)) {
476
+ continue;
477
+ }
478
+ if (excludeTarget !== null && sameShimTarget(candidate, excludeTarget)) {
479
+ continue;
480
+ }
481
+ if (survivors.some((kept) => sameShimTarget(kept, candidate))) {
482
+ continue;
483
+ }
484
+ let carriesMarker = false;
485
+ try {
486
+ carriesMarker = fs.readFileSync(candidate, "utf8").includes(HERMES_SHIM_MARKER);
487
+ } catch {
488
+ carriesMarker = false;
489
+ }
490
+ if (carriesMarker) {
491
+ survivors.push(candidate);
492
+ }
493
+ }
494
+ return survivors;
495
+ }
496
+
497
+ /**
498
+ * Reject symlinked path components between the Hermes root and a config.yaml
499
+ * target (`profiles/`, `profiles/<name>/`, and the file itself). Mirrors the
500
+ * shim component guard: a symlinked component below the accepted root could
501
+ * redirect the token-bearing config write or removal rewrite outside the
502
+ * selected Hermes home (Codex P1 on PR #1938, round 19). Missing components
503
+ * are fine — they get created.
504
+ */
505
+ export function assertConfigComponentsNotSymlinked(cfgPath: string): void {
506
+ const dir = path.dirname(cfgPath);
507
+ const components = [cfgPath, dir];
508
+ const grandparent = path.dirname(dir);
509
+ if (path.basename(grandparent) === "profiles") {
510
+ components.push(grandparent);
511
+ }
512
+ for (const component of components) {
513
+ let isLink = false;
514
+ try {
515
+ isLink = fs.lstatSync(component).isSymbolicLink();
516
+ } catch {
517
+ continue; // does not exist yet
518
+ }
519
+ if (isLink) {
520
+ throw new Error(`refusing to operate through a symbolic link: ${component}`);
521
+ }
522
+ }
523
+ }