@remnic/core 9.7.6 → 9.7.8
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.d.ts +3 -3
- package/dist/access-operations.js +3 -3
- package/dist/access-schema.d.ts +64 -64
- 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/dist/schemas.d.ts +74 -74
- package/dist/shared-context/manager.d.ts +8 -8
- package/dist/transfer/types.d.ts +66 -66
- 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
|
@@ -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
|
+
}
|