@1e0zj/dsh-plugin-mall 0.4.5 → 0.4.7
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/package.json +1 -1
- package/src/client.js +255 -119
- package/src/guard.js +491 -1
- package/src/index.js +42 -3
- package/src/installer.js +101 -1
package/src/guard.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
readdirSync,
|
|
18
18
|
realpathSync,
|
|
19
19
|
rmSync,
|
|
20
|
+
statSync,
|
|
20
21
|
symlinkSync,
|
|
21
22
|
writeFileSync,
|
|
22
23
|
} from "node:fs";
|
|
@@ -811,6 +812,217 @@ function disabledState(value) {
|
|
|
811
812
|
return value ? "disabled" : "enabled";
|
|
812
813
|
}
|
|
813
814
|
|
|
815
|
+
/**
|
|
816
|
+
* Rows that would BRICK startup: an MCP client entry, fatal on failure, whose
|
|
817
|
+
* entry file does not exist in the candidate (issue #14).
|
|
818
|
+
*
|
|
819
|
+
* The shape is deliberately the provable minimum — every condition must hold
|
|
820
|
+
* exactly, and anything short of it is left alone:
|
|
821
|
+
*
|
|
822
|
+
* - name is exactly @deepseek-ai/dsh-mcp-client, transport stdio,
|
|
823
|
+
* failOnStartupError is the literal true, command the literal "node";
|
|
824
|
+
* - the row's FINAL state is enabled (checked on the composed tree, so a
|
|
825
|
+
* user patch that disables it, retargets args, or drops the fatal flag
|
|
826
|
+
* exempts it — and a candidate that overrides an EXISTING mcp row into
|
|
827
|
+
* this shape is caught just the same);
|
|
828
|
+
* - args has exactly one element — the entry — which is EXACTLY
|
|
829
|
+
* `!!js dshHomePath('<literal>')`, no concatenation, no ternary;
|
|
830
|
+
* - the literal points INSIDE `profiles/<this profile>/node_modules/<the
|
|
831
|
+
* candidate's own package>/…` — other profiles and other packages are out
|
|
832
|
+
* of scope (the probe tree cannot verify them), and the remainder is
|
|
833
|
+
* clamped segment-wise;
|
|
834
|
+
* - the last segment carries a runnable extension and resolves to a plain
|
|
835
|
+
* file.
|
|
836
|
+
*
|
|
837
|
+
* A miss in the isolated probe tree is a BLOCK only when nothing could have
|
|
838
|
+
* built the entry later: no install-time scripts, no binding.gyp, no .hooks/
|
|
839
|
+
* (pnpm's requiresBuild treats all three as build-capable — verified against
|
|
840
|
+
* its worker.js source, see docs/dsh-notes.md). A build-capable candidate
|
|
841
|
+
* downgrades to a WARNING here because the probe disables lifecycle scripts
|
|
842
|
+
* while a real install may run them after approval; the hard gate for that
|
|
843
|
+
* case is mcpEntryAuditForInstall at finalize time. The browsing-time scan
|
|
844
|
+
* cannot run any of this (no file tree); this is install-preflight only.
|
|
845
|
+
*/
|
|
846
|
+
const DSH_MCP_CLIENT_MODULE = "@deepseek-ai/dsh-mcp-client";
|
|
847
|
+
// dshHomePath 只有作为 !!js 表达式才被 loader 求值成路径;!!js 在内存里是
|
|
848
|
+
// { __jsExpr: source }。只认「恰好一个单字面量实参的调用」,其余形态
|
|
849
|
+
// (拼接、三元、嵌套)求值前无从判断,一律不碰。实参里允许反斜杠
|
|
850
|
+
// (Windows 写法,匹配后归一成 /);引号排除——出现引号即不是这种形状。
|
|
851
|
+
const DSH_HOME_PATH_EXACT = /^dshHomePath\(\s*(['"])([^'"]*)\1\s*\)$/;
|
|
852
|
+
// 安装期 lifecycle 脚本。pnpm 的 requiresBuild(worker.js: pkgRequiresBuild,
|
|
853
|
+
// 11.21.0 逐字核对)实际只认 preinstall/install/postinstall;下面的
|
|
854
|
+
// prepare/prepublish 是本守卫额外的保守信号——它们不在 pnpm 的清单里,
|
|
855
|
+
// 但也绝非不可能在安装期产出文件,宁可多 warn 一次终检,不冒误杀风险。
|
|
856
|
+
const INSTALL_TIME_SCRIPT_RE = /^(?:pre|post)?install$|^prepare$|^prepublish$/;
|
|
857
|
+
const RUNNABLE_ENTRY_RE = /\.(?:js|mjs|cjs)$/i;
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Whether the entry might only exist after a build pnpm would gate behind
|
|
861
|
+
* approval. Aligned with pnpm's own requiresBuild (worker.js:
|
|
862
|
+
* pkgRequiresBuild + filesIncludeInstallScripts, verified verbatim against
|
|
863
|
+
* 11.21.0): scripts preinstall/install/postinstall, a root binding.gyp, or
|
|
864
|
+
* anything under .hooks/. guard additionally treats prepare/prepublish as
|
|
865
|
+
* build-capable — a conservative addition of ours beyond pnpm's actual set,
|
|
866
|
+
* widening the warn lane, never narrowing the block lane.
|
|
867
|
+
*/
|
|
868
|
+
function candidateMayBuildEntry(candidateManifest, candidateDir) {
|
|
869
|
+
if (Object.keys(candidateManifest?.scripts ?? {}).some((name) => INSTALL_TIME_SCRIPT_RE.test(name))) return true;
|
|
870
|
+
if (typeof candidateDir !== "string") return false;
|
|
871
|
+
if (isPlainFile(join(candidateDir, "binding.gyp"))) return true;
|
|
872
|
+
try {
|
|
873
|
+
return readdirSync(join(candidateDir, ".hooks")).length > 0;
|
|
874
|
+
} catch {
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** A plain file at the path (not a directory, not missing). */
|
|
880
|
+
function isPlainFile(path) {
|
|
881
|
+
try {
|
|
882
|
+
return statSync(path).isFile();
|
|
883
|
+
} catch {
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Match a row against the fatal shape; returns `{ pkgName, fileSegments }` or
|
|
890
|
+
* undefined. Pure — no filesystem access — so preflight and the post-install
|
|
891
|
+
* audit share one definition of what counts.
|
|
892
|
+
*
|
|
893
|
+
* Path handling notes (issue #14 review): the dshHomePath literal may use
|
|
894
|
+
* backslashes on Windows, so it is normalized to `/` before matching and the
|
|
895
|
+
* profile-name segment compares case-insensitively there; and existsSync is
|
|
896
|
+
* NOT entry resolution (`node src/guard` resolves to src/guard.js) — so only
|
|
897
|
+
* paths whose last segment carries a runnable extension (.js/.mjs/.cjs) are
|
|
898
|
+
* judged at all, and a target that exists but is not a plain file counts as
|
|
899
|
+
* missing.
|
|
900
|
+
*/
|
|
901
|
+
function fatalMcpEntry(row, profileName) {
|
|
902
|
+
if (row.name !== DSH_MCP_CLIENT_MODULE || row.state !== "enabled") return undefined;
|
|
903
|
+
const config = row.options?.config;
|
|
904
|
+
if (config === null || typeof config !== "object" || Array.isArray(config)) return undefined;
|
|
905
|
+
if (config.transport !== "stdio" || config.failOnStartupError !== true || config.command !== "node") return undefined;
|
|
906
|
+
const args = Array.isArray(config.args) ? config.args : [];
|
|
907
|
+
// 唯一参数才是入口。多个参数里混着 Node CLI 开关和业务路径(如
|
|
908
|
+
// --output dshHomePath('runtime/result.json')),逐一当入口查会把运行期
|
|
909
|
+
// 产物误判成缺失;不解析 Node CLI,多参行一律不判。
|
|
910
|
+
if (args.length !== 1 || !isJsExpr(args[0])) return undefined;
|
|
911
|
+
const call = DSH_HOME_PATH_EXACT.exec(String(args[0].__jsExpr ?? ""));
|
|
912
|
+
if (call === null) return undefined;
|
|
913
|
+
// JS 字符串字面量里的 Windows 分隔符写作 \\(求值后是单个 \);单独的 \
|
|
914
|
+
// 是转义序列(\w 之类),求值结果无法静态确定——含未成对反斜杠的形态
|
|
915
|
+
// 一律不判。成对的 \\ 直接折成 /。
|
|
916
|
+
const rawLiteral = call[2];
|
|
917
|
+
let soloBackslash = false;
|
|
918
|
+
const normalized = [];
|
|
919
|
+
for (let i = 0; i < rawLiteral.length; i++) {
|
|
920
|
+
const ch = rawLiteral[i];
|
|
921
|
+
if (ch === "\\") {
|
|
922
|
+
if (rawLiteral[i + 1] === "\\") {
|
|
923
|
+
normalized.push("/");
|
|
924
|
+
i++;
|
|
925
|
+
} else {
|
|
926
|
+
soloBackslash = true;
|
|
927
|
+
break;
|
|
928
|
+
}
|
|
929
|
+
} else {
|
|
930
|
+
normalized.push(ch);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
if (soloBackslash) return undefined;
|
|
934
|
+
const segments = normalized.join("").split("/");
|
|
935
|
+
const fold = process.platform === "win32" ? (value) => value.toLowerCase() : (value) => value;
|
|
936
|
+
if (segments.length < 5) return undefined;
|
|
937
|
+
if (fold(segments[0]) !== "profiles" || fold(segments[1]) !== fold(profileName) || fold(segments[2]) !== "node_modules") return undefined;
|
|
938
|
+
const rest = segments.slice(3);
|
|
939
|
+
const pkgName = rest[0]?.startsWith("@") === true && rest.length > 1 ? `${rest[0]}/${rest[1]}` : rest[0];
|
|
940
|
+
if (typeof pkgName !== "string" || pkgName.length === 0 || !NPM_PACKAGE_NAME_RE.test(pkgName)) return undefined;
|
|
941
|
+
const fileSegments = rest.slice(pkgName.split("/").length);
|
|
942
|
+
if (fileSegments.length === 0 || fileSegments.some((part) => part.length === 0 || part === "." || part === "..")) return undefined;
|
|
943
|
+
if (!RUNNABLE_ENTRY_RE.test(fileSegments[fileSegments.length - 1])) return undefined;
|
|
944
|
+
return { pkgName, fileSegments };
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Package-name equality with the platform's own semantics: Windows resolves
|
|
949
|
+
* node_modules case-insensitively, so a row written as node_modules/MCP-BRICK
|
|
950
|
+
* targets the same package as manifest name mcp-brick — comparing strictly
|
|
951
|
+
* there would let a missing entry slip past both preflight and the audit.
|
|
952
|
+
*/
|
|
953
|
+
function samePackageName(left, right) {
|
|
954
|
+
return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function mcpStartupFileIssues(mountedRows, candidateManifest, candidateName, candidateDir, profileDir) {
|
|
958
|
+
if (candidateName.length === 0 || typeof candidateDir !== "string") return [];
|
|
959
|
+
const profileName = basename(profileDir);
|
|
960
|
+
const hasInstallScripts = candidateMayBuildEntry(candidateManifest, candidateDir);
|
|
961
|
+
const issues = [];
|
|
962
|
+
for (const row of mountedRows) {
|
|
963
|
+
const fatal = fatalMcpEntry(row, profileName);
|
|
964
|
+
if (fatal === undefined || !samePackageName(fatal.pkgName, candidateName)) continue; // 只判候选自己的包:探针树里只有它
|
|
965
|
+
if (isPlainFile(join(candidateDir, ...fatal.fileSegments))) continue;
|
|
966
|
+
const fileRel = fatal.fileSegments.join("/");
|
|
967
|
+
if (hasInstallScripts) {
|
|
968
|
+
// 探装禁了 lifecycle 脚本,正式安装可能在用户审批后执行它们并生成
|
|
969
|
+
// 入口——静态「必缺」在这里不成立;硬保证移到装后终检。
|
|
970
|
+
issues.push(issue(
|
|
971
|
+
"warn",
|
|
972
|
+
"mcp-entry-unverifiable",
|
|
973
|
+
"入口缺失,但候选可能在构建后才生成它",
|
|
974
|
+
`行 ${row.id}(${DSH_MCP_CLIENT_MODULE})设置了 failOnStartupError: true 并以 node 启动 ${fileRel},探装(脚本禁用)里该文件不存在。候选带安装期脚本或构建清单(binding.gyp / .hooks),入口可能要等构建执行后才生成:预检不据此拦截,安装完成前会对照真树终检,仍缺失则回滚。`,
|
|
975
|
+
{ row: row.id, file: fileRel },
|
|
976
|
+
));
|
|
977
|
+
} else {
|
|
978
|
+
issues.push(issue(
|
|
979
|
+
"block",
|
|
980
|
+
"mcp-entry-missing",
|
|
981
|
+
"安装会使 dsh 无法启动",
|
|
982
|
+
`行 ${row.id}(${DSH_MCP_CLIENT_MODULE},stdio)设置了 failOnStartupError: true,并以 node 启动候选包内的 ${fileRel},但候选包里没有这个文件(未构建的源码安装的典型形态,且没有安装期脚本可能生成它)。安装后 dsh 会在装配阶段因该行失败而直接退出。`,
|
|
983
|
+
{ row: row.id, file: fileRel },
|
|
984
|
+
));
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return issues;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Post-install audit (issue #14): the REAL profile tree, at the moment a
|
|
992
|
+
* successful install is about to commit. Scripts that were going to run have
|
|
993
|
+
* run (or were never declared); if the fatal entry is still not a plain file,
|
|
994
|
+
* committing would leave a profile that cannot start. The caller treats any
|
|
995
|
+
* returned issue as install failure so the outer handler rolls back.
|
|
996
|
+
*
|
|
997
|
+
* Only rows whose entry points into THE CANDIDATE's own package are judged —
|
|
998
|
+
* other packages' rows are not this install's doing, and the audit is not the
|
|
999
|
+
* place to condemn pre-existing profile state.
|
|
1000
|
+
*/
|
|
1001
|
+
export function mcpEntryAuditForInstall({ profileDir, candidateName }) {
|
|
1002
|
+
if (typeof candidateName !== "string" || candidateName.length === 0 || typeof profileDir !== "string") return [];
|
|
1003
|
+
let current;
|
|
1004
|
+
try {
|
|
1005
|
+
current = installedProfile(profileDir, []);
|
|
1006
|
+
} catch {
|
|
1007
|
+
return []; // unreadable profile is somebody else's error to report
|
|
1008
|
+
}
|
|
1009
|
+
const profileName = basename(profileDir);
|
|
1010
|
+
const issues = [];
|
|
1011
|
+
for (const row of current.composedRows) {
|
|
1012
|
+
const fatal = fatalMcpEntry(row, profileName);
|
|
1013
|
+
if (fatal === undefined || !samePackageName(fatal.pkgName, candidateName)) continue;
|
|
1014
|
+
// Windows 文件系统本身不区分大小写,行里写 MCP-BRICK 也能落到同一目录。
|
|
1015
|
+
if (isPlainFile(join(profileDir, "node_modules", ...fatal.pkgName.split("/"), ...fatal.fileSegments))) continue;
|
|
1016
|
+
issues.push(issue(
|
|
1017
|
+
"block",
|
|
1018
|
+
"mcp-entry-missing",
|
|
1019
|
+
"安装完成但入口仍缺失",
|
|
1020
|
+
`行 ${row.id}(${DSH_MCP_CLIENT_MODULE})以 failOnStartupError: true 启动 ${fatal.fileSegments.join("/")},安装完成后该文件仍不存在——提交这个 profile 会让 dsh 在装配阶段退出。`,
|
|
1021
|
+
{ row: row.id, file: fatal.fileSegments.join("/") },
|
|
1022
|
+
));
|
|
1023
|
+
}
|
|
1024
|
+
return issues;
|
|
1025
|
+
}
|
|
814
1026
|
/** How a pair of projected rows would mount together. */
|
|
815
1027
|
function bothMount(left, right) {
|
|
816
1028
|
const states = [left.state, right.state];
|
|
@@ -1310,6 +1522,11 @@ export function inspectCandidate({ profileDir, candidateManifestPath, spec }) {
|
|
|
1310
1522
|
issues.push(...namelessRowIssues(simulated.mounted.filter((row) => row.owner === candidateName), candidateName || spec));
|
|
1311
1523
|
issues.push(...newConflictIssues(candidateName, current.composedRows, simulated.mounted));
|
|
1312
1524
|
issues.push(...patchTargetIssues(candidateName, candidate.manifest, patch, current, simulated));
|
|
1525
|
+
// 落盘后的文件树检查只能在这里做:探针树里候选包已实体化,MCP 入口存不
|
|
1526
|
+
// 存在是可判定的(带安装期脚本的候选降为 warn,硬保证在装后终检——见
|
|
1527
|
+
// mcpStartupFileIssues 的注释)。浏览期扫描(inspectRemoteCandidate)
|
|
1528
|
+
// 没有文件树,不参与。
|
|
1529
|
+
issues.push(...mcpStartupFileIssues(simulated.mounted, candidate.manifest, candidateName, candidate.dir, profileDir));
|
|
1313
1530
|
|
|
1314
1531
|
// UI replacements historically predate exclusiveGroups. Keep the
|
|
1315
1532
|
// heuristic advisory-only to avoid blocking legitimate sidebar extensions.
|
|
@@ -3368,6 +3585,279 @@ async function selfTest() {
|
|
|
3368
3585
|
if (validateInstalledProfile(p).ok !== true) throw new Error("healthy profile should validate clean");
|
|
3369
3586
|
}
|
|
3370
3587
|
|
|
3588
|
+
// ── MCP failOnStartupError 指向缺失文件(issue #14)────────────────────
|
|
3589
|
+
//
|
|
3590
|
+
// 事故形态(2026-08-22,managed-agents):候选从 github: 源装下来没有
|
|
3591
|
+
// dist/,insert 的 MCP 行 failOnStartupError: true 指向不存在的入口,
|
|
3592
|
+
// 装上即砖、dsh 装配阶段退出。判定只认可证形状,扫的是**最终组合行**:
|
|
3593
|
+
// 用户 patch 停用或改参可豁免,候选改坏既有 MCP 行同样抓得到。
|
|
3594
|
+
{
|
|
3595
|
+
const p = join(root, "profiles", "mcpcheck");
|
|
3596
|
+
mkdirSync(p, { recursive: true });
|
|
3597
|
+
const resetProfile = (userPatch) => {
|
|
3598
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: {} }));
|
|
3599
|
+
writeFileSync(join(p, "cordis.patch.yml"), userPatch ?? "[]\n");
|
|
3600
|
+
};
|
|
3601
|
+
const mcpInsert = (file, extra = {}) => `
|
|
3602
|
+
- insert:
|
|
3603
|
+
- id: mcp-x
|
|
3604
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
3605
|
+
config:
|
|
3606
|
+
serverName: x
|
|
3607
|
+
transport: stdio
|
|
3608
|
+
command: node
|
|
3609
|
+
args:
|
|
3610
|
+
- !!js dshHomePath('profiles/mcpcheck/node_modules/mcp-brick/${file}')
|
|
3611
|
+
failOnStartupError: true
|
|
3612
|
+
`.replace("failOnStartupError: true", `failOnStartupError: ${extra.fatal ?? true}`)
|
|
3613
|
+
+ (extra.append ?? "");
|
|
3614
|
+
const candidate = (label, pkgName, patchText) => {
|
|
3615
|
+
const dir = join(root, `cand-${label}`);
|
|
3616
|
+
mkdirSync(dir, { recursive: true });
|
|
3617
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: pkgName, version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
|
|
3618
|
+
writeFileSync(join(dir, "cordis.patch.yml"), patchText);
|
|
3619
|
+
return dir;
|
|
3620
|
+
};
|
|
3621
|
+
const inspect = (dir, name, userPatch) => {
|
|
3622
|
+
resetProfile(userPatch);
|
|
3623
|
+
return inspectCandidate({ profileDir: p, candidateManifestPath: join(dir, "package.json"), spec: name });
|
|
3624
|
+
};
|
|
3625
|
+
|
|
3626
|
+
// 1) 入口缺失 → block(事故原样:无 dist 的源码树)。
|
|
3627
|
+
const missing = inspect(candidate("missing", "mcp-brick", mcpInsert("dist/mcp/index.js")), "mcp-brick");
|
|
3628
|
+
if (missing.verdict !== "blocked" || !missing.issues.some((e) => e.code === "mcp-entry-missing")) {
|
|
3629
|
+
throw new Error(`MCP 入口缺失必须 block,实得 ${missing.verdict}: ${JSON.stringify(missing.issues.map((e) => e.code))}`);
|
|
3630
|
+
}
|
|
3631
|
+
console.log("PASS MCP 入口:缺失 + failOnStartupError → block");
|
|
3632
|
+
|
|
3633
|
+
// 2) 入口存在 → 放行。
|
|
3634
|
+
const presentDir = candidate("present", "mcp-brick", mcpInsert("dist/mcp/index.js"));
|
|
3635
|
+
mkdirSync(join(presentDir, "dist", "mcp"), { recursive: true });
|
|
3636
|
+
writeFileSync(join(presentDir, "dist", "mcp", "index.js"), "");
|
|
3637
|
+
const present = inspect(presentDir, "mcp-brick");
|
|
3638
|
+
if (present.issues.some((e) => e.code === "mcp-entry-missing")) throw new Error("入口存在时不得报 mcp-entry-missing");
|
|
3639
|
+
console.log("PASS MCP 入口:文件存在 → 放行");
|
|
3640
|
+
|
|
3641
|
+
// 3) failOnStartupError: false → 不判(失败不致命,装上也不会砖)。
|
|
3642
|
+
const lenient = inspect(candidate("lenient", "mcp-brick", mcpInsert("dist/none.js", { fatal: false })), "mcp-brick");
|
|
3643
|
+
if (lenient.issues.some((e) => e.code === "mcp-entry-missing")) throw new Error("failOnStartupError:false 不得判入口");
|
|
3644
|
+
console.log("PASS MCP 入口:failOnStartupError:false → 放行");
|
|
3645
|
+
|
|
3646
|
+
// 4) 用户 patch 停用该行 → 最终行不运行,不判。
|
|
3647
|
+
const disabledRow = inspect(candidate("disabled", "mcp-brick", mcpInsert("dist/none.js")), "mcp-brick", "- id: mcp-x\n disabled: true\n");
|
|
3648
|
+
if (disabledRow.issues.some((e) => e.code === "mcp-entry-missing")) throw new Error("用户停用的行不得判入口");
|
|
3649
|
+
console.log("PASS MCP 入口:用户 patch 停用 → 放行");
|
|
3650
|
+
|
|
3651
|
+
// 5) 用户 patch 修正 args 指向存在的文件 → 放行。
|
|
3652
|
+
const fixDir = candidate("fixed", "mcp-brick", mcpInsert("dist/none.js"));
|
|
3653
|
+
mkdirSync(join(fixDir, "dist"), { recursive: true });
|
|
3654
|
+
writeFileSync(join(fixDir, "dist", "fixed.js"), "");
|
|
3655
|
+
const fixed = inspect(fixDir, "mcp-brick", `
|
|
3656
|
+
- id: mcp-x
|
|
3657
|
+
config:
|
|
3658
|
+
serverName: x
|
|
3659
|
+
transport: stdio
|
|
3660
|
+
command: node
|
|
3661
|
+
args:
|
|
3662
|
+
- !!js dshHomePath('profiles/mcpcheck/node_modules/mcp-brick/dist/fixed.js')
|
|
3663
|
+
failOnStartupError: true
|
|
3664
|
+
`);
|
|
3665
|
+
if (fixed.issues.some((e) => e.code === "mcp-entry-missing")) throw new Error("用户已把 args 修到存在的文件,不得再判");
|
|
3666
|
+
console.log("PASS MCP 入口:用户 patch 修正 args → 放行");
|
|
3667
|
+
|
|
3668
|
+
// 6) 非精确 !!js 表达式(拼接)→ 求值前无从判断,不判。
|
|
3669
|
+
const inexact = inspect(candidate("inexact", "mcp-brick", `
|
|
3670
|
+
- insert:
|
|
3671
|
+
- id: mcp-x
|
|
3672
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
3673
|
+
config:
|
|
3674
|
+
transport: stdio
|
|
3675
|
+
command: node
|
|
3676
|
+
args:
|
|
3677
|
+
- !!js dshHomePath('profiles/mcpcheck/node_modules/' + 'mcp-brick/dist/none.js')
|
|
3678
|
+
failOnStartupError: true
|
|
3679
|
+
`), "mcp-brick");
|
|
3680
|
+
if (inexact.issues.some((e) => e.code === "mcp-entry-missing")) throw new Error("拼接表达式不得判入口");
|
|
3681
|
+
console.log("PASS MCP 入口:非精确 !!js 表达式 → 不误判");
|
|
3682
|
+
|
|
3683
|
+
// 7) 候选改坏既有 MCP 行(override)→ 同样检出。行是 existing-host
|
|
3684
|
+
// 插的、原本不致命;候选把 config 整个换成指向自己包内缺失文件
|
|
3685
|
+
// 且 failOnStartupError: true —— 只看 insert 会漏掉这一种。
|
|
3686
|
+
resetProfile();
|
|
3687
|
+
// 真实形态:已装插件同时在 dependencies 和 dsh.profile.bundles 里
|
|
3688
|
+
// (bundleLayers 只从 bundles 列表构建,光有依赖不成层)。
|
|
3689
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { "existing-host": "1.0.0" }, dsh: { profile: { bundles: ["existing-host"] } } }));
|
|
3690
|
+
mkdirSync(join(p, "node_modules", "existing-host"), { recursive: true });
|
|
3691
|
+
writeFileSync(join(p, "node_modules", "existing-host", "package.json"), JSON.stringify({ name: "existing-host", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
|
|
3692
|
+
writeFileSync(join(p, "node_modules", "existing-host", "cordis.patch.yml"), `
|
|
3693
|
+
- insert:
|
|
3694
|
+
- id: mcp-existing
|
|
3695
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
3696
|
+
config:
|
|
3697
|
+
transport: stdio
|
|
3698
|
+
command: node
|
|
3699
|
+
args:
|
|
3700
|
+
- !!js dshHomePath('profiles/mcpcheck/node_modules/existing-host/dist/mcp.js')
|
|
3701
|
+
failOnStartupError: false
|
|
3702
|
+
`);
|
|
3703
|
+
const breaker = inspectCandidate({ profileDir: p, candidateManifestPath: join(candidate("breaker", "mcp-brick", `
|
|
3704
|
+
- id: mcp-existing
|
|
3705
|
+
config:
|
|
3706
|
+
transport: stdio
|
|
3707
|
+
command: node
|
|
3708
|
+
args:
|
|
3709
|
+
- !!js dshHomePath('profiles/mcpcheck/node_modules/mcp-brick/dist/none.js')
|
|
3710
|
+
failOnStartupError: true
|
|
3711
|
+
`), "package.json"), spec: "mcp-brick" });
|
|
3712
|
+
if (!breaker.issues.some((e) => e.code === "mcp-entry-missing")) {
|
|
3713
|
+
throw new Error(`候选 override 既有 MCP 行改坏必须检出,实得 ${JSON.stringify(breaker.issues.map((e) => e.code))}`);
|
|
3714
|
+
}
|
|
3715
|
+
console.log("PASS MCP 入口:候选 override 既有行改坏 → 检出");
|
|
3716
|
+
|
|
3717
|
+
// 8) scoped 候选包:路径前缀含 scope 段,同样判得到。
|
|
3718
|
+
const scopedDir = candidate("scoped", "@scope/mcp-pkg", `
|
|
3719
|
+
- insert:
|
|
3720
|
+
- id: mcp-x
|
|
3721
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
3722
|
+
config:
|
|
3723
|
+
transport: stdio
|
|
3724
|
+
command: node
|
|
3725
|
+
args:
|
|
3726
|
+
- !!js dshHomePath('profiles/mcpcheck/node_modules/@scope/mcp-pkg/dist/none.js')
|
|
3727
|
+
failOnStartupError: true
|
|
3728
|
+
`);
|
|
3729
|
+
resetProfile();
|
|
3730
|
+
const scopedRep = inspectCandidate({ profileDir: p, candidateManifestPath: join(scopedDir, "package.json"), spec: "@scope/mcp-pkg" });
|
|
3731
|
+
if (!scopedRep.issues.some((e) => e.code === "mcp-entry-missing")) throw new Error("scoped 候选的缺失入口必须检出");
|
|
3732
|
+
console.log("PASS MCP 入口:scoped 包路径 → 检出");
|
|
3733
|
+
|
|
3734
|
+
// 9) 多参数:唯一参数才是入口,业务路径不参与判定(review P1)。业务
|
|
3735
|
+
// 路径故意用 .js 后缀——过得了扩展名闸门,钉的就是「唯一参数」这
|
|
3736
|
+
// 条规则本身,不是被扩展名顺带挡住的。
|
|
3737
|
+
const multiArg = inspect(candidate("multiarg", "mcp-brick", `
|
|
3738
|
+
- insert:
|
|
3739
|
+
- id: mcp-x
|
|
3740
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
3741
|
+
config:
|
|
3742
|
+
transport: stdio
|
|
3743
|
+
command: node
|
|
3744
|
+
args:
|
|
3745
|
+
- server.js
|
|
3746
|
+
- '--output'
|
|
3747
|
+
- !!js dshHomePath('profiles/mcpcheck/node_modules/mcp-brick/runtime/out.js')
|
|
3748
|
+
failOnStartupError: true
|
|
3749
|
+
`), "mcp-brick");
|
|
3750
|
+
if (multiArg.issues.some((e) => e.code === "mcp-entry-missing" || e.code === "mcp-entry-unverifiable")) {
|
|
3751
|
+
throw new Error("多参数行的业务路径不得当入口判");
|
|
3752
|
+
}
|
|
3753
|
+
console.log("PASS MCP 入口:多参数(业务路径)→ 不判");
|
|
3754
|
+
|
|
3755
|
+
// 10) 无扩展名入口:node 会做扩展名/目录解析,existsSync 不等价——不判。
|
|
3756
|
+
const extless = inspect(candidate("extless", "mcp-brick", mcpInsert("dist/mcp/entry")), "mcp-brick");
|
|
3757
|
+
if (extless.issues.some((e) => e.code === "mcp-entry-missing")) throw new Error("无扩展名路径不得判入口");
|
|
3758
|
+
console.log("PASS MCP 入口:无扩展名 → 不判");
|
|
3759
|
+
|
|
3760
|
+
// 11) 路径存在但是目录(名字带 .js):不是普通文件,按缺失论。
|
|
3761
|
+
const dirDir = candidate("dir-entry", "mcp-brick", mcpInsert("dist/index.js"));
|
|
3762
|
+
mkdirSync(join(dirDir, "dist", "index.js"), { recursive: true }); // 目录!
|
|
3763
|
+
const dirEntry = inspect(dirDir, "mcp-brick");
|
|
3764
|
+
if (!dirEntry.issues.some((e) => e.code === "mcp-entry-missing")) throw new Error("目录顶替入口文件必须 block");
|
|
3765
|
+
console.log("PASS MCP 入口:路径是目录(非普通文件)→ block");
|
|
3766
|
+
|
|
3767
|
+
// 12) 候选带 postinstall + 入口缺失 → 降为 warn(装后终检把关),不 block。
|
|
3768
|
+
const scriptedDir = candidate("scripted", "mcp-brick", mcpInsert("dist/mcp/index.js"));
|
|
3769
|
+
writeFileSync(join(scriptedDir, "package.json"), JSON.stringify({ name: "mcp-brick", version: "1.0.0", scripts: { postinstall: "node build.js" }, dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
|
|
3770
|
+
const scripted = inspect(scriptedDir, "mcp-brick");
|
|
3771
|
+
const unverifiable = scripted.issues.find((e) => e.code === "mcp-entry-unverifiable");
|
|
3772
|
+
if (scripted.verdict !== "warning" || unverifiable === undefined || scripted.issues.some((e) => e.code === "mcp-entry-missing")) {
|
|
3773
|
+
throw new Error(`带安装期脚本的候选应降为 warn(终检把关),实得 ${scripted.verdict}: ${JSON.stringify(scripted.issues.map((e) => e.code))}`);
|
|
3774
|
+
}
|
|
3775
|
+
console.log("PASS MCP 入口:候选带 postinstall → warn 不 block(终检把关)");
|
|
3776
|
+
|
|
3777
|
+
// 13) 反斜杠路径:JS 字面量里的真实写法是 \\(求值后单个 \)——归一
|
|
3778
|
+
// 后同样检出。单个不成对的 \ 是转义序列(\w 之类),求值结果无
|
|
3779
|
+
// 从静态确定,不判。
|
|
3780
|
+
const backslash = inspect(candidate("backslash", "mcp-brick", `
|
|
3781
|
+
- insert:
|
|
3782
|
+
- id: mcp-x
|
|
3783
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
3784
|
+
config:
|
|
3785
|
+
transport: stdio
|
|
3786
|
+
command: node
|
|
3787
|
+
args:
|
|
3788
|
+
- !!js dshHomePath('profiles\\\\mcpcheck\\\\node_modules\\\\mcp-brick\\\\dist\\\\none.js')
|
|
3789
|
+
failOnStartupError: true
|
|
3790
|
+
`), "mcp-brick");
|
|
3791
|
+
if (!backslash.issues.some((e) => e.code === "mcp-entry-missing")) {
|
|
3792
|
+
throw new Error(`反斜杠路径(\\\\ 形式)应归一检出,实得 ${JSON.stringify(backslash.issues.map((e) => e.code))}`);
|
|
3793
|
+
}
|
|
3794
|
+
console.log("PASS MCP 入口:反斜杠路径(\\\\ 形式)归一 → 检出");
|
|
3795
|
+
const soloBackslash = inspect(candidate("solo-backslash", "mcp-brick", `
|
|
3796
|
+
- insert:
|
|
3797
|
+
- id: mcp-x
|
|
3798
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
3799
|
+
config:
|
|
3800
|
+
transport: stdio
|
|
3801
|
+
command: node
|
|
3802
|
+
args:
|
|
3803
|
+
- !!js dshHomePath('profiles\\mcpcheck\\node_modules\\mcp-brick\\dist\\none.js')
|
|
3804
|
+
failOnStartupError: true
|
|
3805
|
+
`), "mcp-brick");
|
|
3806
|
+
if (soloBackslash.issues.some((e) => e.code === "mcp-entry-missing" || e.code === "mcp-entry-unverifiable")) {
|
|
3807
|
+
throw new Error("不成对的单个反斜杠(转义序列)不得判");
|
|
3808
|
+
}
|
|
3809
|
+
console.log("PASS MCP 入口:单个反斜杠(转义序列)→ 不判");
|
|
3810
|
+
|
|
3811
|
+
// 13b) binding.gyp(无任何 scripts):pnpm 的 requiresBuild 同样视为
|
|
3812
|
+
// 需构建——入口可能由 node-gyp 链路产出,静态只 warn。
|
|
3813
|
+
const gypDir = candidate("gyp", "mcp-brick", mcpInsert("dist/mcp/index.js"));
|
|
3814
|
+
writeFileSync(join(gypDir, "binding.gyp"), "{}\n");
|
|
3815
|
+
const gyp = inspect(gypDir, "mcp-brick");
|
|
3816
|
+
if (!gyp.issues.some((e) => e.code === "mcp-entry-unverifiable") || gyp.issues.some((e) => e.code === "mcp-entry-missing")) {
|
|
3817
|
+
throw new Error(`binding.gyp 候选应降为 warn,实得 ${gyp.verdict}: ${JSON.stringify(gyp.issues.map((e) => e.code))}`);
|
|
3818
|
+
}
|
|
3819
|
+
console.log("PASS MCP 入口:binding.gyp(无 scripts)→ warn 不 block");
|
|
3820
|
+
|
|
3821
|
+
// 13c) 包名大小写变体:manifest 是 mcp-brick,行里写 MCP-BRICK。
|
|
3822
|
+
// win32 上 node_modules 解析不区分大小写——同一个包,照判;
|
|
3823
|
+
// posix 上那是另一个名字,不判。两平台各自的判定才是正确语义。
|
|
3824
|
+
const cased = inspect(candidate("cased", "mcp-brick", `
|
|
3825
|
+
- insert:
|
|
3826
|
+
- id: mcp-x
|
|
3827
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
3828
|
+
config:
|
|
3829
|
+
transport: stdio
|
|
3830
|
+
command: node
|
|
3831
|
+
args:
|
|
3832
|
+
- !!js dshHomePath('profiles/mcpcheck/node_modules/MCP-BRICK/dist/none.js')
|
|
3833
|
+
failOnStartupError: true
|
|
3834
|
+
`), "mcp-brick");
|
|
3835
|
+
const casedBlocked = cased.issues.some((e) => e.code === "mcp-entry-missing");
|
|
3836
|
+
if (process.platform === "win32" ? !casedBlocked : casedBlocked) {
|
|
3837
|
+
throw new Error(`包名大小写变体在 ${process.platform} 上判定错误: ${JSON.stringify(cased.issues.map((e) => e.code))}`);
|
|
3838
|
+
}
|
|
3839
|
+
console.log(`PASS MCP 入口:包名大小写变体按平台语义判定(${process.platform} → ${casedBlocked ? "检出" : "不判"})`);
|
|
3840
|
+
|
|
3841
|
+
// 14) 装后终检:真树里入口仍缺 → issue;存在 → 空;别人的包 → 不判。
|
|
3842
|
+
{
|
|
3843
|
+
resetProfile();
|
|
3844
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { "mcp-brick": "1.0.0" }, dsh: { profile: { bundles: ["mcp-brick"] } } }));
|
|
3845
|
+
const installedCand = join(p, "node_modules", "mcp-brick");
|
|
3846
|
+
mkdirSync(installedCand, { recursive: true });
|
|
3847
|
+
writeFileSync(join(installedCand, "package.json"), JSON.stringify({ name: "mcp-brick", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
|
|
3848
|
+
writeFileSync(join(installedCand, "cordis.patch.yml"), mcpInsert("dist/mcp/index.js"));
|
|
3849
|
+
const auditMissing = mcpEntryAuditForInstall({ profileDir: p, candidateName: "mcp-brick" });
|
|
3850
|
+
if (auditMissing.length !== 1 || auditMissing[0].code !== "mcp-entry-missing") throw new Error("终检:入口缺失必须报");
|
|
3851
|
+
mkdirSync(join(installedCand, "dist", "mcp"), { recursive: true });
|
|
3852
|
+
writeFileSync(join(installedCand, "dist", "mcp", "index.js"), "");
|
|
3853
|
+
if (mcpEntryAuditForInstall({ profileDir: p, candidateName: "mcp-brick" }).length !== 0) throw new Error("终检:入口存在不得报");
|
|
3854
|
+
if (mcpEntryAuditForInstall({ profileDir: p, candidateName: "someone-else" }).length !== 0) throw new Error("终检:别人的包不归这次安装判");
|
|
3855
|
+
console.log("PASS MCP 入口:装后终检(缺→报、有→过、他人包→不判)");
|
|
3856
|
+
}
|
|
3857
|
+
|
|
3858
|
+
resetProfile();
|
|
3859
|
+
}
|
|
3860
|
+
|
|
3371
3861
|
// validateInstalledProfile detects a loader-id collision between two
|
|
3372
3862
|
// already-installed bundles (the failure mode a bad install introduces).
|
|
3373
3863
|
{
|
|
@@ -4789,4 +5279,4 @@ if (process.argv[1]?.endsWith("guard.js") && process.argv.includes("--self-test"
|
|
|
4789
5279
|
console.error(`FAIL ${error.stack ?? error.message}`);
|
|
4790
5280
|
process.exitCode = 1;
|
|
4791
5281
|
});
|
|
4792
|
-
}
|
|
5282
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1084,14 +1084,13 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
1084
1084
|
const prune = () => {
|
|
1085
1085
|
const now = Date.now();
|
|
1086
1086
|
for (const [id, record] of records) {
|
|
1087
|
-
|
|
1088
|
-
if (terminal && record.finishedAt !== undefined && now - record.finishedAt > 3600000) records.delete(id);
|
|
1087
|
+
if (record.finishedAt !== undefined && now - record.finishedAt > 3600000) records.delete(id);
|
|
1089
1088
|
}
|
|
1090
1089
|
if (records.size > 20) {
|
|
1091
1090
|
const ordered = [...records.entries()].sort((a, b) => a[1].startedAt - b[1].startedAt);
|
|
1092
1091
|
for (const [id, record] of ordered) {
|
|
1093
1092
|
if (records.size <= 20) break;
|
|
1094
|
-
if (record.
|
|
1093
|
+
if (record.finishedAt !== undefined) records.delete(id);
|
|
1095
1094
|
}
|
|
1096
1095
|
}
|
|
1097
1096
|
};
|
|
@@ -1314,6 +1313,9 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
1314
1313
|
}
|
|
1315
1314
|
}
|
|
1316
1315
|
if (typeof record.producer?.cancel === "function") {
|
|
1316
|
+
if (record.finishedAt === undefined && record.status === "running") {
|
|
1317
|
+
record.status = "stopping";
|
|
1318
|
+
}
|
|
1317
1319
|
record.producer.cancel();
|
|
1318
1320
|
}
|
|
1319
1321
|
if (record.approvalToken) {
|
|
@@ -1330,6 +1332,9 @@ export function createJobTracker({ producerFactory } = {}) {
|
|
|
1330
1332
|
if (record.surface === "browser" && (record.session === "" || record.session !== session)) {
|
|
1331
1333
|
return false;
|
|
1332
1334
|
}
|
|
1335
|
+
// Dismiss hides settled history. Live work must go through cancel and
|
|
1336
|
+
// remain observable until its producer reports a terminal outcome.
|
|
1337
|
+
if (record.finishedAt === undefined) return false;
|
|
1333
1338
|
// Marked, not deleted: `list` (panel restore after a remount) skips these,
|
|
1334
1339
|
// so a cleared panel stays cleared across remounts.
|
|
1335
1340
|
record.dismissed = true;
|
|
@@ -3012,6 +3017,40 @@ export async function runSelfTests() {
|
|
|
3012
3017
|
const snapAfterDismiss = sessionTracker.get(sessionJobId, "session-alpha").snapshot;
|
|
3013
3018
|
check("dismiss 后 job snapshot 中 approvalToken 为 undefined", snapAfterDismiss.approvalToken === undefined);
|
|
3014
3019
|
|
|
3020
|
+
// 运行中任务不能靠 dismiss 从观察面消失;cancel 先进入 stopping,等
|
|
3021
|
+
// producer 真正结算后才成为可清理的历史。
|
|
3022
|
+
{
|
|
3023
|
+
let settleLive;
|
|
3024
|
+
let cancelCalls = 0;
|
|
3025
|
+
const liveProducer = {
|
|
3026
|
+
cancel: () => { cancelCalls++; },
|
|
3027
|
+
done: new Promise((resolvePromise) => { settleLive = resolvePromise; }),
|
|
3028
|
+
readOutput: () => "",
|
|
3029
|
+
};
|
|
3030
|
+
const liveTracker = createJobTracker({ producerFactory: () => liveProducer });
|
|
3031
|
+
const liveJobId = liveTracker.start({
|
|
3032
|
+
profile: "web",
|
|
3033
|
+
spec: "live-pkg",
|
|
3034
|
+
surface: "browser",
|
|
3035
|
+
session: "session-alpha",
|
|
3036
|
+
});
|
|
3037
|
+
check("运行中 job 拒绝 dismiss", liveTracker.dismiss(liveJobId, "session-alpha") === false);
|
|
3038
|
+
check("dismiss 被拒后运行中 job 仍可恢复",
|
|
3039
|
+
liveTracker.list("session-alpha").some((entry) => entry.id === liveJobId));
|
|
3040
|
+
check("cancel 请求把可杀 job 标为 stopping",
|
|
3041
|
+
liveTracker.cancel(liveJobId, "session-alpha") === "requested"
|
|
3042
|
+
&& liveTracker.get(liveJobId, "session-alpha").snapshot.status === "stopping"
|
|
3043
|
+
&& cancelCalls === 1);
|
|
3044
|
+
check("stopping job 仍拒绝 dismiss", liveTracker.dismiss(liveJobId, "session-alpha") === false);
|
|
3045
|
+
settleLive({ status: "killed", detail: "cancelled" });
|
|
3046
|
+
await new Promise((resolvePromise) => setImmediate(resolvePromise));
|
|
3047
|
+
check("producer 结算后 job 进入 killed",
|
|
3048
|
+
liveTracker.get(liveJobId, "session-alpha").snapshot.status === "killed");
|
|
3049
|
+
check("终态 job 可 dismiss 并不再恢复",
|
|
3050
|
+
liveTracker.dismiss(liveJobId, "session-alpha") === true
|
|
3051
|
+
&& liveTracker.list("session-alpha").every((entry) => entry.id !== liveJobId));
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3015
3054
|
// ── 5b. list(重挂载恢复):日志累积、dismissed 过滤、session 隔离 ────────
|
|
3016
3055
|
// 安装事务改写 cordis.patch.yml 会让 dsh 重放装配树、市场 UI 整体重挂载,
|
|
3017
3056
|
// 前端 state 全丢。任务记录在后端活着——list 就是恢复通道:drain 过的
|