@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.
Files changed (45) 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.d.ts +3 -3
  9. package/dist/access-operations.js +3 -3
  10. package/dist/access-schema.d.ts +64 -64
  11. package/dist/{chunk-TBQ4CFIP.js → chunk-57INMZ6F.js} +1 -1
  12. package/dist/chunk-57INMZ6F.js.map +1 -0
  13. package/dist/{chunk-BFCQPJ5B.js → chunk-CHVU4RE5.js} +24 -6
  14. package/dist/chunk-CHVU4RE5.js.map +1 -0
  15. package/dist/{chunk-3CRHW42H.js → chunk-FYKIEOG6.js} +2 -2
  16. package/dist/{chunk-ESE55PZJ.js → chunk-NMMKRVUF.js} +10 -6
  17. package/dist/chunk-NMMKRVUF.js.map +1 -0
  18. package/dist/{chunk-3UPBVNBX.js → chunk-Q6JPMCPO.js} +3 -3
  19. package/dist/{chunk-P2UA6XQG.js → chunk-UHGHTOR5.js} +5 -5
  20. package/dist/chunk-UHGHTOR5.js.map +1 -0
  21. package/dist/{chunk-H67QFYUK.js → chunk-UJBESW6X.js} +918 -146
  22. package/dist/chunk-UJBESW6X.js.map +1 -0
  23. package/dist/cli.js +6 -6
  24. package/dist/connectors/index.d.ts +7 -0
  25. package/dist/connectors/index.js +1 -1
  26. package/dist/index.js +7 -7
  27. package/dist/orchestrator.js +7 -7
  28. package/dist/schemas.d.ts +74 -74
  29. package/dist/shared-context/manager.d.ts +8 -8
  30. package/dist/transfer/types.d.ts +66 -66
  31. package/package.json +2 -2
  32. package/src/access-boundary.ts +2 -0
  33. package/src/access-http.ts +15 -2
  34. package/src/access-mcp-cancellation.test.ts +405 -0
  35. package/src/access-mcp.ts +25 -1
  36. package/src/access-operations-batch.ts +3 -3
  37. package/src/connectors/hermes-shim.ts +523 -0
  38. package/src/connectors/index.ts +769 -15
  39. package/dist/chunk-BFCQPJ5B.js.map +0 -1
  40. package/dist/chunk-ESE55PZJ.js.map +0 -1
  41. package/dist/chunk-H67QFYUK.js.map +0 -1
  42. package/dist/chunk-P2UA6XQG.js.map +0 -1
  43. package/dist/chunk-TBQ4CFIP.js.map +0 -1
  44. /package/dist/{chunk-3CRHW42H.js.map → chunk-FYKIEOG6.js.map} +0 -0
  45. /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
+ }