dsh-eda-mcp 1.1.1 → 1.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.2 — 2026-10-21
4
+
5
+ ### 扩展包改为**从服务器下载**(发布包不再自带 zip)
6
+
7
+ 按你的要求,本地不再保留副本,下载一律走服务器,同时保住"发出去的一定是对的那一版":
8
+
9
+ - `build.mjs` 现在生成 `lib/extension-digest.json`(version / file / bytes / sha256),发布包 `files` **移除** `dsh-eda-bridge-extension-*.zip`(实测 tarball 只剩 170 B 的清单,49 个文件,不再有 zip)。
10
+ - `GET /api/dsh-eda-mcp/extension.zip`:从下载服务器取回 → 与清单 sha256 比对 → **一致才发**;不一致返回 502 并说明"拒绝发送不同版本";429 报"限流,不是文件坏了";服务器不可达报 502 带地址。响应头带 `x-extension-source: server`、`x-extension-version`、`x-extension-sha256`。
11
+ - 设置卡片:按钮文案带版本(「下载 JLCEDA 扩展包 v0.4.9」),下面显示 `本地 v0.4.9 · 9827 字节 · sha256 …`(来自新增的本地路由 `/extension/info`,不联网);「校验服务器副本」按钮保留。
12
+ - `eda_extension_package` 也改为以清单为准:不再需要本地 zip,`download:true` 校验清单后落盘;机器上若恰好有 zip 会额外报告它。
13
+ - 第 11 套单测重写:真实本地 HTTP 服务模拟**一致 / 不同版本 / 429** 三种响应,断言一致才发出、不一致 502 且**不发任何字节**、429 如实上报;并覆盖 `/extension/info` 不联网。
14
+
15
+ 代价(明确记录):**离线安装不再可能**,且服务器那份 zip 一旦与 `lib/extension-digest.json` 不同步,下载会被拒绝——这时必须重新上传构建产物。
16
+
17
+
3
18
  ## 1.1.1 — 2026-10-21
4
19
 
5
20
  ### 设置卡片:下载仍是本地那份,但**能校验服务器副本**
package/README.md CHANGED
@@ -36,10 +36,11 @@ pnpm add dsh-eda-mcp # or use the plugin manager in the Web GUI
36
36
  # restart DSH
37
37
  ```
38
38
 
39
- The published package already contains `lib/` and `dsh-eda-bridge-extension-<version>.zip`, so no build
40
- toolchain is needed. The extension still has to be imported into JLCEDA Pro by hand — the zip
41
- sits at `node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<version>.zip` (or download it from the
42
- settings card).
39
+ The published package ships `lib/` and `lib/extension-digest.json` (no zip), so no build toolchain
40
+ is needed. The extension zip is **downloaded, not bundled**: the settings card (or the link below)
41
+ fetches it from the download server and the plugin verifies the sha256 against its own manifest
42
+ before the file is handed to you — a served copy that does not match is refused instead of being
43
+ installed as a different wire version. Import it into JLCEDA Pro by hand.
43
44
 
44
45
  Direct links (no package install needed):
45
46
 
package/README.zh.md CHANGED
@@ -47,8 +47,7 @@ pnpm add dsh-eda-mcp # 或用 Web GUI 的插件管理器安装
47
47
  # 重启 DSH
48
48
  ```
49
49
 
50
- 发布包里已包含 `lib/` 与 `dsh-eda-bridge-extension-<线协议版本>.zip`,**不需要构建工具链**。扩展仍需手动导入
51
- 嘉立创EDA:zip 位于 `node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<线协议版本>.zip`(也可从设置卡片下载,下载到的文件名自带版本号)。
50
+ 发布包里已包含 `lib/` 与 `lib/extension-digest.json`(**不含 zip**),**不需要构建工具链**。扩展 zip 改成**下载获取**:设置卡片(或下面的直链)从下载服务器取回,插件在把文件交给你之前用自带清单校验 sha256 —— 对不上就拒绝发送,不会让你误装成另一个线协议版本。扩展仍需手动导入嘉立创EDA。
52
51
 
53
52
  直链(不用装包也能下):
54
53
 
@@ -293,5 +293,5 @@ C. 只做 PCB:
293
293
  - 版本纪律:扩展线协议**只递增最小位**(`0.4.1 → 0.4.2`),每次改动必须同时改 `jlc-extension/extension.json` 的 `version` 与桥接的 `VERSION`;不一致时 `eda_status.versionMismatch` 为 true。
294
294
  - 新会话开工顺序:`eda_status`(连没连、版本对不对)→ 本技能(怎么干)→ 再动手;不要先假设环境就绪。
295
295
  | 需要扩展 zip(换机器/给别人) | 不清楚该给哪一份 | `eda_extension_package` 报路径+sha256;`download:true` 从服务器取回并逐字节比对(`verified`),一致才落盘 |
296
- - 扩展 zip 位置:`node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<版本>.zip`,或设置卡片下载(文件名自带版本号)。
296
+ - 扩展 zip 位置:**发布包不再自带 zip**(只有 `lib/extension-digest.json`)。用设置卡片的下载按钮或 `eda_extension_package({download:true})` 从下载服务器取回,插件会用清单 sha256 校验后才落盘;服务器那份对不上时会被拒绝,需要先重新上传。
297
297
  - 排查顺序:`eda_status` → `/api/dsh-eda-mcp/diagnostics`(`clientStartupError` / `skills` / `bridgeHealth`)→ `eda_list_api_methods`(白名单)→ 应用侧 DRC 面板。
package/lib/bridge.mjs CHANGED
@@ -3019,37 +3019,24 @@ async function executeTool(name, rawArgs, timeoutMs) {
3019
3019
  mirror: args.mirror === void 0 ? void 0 : Boolean(args.mirror)
3020
3020
  }], timeoutMs);
