@sanqianx/project-knowledge 5.0.26 → 5.0.28
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/.claude-plugin/marketplace.json +1 -1
- package/_modules/knowledge-engine/README.md +2 -0
- package/_modules/knowledge-engine/package.json +1 -1
- package/_modules/knowledge-engine/src/memories.js +50 -5
- package/_modules/knowledge-engine/src/server.js +5 -0
- package/_modules/knowledge-engine/src/store.js +12 -2
- package/_site/_test/helpers/qa-r44-env.js +418 -0
- package/_site/_test/helpers/qa-r44-report.js +656 -0
- package/_site/_test/helpers/qa-r44-ui.js +347 -0
- package/_site/_test/helpers/qa-r49-wb-host.js +58 -0
- package/_site/_test/helpers/qa-r91-harness.cjs +531 -0
- package/_site/_test/qa-r44-permission-question.js +756 -0
- package/_site/_test/qa-r49-sys003-real-blank-probe.js +374 -0
- package/module-runtime-manifest.json +35 -32
- package/package.json +2 -2
- package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.mcp.json +1 -1
- package/ui/index.html +1 -0
- package/ui/product.css +1 -1
- package/ui/product.js +28 -2
- package/vendor-manifest.json +7 -7
|
@@ -69,6 +69,7 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
|
|
|
69
69
|
| POST | `/api/bridge/notify` | 唤醒 journal 消费;通知丢失仍由轮询恢复 |
|
|
70
70
|
| GET / PATCH | `/api/memories`、`/api/memories/:id` | 按 status/scopeType/scopeId 筛选;一次编辑内容、适用范围和确认状态;来源证据只读 |
|
|
71
71
|
| POST | `/api/memories` | `{ text, brainId?, scopeType?, scopeId? }` → 手动添加已确认记忆;默认全局,领域/项目必须指定已登记目标,不接受伪造来源 |
|
|
72
|
+
| DELETE | `/api/memories/:id` | 软删除为无正文墓碑:默认列表与新任务不再使用,原始对话与知识来源保留;重复删除返回 404;完整契约见下方记忆删除边界 |
|
|
72
73
|
| POST | `/api/scopes` | 外壳投影智脑、领域共享目录;受运行期鉴权保护 |
|
|
73
74
|
| POST | `/api/runtime-config` | 运行期模型配置,仅保留在内存;受鉴权保护 |
|
|
74
75
|
| GET | `/api/runtime-auth` | 本机服务确认运行期鉴权;受鉴权保护,不对浏览器反代 |
|
|
@@ -109,6 +110,7 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
|
|
|
109
110
|
- 长期记忆统一保存在已绑定的智脑共享目录:`memories/confirmed.md`(全局)、`memories/domains/<id>.md`、`memories/projects/<id>.md`。不创建第二份项目知识目录。现有 v5 缺少范围字段的记忆仍按全局解释;不读取 pre-v5 状态、不重扫历史。
|
|
110
111
|
- 手动记忆以用户确认作为来源,记录 `sourceType: manual`、`evidence: null`,不伪造提交或分析运行。内容限制为 1–2000 字符,同范围内容去重;创建、编辑、投影与恢复复用引擎受控写入。空项目列表也可添加全局记忆,但必须已绑定共享目录;无需模型分析或 Git 提交,索引状态独立报告。
|
|
111
112
|
- 任务检索只允许当前项目、当前所属领域与全局的已确认记忆,即使检索整个智脑也不放宽专属记忆范围。过滤在排名之前完成,`get` 同样校验;向量旧内容不能绕过当前状态和文件一致性校验。已确认记录修改范围会同步清理旧投影;写入失败显示待同步并自动重试,未同步文件不能进入上下文。
|
|
113
|
+
- 删除记忆写入无正文墓碑记录:删除后默认列表与新任务上下文不再使用该记忆,原始对话与知识来源保持保留;同文本的自动候选按墓碑去重不会复活,同一适用范围仍可手动重加;重复删除返回 404;派生记忆文件写入失败时如实记录待同步并自动重试,不伪造一致状态。
|
|
112
114
|
- 旧格式不读取、不自动迁移。损坏的正式状态不覆盖、不清空。
|
|
113
115
|
- 引擎状态复用受控原子写入:独占随机临时文件、文件同步、Windows 短暂共享冲突有界重试;永久失败保留原状态并清理本次临时文件,绝不先删正式状态。
|
|
114
116
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanqianx/ai-coding-knowledge-engine",
|
|
3
|
-
"version": "0.2.7-rc.
|
|
3
|
+
"version": "0.2.7-rc.12",
|
|
4
4
|
"description": "Knowledge engine module for project-knowledge: commit-boundary triggered knowledge analysis/generation (trigger plane) and retrieval for development assistants (retrieval plane).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"main": "src/server.js",
|
|
@@ -96,7 +96,7 @@ class MemoryService {
|
|
|
96
96
|
}
|
|
97
97
|
list({ brainId, status, scopeType, scopeId } = {}) {
|
|
98
98
|
if (scopeType && !TYPES.includes(scopeType)) throw new DomainError('INVALID_ARGUMENT', '记忆筛选范围无效。');
|
|
99
|
-
return this.store.state.memories.filter(m => (!brainId || m.brainId === brainId) && (!status || m.status === status) && (!scopeType || this.scopeOf(m).type === scopeType) && (!scopeId || this.scopeOf(m).id === scopeId)).map(m => this.project(m));
|
|
99
|
+
return this.store.state.memories.filter(m => (m.status !== 'deleted' || status === 'deleted') && (!brainId || m.brainId === brainId) && (!status || m.status === status) && (!scopeType || this.scopeOf(m).type === scopeType) && (!scopeId || this.scopeOf(m).id === scopeId)).map(m => this.project(m));
|
|
100
100
|
}
|
|
101
101
|
capture(run) {
|
|
102
102
|
const report = { accepted: 0, deduplicated: 0, rejected: [] };
|
|
@@ -123,7 +123,15 @@ class MemoryService {
|
|
|
123
123
|
const scopeType = raw.scopeType;
|
|
124
124
|
const scopeId = scopeType === 'global' ? brainId : project.domainId;
|
|
125
125
|
const key = fingerprint(brainId, scopeType, scopeId, text);
|
|
126
|
-
|
|
126
|
+
// Tombstones never hold text, so they dedup through their fingerprints:
|
|
127
|
+
// the fresh pair covers the record's last scope and text, while the kept
|
|
128
|
+
// original capture key still covers a pre-edit/pre-move scope.
|
|
129
|
+
const duplicateOf = m => {
|
|
130
|
+
if (m.captureFingerprint === key) return true;
|
|
131
|
+
if (m.status === 'deleted') return m.fingerprint === key || m.originalCaptureFingerprint === key;
|
|
132
|
+
return fingerprint(m.brainId || 'brain-default', this.scopeOf(m).type, this.scopeOf(m).id, m.text) === key || !m.scopeType && m.evidence?.projectId === project.projectId && normalized(m.originalText || m.text) === normalized(text);
|
|
133
|
+
};
|
|
134
|
+
if (next.some(duplicateOf)) { report.deduplicated++; continue; }
|
|
127
135
|
next.push({ id: `memory-${crypto.randomUUID()}`, fingerprint: key, captureFingerprint: key, brainId, domainId: project.domainId || null, scopeType, scopeId, text, originalText: text, status: 'candidate', sourceEventId: provenance.sourceEventId, sourceHash: provenance.sourceHash, eligibility: provenance.eligibility, evidence: { projectId: project.projectId, commitSha: run.commitSha, runId: run.runId, entryId, quote }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
|
|
128
136
|
report.accepted++;
|
|
129
137
|
}
|
|
@@ -145,7 +153,9 @@ class MemoryService {
|
|
|
145
153
|
this.validateScope(type, targetId, brainId);
|
|
146
154
|
if (!this.brainScope(brainId)) throw new DomainError('INVALID_ARGUMENT', '请先绑定智脑共享目录。', { status: 409 });
|
|
147
155
|
const text = input.text.trim(), key = fingerprint(brainId, type, targetId, text);
|
|
148
|
-
|
|
156
|
+
// An explicit manual add is the user changing their mind after a delete:
|
|
157
|
+
// tombstones never block it, only another live record in the same scope does.
|
|
158
|
+
if (this.store.state.memories.some(m => m.status !== 'deleted' && fingerprint(m.brainId || 'brain-default', this.scopeOf(m).type, this.scopeOf(m).id, m.text) === key)) throw new DomainError('MEMORY_DUPLICATE', '该范围内已存在相同记忆。', { status: 409 });
|
|
149
159
|
// User confirmation is the provenance; never invent a model run or Git commit.
|
|
150
160
|
const now = new Date().toISOString();
|
|
151
161
|
const memory = { id: `memory-${crypto.randomUUID()}`, fingerprint: key, brainId, scopeType: type, scopeId: targetId, sourceType: 'manual', text, originalText: text, status: 'confirmed', evidence: null, createdAt: now, updatedAt: now };
|
|
@@ -162,7 +172,7 @@ class MemoryService {
|
|
|
162
172
|
if (Object.keys(input).some(key => !['text', 'status', 'scopeType', 'scopeId'].includes(key))) throw new DomainError('INVALID_ARGUMENT', '来源证据和文件路径不能修改。');
|
|
163
173
|
const job = AtomicFile.withFileLock(this.lock(), async () => {
|
|
164
174
|
const memory = this.store.state.memories.find(m => m.id === id);
|
|
165
|
-
if (!memory) throw new DomainError('PROJECT_NOT_FOUND', '记忆不存在。', { status: 404 });
|
|
175
|
+
if (!memory || memory.status === 'deleted') throw new DomainError('PROJECT_NOT_FOUND', '记忆不存在。', { status: 404 });
|
|
166
176
|
if (input.text !== undefined && (typeof input.text !== 'string' || !input.text.trim() || input.text.length > 2000)) throw new DomainError('INVALID_ARGUMENT', '记忆内容必须为 1–2000 个字符。');
|
|
167
177
|
if (input.status !== undefined && !['confirmed', 'ignored'].includes(input.status)) throw new DomainError('INVALID_ARGUMENT', '记忆状态无效。');
|
|
168
178
|
if (memory.status === 'ignored') throw new DomainError('INVALID_ARGUMENT', '该候选已忽略。', { status: 409 });
|
|
@@ -172,7 +182,7 @@ class MemoryService {
|
|
|
172
182
|
if (input.status !== 'ignored' || input.scopeType !== undefined || input.scopeId !== undefined) this.validateScope(type, targetId, brainId);
|
|
173
183
|
if ((input.status || memory.status) === 'confirmed' && !this.brainScope(brainId)) throw new DomainError('INVALID_ARGUMENT', '请先绑定智脑共享目录。', { status: 409 });
|
|
174
184
|
const text = input.text?.trim() || memory.text, key = fingerprint(brainId, type, targetId, text);
|
|
175
|
-
if (this.store.state.memories.some(m => m.id !== id && fingerprint(m.brainId || 'brain-default', this.scopeOf(m).type, this.scopeOf(m).id, m.text) === key)) throw new DomainError('MEMORY_DUPLICATE', '该范围内已存在相同记忆。', { status: 409 });
|
|
185
|
+
if (this.store.state.memories.some(m => m.id !== id && m.status !== 'deleted' && fingerprint(m.brainId || 'brain-default', this.scopeOf(m).type, this.scopeOf(m).id, m.text) === key)) throw new DomainError('MEMORY_DUPLICATE', '该范围内已存在相同记忆。', { status: 409 });
|
|
176
186
|
const next = { ...memory, originalText: memory.originalText || memory.text, captureFingerprint: memory.captureFingerprint || fingerprint(brainId, old.type, old.id, memory.originalText || memory.text), fingerprint: key, scopeType: type, scopeId: targetId, text, status: input.status || memory.status, updatedAt: new Date().toISOString() };
|
|
177
187
|
const before = this.store.state.memories;
|
|
178
188
|
this.store.state.memories = before.map(m => m.id === id ? next : m);
|
|
@@ -182,6 +192,41 @@ class MemoryService {
|
|
|
182
192
|
this.writes.add(job);
|
|
183
193
|
try { return await job; } finally { this.writes.delete(job); }
|
|
184
194
|
}
|
|
195
|
+
async remove(id) {
|
|
196
|
+
if (this.closed) throw new DomainError('PROJECT_BUSY', '引擎正在关闭,请稍后重试。', { status: 409 });
|
|
197
|
+
if (typeof id !== 'string' || !id.trim()) throw new DomainError('INVALID_ARGUMENT', '记忆 ID 无效。');
|
|
198
|
+
const job = AtomicFile.withFileLock(this.lock(), async () => {
|
|
199
|
+
const memory = this.store.state.memories.find(m => m.id === id);
|
|
200
|
+
if (!memory || memory.status === 'deleted') throw new DomainError('PROJECT_NOT_FOUND', '记忆不存在。', { status: 404 });
|
|
201
|
+
const brainId = memory.brainId || 'brain-default', scope = this.scopeOf(memory);
|
|
202
|
+
// A tombstone keeps only the dedup fingerprints and audit metadata; the
|
|
203
|
+
// text and quote are dropped so a deleted memory never returns through
|
|
204
|
+
// lists, markdown, retrieval or context injection, and the fingerprints
|
|
205
|
+
// stop the same candidate from coming back on the next capture. Legacy
|
|
206
|
+
// records may predate originalText/captureFingerprint/createdAt, so both
|
|
207
|
+
// keys are always computed from the live record, and a stored original
|
|
208
|
+
// capture key from before an edit or scope move is preserved separately.
|
|
209
|
+
const now = new Date().toISOString();
|
|
210
|
+
const captureKey = fingerprint(brainId, scope.type, scope.id, memory.originalText || memory.text);
|
|
211
|
+
const originalCaptureKey = typeof memory.captureFingerprint === 'string' && /^[a-f0-9]{64}$/.test(memory.captureFingerprint) && memory.captureFingerprint !== captureKey ? memory.captureFingerprint : null;
|
|
212
|
+
const tombstone = {
|
|
213
|
+
id: memory.id, brainId, scopeType: scope.type, scopeId: scope.id,
|
|
214
|
+
...(memory.sourceType !== undefined ? { sourceType: memory.sourceType } : {}),
|
|
215
|
+
status: 'deleted',
|
|
216
|
+
fingerprint: fingerprint(brainId, scope.type, scope.id, memory.text),
|
|
217
|
+
captureFingerprint: captureKey,
|
|
218
|
+
...(originalCaptureKey ? { originalCaptureFingerprint: originalCaptureKey } : {}),
|
|
219
|
+
evidence: memory.evidence ? { projectId: memory.evidence.projectId, commitSha: memory.evidence.commitSha, runId: memory.evidence.runId || null, entryId: memory.evidence.entryId || '' } : null,
|
|
220
|
+
createdAt: typeof memory.createdAt === 'string' && Number.isFinite(Date.parse(memory.createdAt)) ? memory.createdAt : now, updatedAt: now, deletedAt: now,
|
|
221
|
+
};
|
|
222
|
+
const before = this.store.state.memories;
|
|
223
|
+
this.store.state.memories = before.map(m => m.id === id ? tombstone : m);
|
|
224
|
+
try { this.store.save(); } catch (error) { this.store.state.memories = before; throw error; }
|
|
225
|
+
await this.materialize(); return this.project(tombstone);
|
|
226
|
+
});
|
|
227
|
+
this.writes.add(job);
|
|
228
|
+
try { return await job; } finally { this.writes.delete(job); }
|
|
229
|
+
}
|
|
185
230
|
content(brainId, entry = 'memories/confirmed.md') {
|
|
186
231
|
return '# 已确认的长期记忆\n\n' + this.confirmed(brainId, entry).map(m => {
|
|
187
232
|
const scope = this.scopeOf(m);
|
|
@@ -115,6 +115,11 @@ async function api(method, pathname, search, body, store, coordinator, ctx) {
|
|
|
115
115
|
coordinator.emit('progress', store.addEvent({ type: 'memory.updated', summary: memory.status === 'confirmed' ? '已确认长期记忆' : memory.status === 'ignored' ? '已忽略记忆候选' : '已编辑记忆候选' }));
|
|
116
116
|
return { memory };
|
|
117
117
|
}
|
|
118
|
+
if (method === 'DELETE' && memoryMatch) {
|
|
119
|
+
const memory = await coordinator.memories.remove(decodeURIComponent(memoryMatch[1]));
|
|
120
|
+
coordinator.emit('progress', store.addEvent({ type: 'memory.deleted', summary: '已删除长期记忆' }));
|
|
121
|
+
return { memory };
|
|
122
|
+
}
|
|
118
123
|
if (method === 'POST' && pathname === '/api/projects') {
|
|
119
124
|
const result = store.addProject(body);
|
|
120
125
|
if (!result.reused) store.addEvent({ type: 'project.register', projectId: body.projectId, summary: `登记 ${result.project.name}` });
|
|
@@ -40,8 +40,18 @@ class EngineStore {
|
|
|
40
40
|
for (const [journalId, sequence] of Object.entries(raw.journalCursors || {})) if (!/^(global|sha256:[a-f0-9]{64})$/.test(journalId) || !Number.isSafeInteger(sequence) || sequence < 0) throw new Error('Invalid journal cursor');
|
|
41
41
|
for (const scope of raw.sharedScopes || []) if (!scope || !['brain', 'domain'].includes(scope.kind) || !path.isAbsolute(scope.knowledgePath || '')) throw new Error('Invalid shared scope');
|
|
42
42
|
for (const memory of raw.memories || []) {
|
|
43
|
-
if (!memory || !['candidate', 'confirmed', 'ignored'].includes(memory.status) ||
|
|
44
|
-
if (memory.
|
|
43
|
+
if (!memory || !['candidate', 'confirmed', 'ignored', 'deleted'].includes(memory.status) || memory.sourceType !== undefined && !['manual', 'agent'].includes(memory.sourceType)) throw new Error('Invalid memory record');
|
|
44
|
+
if (memory.status === 'deleted') {
|
|
45
|
+
// Tombstones carry no text: the dedup fingerprint and audit metadata
|
|
46
|
+
// are the whole record, so validate those instead of the live shape.
|
|
47
|
+
validateProjectId(memory.brainId);
|
|
48
|
+
// R101: the `memory-` prefix is only a generation convention; records
|
|
49
|
+
// written before it (any stable non-empty string id, ASCII or
|
|
50
|
+
// Unicode) load and delete legally, and the id never reaches a file
|
|
51
|
+
// path — so the tombstone validates the same id contract they had.
|
|
52
|
+
if (typeof memory.id !== 'string' || !memory.id || typeof memory.fingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(memory.fingerprint) || memory.captureFingerprint !== undefined && !/^[a-f0-9]{64}$/.test(memory.captureFingerprint) || memory.originalCaptureFingerprint !== undefined && !/^[a-f0-9]{64}$/.test(memory.originalCaptureFingerprint) || memory.evidence !== null && (!memory.evidence.projectId || !/^[a-f0-9]{40}$/.test(memory.evidence.commitSha || '')) || !Number.isFinite(Date.parse(memory.createdAt)) || !Number.isFinite(Date.parse(memory.deletedAt))) throw new Error('Invalid deleted memory record');
|
|
53
|
+
} else if (typeof memory.text !== 'string') throw new Error('Invalid memory record');
|
|
54
|
+
else if (memory.sourceType === 'manual') {
|
|
45
55
|
validateProjectId(memory.brainId);
|
|
46
56
|
if (memory.status === 'candidate' || !memory.text.trim() || memory.text.length > 2000 || !memory.scopeType || memory.evidence !== null || !Number.isFinite(Date.parse(memory.createdAt)) || !Number.isFinite(Date.parse(memory.updatedAt))) throw new Error('Invalid manual memory record');
|
|
47
57
|
} else if (!memory.evidence?.projectId || !/^[a-f0-9]{40}$/.test(memory.evidence.commitSha || '')) throw new Error('Invalid memory evidence');
|
|
@@ -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
|
+
};
|