@sanqianx/project-knowledge 5.0.16-rc.1 → 5.0.16-rc.2
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/README.md +5 -0
- package/_modules/knowledge-engine/README.md +2 -2
- package/_modules/knowledge-engine/package.json +1 -1
- package/_modules/knowledge-engine/src/retrieval.js +119 -24
- package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +80 -24
- package/_modules/knowledge-engine/src/runtime/knowledge-retrieval-service.js +8 -6
- package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +105 -64
- package/_site/_test/agent-capture-mcp-ui-test.js +67 -0
- package/_site/_test/agent-capture-ui-test.js +2 -1
- package/_site/_test/integration-adapters-test.js +7 -1
- package/_site/_test/integration-runtime-binding-a2-test.js +566 -0
- package/_site/_test/integration-runtime-binding-a3-test.js +227 -0
- package/_site/_test/integration-runtime-binding-test.js +473 -0
- package/_site/_test/native-context-integrations-test.js +1 -1
- package/_site/_test/release-version-sync-test.js +12 -1
- package/_site/lib/integration-installer.js +325 -8
- package/_site/lib/module-bridge.js +6 -2
- package/_site/scripts/sync-product-content.js +1 -1
- package/_site/scripts/sync-release-version.js +9 -5
- package/_site/scripts/verify-product-runtime.js +1 -1
- package/docs/leader-glm-workbench-playbook.md +87 -0
- package/docs/release-5.0.16-acceptance.md +53 -0
- package/module-runtime-manifest.json +29 -28
- package/package.json +5 -3
- 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 +7 -6
- package/plugins/project-knowledge/scripts/mcp-launcher.cjs +78 -0
- package/ui/product.js +3 -1
- package/vendor-manifest.json +8 -8
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
// Run: node _site/_test/integration-runtime-binding-a2-test.js
|
|
2
|
+
//
|
|
3
|
+
// A2 hardening regressions (must fail before the fixes land):
|
|
4
|
+
// - launcher pins the product package constant, joins KB_PLUGIN_VERSION into
|
|
5
|
+
// the version agreement, bounds binding reads, and never echoes untrusted
|
|
6
|
+
// values;
|
|
7
|
+
// - the .mcp.json bootstrap never guesses from process.cwd;
|
|
8
|
+
// - installer binding gating (captureOnly, failure isolation, byte
|
|
9
|
+
// idempotence, direct entries) and zero-write status/dry-run;
|
|
10
|
+
// - SDK-based probe contract (identity, tools, bounded stderr, no echo);
|
|
11
|
+
// - client-scoped mcpStartup probes with dedupe and explicit misses.
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { deepEqual } = require('node:assert/strict');
|
|
17
|
+
const { spawnSync } = require('node:child_process');
|
|
18
|
+
const { IntegrationManager } = require('../lib/integration-installer');
|
|
19
|
+
|
|
20
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
21
|
+
const PLUGIN_ROOT = path.join(ROOT, 'plugins', 'project-knowledge');
|
|
22
|
+
const LAUNCHER = path.join(PLUGIN_ROOT, 'scripts', 'mcp-launcher.cjs');
|
|
23
|
+
const MCP_MANIFEST = path.join(PLUGIN_ROOT, '.mcp.json');
|
|
24
|
+
const RUNTIME_SCHEMA = 'project-knowledge/integration-runtime/v1';
|
|
25
|
+
const ERROR_SCHEMA = 'project-knowledge/mcp-launcher-error/v1';
|
|
26
|
+
const PKG = '@sanqianx/project-knowledge';
|
|
27
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
|
|
28
|
+
const TEMP = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-a2-' + process.pid + '-'));
|
|
29
|
+
|
|
30
|
+
function assert(condition, message) { if (!condition) throw new Error(message); }
|
|
31
|
+
function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
32
|
+
function dir(name) { const target = path.join(TEMP, name); fs.mkdirSync(target, { recursive: true }); return target; }
|
|
33
|
+
function bindingPathOf(dataDir) { return path.join(dataDir, 'integration-runtime.json'); }
|
|
34
|
+
function writeBinding(dataDir, overrides = {}) {
|
|
35
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
36
|
+
fs.writeFileSync(bindingPathOf(dataDir), JSON.stringify({
|
|
37
|
+
schema: RUNTIME_SCHEMA, packageName: PKG, packageRoot: ROOT, version: VERSION,
|
|
38
|
+
updatedAt: new Date().toISOString(), ...overrides,
|
|
39
|
+
}, null, 2));
|
|
40
|
+
}
|
|
41
|
+
function runLauncher(dataDir, env = {}) {
|
|
42
|
+
return spawnSync(process.execPath, [LAUNCHER], {
|
|
43
|
+
encoding: 'utf8', windowsHide: true, timeout: 15000,
|
|
44
|
+
env: { ...process.env, KB_DATA_DIR: dataDir, ...env },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function launcherError(result, name) {
|
|
48
|
+
const lines = String(result.stderr || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
|
49
|
+
assert(lines.length === 1, name + ': exactly one stderr diagnostics line is expected, got ' + lines.length);
|
|
50
|
+
assert(result.status === 1, name + ': launcher failures must exit 1, got ' + result.status);
|
|
51
|
+
const payload = JSON.parse(lines[0]);
|
|
52
|
+
assert(payload.schema === ERROR_SCHEMA, name + ': error payload must carry the launcher error schema');
|
|
53
|
+
assert(String(result.stdout || '').trim() === '', name + ': stdout must stay clean on failure');
|
|
54
|
+
assert(typeof payload.message === 'string' && payload.message.length > 0 && payload.message.length < 500, name + ': fixed bounded message expected');
|
|
55
|
+
return payload;
|
|
56
|
+
}
|
|
57
|
+
function fakePackage(root, { name = PKG, version = VERSION, bin = 'bin/project-knowledge-mcp.js', withEntry = true } = {}) {
|
|
58
|
+
fs.mkdirSync(path.dirname(path.join(root, bin)), { recursive: true });
|
|
59
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name, version, bin: { 'project-knowledge-mcp': bin } }));
|
|
60
|
+
if (withEntry) fs.writeFileSync(path.join(root, bin), 'process.exit(0);\n');
|
|
61
|
+
return path.join(root, bin);
|
|
62
|
+
}
|
|
63
|
+
function bridgeStub() {
|
|
64
|
+
return { createBridge: () => ({
|
|
65
|
+
registerConsumer: async () => ({}), getConsumer: async () => ({}), getHealth: async () => ({}), ensureRuntimeHome: async () => ({}),
|
|
66
|
+
installers: {
|
|
67
|
+
claudeCode: { installClaudeCodeHook: async () => ({ added: [] }), statusClaudeCodeHook: async () => ({ installed: false }), uninstallClaudeCodeHook: async () => ({}) },
|
|
68
|
+
codex: { statusCodexHook: async () => ({ installed: false }) },
|
|
69
|
+
},
|
|
70
|
+
}) };
|
|
71
|
+
}
|
|
72
|
+
function stubRunner(cmd, args) {
|
|
73
|
+
return { status: 0, stdout: cmd === 'codex' && args[1] === 'list' ? '{}' : '[]', stderr: '' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function testManifestContractA2() {
|
|
77
|
+
const server = readJson(MCP_MANIFEST).mcpServers['project-knowledge'];
|
|
78
|
+
assert(server.type === 'stdio' && server.command === 'node' && server.args[0] === '-e', 'the stdio node -e chain stays');
|
|
79
|
+
assert(server.cwd === undefined, 'A2 forbids the relative cwd pin in .mcp.json');
|
|
80
|
+
assert(server.env && server.env.KB_PLUGIN_VERSION === VERSION, 'env.KB_PLUGIN_VERSION must carry the shipping version');
|
|
81
|
+
const bootstrap = server.args[1];
|
|
82
|
+
assert(!bootstrap.includes('process.cwd'), 'the bootstrap must never guess the launcher from process.cwd');
|
|
83
|
+
assert(bootstrap.includes('CLAUDE_PLUGIN_ROOT') && bootstrap.includes('PLUGIN_ROOT'), 'trusted plugin-root variables stay first');
|
|
84
|
+
assert(bootstrap.includes(PKG), 'the bootstrap must pin the product package constant');
|
|
85
|
+
assert(bootstrap.includes('integration-runtime'), 'the bootstrap fallback must consult the durable binding');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function testLauncherPinningMatrix() {
|
|
89
|
+
// Both sides agree on a foreign package name: the constant pin must still reject it.
|
|
90
|
+
const foreign = dir('foreign-pkg');
|
|
91
|
+
fakePackage(foreign, { name: 'echo-marker-EVILPKG', version: '9.9.9-echo-marker' });
|
|
92
|
+
const dataA = dir('foreign-data');
|
|
93
|
+
writeBinding(dataA, { packageName: 'echo-marker-EVILPKG', packageRoot: foreign, version: '9.9.9-echo-marker' });
|
|
94
|
+
const resultA = runLauncher(dataA);
|
|
95
|
+
const errorA = launcherError(resultA, 'foreign-package');
|
|
96
|
+
assert(errorA.code === 'BINDING_PACKAGE_MISMATCH', 'foreign package must be rejected by the constant pin, got ' + errorA.code);
|
|
97
|
+
assert(!String(resultA.stderr).includes('echo-marker-EVILPKG') && !String(resultA.stderr).includes('9.9.9-echo-marker'), 'untrusted names and versions must not be echoed');
|
|
98
|
+
|
|
99
|
+
// The plugin-declared version joins the binding/manifest agreement.
|
|
100
|
+
const dataB = dir('plugin-version-data');
|
|
101
|
+
writeBinding(dataB);
|
|
102
|
+
const errorB = launcherError(runLauncher(dataB, { KB_PLUGIN_VERSION: '0.0.0-other' }), 'plugin-version');
|
|
103
|
+
assert(errorB.code === 'BINDING_VERSION_MISMATCH', 'KB_PLUGIN_VERSION must join the version agreement, got ' + errorB.code);
|
|
104
|
+
assert(!String(runLauncher(dataB, { KB_PLUGIN_VERSION: '0.0.0-other' }).stderr).includes('0.0.0-other'), 'the rejected version must not be echoed');
|
|
105
|
+
|
|
106
|
+
// An entry that exists but is a directory must be rejected.
|
|
107
|
+
const pkgC = dir('dir-entry-pkg');
|
|
108
|
+
fs.mkdirSync(fakePackage(pkgC, { withEntry: false }), { recursive: true });
|
|
109
|
+
const dataC = dir('dir-entry-data');
|
|
110
|
+
writeBinding(dataC, { packageRoot: pkgC });
|
|
111
|
+
assert(launcherError(runLauncher(dataC), 'directory-entry').code === 'BINDING_ENTRY_INVALID', 'a directory entry must be rejected');
|
|
112
|
+
|
|
113
|
+
// A junction inside the package that resolves outside the real root must be rejected.
|
|
114
|
+
const outside = dir('outside-real');
|
|
115
|
+
fs.writeFileSync(path.join(outside, 'server.js'), 'process.exit(0);\n');
|
|
116
|
+
const pkgD = dir('junction-pkg');
|
|
117
|
+
fakePackage(pkgD, { bin: 'datalink/server.js', withEntry: false });
|
|
118
|
+
fs.rmSync(path.join(pkgD, 'datalink'), { recursive: true, force: true });
|
|
119
|
+
fs.symlinkSync(outside, path.join(pkgD, 'datalink'), 'junction');
|
|
120
|
+
const dataD = dir('junction-data');
|
|
121
|
+
writeBinding(dataD, { packageRoot: pkgD });
|
|
122
|
+
assert(launcherError(runLauncher(dataD), 'junction-escape').code === 'BINDING_ENTRY_INVALID', 'realpath escape must be rejected');
|
|
123
|
+
|
|
124
|
+
// Bindings beyond the bounded read are corrupt, not parsed.
|
|
125
|
+
const dataE = dir('oversized-data');
|
|
126
|
+
writeBinding(dataE, { padding: 'x'.repeat(40000) });
|
|
127
|
+
assert(launcherError(runLauncher(dataE), 'oversized-binding').code === 'BINDING_CORRUPT', 'bindings beyond 32KB must be rejected as corrupt');
|
|
128
|
+
|
|
129
|
+
// Untrusted schema values are never echoed back.
|
|
130
|
+
const dataF = dir('schema-echo-data');
|
|
131
|
+
writeBinding(dataF, { schema: 'echo-marker-SCHEMA/v9' });
|
|
132
|
+
const resultF = runLauncher(dataF);
|
|
133
|
+
assert(launcherError(resultF, 'schema-echo').code === 'BINDING_SCHEMA_MISMATCH', 'schema mismatch keeps its code');
|
|
134
|
+
assert(!String(resultF.stderr).includes('echo-marker-SCHEMA'), 'schema values must not be echoed');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function testBootstrapNeverGuessesFromCwd() {
|
|
138
|
+
const bootstrap = readJson(MCP_MANIFEST).mcpServers['project-knowledge'].args[1];
|
|
139
|
+
const decoy = dir('decoy-worktree');
|
|
140
|
+
fs.mkdirSync(path.join(decoy, 'scripts'), { recursive: true });
|
|
141
|
+
const sentinel = path.join(TEMP, 'decoy-sentinel.txt');
|
|
142
|
+
fs.writeFileSync(path.join(decoy, 'scripts', 'mcp-launcher.cjs'), 'require(\'fs\').writeFileSync(process.env.A2_DECOY_SENTINEL, \'loaded\'); process.exit(7);\n');
|
|
143
|
+
const baseEnv = { ...process.env, A2_DECOY_SENTINEL: sentinel };
|
|
144
|
+
delete baseEnv.CLAUDE_PLUGIN_ROOT;
|
|
145
|
+
delete baseEnv.PLUGIN_ROOT;
|
|
146
|
+
const run = (env, cwd) => spawnSync(process.execPath, ['-e', bootstrap], {
|
|
147
|
+
encoding: 'utf8', windowsHide: true, timeout: 15000, cwd, env,
|
|
148
|
+
});
|
|
149
|
+
const emptyData = dir('bootstrap-empty-data');
|
|
150
|
+
const guessed = run({ ...baseEnv, KB_DATA_DIR: emptyData }, decoy);
|
|
151
|
+
assert(!fs.existsSync(sentinel), 'the bootstrap must not load a same-named script from the working directory');
|
|
152
|
+
assert(guessed.status === 127, 'without a trusted root or a usable binding the bootstrap must fail fast, got ' + guessed.status);
|
|
153
|
+
assert(String(guessed.stderr || '').trim().length > 0, 'the bootstrap failure must leave a diagnostic');
|
|
154
|
+
|
|
155
|
+
const trusted = run({ ...baseEnv, KB_DATA_DIR: emptyData, PLUGIN_ROOT: PLUGIN_ROOT }, decoy);
|
|
156
|
+
assert(trusted.status === 1, 'the trusted plugin root must reach the real launcher, got ' + trusted.status);
|
|
157
|
+
assert(/BINDING_MISSING/.test(String(trusted.stderr || '')), 'the real launcher then reports the missing binding');
|
|
158
|
+
assert(!fs.existsSync(sentinel), 'the trusted form must still ignore the decoy');
|
|
159
|
+
|
|
160
|
+
const corruptData = dir('bootstrap-corrupt-data');
|
|
161
|
+
fs.mkdirSync(corruptData, { recursive: true });
|
|
162
|
+
fs.writeFileSync(bindingPathOf(corruptData), '{ broken');
|
|
163
|
+
const bound = run({ ...baseEnv, KB_DATA_DIR: corruptData }, decoy);
|
|
164
|
+
assert(bound.status === 127, 'an unparseable binding is rejected by the bootstrap itself, got ' + bound.status);
|
|
165
|
+
assert(!fs.existsSync(sentinel), 'the binding fallback must not load the decoy');
|
|
166
|
+
|
|
167
|
+
const fakePkg = dir('bootstrap-fake-pkg');
|
|
168
|
+
const fakeLauncher = path.join(fakePkg, 'plugins', 'project-knowledge', 'scripts', 'mcp-launcher.cjs');
|
|
169
|
+
fs.mkdirSync(path.dirname(fakeLauncher), { recursive: true });
|
|
170
|
+
fs.writeFileSync(fakeLauncher, 'process.stderr.write(\'BINDING_FALLBACK_REACHED\'); process.exit(5);\n');
|
|
171
|
+
const fakeData = dir('bootstrap-fake-data');
|
|
172
|
+
writeBinding(fakeData, { packageRoot: fakePkg });
|
|
173
|
+
const reached = run({ ...baseEnv, KB_DATA_DIR: fakeData }, decoy);
|
|
174
|
+
assert(reached.status === 5 && /BINDING_FALLBACK_REACHED/.test(String(reached.stderr || '')), 'a verified binding must resolve and load the in-package launcher');
|
|
175
|
+
|
|
176
|
+
const evilData = dir('bootstrap-evil-data');
|
|
177
|
+
writeBinding(evilData, { packageName: 'evil/pkg', packageRoot: decoy });
|
|
178
|
+
const evil = run({ ...baseEnv, KB_DATA_DIR: evilData }, TEMP);
|
|
179
|
+
assert(evil.status === 127, 'the binding fallback must pin the product package name');
|
|
180
|
+
assert(!fs.existsSync(sentinel), 'a foreign binding must never point back at the working tree');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function testCaptureOnlyNeverWritesKnowledgeBinding() {
|
|
184
|
+
const home = dir('capture-only-home');
|
|
185
|
+
const dataDir = path.join(home, 'data');
|
|
186
|
+
const commands = [];
|
|
187
|
+
const manager = new IntegrationManager({
|
|
188
|
+
rootDir: ROOT, homeDir: home, env: { KB_DATA_DIR: dataDir },
|
|
189
|
+
commandResolver: name => name,
|
|
190
|
+
runner: (cmd, args) => { commands.push(args); return stubRunner(cmd, args); },
|
|
191
|
+
bridgeModule: bridgeStub(),
|
|
192
|
+
});
|
|
193
|
+
const summary = await manager.installAll({ clients: ['claude'], captureOnly: true });
|
|
194
|
+
assert(!fs.existsSync(bindingPathOf(dataDir)), 'captureOnly must not write the knowledge runtime binding');
|
|
195
|
+
assert(commands.length === 0, 'captureOnly must not run knowledge client CLI, got ' + JSON.stringify(commands));
|
|
196
|
+
assert(summary.clients[0].developmentCapture, 'capture results stay present');
|
|
197
|
+
assert(!summary.clients[0].knowledgeIntegration, 'captureOnly must not fake knowledge results');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function testBindingFailureGatesKnowledgeNotCapture() {
|
|
201
|
+
const brokenRoot = dir('broken-pkg-root');
|
|
202
|
+
fs.writeFileSync(path.join(brokenRoot, 'package.json'), JSON.stringify({ name: PKG, version: VERSION, bin: { 'project-knowledge-mcp': 'bin/missing.js' } }));
|
|
203
|
+
fs.mkdirSync(path.join(brokenRoot, '.claude-plugin'), { recursive: true });
|
|
204
|
+
fs.writeFileSync(path.join(brokenRoot, '.claude-plugin', 'marketplace.json'), JSON.stringify({ plugins: [] }));
|
|
205
|
+
const home = dir('broken-home');
|
|
206
|
+
const dataDir = path.join(home, 'data');
|
|
207
|
+
const commands = [];
|
|
208
|
+
const manager = new IntegrationManager({
|
|
209
|
+
rootDir: brokenRoot, homeDir: home, env: { KB_DATA_DIR: dataDir },
|
|
210
|
+
commandResolver: name => name,
|
|
211
|
+
runner: (cmd, args) => { commands.push(args); return stubRunner(cmd, args); },
|
|
212
|
+
bridgeModule: bridgeStub(),
|
|
213
|
+
});
|
|
214
|
+
const summary = await manager.installAll({ clients: ['claude', 'codex'] });
|
|
215
|
+
assert(summary.runtimeBinding && summary.runtimeBinding.ok === false, 'the failed binding must surface in the summary');
|
|
216
|
+
for (const entry of summary.clients) {
|
|
217
|
+
assert(entry.knowledgeIntegration && entry.knowledgeIntegration.ok === false, entry.client + ': knowledge must fail when the binding cannot be written');
|
|
218
|
+
assert(entry.knowledgeIntegration.code === 'INTEGRATION_RUNTIME_BINDING_FAILED', entry.client + ': machine code expected');
|
|
219
|
+
assert(entry.developmentCapture, entry.client + ': capture results stay independent');
|
|
220
|
+
}
|
|
221
|
+
assert(commands.length === 0, 'no client CLI may run while the binding fails, got ' + JSON.stringify(commands));
|
|
222
|
+
assert(!fs.existsSync(bindingPathOf(dataDir)), 'no binding file may appear');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function testRepeatedRepairIsByteIdempotent() {
|
|
226
|
+
const home = dir('idempotence-home');
|
|
227
|
+
const dataDir = path.join(home, 'data');
|
|
228
|
+
const manager = new IntegrationManager({
|
|
229
|
+
rootDir: ROOT, homeDir: home, env: { KB_DATA_DIR: dataDir },
|
|
230
|
+
commandResolver: name => name, runner: stubRunner,
|
|
231
|
+
});
|
|
232
|
+
const first = manager.ensureIntegrationRuntimeBinding();
|
|
233
|
+
assert(first.state === 'written', 'the first repair writes, got ' + first.state);
|
|
234
|
+
const bytes = fs.readFileSync(bindingPathOf(dataDir));
|
|
235
|
+
const mtime = fs.statSync(bindingPathOf(dataDir)).mtimeMs;
|
|
236
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
237
|
+
const again = manager.ensureIntegrationRuntimeBinding();
|
|
238
|
+
assert(again.state === 'unchanged', 'an unchanged binding must not be rewritten, got ' + again.state);
|
|
239
|
+
assert(fs.readFileSync(bindingPathOf(dataDir)).equals(bytes), 'binding bytes must stay identical across repeated repairs');
|
|
240
|
+
assert(fs.statSync(bindingPathOf(dataDir)).mtimeMs === mtime, 'no rewrite means no mtime churn');
|
|
241
|
+
writeBinding(dataDir, { version: '0.0.0-stale' });
|
|
242
|
+
const repaired = manager.ensureIntegrationRuntimeBinding();
|
|
243
|
+
assert(repaired.state === 'written', 'a drifted binding must be repaired, got ' + repaired.state);
|
|
244
|
+
assert(readJson(bindingPathOf(dataDir)).version === VERSION, 'repair restores the shipping version');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function testDirectKnowledgeEntriesEnsureTheBinding() {
|
|
248
|
+
for (const client of ['claude', 'codex']) {
|
|
249
|
+
const home = dir('direct-' + client + '-home');
|
|
250
|
+
const dataDir = path.join(home, 'data');
|
|
251
|
+
const manager = new IntegrationManager({
|
|
252
|
+
rootDir: ROOT, homeDir: home, env: { KB_DATA_DIR: dataDir },
|
|
253
|
+
commandResolver: name => name, runner: stubRunner,
|
|
254
|
+
});
|
|
255
|
+
try { await (client === 'claude' ? manager.installClaude() : manager.installCodex()); } catch { /* verify steps may fail under stubs; the binding contract is what matters */ }
|
|
256
|
+
assert(fs.existsSync(bindingPathOf(dataDir)), client + ': direct knowledge install must ensure the runtime binding');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function testStatusAndDryRunNeverWriteBindings() {
|
|
261
|
+
const home = dir('status-home');
|
|
262
|
+
const dataDir = path.join(home, 'data');
|
|
263
|
+
const manager = new IntegrationManager({
|
|
264
|
+
rootDir: ROOT, homeDir: home, env: { KB_DATA_DIR: dataDir },
|
|
265
|
+
commandResolver: name => name, runner: stubRunner,
|
|
266
|
+
mcpProbe: async () => ({ state: 'ready', checkedAt: new Date().toISOString() }),
|
|
267
|
+
bridgeModule: bridgeStub(),
|
|
268
|
+
});
|
|
269
|
+
await manager.statusAll({ clients: ['claude'] });
|
|
270
|
+
assert(!fs.existsSync(bindingPathOf(dataDir)), 'status must not create bindings');
|
|
271
|
+
const dryHome = dir('dry-home');
|
|
272
|
+
const dry = new IntegrationManager({
|
|
273
|
+
rootDir: ROOT, homeDir: dryHome, env: { KB_DATA_DIR: path.join(dryHome, 'data') },
|
|
274
|
+
dryRun: true, commandResolver: name => name, runner: stubRunner,
|
|
275
|
+
});
|
|
276
|
+
await dry.operate('install', ['claude']);
|
|
277
|
+
assert(dry.planned.some(step => step.command === 'runtime-binding'), 'dry-run plans the binding');
|
|
278
|
+
assert(!fs.existsSync(dry.integrationRuntimeBindingPath()), 'dry-run must not write the binding');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const STUB_TOOLS = ['project_knowledge_resolve', 'project_knowledge_record_requirement', 'project_knowledge_search', 'project_knowledge_ask', 'project_knowledge_get', 'project_knowledge_history'];
|
|
282
|
+
const STUB_SERVER_SOURCE = [
|
|
283
|
+
'const mode = process.env.A2_STUB_MODE || \'ok\';',
|
|
284
|
+
'const version = process.env.A2_STUB_VERSION || \'\';',
|
|
285
|
+
'const tools = ' + JSON.stringify(STUB_TOOLS) + ';',
|
|
286
|
+
'let buffer = \'\';',
|
|
287
|
+
'const send = message => process.stdout.write(JSON.stringify(message) + String.fromCharCode(10));',
|
|
288
|
+
'process.stdin.setEncoding(\'utf8\');',
|
|
289
|
+
'process.stdin.on(\'data\', chunk => {',
|
|
290
|
+
' buffer += chunk;',
|
|
291
|
+
' let index;',
|
|
292
|
+
' while ((index = buffer.indexOf(String.fromCharCode(10))) >= 0) {',
|
|
293
|
+
' const line = buffer.slice(0, index).trim();',
|
|
294
|
+
' buffer = buffer.slice(index + 1);',
|
|
295
|
+
' if (!line) continue;',
|
|
296
|
+
' let message; try { message = JSON.parse(line); } catch { continue; }',
|
|
297
|
+
' if (message.method === \'initialize\' && mode !== \'hang\') {',
|
|
298
|
+
' if (mode === \'wrong-name\') send({ jsonrpc: \'2.0\', id: message.id, result: { protocolVersion: \'2025-06-18\', capabilities: {}, serverInfo: { name: \'evil-server\', version } } });',
|
|
299
|
+
' else if (mode === \'wrong-version\') send({ jsonrpc: \'2.0\', id: message.id, result: { protocolVersion: \'2025-06-18\', capabilities: {}, serverInfo: { name: \'project-knowledge\', version: \'0.0.1-bad\' } } });',
|
|
300
|
+
' else if (mode === \'hostile\') send({ jsonrpc: \'2.0\', id: message.id, error: { code: -32000, message: \'INJECT-SCRIPT-MARKER sk-abcdef123456\' } });',
|
|
301
|
+
' else { send({ jsonrpc: \'2.0\', id: message.id, result: { protocolVersion: \'2025-06-18\', capabilities: {}, serverInfo: { name: \'project-knowledge\', version } } }); if (mode === \'stderr-flood\') { process.stderr.write(Buffer.alloc(65536, 120)); const timer = setInterval(() => process.stderr.write(Buffer.alloc(8192, 120)), 5); setTimeout(() => clearInterval(timer), 4000); } if (mode === \'stdout-flood\') process.stdout.write(String.fromCharCode(34) + \'STDOUT-FLOOD-DO-NOT-ECHO \' + \'x\'.repeat(262144)); }',
|
|
302
|
+
' } else if (message.method === \'notifications/initialized\') {',
|
|
303
|
+
' if (mode === \'stderr-flood\') { const timer = setInterval(() => process.stderr.write(\'FLOOD-MARKER \' + \'x\'.repeat(4096) + String.fromCharCode(10)), 5); setTimeout(() => clearInterval(timer), 4000); }',
|
|
304
|
+
' } else if (message.method === \'tools/list\') {',
|
|
305
|
+
' const list = mode === \'tools-short\' ? tools.slice(0, 5) : tools;',
|
|
306
|
+
' if (mode !== \'stderr-flood\') send({ jsonrpc: \'2.0\', id: message.id, result: { tools: list.map(name => ({ name, inputSchema: { type: \'object\' } })) } });',
|
|
307
|
+
' }',
|
|
308
|
+
' }',
|
|
309
|
+
'});',
|
|
310
|
+
].join('\n');
|
|
311
|
+
|
|
312
|
+
function stubServerPackage(mode) {
|
|
313
|
+
const pkg = dir('stub-' + mode + '-pkg');
|
|
314
|
+
const entry = path.join(pkg, 'bin', 'project-knowledge-mcp.js');
|
|
315
|
+
fs.mkdirSync(path.dirname(entry), { recursive: true });
|
|
316
|
+
fs.writeFileSync(entry, STUB_SERVER_SOURCE);
|
|
317
|
+
fs.writeFileSync(path.join(pkg, 'package.json'), JSON.stringify({ name: PKG, version: VERSION, bin: { 'project-knowledge-mcp': 'bin/project-knowledge-mcp.js' } }));
|
|
318
|
+
const dataDir = dir('stub-' + mode + '-data');
|
|
319
|
+
writeBinding(dataDir, { packageRoot: pkg });
|
|
320
|
+
return dataDir;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function probeManager(mode, dataDir) {
|
|
324
|
+
return new IntegrationManager({
|
|
325
|
+
rootDir: ROOT, homeDir: dir('probe-' + mode + '-home'), env: { KB_DATA_DIR: dataDir, A2_STUB_MODE: mode, A2_STUB_VERSION: VERSION },
|
|
326
|
+
commandResolver: name => name, runner: stubRunner,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function testProbeContractA2() {
|
|
331
|
+
const okData = stubServerPackage('ok');
|
|
332
|
+
const ok = await probeManager('ok', okData).probeMcpStartup({ timeoutMs: 12000 });
|
|
333
|
+
assert(ok.state === 'ready', 'healthy stub must probe ready, got ' + JSON.stringify(ok));
|
|
334
|
+
assert(ok.scope === 'product-runtime', 'the default probe scope is product-runtime');
|
|
335
|
+
assert(ok.serverName === 'project-knowledge', 'the probe must verify the server identity');
|
|
336
|
+
assert(ok.actualVersion === VERSION && ok.expectedVersion === VERSION, 'versions must be reported');
|
|
337
|
+
assert(Array.isArray(ok.tools) && ok.tools.length === 6, 'the six knowledge tools must be verified');
|
|
338
|
+
|
|
339
|
+
const wrongName = await probeManager('wrong-name', stubServerPackage('wrong-name')).probeMcpStartup({ timeoutMs: 12000 });
|
|
340
|
+
assert(wrongName.state === 'failed' && wrongName.code === 'SERVER_IDENTITY', 'foreign serverInfo.name must fail SERVER_IDENTITY, got ' + JSON.stringify(wrongName));
|
|
341
|
+
|
|
342
|
+
const wrongVersion = await probeManager('wrong-version', stubServerPackage('wrong-version')).probeMcpStartup({ timeoutMs: 12000 });
|
|
343
|
+
assert(wrongVersion.state === 'failed' && wrongVersion.code === 'VERSION_MISMATCH', 'a foreign server version must fail VERSION_MISMATCH, got ' + JSON.stringify(wrongVersion));
|
|
344
|
+
|
|
345
|
+
const shortTools = await probeManager('tools-short', stubServerPackage('tools-short')).probeMcpStartup({ timeoutMs: 12000 });
|
|
346
|
+
assert(shortTools.state === 'failed' && shortTools.code === 'TOOLS_CONTRACT', 'a five-tool server must fail TOOLS_CONTRACT, got ' + JSON.stringify(shortTools));
|
|
347
|
+
|
|
348
|
+
const hostile = await probeManager('hostile', stubServerPackage('hostile')).probeMcpStartup({ timeoutMs: 12000 });
|
|
349
|
+
assert(hostile.state === 'failed', 'a hostile initialize must fail');
|
|
350
|
+
assert(!JSON.stringify(hostile).includes('INJECT-SCRIPT-MARKER') && !JSON.stringify(hostile).includes('sk-abcdef123456'), 'upstream error text must never be reflected');
|
|
351
|
+
assert(JSON.stringify(hostile).length < 2000, 'probe results stay bounded');
|
|
352
|
+
|
|
353
|
+
const floodStarted = Date.now();
|
|
354
|
+
const flood = await probeManager('stderr-flood', stubServerPackage('stderr-flood')).probeMcpStartup({ timeoutMs: 9000 });
|
|
355
|
+
assert(flood.state === 'failed' && flood.code === 'PROBE_OUTPUT_OVERFLOW', 'unbounded stderr must fail fast with PROBE_OUTPUT_OVERFLOW, got ' + JSON.stringify(flood));
|
|
356
|
+
assert(Date.now() - floodStarted < 7500, 'the overflow failure must not wait for the whole timeout');
|
|
357
|
+
assert(!JSON.stringify(flood).includes('FLOOD-MARKER'), 'flood text must not be reflected');
|
|
358
|
+
|
|
359
|
+
const hangStarted = Date.now();
|
|
360
|
+
const hang = await probeManager('hang', stubServerPackage('hang')).probeMcpStartup({ timeoutMs: 1200 });
|
|
361
|
+
assert(hang.state === 'failed' && hang.code === 'PROBE_TIMEOUT', 'a silent server must fail with PROBE_TIMEOUT');
|
|
362
|
+
assert(Date.now() - hangStarted < 6000, 'timeout cleanup must be prompt');
|
|
363
|
+
|
|
364
|
+
const fake = path.join(TEMP, 'echo-launcher.cjs');
|
|
365
|
+
fs.writeFileSync(fake, 'process.stderr.write(JSON.stringify({ schema: ' + JSON.stringify(ERROR_SCHEMA) + ', code: \'BINDING_CORRUPT\', message: \'INJECT-LAUNCHER-MARKER\' })); process.exit(1);\n');
|
|
366
|
+
const echoed = await probeManager('ok', okData).probeMcpStartup({ launcherPath: fake, timeoutMs: 9000 });
|
|
367
|
+
assert(echoed.state === 'failed' && echoed.code === 'BINDING_CORRUPT', 'launcher error codes pass through, got ' + JSON.stringify(echoed));
|
|
368
|
+
assert(!JSON.stringify(echoed).includes('INJECT-LAUNCHER-MARKER'), 'launcher error messages must not be reflected');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function installedPluginEntry(dirName) {
|
|
372
|
+
const pluginDir = dir(dirName);
|
|
373
|
+
fs.mkdirSync(path.join(pluginDir, 'scripts'), { recursive: true });
|
|
374
|
+
fs.writeFileSync(path.join(pluginDir, 'scripts', 'mcp-launcher.cjs'), '/* installed copy */\n');
|
|
375
|
+
return pluginDir;
|
|
376
|
+
}
|
|
377
|
+
function claudeHomeWith(cacheDir) {
|
|
378
|
+
const home = dir('claude-home-' + path.basename(cacheDir));
|
|
379
|
+
fs.mkdirSync(path.join(home, '.claude', 'plugins'), { recursive: true });
|
|
380
|
+
fs.writeFileSync(path.join(home, '.claude', 'plugins', 'installed_plugins.json'), JSON.stringify({ plugins: { 'project-knowledge@project-knowledge': [{ version: VERSION, installPath: cacheDir }] } }));
|
|
381
|
+
return home;
|
|
382
|
+
}
|
|
383
|
+
function codexRunnerWith(sourcePath) {
|
|
384
|
+
return (cmd, args) => ({
|
|
385
|
+
status: 0, stderr: '',
|
|
386
|
+
stdout: cmd === 'codex' && args[1] === 'list'
|
|
387
|
+
? JSON.stringify({ installed: [{ pluginId: 'project-knowledge@project-knowledge', version: VERSION, enabled: true, source: { path: sourcePath } }] })
|
|
388
|
+
: '[]',
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function codexMarketplaceHarness(listStdout, addResults, homeName) {
|
|
393
|
+
const calls = [];
|
|
394
|
+
let addIndex = 0;
|
|
395
|
+
const runner = (cmd, args) => {
|
|
396
|
+
calls.push(args.join(' '));
|
|
397
|
+
if (args.join(' ') === 'plugin marketplace list') return { status: 0, stdout: listStdout, stderr: '' };
|
|
398
|
+
if (args.slice(0, 3).join(' ') === 'plugin marketplace add') {
|
|
399
|
+
const result = addResults[addIndex++] || { status: 0, stderr: '' };
|
|
400
|
+
return { status: result.status, stdout: '', stderr: result.stderr };
|
|
401
|
+
}
|
|
402
|
+
return { status: 0, stdout: '{}', stderr: '' };
|
|
403
|
+
};
|
|
404
|
+
const manager = new IntegrationManager({ rootDir: ROOT, homeDir: dir(homeName), env: {}, commandResolver: name => name, runner });
|
|
405
|
+
return { manager, calls };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function testStatusAllClientScopedProbes() {
|
|
409
|
+
const claudeCache = installedPluginEntry('claude-plugin-cache');
|
|
410
|
+
const codexPluginDir = installedPluginEntry('codex-plugin-source');
|
|
411
|
+
const calls = [];
|
|
412
|
+
const manager = new IntegrationManager({
|
|
413
|
+
rootDir: ROOT, homeDir: claudeHomeWith(claudeCache), env: {},
|
|
414
|
+
commandResolver: name => name, runner: codexRunnerWith(codexPluginDir),
|
|
415
|
+
mcpProbe: info => { calls.push(info); return Promise.resolve({ state: 'ready', checkedAt: new Date().toISOString(), scope: info.scope }); },
|
|
416
|
+
codexHookProbe: async () => ({ data: [{ cwd: TEMP, hooks: [{ pluginId: 'project-knowledge@project-knowledge', eventName: 'userPromptSubmit', command: 'node scripts/context-hook.cjs --client codex', enabled: true, trustStatus: 'trusted' }] }] }),
|
|
417
|
+
bridgeModule: bridgeStub(),
|
|
418
|
+
});
|
|
419
|
+
const summary = await manager.statusAll({ clients: ['claude', 'codex'] });
|
|
420
|
+
const claude = summary.clients.find(entry => entry.client === 'claude');
|
|
421
|
+
const codex = summary.clients.find(entry => entry.client === 'codex');
|
|
422
|
+
const claudeCall = calls.find(call => call.scope === 'client:claude');
|
|
423
|
+
const codexCall = calls.find(call => call.scope === 'client:codex');
|
|
424
|
+
assert(claudeCall && claudeCall.launcherPath === path.join(claudeCache, 'scripts', 'mcp-launcher.cjs'), 'the claude probe must target the installed plugin entry, got ' + JSON.stringify(calls));
|
|
425
|
+
assert(codexCall && codexCall.launcherPath === path.join(codexPluginDir, 'scripts', 'mcp-launcher.cjs'), 'the codex probe must target the installed plugin source');
|
|
426
|
+
assert(claude.mcpStartup && claude.mcpStartup.scope === 'client:claude', 'claude status carries the client-scoped probe');
|
|
427
|
+
assert(codex.mcpStartup && codex.mcpStartup.scope === 'client:codex', 'codex status carries the client-scoped probe');
|
|
428
|
+
assert(summary.mcpRuntime && summary.mcpRuntime.scope === 'product-runtime', 'the shared product-runtime probe lands in the summary');
|
|
429
|
+
|
|
430
|
+
const shared = installedPluginEntry('shared-plugin-dir');
|
|
431
|
+
const deduped = [];
|
|
432
|
+
const manager2 = new IntegrationManager({
|
|
433
|
+
rootDir: ROOT, homeDir: claudeHomeWith(shared), env: {},
|
|
434
|
+
commandResolver: name => name, runner: codexRunnerWith(shared),
|
|
435
|
+
mcpProbe: info => { deduped.push(info); return Promise.resolve({ state: 'ready', scope: info.scope }); },
|
|
436
|
+
codexHookProbe: async () => ({ data: [{ cwd: TEMP, hooks: [] }] }),
|
|
437
|
+
bridgeModule: bridgeStub(),
|
|
438
|
+
});
|
|
439
|
+
await manager2.statusAll({ clients: ['claude', 'codex'] });
|
|
440
|
+
const clientScoped = deduped.filter(call => call.scope !== 'product-runtime');
|
|
441
|
+
assert(clientScoped.length === 1, 'identical installed entries must share one probe, got ' + JSON.stringify(clientScoped));
|
|
442
|
+
assert(deduped.some(call => call.scope === 'product-runtime'), 'the host probe still runs');
|
|
443
|
+
|
|
444
|
+
const absentCalls = [];
|
|
445
|
+
const manager3 = new IntegrationManager({
|
|
446
|
+
rootDir: ROOT, homeDir: dir('absent-home'), env: {},
|
|
447
|
+
commandResolver: name => name, runner: stubRunner,
|
|
448
|
+
mcpProbe: info => { absentCalls.push(info); return Promise.resolve({ state: 'ready', scope: info.scope }); },
|
|
449
|
+
bridgeModule: bridgeStub(),
|
|
450
|
+
});
|
|
451
|
+
const missing = await manager3.statusAll({ clients: ['claude', 'codex'] });
|
|
452
|
+
const claudeMiss = missing.clients.find(entry => entry.client === 'claude');
|
|
453
|
+
const codexMiss = missing.clients.find(entry => entry.client === 'codex');
|
|
454
|
+
assert(claudeMiss.mcpStartup && claudeMiss.mcpStartup.state === 'failed' && claudeMiss.mcpStartup.code === 'CLIENT_PLUGIN_ENTRY_MISSING', 'claude without an installed entry reports the explicit miss, got ' + JSON.stringify(claudeMiss.mcpStartup));
|
|
455
|
+
assert(codexMiss.mcpStartup && codexMiss.mcpStartup.code === 'CLIENT_PLUGIN_ENTRY_MISSING', 'codex without an installed entry reports the explicit miss');
|
|
456
|
+
assert(absentCalls.every(call => call.scope === 'product-runtime'), 'no client probe may run when no installed entry exists, got ' + JSON.stringify(absentCalls));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function testEnsureCodexMarketplacePolicy() {
|
|
460
|
+
// An own old local marketplace (a previous accepted npm prefix) is replaced
|
|
461
|
+
// with the current package root; the old package files are never touched.
|
|
462
|
+
const oldRoot = dir('codex-old-npm-root');
|
|
463
|
+
fakePackage(oldRoot, { version: '5.0.15' });
|
|
464
|
+
const replace = codexMarketplaceHarness(`MARKETPLACE ROOT\nproject-knowledge ${oldRoot}\n`, [{ status: 0 }], 'codex-own-home');
|
|
465
|
+
replace.manager.ensureCodexMarketplace();
|
|
466
|
+
deepEqual(replace.calls, ['plugin marketplace list', 'plugin marketplace remove project-knowledge', 'plugin marketplace add ' + ROOT], 'own old local marketplace must be replaced by the current package root');
|
|
467
|
+
assert(fs.existsSync(path.join(oldRoot, 'package.json')), 'replacing a marketplace never deletes old package files');
|
|
468
|
+
|
|
469
|
+
// An unknown third-party same-name source is refused without any removal.
|
|
470
|
+
const foreign = dir('codex-foreign-root');
|
|
471
|
+
fakePackage(foreign, { name: 'other-vendor/plugin-pack', version: '1.0.0' });
|
|
472
|
+
const refuse = codexMarketplaceHarness(`project-knowledge ${foreign}\n`, [], 'codex-foreign-home');
|
|
473
|
+
let refused = false;
|
|
474
|
+
try { refuse.manager.ensureCodexMarketplace(); } catch (error) { refused = /第三方来源/.test(error.message); }
|
|
475
|
+
assert(refused, 'an unverifiable same-name marketplace must be refused');
|
|
476
|
+
deepEqual(refuse.calls, ['plugin marketplace list'], 'a refused marketplace must not be removed or re-added');
|
|
477
|
+
|
|
478
|
+
// A failed replacement restores the previous source; a failed rollback is explicit.
|
|
479
|
+
const rollback = codexMarketplaceHarness(`project-knowledge ${oldRoot}\n`, [{ status: 1, stderr: 'boom' }, { status: 0 }], 'codex-rollback-home');
|
|
480
|
+
let rolled = false;
|
|
481
|
+
try { rollback.manager.ensureCodexMarketplace(); } catch (error) { rolled = /恢复原市场来源/.test(error.message); }
|
|
482
|
+
assert(rolled, 'a failed replacement must report the rollback');
|
|
483
|
+
deepEqual(rollback.calls, ['plugin marketplace list', 'plugin marketplace remove project-knowledge', 'plugin marketplace add ' + ROOT, 'plugin marketplace add ' + oldRoot], 'the old source must be restored after a failed add');
|
|
484
|
+
const broken = codexMarketplaceHarness(`project-knowledge ${oldRoot}\n`, [{ status: 1, stderr: 'boom' }, { status: 1, stderr: 'boom' }], 'codex-broken-home');
|
|
485
|
+
let manual = false;
|
|
486
|
+
try { broken.manager.ensureCodexMarketplace(); } catch (error) { manual = /手动检查/.test(error.message); }
|
|
487
|
+
assert(manual, 'a failed rollback must direct the user to inspect the marketplace manually');
|
|
488
|
+
|
|
489
|
+
// The current root is a no-op; dead paths and own GitHub sources move to the
|
|
490
|
+
// local package; a fresh install never registers the GitHub remote.
|
|
491
|
+
const current = codexMarketplaceHarness(`project-knowledge ${ROOT}\n`, [], 'codex-current-home');
|
|
492
|
+
current.manager.ensureCodexMarketplace();
|
|
493
|
+
deepEqual(current.calls, ['plugin marketplace list'], 'an already-current marketplace is a no-op');
|
|
494
|
+
const deadSource = path.join(dir('codex-dead-root'), 'vanished');
|
|
495
|
+
const deadReplace = codexMarketplaceHarness(`project-knowledge ${deadSource}\n`, [{ status: 0 }], 'codex-dead-home');
|
|
496
|
+
deadReplace.manager.ensureCodexMarketplace();
|
|
497
|
+
assert(deadReplace.calls[2] === 'plugin marketplace add ' + ROOT, 'a dead source path is safe to replace');
|
|
498
|
+
const github = codexMarketplaceHarness('project-knowledge SanQianX/project-knowledge-base\n', [{ status: 0 }], 'codex-github-home');
|
|
499
|
+
github.manager.ensureCodexMarketplace();
|
|
500
|
+
assert(github.calls.includes('plugin marketplace add ' + ROOT), 'our own GitHub marketplace moves to the local package');
|
|
501
|
+
const fresh = codexMarketplaceHarness('MARKETPLACE ROOT\n', [], 'codex-fresh-home');
|
|
502
|
+
fresh.manager.ensureCodexMarketplace();
|
|
503
|
+
deepEqual(fresh.calls, ['plugin marketplace list', 'plugin marketplace add ' + ROOT], 'a fresh install registers the local npm package, not GitHub');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function testProbeEnvUsesManagerDataDir() {
|
|
507
|
+
// A host KB_DATA_DIR (for example an exported Workbench session dir) must
|
|
508
|
+
// not leak into the probe: the manager's own home-based binding wins.
|
|
509
|
+
const goodData = stubServerPackage('ok-env');
|
|
510
|
+
const goodHome = dir('env-good-home');
|
|
511
|
+
fs.mkdirSync(path.join(goodHome, '.project-knowledge-v5'), { recursive: true });
|
|
512
|
+
fs.copyFileSync(bindingPathOf(goodData), path.join(goodHome, '.project-knowledge-v5', 'integration-runtime.json'));
|
|
513
|
+
const evilPkg = dir('env-evil-pkg');
|
|
514
|
+
fakePackage(evilPkg, { version: '5.0.15' });
|
|
515
|
+
const evilData = dir('env-evil-data');
|
|
516
|
+
writeBinding(evilData, { packageRoot: evilPkg, version: '5.0.15' });
|
|
517
|
+
const manager = new IntegrationManager({
|
|
518
|
+
rootDir: ROOT, homeDir: goodHome, env: { A2_STUB_VERSION: VERSION },
|
|
519
|
+
commandResolver: name => name, runner: stubRunner,
|
|
520
|
+
});
|
|
521
|
+
const previous = process.env.KB_DATA_DIR;
|
|
522
|
+
process.env.KB_DATA_DIR = evilData;
|
|
523
|
+
let probe;
|
|
524
|
+
try { probe = await manager.probeMcpStartup({ timeoutMs: 12000 }); } finally {
|
|
525
|
+
if (previous === undefined) delete process.env.KB_DATA_DIR; else process.env.KB_DATA_DIR = previous;
|
|
526
|
+
}
|
|
527
|
+
assert(probe.state === 'ready', 'the probe must use the manager own data dir, not the inherited KB_DATA_DIR, got ' + JSON.stringify(probe));
|
|
528
|
+
assert(probe.actualVersion === VERSION, 'the isolated binding version must be the one probed');
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function testProbeStdoutOverflow() {
|
|
532
|
+
const started = Date.now();
|
|
533
|
+
const flood = await probeManager('stdout-flood', stubServerPackage('stdout-flood')).probeMcpStartup({ timeoutMs: 9000 });
|
|
534
|
+
assert(flood.state === 'failed' && flood.code === 'PROBE_OUTPUT_OVERFLOW', 'a single-line stdout flood must fail at the byte cap, got ' + JSON.stringify(flood));
|
|
535
|
+
assert(Date.now() - started < 7500, 'the stdout overflow must fail before the whole timeout');
|
|
536
|
+
assert(!JSON.stringify(flood).includes('STDOUT-FLOOD-DO-NOT-ECHO'), 'stdout flood text must not be reflected');
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function main() {
|
|
540
|
+
const tests = [
|
|
541
|
+
['manifest', testManifestContractA2],
|
|
542
|
+
['launcher', testLauncherPinningMatrix],
|
|
543
|
+
['bootstrap', testBootstrapNeverGuessesFromCwd],
|
|
544
|
+
['captureOnly', testCaptureOnlyNeverWritesKnowledgeBinding],
|
|
545
|
+
['gating', testBindingFailureGatesKnowledgeNotCapture],
|
|
546
|
+
['idempotence', testRepeatedRepairIsByteIdempotent],
|
|
547
|
+
['direct', testDirectKnowledgeEntriesEnsureTheBinding],
|
|
548
|
+
['zerowrite', testStatusAndDryRunNeverWriteBindings],
|
|
549
|
+
['probe', testProbeContractA2],
|
|
550
|
+
['scopes', testStatusAllClientScopedProbes],
|
|
551
|
+
['codex-marketplace', testEnsureCodexMarketplacePolicy],
|
|
552
|
+
['probe-env', testProbeEnvUsesManagerDataDir],
|
|
553
|
+
['stdout-flood', testProbeStdoutOverflow],
|
|
554
|
+
];
|
|
555
|
+
const only = process.env.A2_ONLY;
|
|
556
|
+
for (const [name, test] of tests) {
|
|
557
|
+
if (only && name !== only) continue;
|
|
558
|
+
await test();
|
|
559
|
+
}
|
|
560
|
+
console.log('INTEGRATION_RUNTIME_BINDING_A2_TEST_PASSED');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
main().catch(error => {
|
|
564
|
+
console.error('INTEGRATION_RUNTIME_BINDING_A2_TEST_FAILED', error.message);
|
|
565
|
+
process.exit(1);
|
|
566
|
+
});
|