3021
3021
  case "eda_extension_package": {
3022
- const manifestCandidates = [
3023
- join(packageRoot(), "jlc-extension", "extension.json"),
3024
- join(packageRoot(), "dsh-eda-bridge-extension.zip")
3025
- ];
3026
- let version = VERSION;
3022
+ const manifestPath = join(packageRoot(), "lib", "extension-digest.json");
3023
+ let expected = {};
3027
3024
  try {
3028
- const manifest = JSON.parse(await readFile(manifestCandidates[0], "utf8"));
3029
- if (typeof manifest.version === "string" && manifest.version !== "") version = manifest.version;
3025
+ expected = JSON.parse(await readFile(manifestPath, "utf8"));
3030
3026
  } catch {
3027
+ expected = {};
3031
3028
  }
3032
- const localCandidates = [
3033
- join(packageRoot(), extensionZipName(version)),
3034
- join(packageRoot(), "dsh-eda-bridge-extension.zip")
3035
- ];
3036
- let localPath = "";
3037
- for (const candidate of localCandidates) {
3038
- if (existsSync(candidate)) {
3039
- localPath = candidate;
3040
- break;
3041
- }
3042
- }
3043
- if (localPath === "") {
3044
- throw new Error("eda_extension_package: no extension zip next to the plugin (looked for " + localCandidates.join(", ") + ")");
3045
- }
3046
- const localBytes = await readFile(localPath);
3047
- const localSha256 = sha256Of(localBytes);
3029
+ const version = typeof expected.version === "string" && expected.version !== "" ? expected.version : VERSION;
3030
+ const expectedSha256 = typeof expected.sha256 === "string" ? expected.sha256 : "";
3031
+ const localPath = join(packageRoot(), extensionZipName(version));
3032
+ const localSource = existsSync(localPath) ? localPath : null;
3033
+ const localBytes = localSource === null ? null : await readFile(localSource);
3048
3034
  const base = typeof args.base === "string" && args.base !== "" ? args.base : process.env.DSH_EDA_EXTENSION_BASE ?? "http://159.75.130.207/eda/";
3049
3035
  const url = extensionDownloadUrl(base, version);
3050
3036
  const report = {
3051
3037
  version,
3052
- local: { path: localPath, bytes: localBytes.byteLength, sha256: localSha256 },
3038
+ expected: { bytes: Number(expected.bytes ?? 0), sha256: expectedSha256, manifest: manifestPath },
3039
+ localZip: localSource === null ? null : { path: localSource, bytes: localBytes.byteLength, sha256: sha256Of(localBytes) },
3053
3040
  url
3054
3041
  };
3055
3042
  if (args.download !== true) return report;
@@ -3065,10 +3052,10 @@ async function executeTool(name, rawArgs, timeoutMs) {
3065
3052
  }
3066
3053
  const remoteBytes = new Uint8Array(await response.arrayBuffer());
3067
3054
  const remoteSha256 = sha256Of(remoteBytes);
3068
- const verdict = compareDigest(localSha256, remoteSha256);
3055
+ const verdict = compareDigest(expectedSha256, remoteSha256);
3069
3056
  const result2 = {
3070
3057
  ...report,
3071
- remote: { url, bytes: remoteBytes.byteLength, sha256: remoteSha256 },
3058
+ served: { url, bytes: remoteBytes.byteLength, sha256: remoteSha256 },
3072
3059
  verified: verdict
3073
3060
  };
3074
3061
  if (verdict === "match") {
@@ -3078,8 +3065,10 @@ async function executeTool(name, rawArgs, timeoutMs) {
3078
3065
  await writeFile(savedPath, remoteBytes);
3079
3066
  result2.savedPath = savedPath;
3080
3067
  result2.next = "In JLCEDA Pro: \u6269\u5C55 \u2192 \u5BFC\u5165\u6269\u5C55 \u2192 select this zip \u2192 restart the application (not a reload).";
3068
+ } else if (verdict === "mismatch") {
3069
+ result2.next = "The served copy differs from the digest this plugin was built with (wire version " + version + "). Do not import it: re-upload " + extensionZipName(version) + " from the build output to the download server.";
3081
3070
  } else {
3082
- result2.next = "The served copy differs from the one this plugin ships. Do not import it: re-upload " + extensionZipName(version) + " from the plugin directory to the download server.";
3071
+ result2.next = "No expected digest in " + manifestPath + "; rebuild the plugin so it records one.";
3083
3072
  }
3084
3073
  return result2;
3085
3074
  }
package/lib/client.js CHANGED
@@ -1137,6 +1137,7 @@ function EdaMcpSettingsCard(props) {
1137
1137
  const [notice, setNotice] = (0, import_react.useState)();
1138
1138
  const [verifyingExtension, setVerifyingExtension] = (0, import_react.useState)(false);
1139
1139
  const [extensionVerdict, setExtensionVerdict] = (0, import_react.useState)();
1140
+ const [extensionInfo, setExtensionInfo] = (0, import_react.useState)();
1140
1141
  const verifyExtension = async () => {
1141
1142
  if (verifyingExtension) return;
1142
1143
  setVerifyingExtension(true);
@@ -1316,29 +1317,37 @@ function EdaMcpSettingsCard(props) {
1316
1317
  }
1317
1318
  ),
1318
1319
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { ...hintStyle, margin: "8px 0 0" }, children: t("settings.portLabel") }),
1319
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Field, { label: t("settings.downloadExtension"), hint: t("settings.installHint"), children: [
1320
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1321
- "a",
1322
- {
1323
- href: "/api/dsh-eda-mcp/extension.zip",
1324
- style: { ...secondaryButtonStyle, textDecoration: "none", display: "inline-flex", width: "fit-content" },
1325
- children: t("settings.downloadExtension")
1326
- }
1327
- ),
1328
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1329
- "button",
1330
- {
1331
- type: "button",
1332
- onClick: () => {
1333
- void verifyExtension();
1334
- },
1335
- disabled: verifyingExtension,
1336
- style: verifyingExtension ? { ...secondaryButtonStyle, ...disabledButtonStyle, marginLeft: 8 } : { ...secondaryButtonStyle, marginLeft: 8 },
1337
- children: verifyingExtension ? t("settings.verifyBusy") : t("settings.verifyExtension")
1338
- }
1339
- ),
1340
- extensionVerdict !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { role: "status", style: { ...hintStyle, margin: "6px 0 0", color: extensionVerdict.kind === "ok" ? void 0 : "#c0392b" }, children: extensionVerdict.text }) : null
1341
- ] })
1320
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1321
+ Field,
1322
+ {
1323
+ label: extensionInfo === void 0 ? t("settings.downloadExtension") : t("settings.downloadExtensionVersioned").replace("{version}", extensionInfo.version),
1324
+ hint: t("settings.installHint"),
1325
+ children: [
1326
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1327
+ "a",
1328
+ {
1329
+ href: "/api/dsh-eda-mcp/extension.zip",
1330
+ style: { ...secondaryButtonStyle, textDecoration: "none", display: "inline-flex", width: "fit-content" },
1331
+ children: extensionInfo === void 0 ? t("settings.downloadExtension") : t("settings.downloadExtensionVersioned").replace("{version}", extensionInfo.version)
1332
+ }
1333
+ ),
1334
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1335
+ "button",
1336
+ {
1337
+ type: "button",
1338
+ onClick: () => {
1339
+ void verifyExtension();
1340
+ },
1341
+ disabled: verifyingExtension,
1342
+ style: verifyingExtension ? { ...secondaryButtonStyle, ...disabledButtonStyle, marginLeft: 8 } : { ...secondaryButtonStyle, marginLeft: 8 },
1343
+ children: verifyingExtension ? t("settings.verifyBusy") : t("settings.verifyExtension")
1344
+ }
1345
+ ),
1346
+ extensionInfo !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { ...hintStyle, margin: "6px 0 0" }, children: t("settings.extensionInfo").replace("{version}", extensionInfo.version).replace("{bytes}", String(extensionInfo.bytes)).replace("{hash}", extensionInfo.sha256.slice(0, 16) + "\u2026") }) : null,
1347
+ extensionVerdict !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { role: "status", style: { ...hintStyle, margin: "6px 0 0", color: extensionVerdict.kind === "ok" ? void 0 : "#c0392b" }, children: extensionVerdict.text }) : null
1348
+ ]
1349
+ }
1350
+ )
1342
1351
  ] }),
