@drakon-systems/shieldcortex-realtime 4.47.39 → 4.47.40

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/index.js CHANGED
@@ -29,6 +29,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
29
29
  import path from "node:path";
30
30
  import { homedir, hostname } from "node:os";
31
31
  import { fileURLToPath, pathToFileURL } from "node:url";
32
+ import { createRequire } from "node:module";
32
33
  import { readConversationAccess, describeRegisteredHooks } from './conversation-access.js';
33
34
  import { createSessionTaintStore } from './session-taint.js';
34
35
  import { classifyConversationOrigin } from './conversation-trust.js';
@@ -52,13 +53,66 @@ function addAncestorCandidates(candidates, startPath) {
52
53
  current = path.dirname(current);
53
54
  }
54
55
  }
55
- function collectRuntimeCandidates() {
56
+ /**
57
+ * Ask Node where the `shieldcortex` package actually is (#174).
58
+ *
59
+ * The plugin declares `shieldcortex` as a peer, so on ANY layout Node's own
60
+ * resolver can find it from here — no guessing at install prefixes. Resolving
61
+ * `shieldcortex/package.json` rather than the runtime file directly is
62
+ * deliberate: `./package.json` is the one subpath the main package's `exports`
63
+ * map always declares, whereas `hooks/openclaw/**` is in `files` but NOT in
64
+ * `exports`, so resolving it throws ERR_PACKAGE_PATH_NOT_EXPORTED.
65
+ *
66
+ * This is the strategy that fixes the reported `~/.local` host, and it works
67
+ * without widening the public `exports` surface.
68
+ */
69
+ function addResolvedPeerCandidate(candidates, fromUrl, resolve = (spec, from) => createRequire(from).resolve(spec)) {
70
+ try {
71
+ addRuntimeCandidate(candidates, path.dirname(resolve("shieldcortex/package.json", fromUrl)));
72
+ }
73
+ catch { /* not resolvable from here — later strategies still apply */ }
74
+ }
75
+ /**
76
+ * Every place the runtime might live, in the order we should try them.
77
+ *
78
+ * `home` is injected because Jest sandboxes SHIELDCORTEX_CONFIG_DIR but never
79
+ * HOME, so a test that did not inject it would probe the developer's real
80
+ * install and pass for the wrong reason.
81
+ */
82
+ function collectRuntimeCandidates(home = homedir(), resolveFrom = import.meta.url) {
56
83
  const candidates = new Set();
57
- // 1. Relative path (works when running from within npm package tree)
58
- candidates.add(new URL("../../hooks/openclaw/cortex-memory/runtime.mjs", import.meta.url).href);
59
- // 2. Config file override (reads path from ~/.shieldcortex/config.json instead of env var)
84
+ // 0. Operator escape hatch. `resolveOpenClawBinary` and the approval channel
85
+ // already honour an env override for the same class of "we guessed your
86
+ // install prefix wrong" problem; this list was the only copy without one,
87
+ // which is why every new prefix (bun, volta, asdf, ~/.local) has needed a
88
+ // code change. Accepts either the runtime file itself or a package root.
89
+ const envOverride = process.env.SHIELDCORTEX_RUNTIME_PATH?.trim();
90
+ if (envOverride) {
91
+ if (envOverride.endsWith(".mjs") && existsSync(envOverride)) {
92
+ candidates.add(pathToFileURL(envOverride).href);
93
+ }
94
+ else {
95
+ addRuntimeCandidate(candidates, envOverride);
96
+ }
97
+ }
98
+ // 1. Ask Node. Works on every layout including the ~/.local one this fixes.
99
+ addResolvedPeerCandidate(candidates, resolveFrom);
100
+ // 2. Relative path — the repo/source-tree layout, where ../../hooks/… is real.
101
+ // GUARDED, unlike before: on an installed layout this resolves to a
102
+ // non-existent scoped path (`@drakon-systems/hooks/…`), and because it was
103
+ // the only unguarded entry it became the SOLE list member and its
104
+ // ERR_MODULE_NOT_FOUND became the operator-visible failure — the exact
105
+ // message #174 reports. It is a real candidate in the source tree, so it
106
+ // is kept and screened rather than deleted.
107
+ const relative = fileURLToPath(new URL("../../hooks/openclaw/cortex-memory/runtime.mjs", resolveFrom));
108
+ if (existsSync(relative))
109
+ candidates.add(pathToFileURL(relative).href);
110
+ // 3. Config file override. Honours SHIELDCORTEX_CONFIG_DIR like the rest of
111
+ // the product — reading homedir() directly made this permanently blind on
112
+ // a host that relocates its config.
60
113
  try {
61
- const cfgPath = path.join(homedir(), ".shieldcortex", "config.json");
114
+ const configDir = process.env.SHIELDCORTEX_CONFIG_DIR?.trim() || path.join(home, ".shieldcortex");
115
+ const cfgPath = path.join(configDir, "config.json");
62
116
  if (existsSync(cfgPath)) {
63
117
  const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
64
118
  if (cfg.installRoot)
@@ -66,10 +120,15 @@ function collectRuntimeCandidates() {
66
120
  }
67
121
  }
68
122
  catch { /* no config */ }
69
- // 3. Walk up from current file location
70
- addAncestorCandidates(candidates, path.dirname(fileURLToPath(import.meta.url)));
71
- // 4. Resolve via common bin symlink paths (no child_process needed)
72
- for (const binDir of ["/usr/local/bin", "/opt/homebrew/bin", path.join(homedir(), ".npm-global", "bin")]) {
123
+ // 4. Walk up from current file location
124
+ addAncestorCandidates(candidates, path.dirname(fileURLToPath(resolveFrom)));
125
+ // 5. Resolve via common bin symlink paths (no child_process needed)
126
+ for (const binDir of [
127
+ "/usr/local/bin",
128
+ "/opt/homebrew/bin",
129
+ path.join(home, ".npm-global", "bin"),
130
+ path.join(home, ".local", "bin"), // #174: pip-style / npm --prefix ~/.local
131
+ ]) {
73
132
  const binPath = path.join(binDir, "shieldcortex");
74
133
  try {
75
134
  if (existsSync(binPath))
@@ -77,18 +136,19 @@ function collectRuntimeCandidates() {
77
136
  }
78
137
  catch { /* broken symlink */ }
79
138
  }
80
- // 5. Common global install paths (covers npm root -g results without spawning npm)
139
+ // 6. Common global install paths (covers npm root -g results without spawning npm)
81
140
  for (const root of [
82
141
  "/usr/lib/node_modules/shieldcortex",
83
142
  "/usr/local/lib/node_modules/shieldcortex",
84
143
  "/opt/homebrew/lib/node_modules/shieldcortex",
85
- path.join(homedir(), ".npm-global", "lib", "node_modules", "shieldcortex"),
86
- path.join(homedir(), ".nvm", "versions", "node"), // nvm users
144
+ path.join(home, ".npm-global", "lib", "node_modules", "shieldcortex"),
145
+ path.join(home, ".local", "lib", "node_modules", "shieldcortex"), // #174
146
+ path.join(home, ".nvm", "versions", "node"), // nvm users
87
147
  ]) {
88
148
  if (root.includes(".nvm")) {
89
149
  // For nvm, check the current symlink
90
150
  try {
91
- const currentNode = path.join(homedir(), ".nvm", "current", "lib", "node_modules", "shieldcortex");
151
+ const currentNode = path.join(home, ".nvm", "current", "lib", "node_modules", "shieldcortex");
92
152
  addRuntimeCandidate(candidates, currentNode);
93
153
  }
94
154
  catch { /* no nvm */ }
@@ -120,6 +180,16 @@ async function getRuntime() {
120
180
  lastError = error;
121
181
  }
122
182
  }
183
+ // #174: with every candidate screened by existsSync, "none found" is a
184
+ // real outcome and must not render as `Tried: . Last error: unknown
185
+ // error`. Name the escape hatch instead — this message is the only thing
186
+ // an operator on an unusual install prefix has to go on.
187
+ if (tried.length === 0) {
188
+ throw new Error("Could not load OpenClaw runtime: the shieldcortex package was not found from the plugin, " +
189
+ "and no known install prefix contained hooks/openclaw/cortex-memory/runtime.mjs. " +
190
+ "Point at it explicitly with SHIELDCORTEX_RUNTIME_PATH=/path/to/shieldcortex " +
191
+ "(or to the runtime.mjs itself), or reinstall so `shieldcortex` resolves as a peer of the plugin.");
192
+ }
123
193
  const detail = lastError instanceof Error ? lastError.message : String(lastError ?? "unknown error");
124
194
  throw new Error(`Could not load OpenClaw runtime. Tried: ${tried.join(", ")}. Last error: ${detail}`);
125
195
  })();
@@ -159,6 +229,12 @@ async function getDefenceModule() {
159
229
  export function __getSessionTaintForTest() {
160
230
  return sessionTaint;
161
231
  }
232
+ /** Test seam for #174 runtime resolution: `home` and the resolving module URL
233
+ * are injectable because Jest sandboxes SHIELDCORTEX_CONFIG_DIR but never
234
+ * HOME, so an un-injected probe would read the developer's real install. */
235
+ export function __collectRuntimeCandidatesForTest(home, from) {
236
+ return collectRuntimeCandidates(home, from);
237
+ }
162
238
  export function __setDefenceModuleForTest(mod) {
163
239
  _defenceModOverride = mod;
164
240
  _defenceModPromise = null;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.47.39",
3
+ "version": "4.47.40",
4
4
  "name": "ShieldCortex Real-time Scanner",
5
5
  "description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
6
6
  "kind": null,
package/index.ts CHANGED
@@ -30,6 +30,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
30
30
  import path from "node:path";
31
31
  import { homedir, hostname } from "node:os";
32
32
  import { fileURLToPath, pathToFileURL } from "node:url";
33
+ import { createRequire } from "node:module";
33
34
 
34
35
  import { readConversationAccess, describeRegisteredHooks } from './conversation-access.js';
35
36
  import { createSessionTaintStore } from './session-taint.js';
@@ -120,44 +121,110 @@ function addAncestorCandidates(candidates: Set<string>, startPath: string) {
120
121
  }
121
122
  }
122
123
 
123
- function collectRuntimeCandidates(): string[] {
124
+ /**
125
+ * Ask Node where the `shieldcortex` package actually is (#174).
126
+ *
127
+ * The plugin declares `shieldcortex` as a peer, so on ANY layout Node's own
128
+ * resolver can find it from here — no guessing at install prefixes. Resolving
129
+ * `shieldcortex/package.json` rather than the runtime file directly is
130
+ * deliberate: `./package.json` is the one subpath the main package's `exports`
131
+ * map always declares, whereas `hooks/openclaw/**` is in `files` but NOT in
132
+ * `exports`, so resolving it throws ERR_PACKAGE_PATH_NOT_EXPORTED.
133
+ *
134
+ * This is the strategy that fixes the reported `~/.local` host, and it works
135
+ * without widening the public `exports` surface.
136
+ */
137
+ function addResolvedPeerCandidate(
138
+ candidates: Set<string>,
139
+ fromUrl: string,
140
+ resolve: (spec: string, from: string) => string = (spec, from) => createRequire(from).resolve(spec),
141
+ ): void {
142
+ try {
143
+ addRuntimeCandidate(candidates, path.dirname(resolve("shieldcortex/package.json", fromUrl)));
144
+ } catch { /* not resolvable from here — later strategies still apply */ }
145
+ }
146
+
147
+ /**
148
+ * Every place the runtime might live, in the order we should try them.
149
+ *
150
+ * `home` is injected because Jest sandboxes SHIELDCORTEX_CONFIG_DIR but never
151
+ * HOME, so a test that did not inject it would probe the developer's real
152
+ * install and pass for the wrong reason.
153
+ */
154
+ function collectRuntimeCandidates(
155
+ home: string = homedir(),
156
+ resolveFrom: string = import.meta.url,
157
+ ): string[] {
124
158
  const candidates = new Set<string>();
125
159
 
126
- // 1. Relative path (works when running from within npm package tree)
127
- candidates.add(new URL("../../hooks/openclaw/cortex-memory/runtime.mjs", import.meta.url).href);
160
+ // 0. Operator escape hatch. `resolveOpenClawBinary` and the approval channel
161
+ // already honour an env override for the same class of "we guessed your
162
+ // install prefix wrong" problem; this list was the only copy without one,
163
+ // which is why every new prefix (bun, volta, asdf, ~/.local) has needed a
164
+ // code change. Accepts either the runtime file itself or a package root.
165
+ const envOverride = process.env.SHIELDCORTEX_RUNTIME_PATH?.trim();
166
+ if (envOverride) {
167
+ if (envOverride.endsWith(".mjs") && existsSync(envOverride)) {
168
+ candidates.add(pathToFileURL(envOverride).href);
169
+ } else {
170
+ addRuntimeCandidate(candidates, envOverride);
171
+ }
172
+ }
173
+
174
+ // 1. Ask Node. Works on every layout including the ~/.local one this fixes.
175
+ addResolvedPeerCandidate(candidates, resolveFrom);
176
+
177
+ // 2. Relative path — the repo/source-tree layout, where ../../hooks/… is real.
178
+ // GUARDED, unlike before: on an installed layout this resolves to a
179
+ // non-existent scoped path (`@drakon-systems/hooks/…`), and because it was
180
+ // the only unguarded entry it became the SOLE list member and its
181
+ // ERR_MODULE_NOT_FOUND became the operator-visible failure — the exact
182
+ // message #174 reports. It is a real candidate in the source tree, so it
183
+ // is kept and screened rather than deleted.
184
+ const relative = fileURLToPath(new URL("../../hooks/openclaw/cortex-memory/runtime.mjs", resolveFrom));
185
+ if (existsSync(relative)) candidates.add(pathToFileURL(relative).href);
128
186
 
129
- // 2. Config file override (reads path from ~/.shieldcortex/config.json instead of env var)
187
+ // 3. Config file override. Honours SHIELDCORTEX_CONFIG_DIR like the rest of
188
+ // the product — reading homedir() directly made this permanently blind on
189
+ // a host that relocates its config.
130
190
  try {
131
- const cfgPath = path.join(homedir(), ".shieldcortex", "config.json");
191
+ const configDir = process.env.SHIELDCORTEX_CONFIG_DIR?.trim() || path.join(home, ".shieldcortex");
192
+ const cfgPath = path.join(configDir, "config.json");
132
193
  if (existsSync(cfgPath)) {
133
194
  const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
134
195
  if (cfg.installRoot) addRuntimeCandidate(candidates, cfg.installRoot);
135
196
  }
136
197
  } catch { /* no config */ }
137
198
 
138
- // 3. Walk up from current file location
139
- addAncestorCandidates(candidates, path.dirname(fileURLToPath(import.meta.url)));
199
+ // 4. Walk up from current file location
200
+ addAncestorCandidates(candidates, path.dirname(fileURLToPath(resolveFrom)));
140
201
 
141
- // 4. Resolve via common bin symlink paths (no child_process needed)
142
- for (const binDir of ["/usr/local/bin", "/opt/homebrew/bin", path.join(homedir(), ".npm-global", "bin")]) {
202
+ // 5. Resolve via common bin symlink paths (no child_process needed)
203
+ for (const binDir of [
204
+ "/usr/local/bin",
205
+ "/opt/homebrew/bin",
206
+ path.join(home, ".npm-global", "bin"),
207
+ path.join(home, ".local", "bin"), // #174: pip-style / npm --prefix ~/.local
208
+ ]) {
143
209
  const binPath = path.join(binDir, "shieldcortex");
144
210
  try {
145
211
  if (existsSync(binPath)) addAncestorCandidates(candidates, realpathSync(binPath));
146
212
  } catch { /* broken symlink */ }
147
213
  }
148
214
 
149
- // 5. Common global install paths (covers npm root -g results without spawning npm)
215
+ // 6. Common global install paths (covers npm root -g results without spawning npm)
150
216
  for (const root of [
151
217
  "/usr/lib/node_modules/shieldcortex",
152
218
  "/usr/local/lib/node_modules/shieldcortex",
153
219
  "/opt/homebrew/lib/node_modules/shieldcortex",
154
- path.join(homedir(), ".npm-global", "lib", "node_modules", "shieldcortex"),
155
- path.join(homedir(), ".nvm", "versions", "node"), // nvm users
220
+ path.join(home, ".npm-global", "lib", "node_modules", "shieldcortex"),
221
+ path.join(home, ".local", "lib", "node_modules", "shieldcortex"), // #174
222
+ path.join(home, ".nvm", "versions", "node"), // nvm users
156
223
  ]) {
157
224
  if (root.includes(".nvm")) {
158
225
  // For nvm, check the current symlink
159
226
  try {
160
- const currentNode = path.join(homedir(), ".nvm", "current", "lib", "node_modules", "shieldcortex");
227
+ const currentNode = path.join(home, ".nvm", "current", "lib", "node_modules", "shieldcortex");
161
228
  addRuntimeCandidate(candidates, currentNode);
162
229
  } catch { /* no nvm */ }
163
230
  } else {
@@ -190,6 +257,18 @@ async function getRuntime(): Promise<OpenClawRuntime> {
190
257
  }
191
258
  }
192
259
 
260
+ // #174: with every candidate screened by existsSync, "none found" is a
261
+ // real outcome and must not render as `Tried: . Last error: unknown
262
+ // error`. Name the escape hatch instead — this message is the only thing
263
+ // an operator on an unusual install prefix has to go on.
264
+ if (tried.length === 0) {
265
+ throw new Error(
266
+ "Could not load OpenClaw runtime: the shieldcortex package was not found from the plugin, " +
267
+ "and no known install prefix contained hooks/openclaw/cortex-memory/runtime.mjs. " +
268
+ "Point at it explicitly with SHIELDCORTEX_RUNTIME_PATH=/path/to/shieldcortex " +
269
+ "(or to the runtime.mjs itself), or reinstall so `shieldcortex` resolves as a peer of the plugin.",
270
+ );
271
+ }
193
272
  const detail = lastError instanceof Error ? lastError.message : String(lastError ?? "unknown error");
194
273
  throw new Error(`Could not load OpenClaw runtime. Tried: ${tried.join(", ")}. Last error: ${detail}`);
195
274
  })();
@@ -232,6 +311,13 @@ export function __getSessionTaintForTest(): typeof sessionTaint {
232
311
  return sessionTaint;
233
312
  }
234
313
 
314
+ /** Test seam for #174 runtime resolution: `home` and the resolving module URL
315
+ * are injectable because Jest sandboxes SHIELDCORTEX_CONFIG_DIR but never
316
+ * HOME, so an un-injected probe would read the developer's real install. */
317
+ export function __collectRuntimeCandidatesForTest(home?: string, from?: string): string[] {
318
+ return collectRuntimeCandidates(home, from);
319
+ }
320
+
235
321
  export function __setDefenceModuleForTest(mod: DefenceModule | null | undefined): void {
236
322
  _defenceModOverride = mod;
237
323
  _defenceModPromise = null;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.47.39",
3
+ "version": "4.47.40",
4
4
  "name": "ShieldCortex Real-time Scanner",
5
5
  "description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
6
6
  "kind": null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "4.47.39",
3
+ "version": "4.47.40",
4
4
  "description": "OpenClaw plugin for ShieldCortex real-time defence scanning and optional memory extraction.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",