@sanqianx/project-knowledge 5.0.26 → 5.0.27

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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "project-knowledge",
12
- "version": "5.0.26",
12
+ "version": "5.0.27",
13
13
  "source": "./plugins/project-knowledge",
14
14
  "description": "Search durable project decisions and implementation history through MCP."
15
15
  }
@@ -0,0 +1,418 @@
1
+ 'use strict';
2
+ // _site/_test/helpers/qa-r44-env.js
3
+ //
4
+ // The R44 lane isolation helper (WB-006 / WB-007).
5
+ //
6
+ // Contract (from the lane dispatch):
7
+ // * the driver's FIRST action is to capture the real environment and the
8
+ // real tool paths, and then to harden the driver's OWN process.env, so
9
+ // that every child process it spawns inherits the isolation. The product
10
+ // is never bootstrapped under the real environment first;
11
+ // * HOME / USERPROFILE / HOMEDRIVE / HOMEPATH / APPDATA / LOCALAPPDATA /
12
+ // CODEX_HOME / CLAUDE_CONFIG_DIR / TEMP / TMP / TMPDIR are redirected;
13
+ // * KB_DATA_DIR and KB_KNOWLEDGE_DATA_DIR are redirected;
14
+ // * the Workbench data dir and the Bridge home are redirected;
15
+ // * the temp root lives OUTSIDE the repository;
16
+ // * the ambient module addresses and every ambient model credential are
17
+ // dropped, so a call can only reach the profile the product resolves
18
+ // itself, in memory;
19
+ // * the live product ports are refused.
20
+ //
21
+ // The real Chrome executable and the real Claude Code executable are captured
22
+ // BEFORE the environment is hardened (the R33 lesson: Chrome and the CLI are
23
+ // resolved from LOCALAPPDATA / APPDATA, and those are exactly the variables
24
+ // this lane isolates). They are re-published to the isolated environment as
25
+ // KB_CHROME_PATH and CLAUDE_CODE_EXECPATH, so the isolation never turns into
26
+ // a "tool not found" failure.
27
+ //
28
+ // Credential contract: no credential value is read, copied, logged, written
29
+ // to a fixture or reported. Only variable NAMES are ever recorded.
30
+ //
31
+ // R44C: the lane no longer depends on the older batch fixture. It uses the
32
+ // CANONICAL tracked fixture (helpers/parallel-workbench-integration-5025-
33
+ // fixture.js) — the same one the tracked runtime harness uses — so the lane's
34
+ // layout and the harness' child environment are built by one contract. The two
35
+ // directory-digest helpers the lane needs are implemented here, in the lane's
36
+ // own file, so the old fixture leaves the dependency closure entirely.
37
+ const fs = require('node:fs');
38
+ const os = require('node:os');
39
+ const path = require('node:path');
40
+ const crypto = require('node:crypto');
41
+ const { execFileSync } = require('node:child_process');
42
+ const fixture = require('./parallel-workbench-integration-5025-fixture');
43
+ const { findChromeDetailed } = require('./find-chrome');
44
+
45
+ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
46
+ const LANE_NAME = 'wb-permission-question-r44';
47
+
48
+ // Ambient module addresses that would point a lane at a live instance.
49
+ const AMBIENT_MODULE_URLS = [
50
+ 'KB_KNOWLEDGE_ENGINE_URL',
51
+ 'KB_VECTORHUB_URL',
52
+ 'KB_TERMINAL_URL',
53
+ 'KB_EVENTBRIDGE_URL',
54
+ 'KB_EVENT_BRIDGE_URL',
55
+ 'KB_WORKBENCH_RUNTIME_TOKEN',
56
+ 'KB_MODULES_AUTOSTART',
57
+ ];
58
+
59
+ // Ambient model credentials. A lane may only use the credential the product
60
+ // resolves from its own read-only profile store, in memory.
61
+ const AMBIENT_MODEL_ENV = [
62
+ 'ANTHROPIC_API_KEY',
63
+ 'ANTHROPIC_AUTH_TOKEN',
64
+ 'ANTHROPIC_BASE_URL',
65
+ 'ANTHROPIC_MODEL',
66
+ 'ANTHROPIC_DEFAULT_THINKING_MODEL',
67
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
68
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
69
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
70
+ 'API_TIMEOUT_MS',
71
+ 'CLAUDE_CODE_OAUTH_TOKEN',
72
+ 'CLAUDE_CODE_USE_BEDROCK',
73
+ 'CLAUDE_CODE_USE_VERTEX',
74
+ 'CLAUDE_CODE_USE_FOUNDRY',
75
+ ];
76
+
77
+ // The variables this lane redirects. Every one of them must point inside the
78
+ // lane's own run root once the driver is hardened.
79
+ const REDIRECTED_VARS = [
80
+ 'HOME',
81
+ 'USERPROFILE',
82
+ 'HOMEDRIVE',
83
+ 'HOMEPATH',
84
+ 'APPDATA',
85
+ 'LOCALAPPDATA',
86
+ 'CODEX_HOME',
87
+ 'CLAUDE_CONFIG_DIR',
88
+ 'TEMP',
89
+ 'TMP',
90
+ 'TMPDIR',
91
+ 'KB_DATA_DIR',
92
+ 'KB_KNOWLEDGE_DATA_DIR',
93
+ 'AGENT_TERMINAL_DATA_DIR',
94
+ 'CLAUDE_AI_WORKBENCH_DATA_DIR',
95
+ 'AI_CODING_EVENT_BRIDGE_HOME',
96
+ ];
97
+
98
+ function isInside(candidate, parent) {
99
+ const relative = path.relative(path.resolve(parent), path.resolve(candidate));
100
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
101
+ }
102
+
103
+ // The real profile store of the product. It is resolved while the REAL
104
+ // environment is still in place: this module is loaded before the driver
105
+ // hardens its own environment, and os.homedir() reads the REDIRECTED
106
+ // USERPROFILE once the isolation is applied. The value is therefore captured
107
+ // at load time and refreshed by the capture, never re-derived later.
108
+ let REAL_PROFILE_DIR = path.join(os.homedir(), '.project-knowledge-v5', 'workbench');
109
+ function realProfileDir() { return REAL_PROFILE_DIR; }
110
+
111
+ // The lane's own temp root, outside the repository and outside the real
112
+ // home. Portable: it follows the repository location, never a fixed drive.
113
+ function resolveLaneTempRoot() {
114
+ const override = String(process.env.QA_R44_TEMP_ROOT || '').trim();
115
+ const base = override
116
+ ? path.resolve(override)
117
+ : path.join(path.dirname(REPO_ROOT), '.project-knowledge-test-temp');
118
+ const root = path.join(base, LANE_NAME);
119
+ if (isInside(root, REPO_ROOT)) throw new Error(`refusing a lane temp root inside the repository: ${root}`);
120
+ fs.mkdirSync(root, { recursive: true });
121
+ return root;
122
+ }
123
+
124
+ // The real Claude Code executable, resolved the way the product resolves it
125
+ // but WITHOUT loading the product module (loading it would boot its own
126
+ // session housekeeping inside the driver).
127
+ function findClaudeExecutable() {
128
+ const rejected = [];
129
+ const push = (file, source) => {
130
+ if (!file) return;
131
+ try {
132
+ if (!fs.statSync(file).isFile()) { rejected.push({ path: file, reason: 'not a file' }); return; }
133
+ } catch { rejected.push({ path: file, reason: 'missing' }); return; }
134
+ return { path: path.resolve(file), source };
135
+ };
136
+ const override = String(process.env.CLAUDE_CODE_EXECPATH || '').trim();
137
+ if (override) {
138
+ const found = push(override, 'environment');
139
+ if (found) return found;
140
+ }
141
+ const packageDirs = [];
142
+ if (process.env.APPDATA) packageDirs.push(path.join(process.env.APPDATA, 'npm', 'node_modules', '@anthropic-ai', 'claude-code'));
143
+ for (const dir of packageDirs) {
144
+ try {
145
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
146
+ const bin = typeof pkg.bin === 'string' ? pkg.bin : (pkg.bin && pkg.bin.claude) || null;
147
+ if (bin) {
148
+ const entry = path.join(dir, ...String(bin).split(/[\\/]+/).filter(Boolean));
149
+ const found = push(entry, 'npm-global-package');
150
+ if (found) return found;
151
+ }
152
+ } catch { /* the package is simply not there */ }
153
+ const found = push(path.join(dir, 'bin', 'claude.exe'), 'npm-global-package');
154
+ if (found) return found;
155
+ }
156
+ const userProfile = process.env.USERPROFILE || '';
157
+ if (userProfile) {
158
+ const found = push(path.join(userProfile, '.local', 'bin', 'claude.exe'), 'user-local-bin');
159
+ if (found) return found;
160
+ }
161
+ if (process.env.LOCALAPPDATA) {
162
+ for (const candidate of [
163
+ path.join(process.env.LOCALAPPDATA, 'Programs', 'claude-code', 'claude.exe'),
164
+ path.join(process.env.LOCALAPPDATA, 'claude-code', 'claude.exe'),
165
+ ]) {
166
+ const found = push(candidate, 'localappdata');
167
+ if (found) return found;
168
+ }
169
+ }
170
+ for (const dir of String(process.env.PATH || '').split(path.delimiter)) {
171
+ if (!dir) continue;
172
+ const found = push(path.join(dir, 'claude.exe'), 'path');
173
+ if (found) return found;
174
+ }
175
+ const whereExe = process.env.WINDIR ? path.join(process.env.WINDIR, 'System32', 'where.exe') : 'where.exe';
176
+ try {
177
+ const stdout = execFileSync(whereExe, ['claude'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5000 });
178
+ for (const hit of String(stdout).split(/\r?\n/).map(entry => entry.trim()).filter(Boolean)) {
179
+ const found = push(hit, 'where');
180
+ if (found) return found;
181
+ }
182
+ } catch { /* where.exe found nothing */ }
183
+ throw new Error(`Claude Code executable not found for the ${LANE_NAME} lane; rejected: ${JSON.stringify(rejected)}`);
184
+ }
185
+
186
+ // The real environment, captured BEFORE anything is hardened.
187
+ function captureRealEnvironment() {
188
+ const realHome = os.homedir();
189
+ const realUserProfile = process.env.USERPROFILE || realHome;
190
+ const chrome = findChromeDetailed();
191
+ const claude = findClaudeExecutable();
192
+ const present = (name) => Object.keys(process.env).some(key => key.toUpperCase() === name && String(process.env[key] || '').trim() !== '');
193
+ REAL_PROFILE_DIR = path.join(realHome, '.project-knowledge-v5', 'workbench');
194
+ return {
195
+ realHome,
196
+ realUserProfile,
197
+ realProfileDir: REAL_PROFILE_DIR,
198
+ chrome: { path: chrome.path, source: chrome.source },
199
+ claude: { path: claude.path, source: claude.source },
200
+ ambientModuleUrlsPresent: AMBIENT_MODULE_URLS.filter(present),
201
+ ambientCredentialsPresent: AMBIENT_MODEL_ENV.filter(present),
202
+ };
203
+ }
204
+
205
+ // The lane's own isolation layout. It reuses the canonical fixture layout so
206
+ // the frozen host helper can host it unchanged, and adds the directories this
207
+ // lane has to isolate on its own.
208
+ function createLaneLayout() {
209
+ const laneTempRoot = resolveLaneTempRoot();
210
+ // The canonical fixture resolves its temp root from this variable, so the
211
+ // lane root is published BEFORE the layout is created: the whole run then
212
+ // lives under one lane-owned directory outside the repository.
213
+ process.env.PK_TEST_TEMP_ROOT = laneTempRoot;
214
+ const layout = fixture.createIsolationLayout('qa-r44');
215
+ layout.laneTempRoot = laneTempRoot;
216
+ layout.logs = path.join(laneTempRoot, 'logs');
217
+ fs.mkdirSync(layout.logs, { recursive: true });
218
+ // Chrome refuses remote debugging when the profile root it resolves is
219
+ // missing, so the empty scaffold is created (the R33 lesson). The canonical
220
+ // fixture already creates the home-shaped one; the LOCALAPPDATA-shaped one
221
+ // is kept because the lane redirects LOCALAPPDATA as well.
222
+ for (const scaffold of [
223
+ path.join(layout.localAppData, 'Google', 'Chrome', 'User Data'),
224
+ path.join(layout.home, 'AppData', 'Local', 'Google', 'Chrome', 'User Data'),
225
+ ]) {
226
+ fs.mkdirSync(scaffold, { recursive: true });
227
+ }
228
+ return layout;
229
+ }
230
+
231
+ // Harden the DRIVER's own environment. Every child process this driver
232
+ // spawns afterwards inherits it; nothing is bootstrapped under the real
233
+ // environment first.
234
+ function hardenDriverEnv(layout, captured) {
235
+ const parsed = path.parse(layout.home);
236
+ const redirected = {
237
+ HOME: layout.home,
238
+ USERPROFILE: layout.home,
239
+ HOMEDRIVE: parsed.root,
240
+ HOMEPATH: parsed.root === path.sep ? layout.home.slice(1) : layout.home.slice(parsed.root.length),
241
+ APPDATA: layout.appData,
242
+ LOCALAPPDATA: layout.localAppData,
243
+ CODEX_HOME: layout.codexHome,
244
+ CLAUDE_CONFIG_DIR: layout.claudeConfigDir,
245
+ TEMP: layout.temp,
246
+ TMP: layout.temp,
247
+ TMPDIR: layout.temp,
248
+ KB_DATA_DIR: layout.knowledgeDataDir,
249
+ KB_KNOWLEDGE_DATA_DIR: layout.knowledgeDataDir,
250
+ AGENT_TERMINAL_DATA_DIR: layout.dataDir,
251
+ CLAUDE_AI_WORKBENCH_DATA_DIR: layout.dataDir,
252
+ AI_CODING_EVENT_BRIDGE_HOME: layout.bridgeHome,
253
+ };
254
+ const droppedModuleUrls = [];
255
+ const droppedCredentials = [];
256
+ for (const name of AMBIENT_MODULE_URLS) {
257
+ if (process.env[name] !== undefined) { delete process.env[name]; droppedModuleUrls.push(name); }
258
+ }
259
+ for (const name of AMBIENT_MODEL_ENV) {
260
+ if (process.env[name] !== undefined) { delete process.env[name]; droppedCredentials.push(name); }
261
+ }
262
+ for (const [name, value] of Object.entries(redirected)) process.env[name] = value;
263
+ // The isolated fixture layout is the temp root of the frozen helper too.
264
+ process.env.PK_TEST_TEMP_ROOT = layout.laneTempRoot;
265
+ process.env.KB_MODULES_AUTOSTART = '0';
266
+ // The real tools are captured before the isolation and re-published as
267
+ // absolute paths, so isolating APPDATA / LOCALAPPDATA never hides them.
268
+ process.env.KB_CHROME_PATH = captured.chrome.path;
269
+ process.env.CLAUDE_CODE_EXECPATH = captured.claude.path;
270
+ // The canonical harness refuses to discover a browser from an isolated
271
+ // environment: it wants the path that was captured BEFORE the isolation.
272
+ // It is published under both names its callers accept.
273
+ process.env.PK_WB_CHROME = captured.chrome.path;
274
+ // The product resolves the ORIGINAL profile store from the real home; the
275
+ // isolated HOME would otherwise hide it. The path is a location, not a
276
+ // credential: the store itself is opened read-only by the product.
277
+ process.env.PK_WB_REAL_PROFILE_DIR = captured.realProfileDir;
278
+ return {
279
+ redirected: Object.keys(redirected),
280
+ redirectedValues: redirected,
281
+ droppedModuleUrls,
282
+ droppedCredentials,
283
+ republishedTools: { KB_CHROME_PATH: captured.chrome.path, PK_WB_CHROME: captured.chrome.path, CLAUDE_CODE_EXECPATH: captured.claude.path },
284
+ preservedRealProfileDir: captured.realProfileDir,
285
+ };
286
+ }
287
+
288
+ // Fail-closed check of the hardened environment.
289
+ function assertIsolatedEnv(env, layout, captured) {
290
+ const problems = [];
291
+ for (const name of REDIRECTED_VARS) {
292
+ const value = env[name];
293
+ if (!value) { problems.push(`${name} is not set`); continue; }
294
+ // HOMEDRIVE and HOMEPATH are one Windows pair: they are checked as a
295
+ // whole below, not as two independent directories.
296
+ if (name === 'HOMEDRIVE' || name === 'HOMEPATH') continue;
297
+ if (!isInside(value, layout.root)) problems.push(`${name} points outside the lane run root: ${value}`);
298
+ }
299
+ if (env.HOMEDRIVE && env.HOMEPATH) {
300
+ const combined = path.resolve(`${env.HOMEDRIVE}${env.HOMEPATH}`);
301
+ if (!isInside(combined, layout.root)) problems.push(`HOMEDRIVE+HOMEPATH points outside the lane run root: ${combined}`);
302
+ else if (env.HOME && path.resolve(combined).toLowerCase() !== path.resolve(env.HOME).toLowerCase()) {
303
+ problems.push(`HOMEDRIVE+HOMEPATH and HOME disagree: ${combined} vs ${env.HOME}`);
304
+ }
305
+ } else {
306
+ problems.push('HOMEDRIVE and HOMEPATH are not both set');
307
+ }
308
+ if (env.USERPROFILE && captured.realUserProfile && path.resolve(env.USERPROFILE).toLowerCase() === path.resolve(captured.realUserProfile).toLowerCase()) {
309
+ problems.push('USERPROFILE still points at the real user profile');
310
+ }
311
+ if (env.HOME && captured.realHome && path.resolve(env.HOME).toLowerCase() === path.resolve(captured.realHome).toLowerCase()) {
312
+ problems.push('HOME still points at the real home');
313
+ }
314
+ for (const name of AMBIENT_MODEL_ENV) {
315
+ if (env[name] !== undefined && String(env[name] || '').trim() !== '') problems.push(`credential variable survived: ${name}`);
316
+ }
317
+ for (const name of AMBIENT_MODULE_URLS) {
318
+ if (name === 'KB_MODULES_AUTOSTART') continue;
319
+ if (env[name] !== undefined && String(env[name] || '').trim() !== '') problems.push(`ambient module address survived: ${name}`);
320
+ }
321
+ if (path.resolve(env.PK_TEST_TEMP_ROOT || '') === path.resolve(REPO_ROOT)) problems.push('the lane temp root is the repository root');
322
+ if (isInside(env.PK_TEST_TEMP_ROOT || '', REPO_ROOT)) problems.push('the lane temp root is inside the repository');
323
+ if (problems.length) throw new Error(`the lane environment is not isolated:\n - ${problems.join('\n - ')}`);
324
+ return true;
325
+ }
326
+
327
+ // A report-friendly summary. It carries paths and variable NAMES only; no
328
+ // credential value is ever read, copied or printed.
329
+ function isolationContract(layout, captured, hardened) {
330
+ return {
331
+ lane: LANE_NAME,
332
+ repoRoot: REPO_ROOT,
333
+ runRoot: layout.root,
334
+ laneTempRoot: layout.laneTempRoot,
335
+ tempRootOutsideRepository: !isInside(layout.laneTempRoot, REPO_ROOT),
336
+ redirectedVariables: hardened.redirected,
337
+ redirectedValues: hardened.redirectedValues,
338
+ droppedAmbientModuleUrls: hardened.droppedModuleUrls,
339
+ droppedAmbientCredentials: hardened.droppedCredentials,
340
+ ambientModuleUrlsPresentBefore: captured.ambientModuleUrlsPresent,
341
+ ambientCredentialsPresentBefore: captured.ambientCredentialsPresent,
342
+ republishedTools: hardened.republishedTools,
343
+ realProfileDirectory: captured.realProfileDir,
344
+ realProfileOpened: 'read-only by the product; the credential stays in the host process memory',
345
+ forbiddenPorts: Array.from(fixture.FORBIDDEN_PORTS),
346
+ realHomeUsed: false,
347
+ };
348
+ }
349
+
350
+ // Explicit containment: every path a lane tool touches must live under
351
+ // `root`. No globbing, no traversal, no absolute path outside the root.
352
+ function containedPath(root, rel) {
353
+ const target = path.resolve(root, ...String(rel).split(/[\\/]+/).filter(Boolean));
354
+ const relative = path.relative(path.resolve(root), target);
355
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
356
+ throw new Error(`path escapes its root: ${rel}`);
357
+ }
358
+ return target;
359
+ }
360
+
361
+ // The byte-level digest of every file of a directory. Only the NAMES and the
362
+ // digests are kept; the content is never copied anywhere. The comparison is
363
+ // done in memory and the digests of a real profile store are never printed.
364
+ function digestDirectory(dir) {
365
+ if (!dir || !fs.existsSync(dir)) return null;
366
+ const entries = {};
367
+ for (const name of fs.readdirSync(dir)) {
368
+ try {
369
+ const file = path.join(dir, name);
370
+ if (!fs.statSync(file).isFile()) continue;
371
+ entries[name] = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
372
+ } catch { entries[name] = null; }
373
+ }
374
+ return entries;
375
+ }
376
+
377
+ function compareDigests(before, after) {
378
+ const names = new Set([...Object.keys(before || {}), ...Object.keys(after || {})]);
379
+ const changed = [];
380
+ const added = [];
381
+ const removed = [];
382
+ const unreadable = [];
383
+ for (const name of names) {
384
+ const a = before ? before[name] : undefined;
385
+ const b = after ? after[name] : undefined;
386
+ if (a === undefined && b !== undefined) added.push(name);
387
+ else if (a !== undefined && b === undefined) removed.push(name);
388
+ else if (a === null || b === null) unreadable.push(name);
389
+ else if (a !== b) changed.push(name);
390
+ }
391
+ return {
392
+ count: names.size,
393
+ byteIdentical: changed.length === 0 && added.length === 0 && removed.length === 0 && unreadable.length === 0,
394
+ changedFiles: changed,
395
+ addedFiles: added,
396
+ removedFiles: removed,
397
+ unreadableFiles: unreadable,
398
+ };
399
+ }
400
+
401
+ module.exports = {
402
+ LANE_NAME,
403
+ REPO_ROOT,
404
+ REDIRECTED_VARS,
405
+ AMBIENT_MODULE_URLS,
406
+ AMBIENT_MODEL_ENV,
407
+ isInside,
408
+ realProfileDir,
409
+ resolveLaneTempRoot,
410
+ captureRealEnvironment,
411
+ createLaneLayout,
412
+ hardenDriverEnv,
413
+ assertIsolatedEnv,
414
+ isolationContract,
415
+ containedPath,
416
+ digestDirectory,
417
+ compareDigests,
418
+ };