@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.
- 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/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
|
+
}
|