1343
1352
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("footer", { style: footerStyle, children: [
1344
1353
  notice !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: noticeStyle, role: "status", children: notice.text }) : null,
@@ -1517,12 +1526,15 @@ var zh = {
1517
1526
  "settings.reconnectFailed": "\u91CD\u8FDE\u5931\u8D25",
1518
1527
  "settings.portLabel": "\u6865\u63A5 WebSocket \u7AEF\u53E3\uFF1A39009\uFF08\u56FA\u5B9A\uFF09",
1519
1528
  "settings.downloadExtension": "\u4E0B\u8F7D JLCEDA \u6269\u5C55\u5305",
1529
+ "settings.downloadExtensionVersioned": "\u4E0B\u8F7D JLCEDA \u6269\u5C55\u5305 v{version}",
1530
+ "settings.extensionInfo": "\u672C\u5730 v{version} \xB7 {bytes} \u5B57\u8282 \xB7 sha256 {hash}",
1531
+ "settings.extensionInfoUnknown": "\u6269\u5C55\u5305\u4FE1\u606F\u4E0D\u53EF\u7528",
1520
1532
  "settings.verifyExtension": "\u6821\u9A8C\u670D\u52A1\u5668\u526F\u672C",
1521
1533
  "settings.verifyBusy": "\u6821\u9A8C\u4E2D\u2026",
1522
1534
  "settings.verifyMatch": "\u4E00\u81F4\uFF1A\u670D\u52A1\u5668\u4E0A\u7684 zip \u4E0E\u672C\u5730\u8FD9\u4EFD\u9010\u5B57\u8282\u76F8\u540C\uFF08sha256 {hash}\uFF09",
1523
1535
  "settings.verifyMismatch": "\u4E0D\u4E00\u81F4\uFF1A\u670D\u52A1\u5668\u90A3\u4EFD\u4E0E\u672C\u5730\u4E0D\u540C\uFF0C\u8BF7\u91CD\u65B0\u4E0A\u4F20 {hash}",
1524
1536
  "settings.verifyUnverified": "\u65E0\u6CD5\u6821\u9A8C\uFF1A{reason}",
1525
- "settings.installHint": "\u5728\u5609\u7ACB\u521BEDA\u4E13\u4E1A\u7248\u4E2D\uFF1A\u6269\u5C55 \u2192 \u5BFC\u5165 \u2192 \u9009\u62E9\u4E0B\u8F7D\u7684 zip \u2192 \u5728\u6269\u5C55\u5217\u8868\u5F00\u542F\u672C\u6269\u5C55\u7684\u300C\u5141\u8BB8\u5916\u90E8\u4EA4\u4E92\u300D\u3002"
1537
+ "settings.installHint": "\u70B9\u4E0B\u8F7D\u4F1A\u4ECE\u4E0B\u8F7D\u670D\u52A1\u5668\u53D6\u56DE\u4E0E\u672C\u63D2\u4EF6\u7EBF\u534F\u8BAE\u4E00\u81F4\u7684 zip\uFF08\u53D1\u9001\u524D\u6821\u9A8C sha256\uFF0C\u4E0D\u4E00\u81F4\u5219\u62D2\u7EDD\u53D1\u9001\uFF09\uFF1B\u5728\u5609\u7ACB\u521BEDA\u4E13\u4E1A\u7248\u4E2D\uFF1A\u6269\u5C55 \u2192 \u5BFC\u5165 \u2192 \u9009\u62E9\u8BE5 zip \u2192 \u5728\u6269\u5C55\u5217\u8868\u5F00\u542F\u672C\u6269\u5C55\u7684\u300C\u5141\u8BB8\u5916\u90E8\u4EA4\u4E92\u300D\u3002"
1526
1538
  };
1527
1539
  var en = {
1528
1540
  "settings.title": "JLCEDA MCP",
@@ -1554,12 +1566,15 @@ var en = {
1554
1566
  "settings.reconnectFailed": "Reconnect failed",
1555
1567
  "settings.portLabel": "Bridge WebSocket port: 39009 (fixed)",
1556
1568
  "settings.downloadExtension": "Download JLCEDA extension zip",
1569
+ "settings.downloadExtensionVersioned": "Download JLCEDA extension zip v{version}",
1570
+ "settings.extensionInfo": "Bundled v{version} \xB7 {bytes} bytes \xB7 sha256 {hash}",
1571
+ "settings.extensionInfoUnknown": "Extension package info unavailable",
1557
1572
  "settings.verifyExtension": "Verify server copy",
1558
1573
  "settings.verifyBusy": "Verifying\u2026",
1559
1574
  "settings.verifyMatch": "Match: the served zip is byte-identical to the local one (sha256 {hash})",
1560
1575
  "settings.verifyMismatch": "Mismatch: the served zip differs \u2014 re-upload {hash}",
1561
1576
  "settings.verifyUnverified": "Could not verify: {reason}",
1562
- "settings.installHint": 'In JLCEDA Pro: Extensions \u2192 Import \u2192 select the downloaded zip \u2192 enable "Allow external interactions" for this extension.'
1577
+ "settings.installHint": 'Download fetches the zip whose wire version matches this plugin from the download server (sha256 is verified before the file is sent; a mismatch is refused). Then in JLCEDA Pro: Extensions \u2192 Import \u2192 select that zip \u2192 enable "Allow external interactions".'
1563
1578
  };
1564
1579
 
1565
1580
  // src/client/index.ts
@@ -0,0 +1,6 @@
1
+ {
2
+ "version": "0.4.9",
3
+ "file": "dsh-eda-bridge-extension-0.4.9.zip",
4
+ "bytes": 9827,
5
+ "sha256": "518fef209574171dda8672dbeb830603bca58acdf3ed576b5c45c14e08929167"
6
+ }
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import * as mcpClient from "@deepseek-ai/dsh-mcp-client";
3
3
  import z from "@deepseek-ai/schemastery";
4
4
  import { fileURLToPath } from "node:url";
5
- import { existsSync as existsSync2, readFileSync } from "node:fs";
5
+ import { readFileSync } from "node:fs";
6
6
  import { dirname as dirname2 } from "node:path";
7
7
 
8
8
  // src/guidance.ts
@@ -326,7 +326,7 @@ var SKILL_PLAYBOOK_LINES = [
326
326
  "- \u7248\u672C\u7EAA\u5F8B\uFF1A\u6269\u5C55\u7EBF\u534F\u8BAE**\u53EA\u9012\u589E\u6700\u5C0F\u4F4D**\uFF08`0.4.1 \u2192 0.4.2`\uFF09\uFF0C\u6BCF\u6B21\u6539\u52A8\u5FC5\u987B\u540C\u65F6\u6539 `jlc-extension/extension.json` \u7684 `version` \u4E0E\u6865\u63A5\u7684 `VERSION`\uFF1B\u4E0D\u4E00\u81F4\u65F6 `eda_status.versionMismatch` \u4E3A true\u3002",
327
327
  "- \u65B0\u4F1A\u8BDD\u5F00\u5DE5\u987A\u5E8F\uFF1A`eda_status`\uFF08\u8FDE\u6CA1\u8FDE\u3001\u7248\u672C\u5BF9\u4E0D\u5BF9\uFF09\u2192 \u672C\u6280\u80FD\uFF08\u600E\u4E48\u5E72\uFF09\u2192 \u518D\u52A8\u624B\uFF1B\u4E0D\u8981\u5148\u5047\u8BBE\u73AF\u5883\u5C31\u7EEA\u3002",
328
328
  "| \u9700\u8981\u6269\u5C55 zip\uFF08\u6362\u673A\u5668/\u7ED9\u522B\u4EBA\uFF09 | \u4E0D\u6E05\u695A\u8BE5\u7ED9\u54EA\u4E00\u4EFD | `eda_extension_package` \u62A5\u8DEF\u5F84+sha256\uFF1B`download:true` \u4ECE\u670D\u52A1\u5668\u53D6\u56DE\u5E76\u9010\u5B57\u8282\u6BD4\u5BF9\uFF08`verified`\uFF09\uFF0C\u4E00\u81F4\u624D\u843D\u76D8 |",
329
- "- \u6269\u5C55 zip \u4F4D\u7F6E\uFF1A`node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<\u7248\u672C>.zip`\uFF0C\u6216\u8BBE\u7F6E\u5361\u7247\u4E0B\u8F7D\uFF08\u6587\u4EF6\u540D\u81EA\u5E26\u7248\u672C\u53F7\uFF09\u3002",
329
+ "- \u6269\u5C55 zip \u4F4D\u7F6E\uFF1A**\u53D1\u5E03\u5305\u4E0D\u518D\u81EA\u5E26 zip**\uFF08\u53EA\u6709 `lib/extension-digest.json`\uFF09\u3002\u7528\u8BBE\u7F6E\u5361\u7247\u7684\u4E0B\u8F7D\u6309\u94AE\u6216 `eda_extension_package({download:true})` \u4ECE\u4E0B\u8F7D\u670D\u52A1\u5668\u53D6\u56DE\uFF0C\u63D2\u4EF6\u4F1A\u7528\u6E05\u5355 sha256 \u6821\u9A8C\u540E\u624D\u843D\u76D8\uFF1B\u670D\u52A1\u5668\u90A3\u4EFD\u5BF9\u4E0D\u4E0A\u65F6\u4F1A\u88AB\u62D2\u7EDD\uFF0C\u9700\u8981\u5148\u91CD\u65B0\u4E0A\u4F20\u3002",
330
330
  "- \u6392\u67E5\u987A\u5E8F\uFF1A`eda_status` \u2192 `/api/dsh-eda-mcp/diagnostics`\uFF08`clientStartupError` / `skills` / `bridgeHealth`\uFF09\u2192 `eda_list_api_methods`\uFF08\u767D\u540D\u5355\uFF09\u2192 \u5E94\u7528\u4FA7 DRC \u9762\u677F\u3002"
331
331
  ];
332
332
  var SKILL_SECTIONS = SKILL_PLAYBOOK_LINES.filter((line) => line.startsWith("## "));
@@ -435,7 +435,6 @@ function createEdaSkillProvider() {
435
435
 
436
436
  // src/routes.ts
437
437
  import { readFile } from "node:fs/promises";
438
- import { basename } from "node:path";
439
438
  import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
440
439
 
441
440
  // src/loopback.ts
@@ -501,6 +500,7 @@ var EDA_MCP_API = {
501
500
  reconnect: "/api/dsh-eda-mcp/reconnect",
502
501
  extensionZip: "/api/dsh-eda-mcp/extension.zip",
503
502
  extensionVerify: "/api/dsh-eda-mcp/extension/verify",
503
+ extensionInfo: "/api/dsh-eda-mcp/extension/info",
504
504
  diagnostics: "/api/dsh-eda-mcp/diagnostics",
505
505
  selfTest: "/api/dsh-eda-mcp/self-test",
506
506
  settingsDescribe: "/api/dsh-eda-mcp/settings/describe",
@@ -622,53 +622,104 @@ function makeEdaMcpRoutes(deps) {
622
622
  }
623
623
  }
624
624
  };
625
+ const readExtensionDigest = async () => {
626
+ try {
627
+ const parsed = JSON.parse(await readFile(deps.extensionDigestPath, "utf8"));
628
+ if (typeof parsed.version !== "string" || typeof parsed.sha256 !== "string" || typeof parsed.file !== "string") return null;
629
+ return { version: parsed.version, file: parsed.file, bytes: Number(parsed.bytes ?? 0), sha256: parsed.sha256 };
630
+ } catch {
631
+ return null;
632
+ }
633
+ };
625
634
  const extensionZip = {
626
635
  kind: "exact",
627
636
  path: EDA_MCP_API.extensionZip,
628
637
  handler: async (req, res) => {
629
638
  if (req.method !== "GET") return json(res, 405, { ok: false, error: "method-not-allowed" });
630
639
  if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: "forbidden" });
631
- let bytes;
640
+ const digest = await readExtensionDigest();
641
+ if (digest === null) {
642
+ return json(res, 503, { ok: false, error: "lib/extension-digest.json is missing; run the build before installing the plugin" });
643
+ }
644
+ const requested = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("source");
645
+ const url = extensionDownloadUrl(deps.extensionDownloadBase, digest.version);
646
+ let response;
632
647
  try {
633
- bytes = await readFile(deps.extensionZipPath);
634
- } catch {
635
- return json(res, 404, { ok: false, error: "extension zip missing next to the plugin" });
648
+ response = await fetch(url, { signal: AbortSignal.timeout(3e4) });
649
+ } catch (error) {
650
+ return json(res, 502, {
651
+ ok: false,
652
+ error: "could not reach the download server: " + (error instanceof Error ? error.message : String(error)),
653
+ url
654
+ });
655
+ }
656
+ if (!response.ok) {
657
+ const throttled = response.status === 429;
658
+ return json(res, throttled ? 429 : 502, {
659
+ ok: false,
660
+ httpStatus: response.status,
661
+ url,
662
+ error: throttled ? "the download server throttled this IP (5 downloads/min per IP). Wait about a minute and click download again." : "the download server answered HTTP " + response.status
663
+ });
664
+ }
665
+ const bytes = Buffer.from(new Uint8Array(await response.arrayBuffer()));
666
+ const servedSha256 = sha256Of(bytes);
667
+ const verdict = compareDigest(digest.sha256, servedSha256);
668
+ if (verdict !== "match") {
669
+ return json(res, 502, {
670
+ ok: false,
671
+ url,
672
+ verified: verdict,
673
+ expected: { version: digest.version, sha256: digest.sha256, bytes: digest.bytes },
674
+ served: { sha256: servedSha256, bytes: bytes.byteLength },
675
+ error: "the served zip is not the build this plugin ships (wire version " + digest.version + "); refusing to hand out a different version. Re-upload " + digest.file + " to the download server."
676
+ });
636
677
  }
637
678
  res.writeHead(200, {
638
679
  "content-type": "application/zip",
639
- // Keep the versioned file name the browser saves it as.
640
- "content-disposition": 'attachment; filename="' + basename(deps.extensionZipPath) + '"',
680
+ "content-disposition": 'attachment; filename="' + digest.file + '"',
641
681
  "cache-control": "no-store",
642
- "x-extension-version": deps.extensionVersion,
643
- "x-extension-sha256": sha256Of(bytes),
644
- "content-length": String(bytes.byteLength)
682
+ "content-length": String(bytes.byteLength),
683
+ "x-extension-source": "server",
684
+ "x-extension-version": digest.version,
685
+ "x-extension-sha256": servedSha256,
686
+ ...requested === "local" ? { "x-extension-note": "source=local requested; the served copy was verified and used" } : {}
645
687
  });
646
688
  res.end(bytes);
647
689
  }
648
690
  };
