claude-code-memory-explorer 0.9.1 → 0.11.0
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/lib/net-guard.js +267 -0
- package/lib/open-editor.js +267 -0
- package/package.json +5 -3
- package/public/app.js +510 -48
- package/public/index.html +20 -2
- package/public/style.css +607 -32
- package/server.js +550 -45
package/lib/net-guard.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Network boundary for a local-only dev server.
|
|
4
|
+
//
|
|
5
|
+
// Binding loopback is necessary but not sufficient. Three distinct attackers:
|
|
6
|
+
//
|
|
7
|
+
// * LAN peer — http://192.168.1.42:3541/api/... The loopback bind stops this
|
|
8
|
+
// outright; the handshake never completes off-box.
|
|
9
|
+
// * DNS rebinding — the victim loads evil.com, the attacker re-resolves that
|
|
10
|
+
// name to 127.0.0.1, and the page fetches http://evil.com:3541/api/...
|
|
11
|
+
// The page's origin *is* evil.com:3541, so the request is same-origin and
|
|
12
|
+
// CORS never fires; the connection is to loopback, so the bind is satisfied.
|
|
13
|
+
// The only thing that differs from a legitimate request is the Host header,
|
|
14
|
+
// which script cannot forge. A Host allowlist is the entire defense.
|
|
15
|
+
// * Plain CSRF — evil.com POSTs straight at 127.0.0.1:3541. Host is a real
|
|
16
|
+
// allowlisted value, so hostGuard passes it; this needs a separate Origin /
|
|
17
|
+
// Sec-Fetch-Site check on state-changing methods.
|
|
18
|
+
//
|
|
19
|
+
// There is no authentication. Exposing this to a network means trusting everyone
|
|
20
|
+
// on it with the user's Claude Code history.
|
|
21
|
+
//
|
|
22
|
+
// CANONICAL COPY: scripts/security-lib/net-guard.js in the claude-code-hub repo.
|
|
23
|
+
// Run scripts/sync-security-lib.sh after editing.
|
|
24
|
+
|
|
25
|
+
const LOOPBACK_HOSTS = new Set([
|
|
26
|
+
'localhost',
|
|
27
|
+
'127.0.0.1',
|
|
28
|
+
'::1',
|
|
29
|
+
'0:0:0:0:0:0:0:1',
|
|
30
|
+
'::ffff:127.0.0.1',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
// Hoisted: parseHostHeader runs on every request, and neither the regex nor the
|
|
34
|
+
// predicate depends on the argument.
|
|
35
|
+
const PORT_RE = /^[0-9]{1,5}$/;
|
|
36
|
+
// The port is validated but not compared — the listen(0) fallbacks make the real
|
|
37
|
+
// port dynamic, so only the host is allowlisted.
|
|
38
|
+
const validPort = (p) => PORT_RE.test(p) && Number(p) >= 1 && Number(p) <= 65535;
|
|
39
|
+
|
|
40
|
+
function isLoopbackAddress(host) {
|
|
41
|
+
if (!host) return false;
|
|
42
|
+
const h = String(host).toLowerCase();
|
|
43
|
+
return LOOPBACK_HOSTS.has(h) || h.startsWith('127.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Hand-parsed rather than via new URL(), which is far more permissive than we
|
|
47
|
+
// want here and happily accepts things no real client sends. Returns the bare
|
|
48
|
+
// hostname, lowercased, or null if the header is malformed.
|
|
49
|
+
//
|
|
50
|
+
// The port is deliberately ignored: ports are dynamic in these apps because of
|
|
51
|
+
// the listen(0) fallback when the default is busy, so pinning one would break
|
|
52
|
+
// the fallback path.
|
|
53
|
+
function parseHostHeader(value) {
|
|
54
|
+
if (typeof value !== 'string') return null;
|
|
55
|
+
const raw = value.trim();
|
|
56
|
+
if (!raw || raw.length > 259) return null;
|
|
57
|
+
|
|
58
|
+
// Reject whitespace and control characters. Written as a code-point scan rather
|
|
59
|
+
// than a character class so it cannot be misread — a stray range in a regex
|
|
60
|
+
// here would silently reject legitimate hostnames containing a dash.
|
|
61
|
+
for (let i = 0; i < raw.length; i++) {
|
|
62
|
+
const code = raw.charCodeAt(i);
|
|
63
|
+
if (code <= 0x20 || code === 0x7f) return null;
|
|
64
|
+
}
|
|
65
|
+
if (raw.includes('@')) return null; // userinfo has no business in a Host header
|
|
66
|
+
|
|
67
|
+
let host;
|
|
68
|
+
if (raw.startsWith('[')) {
|
|
69
|
+
const end = raw.indexOf(']');
|
|
70
|
+
if (end === -1) return null;
|
|
71
|
+
host = raw.slice(1, end);
|
|
72
|
+
const rest = raw.slice(end + 1);
|
|
73
|
+
if (rest && !(rest.startsWith(':') && validPort(rest.slice(1)))) return null;
|
|
74
|
+
} else {
|
|
75
|
+
const parts = raw.split(':');
|
|
76
|
+
if (parts.length > 2) return null; // bare IPv6 without brackets, or garbage
|
|
77
|
+
host = parts[0];
|
|
78
|
+
if (parts.length === 2 && !validPort(parts[1])) return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!host) return null;
|
|
82
|
+
if (host.endsWith('.')) host = host.slice(0, -1); // one trailing root dot is legal
|
|
83
|
+
if (!host) return null;
|
|
84
|
+
return host.toLowerCase();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseList(value) {
|
|
88
|
+
return String(value || '')
|
|
89
|
+
.split(',')
|
|
90
|
+
.map((s) => s.trim().toLowerCase())
|
|
91
|
+
.filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getFlag(name) {
|
|
95
|
+
const idx = process.argv.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
|
|
96
|
+
if (idx === -1) return null;
|
|
97
|
+
const arg = process.argv[idx];
|
|
98
|
+
if (arg.includes('=')) return arg.slice(arg.indexOf('=') + 1);
|
|
99
|
+
const next = process.argv[idx + 1];
|
|
100
|
+
return next && !next.startsWith('--') ? next : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {{appName?: string}} [config]
|
|
105
|
+
*/
|
|
106
|
+
function createNetGuard(config = {}) {
|
|
107
|
+
const appName = config.appName || 'This app';
|
|
108
|
+
|
|
109
|
+
const BIND_HOST = getFlag('host') || process.env.HOST || '127.0.0.1';
|
|
110
|
+
const BIND_HOST_LC = BIND_HOST.toLowerCase();
|
|
111
|
+
const EXPOSED = !isLoopbackAddress(BIND_HOST);
|
|
112
|
+
// Resolved here and re-exported so the hub can forward the same value to its
|
|
113
|
+
// children rather than re-parsing the flag with its own argv scanner — a parent
|
|
114
|
+
// and child that disagree means the shell serves while every iframe 403s.
|
|
115
|
+
const ALLOWED_HOSTS = getFlag('allowed-hosts') || process.env.ALLOWED_HOSTS || '';
|
|
116
|
+
const extraHosts = new Set(parseList(ALLOWED_HOSTS));
|
|
117
|
+
|
|
118
|
+
// The hub frames each sub-app, so its origin must be accepted as an Origin and
|
|
119
|
+
// permitted as a framing ancestor.
|
|
120
|
+
let hubOrigin = null;
|
|
121
|
+
if (process.env.HUB_URL) {
|
|
122
|
+
try { hubOrigin = new URL(process.env.HUB_URL).origin; } catch { /* ignore a malformed HUB_URL */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Resolved only once listen() succeeds — the requested port may have been busy.
|
|
126
|
+
let actualPort = null;
|
|
127
|
+
|
|
128
|
+
function hostAllowed(host) {
|
|
129
|
+
if (!host) return false;
|
|
130
|
+
if (isLoopbackAddress(host)) return true;
|
|
131
|
+
if (extraHosts.has(host)) return true;
|
|
132
|
+
// Binding a specific non-loopback address implies that address is a valid name for us.
|
|
133
|
+
if (EXPOSED && host === BIND_HOST_LC) return true;
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function hostGuard(req, res, next) {
|
|
138
|
+
const host = parseHostHeader(req.headers.host);
|
|
139
|
+
if (hostAllowed(host)) return next();
|
|
140
|
+
res.status(403).type('text/plain').send(
|
|
141
|
+
`403 Forbidden - unrecognized Host header: ${req.headers.host || '(none)'}\n\n` +
|
|
142
|
+
`${appName} only answers requests addressed to localhost. This protects you\n` +
|
|
143
|
+
`from DNS rebinding, where a website you visit re-points its own hostname at\n` +
|
|
144
|
+
`127.0.0.1 in order to read your local data.\n\n` +
|
|
145
|
+
`If you meant to reach this from another machine, restart with:\n` +
|
|
146
|
+
` --host 0.0.0.0 --allowed-hosts=${host || '<your-hostname>'}\n\n` +
|
|
147
|
+
`Only do that on a network you trust - there is no authentication.\n`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// true = allow, false = block, null = no Origin header, caller decides.
|
|
152
|
+
function originVerdict(origin) {
|
|
153
|
+
if (!origin || origin === 'null') return null;
|
|
154
|
+
let parsed;
|
|
155
|
+
try { parsed = new URL(origin); } catch { return false; }
|
|
156
|
+
if (hubOrigin && parsed.origin === hubOrigin) return true;
|
|
157
|
+
if (!isLoopbackAddress(parsed.hostname)) return false;
|
|
158
|
+
// Same port, any loopback spelling. A different local port is a different app
|
|
159
|
+
// and has no business driving this one.
|
|
160
|
+
if (actualPort && parsed.port && Number(parsed.port) !== Number(actualPort)) return false;
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function originGuard(req, res, next) {
|
|
165
|
+
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
|
|
166
|
+
|
|
167
|
+
const verdict = originVerdict(req.headers.origin);
|
|
168
|
+
if (verdict === true) return next();
|
|
169
|
+
if (verdict === false) {
|
|
170
|
+
return res.status(403).type('text/plain')
|
|
171
|
+
.send(`403 Forbidden - cross-origin ${req.method} from ${req.headers.origin} is not allowed.\n`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// No Origin header: a non-browser client (curl, the CLI, the hub's own fetch).
|
|
175
|
+
// Trust it unless the browser explicitly tells us otherwise.
|
|
176
|
+
const site = req.headers['sec-fetch-site'];
|
|
177
|
+
if (site && site !== 'same-origin' && site !== 'none') {
|
|
178
|
+
return res.status(403).type('text/plain')
|
|
179
|
+
.send(`403 Forbidden - cross-site ${req.method} (Sec-Fetch-Site: ${site}) is not allowed.\n`);
|
|
180
|
+
}
|
|
181
|
+
return next();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Constant for the life of the process, and frameGuard runs ahead of
|
|
185
|
+
// express.static — so this is built once rather than per asset fetch.
|
|
186
|
+
//
|
|
187
|
+
// Under the hub, X-Frame-Options is deliberately omitted: it has no multi-origin
|
|
188
|
+
// form, and sending SAMEORIGIN would break the hub's iframes.
|
|
189
|
+
//
|
|
190
|
+
// The port is wildcarded rather than pinned to HUB_URL's. The hub spawns its
|
|
191
|
+
// children before it binds, so if its own preferred port is busy it hands them a
|
|
192
|
+
// HUB_URL with the wrong port and every iframe would break. Any loopback port may
|
|
193
|
+
// frame us; that is not the threat being addressed here — a remote page still
|
|
194
|
+
// cannot, which is what matters.
|
|
195
|
+
//
|
|
196
|
+
// No IPv6 literal: Chrome rejects `http://[::1]:*` as a source expression and
|
|
197
|
+
// logs it on every framed load. The hub's own origin is added verbatim instead,
|
|
198
|
+
// which covers a hub reached over [::1].
|
|
199
|
+
let CSP;
|
|
200
|
+
if (hubOrigin) {
|
|
201
|
+
const ancestors = ["'self'", 'http://localhost:*', 'http://127.0.0.1:*'];
|
|
202
|
+
if (!/^http:\/\/(localhost|127\.0\.0\.1)(:|$)/.test(hubOrigin)) ancestors.push(hubOrigin);
|
|
203
|
+
CSP = `frame-ancestors ${ancestors.join(' ')}`;
|
|
204
|
+
} else {
|
|
205
|
+
CSP = "frame-ancestors 'none'";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function frameGuard(_req, res, next) {
|
|
209
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
210
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
211
|
+
res.setHeader('Content-Security-Policy', CSP);
|
|
212
|
+
if (!hubOrigin) res.setHeader('X-Frame-Options', 'DENY');
|
|
213
|
+
next();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Bind BIND_HOST, then opportunistically serve the other loopback family on the
|
|
218
|
+
* same resolved port via a second server sharing the same request handler.
|
|
219
|
+
*
|
|
220
|
+
* That second bind is what keeps `http://localhost:<port>` working no matter how
|
|
221
|
+
* the OS resolver orders A/AAAA records - binding only 127.0.0.1 breaks hosts
|
|
222
|
+
* where `localhost` resolves to ::1, and vice versa. Doing it on the *resolved*
|
|
223
|
+
* port means every existing URL, startup banner and postMessage origin is
|
|
224
|
+
* unchanged. Failures are swallowed: it is an optimization, not a requirement.
|
|
225
|
+
*
|
|
226
|
+
* Returns the primary server, so callers keep attaching their own 'error'
|
|
227
|
+
* handler for the EADDRINUSE fallback.
|
|
228
|
+
*
|
|
229
|
+
* @param {any} app express app (or any http request handler)
|
|
230
|
+
* @param {number} port 0 selects a free port
|
|
231
|
+
* @param {(port: number) => void} [onReady]
|
|
232
|
+
* @param {{maxHeaderSize?: number}} [opts]
|
|
233
|
+
*/
|
|
234
|
+
function listenLoopback(app, port, onReady, opts = {}) {
|
|
235
|
+
const http = require('http');
|
|
236
|
+
const serverOpts = opts.maxHeaderSize ? { maxHeaderSize: opts.maxHeaderSize } : {};
|
|
237
|
+
const makeServer = (handler) => http.createServer(serverOpts, handler);
|
|
238
|
+
const server = makeServer(app);
|
|
239
|
+
let secondary = null;
|
|
240
|
+
|
|
241
|
+
server.on('listening', () => {
|
|
242
|
+
actualPort = server.address().port;
|
|
243
|
+
if (!EXPOSED) {
|
|
244
|
+
const otherFamily = BIND_HOST.includes(':') ? '127.0.0.1' : '::1';
|
|
245
|
+
try {
|
|
246
|
+
secondary = makeServer(app);
|
|
247
|
+
secondary.on('error', () => {}); // that family may not exist on this host
|
|
248
|
+
secondary.listen({ host: otherFamily, port: actualPort, ipv6Only: otherFamily === '::1' });
|
|
249
|
+
} catch { /* best effort */ }
|
|
250
|
+
}
|
|
251
|
+
if (onReady) onReady(actualPort);
|
|
252
|
+
});
|
|
253
|
+
server.on('close', () => { try { if (secondary) secondary.close(); } catch { /* already gone */ } });
|
|
254
|
+
|
|
255
|
+
server.listen(port, BIND_HOST);
|
|
256
|
+
return server;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function exposureWarning() {
|
|
260
|
+
if (!EXPOSED) return null;
|
|
261
|
+
return `WARNING: listening on ${BIND_HOST} - reachable from your network, with no authentication.`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return { BIND_HOST, ALLOWED_HOSTS, hostGuard, originGuard, frameGuard, listenLoopback, exposureWarning };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = { createNetGuard, parseHostHeader, isLoopbackAddress };
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Launch a GUI editor without ever handing a string to a shell.
|
|
4
|
+
//
|
|
5
|
+
// The naive fix for command injection here — dropping `shell: true` — does not
|
|
6
|
+
// work on Windows, and fails in two different ways:
|
|
7
|
+
//
|
|
8
|
+
// * `spawn('code', args)` throws ENOENT, because the launcher on PATH is
|
|
9
|
+
// `code.cmd` and Node does not consult PATHEXT.
|
|
10
|
+
// * Resolving to `code.cmd` and spawning that throws EINVAL, because since the
|
|
11
|
+
// CVE-2024-27980 fix (Node 18.20.2 / 20.12.2 / 21.7.3+) a .bat/.cmd target
|
|
12
|
+
// may only be spawned with `shell: true` — the very thing we are removing.
|
|
13
|
+
//
|
|
14
|
+
// So on Windows we resolve the real .exe sitting behind the .cmd shim and spawn
|
|
15
|
+
// that directly. Only when no such .exe exists (npm-style shims, which are node
|
|
16
|
+
// scripts with no binary twin) do we fall back to cmd.exe, and that path applies
|
|
17
|
+
// a character allowlist so the quoting cannot be broken out of.
|
|
18
|
+
//
|
|
19
|
+
// CANONICAL COPY: scripts/security-lib/open-editor.js in the claude-code-hub repo.
|
|
20
|
+
// Duplicated verbatim into cck/, marketplace/ and memory/ — they are independent
|
|
21
|
+
// npm packages and cannot import from each other. Run scripts/sync-security-lib.sh
|
|
22
|
+
// after editing.
|
|
23
|
+
|
|
24
|
+
const { spawn } = require('child_process');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
|
|
28
|
+
const IS_WIN = process.platform === 'win32';
|
|
29
|
+
|
|
30
|
+
// Editors whose CLI understands `-n` (open in a new window). Passing it to
|
|
31
|
+
// vim/nano/emacs would be wrong, so it is only injected for this family.
|
|
32
|
+
const VSCODE_FAMILY = new Set([
|
|
33
|
+
'code', 'code-insiders', 'codium', 'vscodium', 'cursor', 'windsurf', 'positron', 'trae',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
// Characters that survive cmd.exe's double quotes and would let an argument
|
|
37
|
+
// escape it. Inside "..." cmd treats & | < > ^ ( ) as literal, so only these
|
|
38
|
+
// matter: '"' ends the quoted run, '%' triggers variable expansion whose value
|
|
39
|
+
// could itself contain a quote. Neither is legal in an NTFS path anyway.
|
|
40
|
+
const WIN_CMD_FORBIDDEN = /["%\r\n\u0000]/;
|
|
41
|
+
|
|
42
|
+
function fail(message, status) {
|
|
43
|
+
return Object.assign(new Error(message), { status });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Split an EDITOR value into argv. Handles `EDITOR="code -w"` and quoted paths
|
|
47
|
+
// such as `"C:\Program Files\App\app.exe" --flag`. No expansion, no globbing —
|
|
48
|
+
// EDITOR is operator-supplied, not request-supplied.
|
|
49
|
+
function splitCommandLine(str) {
|
|
50
|
+
if (typeof str !== 'string') return []; // String(undefined) would yield an "undefined" argv entry
|
|
51
|
+
const out = [];
|
|
52
|
+
let cur = '';
|
|
53
|
+
let quote = null;
|
|
54
|
+
let quoted = false;
|
|
55
|
+
for (const ch of String(str)) {
|
|
56
|
+
if (quote) {
|
|
57
|
+
if (ch === quote) quote = null;
|
|
58
|
+
else cur += ch;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (ch === '"' || ch === "'") { quote = ch; quoted = true; continue; }
|
|
62
|
+
if (/\s/.test(ch)) {
|
|
63
|
+
if (cur || quoted) { out.push(cur); cur = ''; quoted = false; }
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
cur += ch;
|
|
67
|
+
}
|
|
68
|
+
if (cur || quoted) out.push(cur);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The PATH walk below stats PATH.length × PATHEXT.length candidates — on a
|
|
73
|
+
// typical Windows box ~1200 stats, several milliseconds. The answer cannot change
|
|
74
|
+
// within a process unless PATH itself does, so it is memoized on both.
|
|
75
|
+
const whichCache = new Map();
|
|
76
|
+
|
|
77
|
+
// which(1). On Windows this walks PATHEXT so we learn the resolved extension —
|
|
78
|
+
// that .cmd-vs-.exe distinction is what selects the launch strategy below.
|
|
79
|
+
function whichSync(cmd) {
|
|
80
|
+
if (!cmd) return null;
|
|
81
|
+
if (cmd.includes('/') || cmd.includes('\\')) {
|
|
82
|
+
const abs = path.resolve(cmd);
|
|
83
|
+
try { return fs.statSync(abs).isFile() ? abs : null; } catch { return null; }
|
|
84
|
+
}
|
|
85
|
+
const key = `${process.env.PATH || ''}\u0000${process.env.PATHEXT || ''}\u0000${cmd}`;
|
|
86
|
+
if (whichCache.has(key)) return whichCache.get(key);
|
|
87
|
+
const found = whichUncached(cmd);
|
|
88
|
+
whichCache.set(key, found);
|
|
89
|
+
return found;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function whichUncached(cmd) {
|
|
93
|
+
const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
94
|
+
const exts = IS_WIN
|
|
95
|
+
? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
|
|
96
|
+
: [''];
|
|
97
|
+
for (const dir of dirs) {
|
|
98
|
+
for (const ext of exts) {
|
|
99
|
+
const candidate = path.join(dir, cmd + ext);
|
|
100
|
+
try { if (fs.statSync(candidate).isFile()) return candidate; } catch { /* next */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A GUI editor's .cmd shim normally lives at <install>/bin/foo.cmd next to
|
|
107
|
+
// <install>/Foo.exe. Returns that .exe so we can skip the shell entirely.
|
|
108
|
+
// npm-installed shims have no such twin and correctly return null here.
|
|
109
|
+
function exeBehindShim(shimPath) {
|
|
110
|
+
const bin = path.dirname(shimPath);
|
|
111
|
+
const root = path.dirname(bin);
|
|
112
|
+
const stem = path.basename(shimPath, path.extname(shimPath));
|
|
113
|
+
const candidates = [
|
|
114
|
+
path.join(root, `${stem}.exe`),
|
|
115
|
+
path.join(bin, `${stem}.exe`),
|
|
116
|
+
path.join(root, 'Code.exe'),
|
|
117
|
+
path.join(root, 'Cursor.exe'),
|
|
118
|
+
path.join(root, 'Windsurf.exe'),
|
|
119
|
+
path.join(root, 'VSCodium.exe'),
|
|
120
|
+
path.join(root, 'Positron.exe'),
|
|
121
|
+
];
|
|
122
|
+
for (const candidate of candidates) {
|
|
123
|
+
try { if (fs.statSync(candidate).isFile()) return candidate; } catch { /* next */ }
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function launchViaCmd(exe, args) {
|
|
129
|
+
for (const arg of [exe, ...args]) {
|
|
130
|
+
if (WIN_CMD_FORBIDDEN.test(arg)) {
|
|
131
|
+
throw fail('Path contains a character that cannot be passed safely to this editor', 400);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const line = [exe, ...args].map((a) => `"${a}"`).join(' ');
|
|
135
|
+
return spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `"${line}"`], {
|
|
136
|
+
windowsVerbatimArguments: true,
|
|
137
|
+
windowsHide: true,
|
|
138
|
+
stdio: 'ignore',
|
|
139
|
+
detached: true,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let cachedEditor;
|
|
144
|
+
|
|
145
|
+
function resolveEditor() {
|
|
146
|
+
const spec = (process.env.EDITOR || '').trim() || 'code';
|
|
147
|
+
if (cachedEditor && cachedEditor.spec === spec) return cachedEditor;
|
|
148
|
+
const parts = splitCommandLine(spec);
|
|
149
|
+
const cmd = parts[0];
|
|
150
|
+
if (!cmd) throw fail('No editor configured (set EDITOR)', 500);
|
|
151
|
+
const resolved = whichSync(cmd);
|
|
152
|
+
if (!resolved) throw fail(`Editor not found on PATH: ${cmd}`, 500);
|
|
153
|
+
cachedEditor = { spec, resolved, preArgs: parts.slice(1) };
|
|
154
|
+
return cachedEditor;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Validate a request-supplied path before it becomes an editor argument, and
|
|
159
|
+
* return its resolved absolute form. Lives here rather than in each server so the
|
|
160
|
+
* rule cannot drift between the apps that share this helper.
|
|
161
|
+
*
|
|
162
|
+
* Deliberately not a containment check: opening an arbitrary project directory is
|
|
163
|
+
* the feature these endpoints exist for. The network boundary is the control.
|
|
164
|
+
*
|
|
165
|
+
* @param {unknown} value
|
|
166
|
+
* @param {string} label used in the error message
|
|
167
|
+
* @returns {string} resolved absolute path
|
|
168
|
+
*/
|
|
169
|
+
function assertOpenTarget(value, label = 'path') {
|
|
170
|
+
if (typeof value !== 'string' || !value.trim()) throw fail(`${label} must be a non-empty string`, 400);
|
|
171
|
+
if (/[\r\n\u0000]/.test(value)) throw fail(`${label} contains invalid characters`, 400);
|
|
172
|
+
const resolved = path.resolve(value);
|
|
173
|
+
if (!fs.existsSync(resolved)) throw fail(`${label} does not exist`, 404);
|
|
174
|
+
return resolved;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Open one or more already-validated absolute paths in the user's editor.
|
|
179
|
+
* @param {string[]} targets
|
|
180
|
+
* @param {{newWindow?: boolean}} [opts]
|
|
181
|
+
*/
|
|
182
|
+
function openInEditor(targets, opts = {}) {
|
|
183
|
+
const list = (Array.isArray(targets) ? targets : [targets]).filter(Boolean);
|
|
184
|
+
if (!list.length) throw fail('Nothing to open', 400);
|
|
185
|
+
|
|
186
|
+
const { resolved, preArgs } = resolveEditor();
|
|
187
|
+
const stem = path.basename(resolved, path.extname(resolved)).toLowerCase();
|
|
188
|
+
|
|
189
|
+
const flags = [...preArgs];
|
|
190
|
+
if (opts.newWindow !== false && VSCODE_FAMILY.has(stem) && !flags.includes('-n')) {
|
|
191
|
+
flags.unshift('-n');
|
|
192
|
+
}
|
|
193
|
+
const argv = [...flags, ...list];
|
|
194
|
+
|
|
195
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
196
|
+
if (IS_WIN && (ext === '.cmd' || ext === '.bat')) {
|
|
197
|
+
const exe = exeBehindShim(resolved);
|
|
198
|
+
if (exe) return spawn(exe, argv, { stdio: 'ignore', detached: true, windowsHide: true }).unref();
|
|
199
|
+
return launchViaCmd(resolved, argv).unref();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return spawn(resolved, argv, { stdio: 'ignore', detached: true }).unref();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Run a command without a shell and capture its output. Same Windows .cmd
|
|
207
|
+
* strategy as openInEditor: prefer the real .exe, fall back to an explicitly
|
|
208
|
+
* quoted cmd.exe line guarded by the character allowlist. Callers must still
|
|
209
|
+
* validate every argument — this stops shell metacharacters, not bad flags.
|
|
210
|
+
* @param {string} cmd
|
|
211
|
+
* @param {string[]} args
|
|
212
|
+
* @param {{cwd?: string, timeout?: number}} [opts]
|
|
213
|
+
* @returns {Promise<{stdout: string, stderr: string}>}
|
|
214
|
+
*/
|
|
215
|
+
function execNoShell(cmd, args, opts = {}) {
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
217
|
+
const resolved = whichSync(cmd);
|
|
218
|
+
if (!resolved) return reject(fail(`Command not found on PATH: ${cmd}`, 500));
|
|
219
|
+
|
|
220
|
+
let file = resolved;
|
|
221
|
+
let argv = args;
|
|
222
|
+
const spawnOpts = {
|
|
223
|
+
cwd: opts.cwd || undefined,
|
|
224
|
+
timeout: opts.timeout,
|
|
225
|
+
windowsHide: true,
|
|
226
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
230
|
+
if (IS_WIN && (ext === '.cmd' || ext === '.bat')) {
|
|
231
|
+
const exe = exeBehindShim(resolved);
|
|
232
|
+
if (exe) {
|
|
233
|
+
file = exe;
|
|
234
|
+
} else {
|
|
235
|
+
for (const arg of [resolved, ...args]) {
|
|
236
|
+
if (WIN_CMD_FORBIDDEN.test(String(arg))) {
|
|
237
|
+
return reject(fail('Argument contains a character that cannot be passed safely', 400));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const line = [resolved, ...args].map((a) => `"${a}"`).join(' ');
|
|
241
|
+
file = process.env.ComSpec || 'cmd.exe';
|
|
242
|
+
argv = ['/d', '/s', '/c', `"${line}"`];
|
|
243
|
+
spawnOpts.windowsVerbatimArguments = true;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let child;
|
|
248
|
+
try {
|
|
249
|
+
child = spawn(file, argv, spawnOpts);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
return reject(err);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let stdout = '';
|
|
255
|
+
let stderr = '';
|
|
256
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
257
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
258
|
+
child.on('error', reject);
|
|
259
|
+
child.on('close', (code, signal) => {
|
|
260
|
+
if (signal) return reject(new Error(`Command terminated by ${signal}: ${stderr.trim()}`));
|
|
261
|
+
if (code !== 0) return reject(new Error(stderr.trim() || `Command exited with code ${code}`));
|
|
262
|
+
resolve({ stdout, stderr });
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = { assertOpenTarget, openInEditor, execNoShell, whichSync, splitCommandLine, exeBehindShim };
|
package/package.json
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-memory-explorer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Memory explorer dashboard for Claude Code",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"claude-code-memory-explorer": "
|
|
7
|
+
"claude-code-memory-explorer": "server.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node server.js",
|
|
11
11
|
"dev": "node server.js --open",
|
|
12
|
-
"prepare": "husky || true"
|
|
12
|
+
"prepare": "husky || true",
|
|
13
|
+
"test": "node --test test/*.test.js"
|
|
13
14
|
},
|
|
14
15
|
"engines": {
|
|
15
16
|
"node": ">=20.0.0"
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
"license": "MIT",
|
|
45
46
|
"files": [
|
|
46
47
|
"server.js",
|
|
48
|
+
"lib/",
|
|
47
49
|
"public/",
|
|
48
50
|
"README.md",
|
|
49
51
|
"LICENSE"
|