691
+ const extensionInfo = {
692
+ kind: "exact",
693
+ path: EDA_MCP_API.extensionInfo,
694
+ handler: async (req, res) => {
695
+ if (req.method !== "GET") return json(res, 405, { ok: false, error: "method-not-allowed" });
696
+ if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: "forbidden" });
697
+ const digest = await readExtensionDigest();
698
+ if (digest === null) return json(res, 503, { ok: false, error: "lib/extension-digest.json is missing" });
699
+ json(res, 200, {
700
+ version: digest.version,
701
+ file: digest.file,
702
+ bytes: digest.bytes,
703
+ sha256: digest.sha256,
704
+ downloadPath: EDA_MCP_API.extensionZip,
705
+ serverUrl: extensionDownloadUrl(deps.extensionDownloadBase, digest.version),
706
+ source: "server"
707
+ });
708
+ }
709
+ };
649
710
  const extensionVerify = {
650
711
  kind: "exact",
651
712
  path: EDA_MCP_API.extensionVerify,
652
713
  handler: async (req, res) => {
653
714
  if (req.method !== "GET") return json(res, 405, { ok: false, error: "method-not-allowed" });
654
715
  if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: "forbidden" });
655
- let localBytes;
656
- try {
657
- localBytes = await readFile(deps.extensionZipPath);
658
- } catch {
659
- return json(res, 404, { ok: false, error: "extension zip missing next to the plugin" });
660
- }
661
- const localSha256 = sha256Of(localBytes);
662
- const url = extensionDownloadUrl(deps.extensionDownloadBase, deps.extensionVersion);
663
- const report = {
664
- version: deps.extensionVersion,
665
- file: basename(deps.extensionZipPath),
666
- local: { bytes: localBytes.byteLength, sha256: localSha256 },
667
- url
668
- };
716
+ const digest = await readExtensionDigest();
717
+ if (digest === null) return json(res, 503, { ok: false, error: "lib/extension-digest.json is missing" });
718
+ const url = extensionDownloadUrl(deps.extensionDownloadBase, digest.version);
719
+ const report = { version: digest.version, file: digest.file, expected: { bytes: digest.bytes, sha256: digest.sha256 }, url };
669
720
  let response;
670
721
  try {
671
- response = await fetch(url, { signal: AbortSignal.timeout(2e4) });
722
+ response = await fetch(url, { signal: AbortSignal.timeout(3e4) });
672
723
  } catch (error) {
673
724
  return json(res, 200, { ...report, verified: "unverified", error: "download failed: " + (error instanceof Error ? error.message : String(error)) });
674
725
  }
@@ -680,16 +731,16 @@ function makeEdaMcpRoutes(deps) {
680
731
  error: response.status === 429 ? "the download server throttled this IP (HTTP 429, 5 downloads/min). Wait about a minute and verify again." : "download failed: HTTP " + response.status
681
732
  });
682
733
  }
683
- const remoteBytes = new Uint8Array(await response.arrayBuffer());
684
- const remoteSha256 = sha256Of(remoteBytes);
734
+ const servedBytes = new Uint8Array(await response.arrayBuffer());
735
+ const servedSha256 = sha256Of(servedBytes);
685
736
  return json(res, 200, {
686
737
  ...report,
687
- remote: { bytes: remoteBytes.byteLength, sha256: remoteSha256 },
688
- verified: compareDigest(localSha256, remoteSha256)
738
+ served: { bytes: servedBytes.byteLength, sha256: servedSha256 },
739
+ verified: compareDigest(digest.sha256, servedSha256)
689
740
  });
690
741
  }
691
742
  };
692
- return [test, reconnect, extensionZip, extensionVerify, diagnostics, selfTest];
743
+ return [test, reconnect, extensionZip, extensionInfo, extensionVerify, diagnostics, selfTest];
693
744
  }
694
745
  function makeEdaMcpSettingsRoutes(settings) {
695
746
  const ns = "eda-mcp";
@@ -1028,19 +1079,6 @@ function resolveExtensionVersion() {
1028
1079
  return "";
1029
1080
  }
1030
1081
  }
1031
- function resolveExtensionZipPath() {
1032
- const plain = fileURLToPath(new URL("../dsh-eda-bridge-extension.zip", import.meta.url));
1033
- try {
1034
- const manifest = JSON.parse(readFileSync(fileURLToPath(new URL("../jlc-extension/extension.json", import.meta.url)), "utf8"));
1035
- const version = typeof manifest.version === "string" ? manifest.version : "";
1036
- if (version !== "") {
1037
- const versioned = fileURLToPath(new URL("../dsh-eda-bridge-extension-" + version + ".zip", import.meta.url));
1038
- if (existsSync2(versioned)) return versioned;
1039
- }
1040
- } catch {
1041
- }
1042
- return plain;
1043
- }
1044
1082
  var BRIDGE_PATH = fileURLToPath(BRIDGE_URL);
1045
1083
  function bridgeLaunchFor(active) {
1046
1084
  const runtime = resolveBridgeRuntime();
@@ -1130,7 +1168,7 @@ function apply(ctx, config) {
1130
1168
  ctx.effect(() => {
1131
1169
  const disposers = makeEdaMcpRoutes({
1132
1170
  getConfig: () => current(),
1133
- extensionZipPath: resolveExtensionZipPath(),
1171
+ extensionDigestPath: fileURLToPath(new URL("../lib/extension-digest.json", import.meta.url)),
1134
1172
  extensionVersion: resolveExtensionVersion(),
1135
1173
  extensionDownloadBase: process.env.DSH_EDA_EXTENSION_BASE ?? "http://159.75.130.207/eda/",
1136
1174
  reconnect: remountClient,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-eda-mcp",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "DeepSeek Harness (DSH) plugin — connects DSH to JLCEDA Pro (EasyEDA Pro) via a local MCP bridge and draws schematics through the JLCEDA extension API.",
5
5
  "license": "Apache-2.0",
6
6
  "author": {
@@ -49,7 +49,6 @@
49
49
  "test",
50
50
  "docs",
51
51
  "jlc-extension",
52
- "dsh-eda-bridge-extension-*.zip",
53
52
  "cordis.patch.yml",
54
53
  "CHANGELOG.md",
55
54
  "CONTRIBUTING.md",
@@ -2376,46 +2376,36 @@ async function executeTool(name, rawArgs, timeoutMs) {
2376
2376
  }], timeoutMs)
2377
2377
 
2378
2378
  case 'eda_extension_package': {
2379
- // Two copies of the same artifact exist: the zip inside this npm package and the one
2380
- // on the download server. Nobody could tell whether they match, so this tool reports
2381
- // the local digest and, with download:true, fetches the served copy and compares the
2382
- // two byte for byte. A mismatch is never written to disk.
2383
- const manifestCandidates = [
2384
- join(packageRoot(), 'jlc-extension', 'extension.json'),
2385
- join(packageRoot(), 'dsh-eda-bridge-extension.zip'),
2386
- ]
2387
- let version = VERSION
2379
+ // The package ships only lib/extension-digest.json (no zip): the artifact itself lives on
2380
+ // the download server. The expected digest comes from that manifest, so a served copy can
2381
+ // be verified even on a machine that has never had a local zip.
2382
+ const manifestPath = join(packageRoot(), 'lib', 'extension-digest.json')
2383
+ let expected: { version?: string; file?: string; bytes?: number; sha256?: string } = {}
2388
2384
  try {
2389
- const manifest = JSON.parse(await readFile(manifestCandidates[0], 'utf8')) as { version?: string }
2390
- if (typeof manifest.version === 'string' && manifest.version !== '') version = manifest.version
2391
- } catch { /* keep the bridge VERSION */ }
2392
- const localCandidates = [
2393
- join(packageRoot(), extensionZipName(version)),
2394
- join(packageRoot(), 'dsh-eda-bridge-extension.zip'),
2395
- ]
2396
- let localPath = ''
2397
- for (const candidate of localCandidates) {
2398
- if (existsSync(candidate)) { localPath = candidate; break }
2399
- }
2400
- if (localPath === '') {
2401
- throw new Error('eda_extension_package: no extension zip next to the plugin (looked for ' + localCandidates.join(', ') + ')')
2402
- }
2403
- const localBytes = await readFile(localPath)
2404
- const localSha256 = sha256Of(localBytes)
2385
+ expected = JSON.parse(await readFile(manifestPath, 'utf8')) as typeof expected
2386
+ } catch {
2387
+ expected = {}
2388
+ }
2389
+ const version = typeof expected.version === 'string' && expected.version !== '' ? expected.version : VERSION
2390
+ const expectedSha256 = typeof expected.sha256 === 'string' ? expected.sha256 : ''
2391
+ const localPath = join(packageRoot(), extensionZipName(version))
2392
+ const localSource = existsSync(localPath) ? localPath : null
2393
+ const localBytes = localSource === null ? null : await readFile(localSource)
2405
2394
  const base = typeof args.base === 'string' && args.base !== ''
2406
2395
  ? args.base
2407
2396
  : (process.env.DSH_EDA_EXTENSION_BASE ?? 'http://159.75.130.207/eda/')
2408
2397
  const url = extensionDownloadUrl(base, version)
2409
2398
  const report = {
2410
2399
  version,
2411
- local: { path: localPath, bytes: localBytes.byteLength, sha256: localSha256 },
2400
+ expected: { bytes: Number(expected.bytes ?? 0), sha256: expectedSha256, manifest: manifestPath },
2401
+ localZip: localSource === null
2402
+ ? null
2403
+ : { path: localSource, bytes: localBytes!.byteLength, sha256: sha256Of(localBytes!) },
2412
2404
  url,
2413
2405
  }
2414
2406
  if (args.download !== true) return report
2415
2407
  const response = await fetch(url, { signal: AbortSignal.timeout(30000) })
2416
2408
  if (!response.ok) {
2417
- // 429 is the download server's rate limit (5 req/min per IP), not a broken artifact:
2418
- // say so, otherwise the caller retries in a loop and never succeeds.
2419
2409
  const throttled = response.status === 429
2420
2410
  return {
2421
2411
  ...report,
@@ -2428,10 +2418,10 @@ async function executeTool(name, rawArgs, timeoutMs) {
2428
2418
  }
2429
2419
  const remoteBytes = new Uint8Array(await response.arrayBuffer())
2430
2420
  const remoteSha256 = sha256Of(remoteBytes)
2431
- const verdict = compareDigest(localSha256, remoteSha256)
2421
+ const verdict = compareDigest(expectedSha256, remoteSha256)
2432
2422
  const result: Record<string, unknown> = {
2433
2423
  ...report,
2434
- remote: { url, bytes: remoteBytes.byteLength, sha256: remoteSha256 },
2424
+ served: { url, bytes: remoteBytes.byteLength, sha256: remoteSha256 },
2435
2425
  verified: verdict,
2436
2426
  }
2437
2427
  if (verdict === 'match') {
@@ -2441,9 +2431,11 @@ async function executeTool(name, rawArgs, timeoutMs) {
2441
2431
  await writeFile(savedPath, remoteBytes)
2442
2432
  result.savedPath = savedPath
2443
2433
  result.next = 'In JLCEDA Pro: 扩展 → 导入扩展 → select this zip → restart the application (not a reload).'
2434
+ } else if (verdict === 'mismatch') {
2435
+ result.next = 'The served copy differs from the digest this plugin was built with (wire version ' + version +
2436
+ '). Do not import it: re-upload ' + extensionZipName(version) + ' from the build output to the download server.'
2444
2437
  } else {
2445
- result.next = 'The served copy differs from the one this plugin ships. Do not import it: re-upload ' +
2446
- extensionZipName(version) + ' from the plugin directory to the download server.'
2438
+ result.next = 'No expected digest in ' + manifestPath + '; rebuild the plugin so it records one.'
2447
2439
  }
2448
2440
  return result
2449
2441
  }
@@ -329,6 +329,8 @@ export function EdaMcpSettingsCard(props: EdaMcpSettingsCardProps) {
329
329
  const [notice, setNotice] = useState<{ kind: 'ok' | 'error'; text: string } | undefined>()
330
330
  const [verifyingExtension, setVerifyingExtension] = useState(false)
331
331
  const [extensionVerdict, setExtensionVerdict] = useState<{ kind: 'ok' | 'error'; text: string } | undefined>()
332
+ const [extensionInfo, setExtensionInfo] = useState<{ version: string; bytes: number; sha256: string } | undefined>()
333
+
332
334
 
333
335
  const verifyExtension = async (): Promise<void> => {
334
336
  if (verifyingExtension) return
@@ -491,14 +493,21 @@ export function EdaMcpSettingsCard(props: EdaMcpSettingsCardProps) {
491
493
 
492
494
  <p style={{ ...hintStyle, margin: '8px 0 0' }}>{t('settings.portLabel')}</p>
493
495
 
494
- <Field label={t('settings.downloadExtension')} hint={t('settings.installHint')}>
496
+ <Field
497
+ label={extensionInfo === undefined
498
+ ? t('settings.downloadExtension')
499
+ : t('settings.downloadExtensionVersioned').replace('{version}', extensionInfo.version)}
500
+ hint={t('settings.installHint')}
501
+ >
495
502
  <a
496
503
  href="/api/dsh-eda-mcp/extension.zip"
497
504
  // No download name: the server sends the versioned file name, so the
498
505
  // saved copy always states which build it is.
499
506
  style={{ ...secondaryButtonStyle, textDecoration: 'none', display: 'inline-flex', width: 'fit-content' }}
500
507
  >
501
- {t('settings.downloadExtension')}
508
+ {extensionInfo === undefined
509
+ ? t('settings.downloadExtension')
510
+ : t('settings.downloadExtensionVersioned').replace('{version}', extensionInfo.version)}
502
511
  </a>
503
512
  <button
504
513
  type="button"
@@ -508,6 +517,12 @@ export function EdaMcpSettingsCard(props: EdaMcpSettingsCardProps) {
508
517
  >
509
518
  {verifyingExtension ? t('settings.verifyBusy') : t('settings.verifyExtension')}
510
519
  </button>
520
+ {extensionInfo !== undefined
521
+ ? <p style={{ ...hintStyle, margin: '6px 0 0' }}>{t('settings.extensionInfo')
522
+ .replace('{version}', extensionInfo.version)
523
+ .replace('{bytes}', String(extensionInfo.bytes))
524
+ .replace('{hash}', extensionInfo.sha256.slice(0, 16) + '…')}</p>
525
+ : null}
511
526
  {extensionVerdict !== undefined
512
527
  ? <p role="status" style={{ ...hintStyle, margin: '6px 0 0', color: extensionVerdict.kind === 'ok' ? undefined : '#c0392b' }}>{extensionVerdict.text}</p>
513
528
  : null}
@@ -28,12 +28,15 @@ export const zh = {
28
28
  'settings.reconnectFailed': '重连失败',
29
29
  'settings.portLabel': '桥接 WebSocket 端口:39009(固定)',
30
30
  'settings.downloadExtension': '下载 JLCEDA 扩展包',
31
+ 'settings.downloadExtensionVersioned': '下载 JLCEDA 扩展包 v{version}',
32
+ 'settings.extensionInfo': '本地 v{version} · {bytes} 字节 · sha256 {hash}',
33
+ 'settings.extensionInfoUnknown': '扩展包信息不可用',
31
34
  'settings.verifyExtension': '校验服务器副本',
32
35
  'settings.verifyBusy': '校验中…',
33
36
  'settings.verifyMatch': '一致:服务器上的 zip 与本地这份逐字节相同(sha256 {hash})',
34
37
  'settings.verifyMismatch': '不一致:服务器那份与本地不同,请重新上传 {hash}',
35
38
  'settings.verifyUnverified': '无法校验:{reason}',
36
- 'settings.installHint': '在嘉立创EDA专业版中:扩展 → 导入 → 选择下载的 zip → 在扩展列表开启本扩展的「允许外部交互」。',
39
+ 'settings.installHint': '点下载会从下载服务器取回与本插件线协议一致的 zip(发送前校验 sha256,不一致则拒绝发送);在嘉立创EDA专业版中:扩展 → 导入 → 选择该 zip → 在扩展列表开启本扩展的「允许外部交互」。',
37
40
  } as const
38
41
 
39
42
  export const en = {
@@ -66,12 +69,15 @@ export const en = {
66
69
  'settings.reconnectFailed': 'Reconnect failed',
67
70
  'settings.portLabel': 'Bridge WebSocket port: 39009 (fixed)',
68
71
  'settings.downloadExtension': 'Download JLCEDA extension zip',
72
+ 'settings.downloadExtensionVersioned': 'Download JLCEDA extension zip v{version}',
73
+ 'settings.extensionInfo': 'Bundled v{version} · {bytes} bytes · sha256 {hash}',
74
+ 'settings.extensionInfoUnknown': 'Extension package info unavailable',
69
75
  'settings.verifyExtension': 'Verify server copy',
70
76
  'settings.verifyBusy': 'Verifying…',
71
77
  'settings.verifyMatch': 'Match: the served zip is byte-identical to the local one (sha256 {hash})',
72
78
  'settings.verifyMismatch': 'Mismatch: the served zip differs — re-upload {hash}',
73
79
  'settings.verifyUnverified': 'Could not verify: {reason}',
74
- 'settings.installHint': 'In JLCEDA Pro: Extensions → Import → select the downloaded zip → enable "Allow external interactions" for this extension.',
80
+ 'settings.installHint': 'Download fetches the zip whose wire version matches this plugin from the download server (sha256 is verified before the file is sent; a mismatch is refused). Then in JLCEDA Pro: Extensions → Import → select that zip → enable "Allow external interactions".',
75
81
  } as const
76
82
 
77
83
  export type EdaMcpKey = keyof typeof zh
package/src/index.ts CHANGED
@@ -166,21 +166,6 @@ function resolveExtensionVersion(): string {
166
166
  }
167
167
  }
168
168
 
169
- function resolveExtensionZipPath(): string {
170
- const plain = fileURLToPath(new URL('../dsh-eda-bridge-extension.zip', import.meta.url))
171
- try {
172
- const manifest = JSON.parse(readFileSync(fileURLToPath(new URL('../jlc-extension/extension.json', import.meta.url)), 'utf8')) as { version?: string }
173
- const version = typeof manifest.version === 'string' ? manifest.version : ''
174
- if (version !== '') {
175
- const versioned = fileURLToPath(new URL('../dsh-eda-bridge-extension-' + version + '.zip', import.meta.url))
176
- if (existsSync(versioned)) return versioned
177
- }
178
- } catch {
179
- // Bundle-only installs may lack the manifest; the plain name is the fallback.
180
- }
181
- return plain
182
- }
183
-
184
169
  /** Bridge script path, resolved once. */
185
170
  const BRIDGE_PATH = fileURLToPath(BRIDGE_URL)
186
171
 
@@ -300,7 +285,7 @@ let current: () => EdaMcpConfig = () => unwrapConfig(config ?? {})
300
285
  ctx.effect(() => {
301
286
  const disposers = makeEdaMcpRoutes({
302
287
  getConfig: () => current(),
303
- extensionZipPath: resolveExtensionZipPath(),
288
+ extensionDigestPath: fileURLToPath(new URL('../lib/extension-digest.json', import.meta.url)),
304
289
  extensionVersion: resolveExtensionVersion(),
305
290
  extensionDownloadBase: process.env.DSH_EDA_EXTENSION_BASE ?? 'http://159.75.130.207/eda/',
306
291
  reconnect: remountClient,
package/src/routes.ts CHANGED
@@ -34,6 +34,7 @@ export const EDA_MCP_API = {
34
34
  reconnect: '/api/dsh-eda-mcp/reconnect',
35
35
  extensionZip: '/api/dsh-eda-mcp/extension.zip',
36
36
  extensionVerify: '/api/dsh-eda-mcp/extension/verify',
37
+ extensionInfo: '/api/dsh-eda-mcp/extension/info',
37
38
  diagnostics: '/api/dsh-eda-mcp/diagnostics',
38
39
  selfTest: '/api/dsh-eda-mcp/self-test',
39
40
  settingsDescribe: '/api/dsh-eda-mcp/settings/describe',
@@ -88,7 +89,7 @@ function toView(descriptor: SettingsBridgeView): unknown {
88
89
 
89
90
  export interface EdaMcpRouteDeps {
90
91
  getConfig: () => EdaMcpConfig
91
- extensionZipPath: string
92
+ extensionDigestPath: string
92
93
  /** Wire version of the bundled extension zip (from jlc-extension/extension.json). */
93
94
  extensionVersion: string
94
95
  /** Base URL of the download server; override with DSH_EDA_EXTENSION_BASE. */
@@ -185,62 +186,120 @@ export function makeEdaMcpRoutes(deps: EdaMcpRouteDeps): WebRoute[] {
185
186
  },
186
187
  }
187
188
 
188
- // The card serves the LOCAL copy on purpose: it works offline and cannot be throttled.
189
- // The digest travels with it, and a separate verify route compares it with the served copy
190
- // so "is this the same artifact?" has an answer without downloading twice by hand.
189
+ // The package ships no zip any more (only lib/extension-digest.json), so the card downloads
190
+ // from the download server. Every served copy is compared against the build-recorded digest
191
+ // first: a different wire version is refused rather than handed to the user, and a 429 is
192
+ // reported as throttling instead of looking like a broken artifact.
193
+ const readExtensionDigest = async (): Promise<{ version: string; file: string; bytes: number; sha256: string } | null> => {
194
+ try {
195
+ const parsed = JSON.parse(await readFile(deps.extensionDigestPath, 'utf8')) as {
196
+ version?: string; file?: string; bytes?: number; sha256?: string
197
+ }
198
+ if (typeof parsed.version !== 'string' || typeof parsed.sha256 !== 'string' || typeof parsed.file !== 'string') return null
199
+ return { version: parsed.version, file: parsed.file, bytes: Number(parsed.bytes ?? 0), sha256: parsed.sha256 }
200
+ } catch {
201
+ return null
202
+ }
203
+ }
204
+
191
205
  const extensionZip: WebRoute = {
192
206
  kind: 'exact',
193
207
  path: EDA_MCP_API.extensionZip,
194
208
  handler: async (req, res): Promise<void> => {
195
209
  if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'method-not-allowed' })
196
210
  if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: 'forbidden' })
197
- let bytes: Buffer
211
+ const digest = await readExtensionDigest()
212
+ if (digest === null) {
213
+ return json(res, 503, { ok: false, error: 'lib/extension-digest.json is missing; run the build before installing the plugin' })
214
+ }
215
+ const requested = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('source')
216
+ const url = extensionDownloadUrl(deps.extensionDownloadBase, digest.version)
217
+ let response: Response
198
218
  try {
199
- bytes = await readFile(deps.extensionZipPath)
200
- } catch {
201
- return json(res, 404, { ok: false, error: 'extension zip missing next to the plugin' })
219
+ response = await fetch(url, { signal: AbortSignal.timeout(30000) })
220
+ } catch (error) {
221
+ return json(res, 502, {
222
+ ok: false,
223
+ error: 'could not reach the download server: ' + (error instanceof Error ? error.message : String(error)),
224
+ url,
225
+ })
226
+ }
227
+ if (!response.ok) {
228
+ const throttled = response.status === 429
229
+ return json(res, throttled ? 429 : 502, {
230
+ ok: false,
231
+ httpStatus: response.status,
232
+ url,
233
+ error: throttled
234
+ ? 'the download server throttled this IP (5 downloads/min per IP). Wait about a minute and click download again.'
235
+ : 'the download server answered HTTP ' + response.status,
236
+ })
237
+ }
238
+ const bytes = Buffer.from(new Uint8Array(await response.arrayBuffer()))
239
+ const servedSha256 = sha256Of(bytes)
240
+ const verdict = compareDigest(digest.sha256, servedSha256)
241
+ if (verdict !== 'match') {
242
+ return json(res, 502, {
243
+ ok: false,
244
+ url,
245
+ verified: verdict,
246
+ expected: { version: digest.version, sha256: digest.sha256, bytes: digest.bytes },
247
+ served: { sha256: servedSha256, bytes: bytes.byteLength },
248
+ error: 'the served zip is not the build this plugin ships (wire version ' + digest.version + '); '
249
+ + 'refusing to hand out a different version. Re-upload ' + digest.file + ' to the download server.',
250
+ })
202
251
  }
203
252
  res.writeHead(200, {
204
253
  'content-type': 'application/zip',
205
- // Keep the versioned file name the browser saves it as.
206
- 'content-disposition': 'attachment; filename="' + basename(deps.extensionZipPath) + '"',
254
+ 'content-disposition': 'attachment; filename="' + digest.file + '"',
207
255
  'cache-control': 'no-store',
208
- 'x-extension-version': deps.extensionVersion,
209
- 'x-extension-sha256': sha256Of(bytes),
210
256
  'content-length': String(bytes.byteLength),
257
+ 'x-extension-source': 'server',
258
+ 'x-extension-version': digest.version,
259
+ 'x-extension-sha256': servedSha256,
260
+ ...(requested === 'local' ? { 'x-extension-note': 'source=local requested; the served copy was verified and used' } : {}),
211
261
  })
212
262
  res.end(bytes)
213
263
  },
214
264
  }
215
265
 
266
+ const extensionInfo: WebRoute = {
267
+ kind: 'exact',
268
+ path: EDA_MCP_API.extensionInfo,
269
+ handler: async (req, res): Promise<void> => {
270
+ if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'method-not-allowed' })
271
+ if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: 'forbidden' })
272
+ const digest = await readExtensionDigest()
273
+ if (digest === null) return json(res, 503, { ok: false, error: 'lib/extension-digest.json is missing' })
274
+ json(res, 200, {
275
+ version: digest.version,
276
+ file: digest.file,
277
+ bytes: digest.bytes,
278
+ sha256: digest.sha256,
279
+ downloadPath: EDA_MCP_API.extensionZip,
280
+ serverUrl: extensionDownloadUrl(deps.extensionDownloadBase, digest.version),
281
+ source: 'server',
282
+ })
283
+ },
284
+ }
285
+
216
286
  const extensionVerify: WebRoute = {
217
287
  kind: 'exact',
218
288
  path: EDA_MCP_API.extensionVerify,
219
289
  handler: async (req, res): Promise<void> => {
220
290
  if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'method-not-allowed' })
221
291
  if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: 'forbidden' })
222
- let localBytes: Buffer
223
- try {
224
- localBytes = await readFile(deps.extensionZipPath)
225
- } catch {
226
- return json(res, 404, { ok: false, error: 'extension zip missing next to the plugin' })
227
- }
228
- const localSha256 = sha256Of(localBytes)
229
- const url = extensionDownloadUrl(deps.extensionDownloadBase, deps.extensionVersion)
230
- const report: Record<string, unknown> = {
231
- version: deps.extensionVersion,
232
- file: basename(deps.extensionZipPath),
233
- local: { bytes: localBytes.byteLength, sha256: localSha256 },
234
- url,
235
- }
292
+ const digest = await readExtensionDigest()
293
+ if (digest === null) return json(res, 503, { ok: false, error: 'lib/extension-digest.json is missing' })
294
+ const url = extensionDownloadUrl(deps.extensionDownloadBase, digest.version)
295
+ const report = { version: digest.version, file: digest.file, expected: { bytes: digest.bytes, sha256: digest.sha256 }, url }
236
296
  let response: Response
237
297
  try {
238
- response = await fetch(url, { signal: AbortSignal.timeout(20000) })
298
+ response = await fetch(url, { signal: AbortSignal.timeout(30000) })
239
299
  } catch (error) {
240
300
  return json(res, 200, { ...report, verified: 'unverified', error: 'download failed: ' + (error instanceof Error ? error.message : String(error)) })
241
301
  }
242
302
  if (!response.ok) {
243
- // 429 = the server throttles an IP to 5 downloads/min; that is not a checksum problem.
244
303
  return json(res, 200, {
245
304
  ...report,
246
305
  verified: 'unverified',
@@ -250,17 +309,16 @@ export function makeEdaMcpRoutes(deps: EdaMcpRouteDeps): WebRoute[] {
250
309
  : 'download failed: HTTP ' + response.status,
251
310
  })
252
311
  }
253
- const remoteBytes = new Uint8Array(await response.arrayBuffer())
254
- const remoteSha256 = sha256Of(remoteBytes)
312
+ const servedBytes = new Uint8Array(await response.arrayBuffer())
313
+ const servedSha256 = sha256Of(servedBytes)
255
314
  return json(res, 200, {
256
315
  ...report,
257
- remote: { bytes: remoteBytes.byteLength, sha256: remoteSha256 },
258
- verified: compareDigest(localSha256, remoteSha256),
316
+ served: { bytes: servedBytes.byteLength, sha256: servedSha256 },
317
+ verified: compareDigest(digest.sha256, servedSha256),
259
318
  })
260
319
  },
261
320
  }
262
-
263
- return [test, reconnect, extensionZip, extensionVerify, diagnostics, selfTest]
321
+ return [test, reconnect, extensionZip, extensionInfo, extensionVerify, diagnostics, selfTest]
264
322
  }
265
323
 
266
324
  export function makeEdaMcpSettingsRoutes(settings: SettingsBridge): WebRoute[] {
@@ -300,7 +300,7 @@ export const SKILL_PLAYBOOK_LINES: readonly string[] = [
300
300
  "- 版本纪律:扩展线协议**只递增最小位**(`0.4.1 → 0.4.2`),每次改动必须同时改 `jlc-extension/extension.json` 的 `version` 与桥接的 `VERSION`;不一致时 `eda_status.versionMismatch` 为 true。",
301
301
  "- 新会话开工顺序:`eda_status`(连没连、版本对不对)→ 本技能(怎么干)→ 再动手;不要先假设环境就绪。",
302
302
  "| 需要扩展 zip(换机器/给别人) | 不清楚该给哪一份 | `eda_extension_package` 报路径+sha256;`download:true` 从服务器取回并逐字节比对(`verified`),一致才落盘 |",
303
- "- 扩展 zip 位置:`node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<版本>.zip`,或设置卡片下载(文件名自带版本号)。",
303
+ "- 扩展 zip 位置:**发布包不再自带 zip**(只有 `lib/extension-digest.json`)。用设置卡片的下载按钮或 `eda_extension_package({download:true})` 从下载服务器取回,插件会用清单 sha256 校验后才落盘;服务器那份对不上时会被拒绝,需要先重新上传。",
304
304
  "- 排查顺序:`eda_status` → `/api/dsh-eda-mcp/diagnostics`(`clientStartupError` / `skills` / `bridgeHealth`)→ `eda_list_api_methods`(白名单)→ 应用侧 DRC 面板。",
305
305
  ]
306
306
 
@@ -1,34 +1,40 @@
1
1
  import { strict as assert } from 'node:assert'
2
2
  import { createServer } from 'node:http'
3
- import { readFileSync } from 'node:fs'
3
+ import { mkdtempSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
4
6
  import { makeEdaMcpRoutes, EDA_MCP_API } from '../src/routes.ts'
5
7
  import { sha256Of } from '../src/bridge/extension-package.ts'
6
8
 
7
- const zipPath = 'dsh-eda-bridge-extension-0.4.9.zip'
8
- const localBytes = readFileSync(zipPath)
9
- const localSha = sha256Of(localBytes)
9
+ // The package ships no zip: only lib/extension-digest.json. These cases pin the contract that
10
+ // the download route serves the SERVER copy but only after it matches the build digest.
11
+ const goodBytes = Buffer.from('PK\x03\x04 pretending to be the extension zip')
12
+ const goodSha = sha256Of(goodBytes)
13
+ const digestDir = mkdtempSync(join(tmpdir(), 'dsh-digest-'))
14
+ const digestPath = join(digestDir, 'extension-digest.json')
15
+ writeFileSync(digestPath, JSON.stringify({
16
+ version: '0.4.9',
17
+ file: 'dsh-eda-bridge-extension-0.4.9.zip',
18
+ bytes: goodBytes.byteLength,
19
+ sha256: goodSha,
20
+ }))
10
21
 
11
- // Minimal IncomingMessage/ServerResponse doubles: the handlers only use method, socket,
12
- // url, writeHead, end and (for errors) destroy.
13
- function fakeReq(method: string) {
14
- return { method, url: '/', socket: { remoteAddress: '127.0.0.1' }, headers: { host: '127.0.0.1' } }
22
+ function fakeReq(method: string, url = '/') {
23
+ return { method, url, socket: { remoteAddress: '127.0.0.1' }, headers: { host: '127.0.0.1' } }
15
24
  }
16
25
  function fakeRes() {
17
26
  const captured: { status?: number; headers?: Record<string, string>; body?: unknown; bytes?: Buffer } = {}
18
27
  const res = {
19
28
  writeHead(status: number, headers: Record<string, string>) { captured.status = status; captured.headers = headers; return res },
20
29
  end(payload?: unknown) { if (Buffer.isBuffer(payload)) captured.bytes = payload; else if (typeof payload === 'string') captured.body = payload; return res },
21
- destroy() { return res },
22
- getHeader() { return undefined },
23
- setHeader() { return res },
30
+ destroy() { return res }, getHeader() { return undefined }, setHeader() { return res },
24
31
  }
25
32
  return { res, captured }
26
33
  }
27
-
28
34
  function deps(base: string) {
29
35
  return {
30
36
  getConfig: () => ({}),
31
- extensionZipPath: zipPath,
37
+ extensionDigestPath: digestPath,
32
38
  extensionVersion: '0.4.9',
33
39
  extensionDownloadBase: base,
34
40
  reconnect: () => {},
@@ -38,13 +44,11 @@ function deps(base: string) {
38
44
  selfTest: async () => ({}),
39
45
  }
40
46
  }
41
-
42
47
  function routeOf(routes: ReturnType<typeof makeEdaMcpRoutes>, path: string) {
43
48
  const route = routes.find(r => r.path === path)
44
49
  assert.ok(route, 'route ' + path + ' must exist')
45
50
  return route
46
51
  }
47
-
48
52
  async function withServer(handler: Parameters<typeof createServer>[1], run: (base: string) => Promise<void>) {
49
53
  const server = createServer(handler)
50
54
  await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
@@ -52,44 +56,61 @@ async function withServer(handler: Parameters<typeof createServer>[1], run: (bas
52
56
  try { await run('http://127.0.0.1:' + port + '/eda/') } finally { await new Promise<void>(resolve => server.close(() => resolve())) }
53
57
  }
54
58
 
55
- // 1. the zip route stamps the digest it is handing out
56
- {
57
- const routes = makeEdaMcpRoutes(deps('http://127.0.0.1:1/eda/'))
59
+ // 1. matching served copy -> handed out, labelled as coming from the server
60
+ await withServer((_req, res) => { res.writeHead(200); res.end(goodBytes) }, async (base) => {
58
61
  const { res, captured } = fakeRes()
59
- await routeOf(routes, EDA_MCP_API.extensionZip).handler(fakeReq('GET') as never, res as never)
62
+ await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionZip).handler(fakeReq('GET') as never, res as never)
60
63
  assert.equal(captured.status, 200)
61
- assert.equal(captured.headers?.['x-extension-sha256'], localSha, 'digest header must describe the bytes served')
62
- assert.equal(captured.headers?.['content-length'], String(localBytes.byteLength))
63
- assert.equal(captured.headers?.['content-disposition']?.includes(zipPath), true, 'versioned file name is kept')
64
- assert.equal(sha256Of(captured.bytes as Buffer), localSha, 'served bytes are the local zip')
65
- }
64
+ assert.equal(captured.headers?.['x-extension-source'], 'server')
65
+ assert.equal(captured.headers?.['x-extension-sha256'], goodSha)
66
+ assert.equal(captured.headers?.['content-disposition'], 'attachment; filename="dsh-eda-bridge-extension-0.4.9.zip"')
67
+ assert.equal(sha256Of(captured.bytes as Buffer), goodSha, 'the bytes handed out are the served ones')
68
+ })
66
69
 
67
- // 2. identical remote copy -> match
68
- await withServer((_req, res) => { res.writeHead(200, { 'content-type': 'application/zip' }); res.end(localBytes) }, async (base) => {
70
+ // 2. different wire version served -> refused, nothing downloaded
71
+ await withServer((_req, res) => { res.writeHead(200); res.end(Buffer.from('some other build')) }, async (base) => {
69
72
  const { res, captured } = fakeRes()
70
- await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionVerify).handler(fakeReq('GET') as never, res as never)
73
+ await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionZip).handler(fakeReq('GET') as never, res as never)
71
74
  const body = JSON.parse(String(captured.body))
72
- assert.equal(body.verified, 'match', 'identical bytes verify')
73
- assert.equal(body.local.sha256, localSha)
74
- assert.equal(body.remote.sha256, localSha)
75
+ assert.equal(captured.status, 502)
76
+ assert.equal(body.expected.sha256, goodSha)
77
+ assert.equal(String(body.error).includes('refusing'), true)
78
+ assert.equal(captured.bytes, undefined, 'no bytes may be handed out on mismatch')
75
79
  })
76
80
 
77
- // 3. different remote copy -> mismatch (never silently equal)
78
- await withServer((_req, res) => { res.writeHead(200); res.end(Buffer.from('different bytes')) }, async (base) => {
81
+ // 3. throttled server -> 429 surfaced as throttling
82
+ await withServer((_req, res) => { res.writeHead(429); res.end('slow down') }, async (base) => {
83
+ const { res, captured } = fakeRes()
84
+ await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionZip).handler(fakeReq('GET') as never, res as never)
85
+ const body = JSON.parse(String(captured.body))
86
+ assert.equal(captured.status, 429)
87
+ assert.equal(body.httpStatus, 429)
88
+ assert.equal(String(body.error).includes('throttled'), true)
89
+ })
90
+
91
+ // 4. verify route compares against the digest without any local zip
92
+ await withServer((_req, res) => { res.writeHead(200); res.end(goodBytes) }, async (base) => {
93
+ const { res, captured } = fakeRes()
94
+ await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionVerify).handler(fakeReq('GET') as never, res as never)
95
+ const body = JSON.parse(String(captured.body))
96
+ assert.equal(body.verified, 'match')
97
+ assert.equal(body.expected.sha256, goodSha)
98
+ })
99
+ await withServer((_req, res) => { res.writeHead(200); res.end(Buffer.from('other')) }, async (base) => {
79
100
  const { res, captured } = fakeRes()
80
101
  await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionVerify).handler(fakeReq('GET') as never, res as never)
81
102
  const body = JSON.parse(String(captured.body))
82
103
  assert.equal(body.verified, 'mismatch')
83
104
  })
84
105
 
85
- // 4. throttled remote copy -> unverified + status, not a mismatch
86
- await withServer((_req, res) => { res.writeHead(429, { 'content-type': 'text/plain' }); res.end('slow down') }, async (base) => {
106
+ // 5. info route needs no network and reports the build digest
107
+ await withServer((_req, res) => { res.writeHead(500); res.end('must not be called') }, async (base) => {
87
108
  const { res, captured } = fakeRes()
88
- await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionVerify).handler(fakeReq('GET') as never, res as never)
109
+ await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionInfo).handler(fakeReq('GET') as never, res as never)
89
110
  const body = JSON.parse(String(captured.body))
90
- assert.equal(body.verified, 'unverified', '429 is throttling, not a checksum mismatch')
91
- assert.equal(body.httpStatus, 429)
92
- assert.equal(String(body.error).includes('429'), true)
111
+ assert.equal(body.version, '0.4.9')
112
+ assert.equal(body.sha256, goodSha)
113
+ assert.equal(body.source, 'server')
93
114
  })
94
115
 
95
116
  console.log('ALL PASS extension-routes')
Binary file