dsh-eda-mcp 1.1.0 → 1.1.1

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.1 — 2026-10-21
4
+
5
+ ### 设置卡片:下载仍是本地那份,但**能校验服务器副本**
6
+
7
+ - 事实澄清(改前就是如此):设置卡片的「下载 JLCEDA 扩展包」是 `<a href="/api/dsh-eda-mcp/extension.zip">`,由插件读**包内自带**的 zip 直接回传,**不经过服务器**。这是有意的:离线也能装、不会被限流、不依赖服务器可用性。
8
+ - 现在响应头带 `x-extension-version`、`x-extension-sha256`、`content-length`,下载到的文件可自行核对。
9
+ - 新增路由 `GET /api/dsh-eda-mcp/extension/verify`:从下载服务器取回同一文件并逐字节比对,返回 `verified: match | mismatch | unverified`(429 会带 `httpStatus` 与"是限流不是不一致"的说明)。卡片上多一个「校验服务器副本」按钮直接显示结果。
10
+ - 新增第 **11** 套单测 `extension-routes`:用真实本地 HTTP 服务模拟三种服务器响应(同字节 / 不同字节 / 429),断言 `match`、`mismatch`、`unverified + 429`,并断言下载响应的 sha256 头与实际发出的字节一致。
11
+
12
+
3
13
  ## 1.1.0 — 2026-10-21
4
14
 
5
15
  ### `/eda/` 下载限流(服务器侧,5 次/分钟/IP)
package/README.md CHANGED
@@ -58,6 +58,14 @@ produce the same sha256.
58
58
  The download server throttles `/eda/` to 5 requests/minute per IP (burst 5, loopback exempt), so
59
59
  a `429` shows up as `verified: 'unverified'` with `httpStatus: 429` — that is throttling, not a
60
60
  checksum mismatch; wait a minute and retry.
61
+ The settings card serves the **bundled** copy (works offline, never throttled) and stamps
62
+ `x-extension-version` / `x-extension-sha256` on the response; its "Verify server copy" button calls
63
+ `/api/dsh-eda-mcp/extension/verify` and reports match / mismatch / why verification was impossible
64
+ (429 throttling included).
65
+ The settings card serves the **bundled** copy (works offline, never throttled) and stamps
66
+ `x-extension-version` / `x-extension-sha256` on the response; its "Verify server copy" button calls
67
+ `/api/dsh-eda-mcp/extension/verify` and reports match / mismatch / why verification was impossible
68
+ (429 throttling included).
61
69
 
62
70
  ### 1. Install into the DSH web profile
63
71
 
package/README.zh.md CHANGED
@@ -61,6 +61,10 @@ pnpm add dsh-eda-mcp # 或用 Web GUI 的插件管理器安装
61
61
 
62
62
  下载服务器对 `/eda/` 限流 **5 次/分钟/IP**(burst 5,回环地址豁免):被限流时工具返回 `verified: unverified` 且 `httpStatus: 429` —— 这是限流,不是校验不一致,等一分钟重试即可。
63
63
 
64
+ 设置卡片的下载按钮给的是**插件自带的那份**(离线可用、不被限流),响应头带 `x-extension-version` 与 `x-extension-sha256`;旁边的「校验服务器副本」按钮调用 `/api/dsh-eda-mcp/extension/verify`,逐字节比对后给出 `match / mismatch / 无法校验(含 429 限流原因)`。
65
+
66
+ 设置卡片的下载按钮给的是**插件自带的那份**(离线可用、不被限流),响应头带 `x-extension-version` 与 `x-extension-sha256`;旁边的「校验服务器副本」按钮调用 `/api/dsh-eda-mcp/extension/verify`,逐字节比对后直接给出 `match / mismatch / 无法校验(含 429 限流原因)`。
67
+
64
68
  ### 1. 安装到 DSH Web profile
65
69
 
66
70
  ```sh
package/lib/client.js CHANGED
@@ -1135,6 +1135,30 @@ function EdaMcpSettingsCard(props) {
1135
1135
  const [testing, setTesting] = (0, import_react.useState)(false);
1136
1136
  const [reconnecting, setReconnecting] = (0, import_react.useState)(false);
1137
1137
  const [notice, setNotice] = (0, import_react.useState)();
1138
+ const [verifyingExtension, setVerifyingExtension] = (0, import_react.useState)(false);
1139
+ const [extensionVerdict, setExtensionVerdict] = (0, import_react.useState)();
1140
+ const verifyExtension = async () => {
1141
+ if (verifyingExtension) return;
1142
+ setVerifyingExtension(true);
1143
+ try {
1144
+ const response = await fetch("/api/dsh-eda-mcp/extension/verify");
1145
+ const body = await response.json();
1146
+ if (body.verified === "match") {
1147
+ const short = String(body.local?.sha256 ?? "").slice(0, 16) + "\u2026";
1148
+ setExtensionVerdict({ kind: "ok", text: t("settings.verifyMatch").replace("{hash}", short) });
1149
+ } else if (body.verified === "mismatch") {
1150
+ setExtensionVerdict({ kind: "error", text: t("settings.verifyMismatch").replace("{hash}", String(body.file ?? "")) });
1151
+ } else {
1152
+ const reason = String(body.error ?? "unknown");
1153
+ setExtensionVerdict({ kind: "error", text: t("settings.verifyUnverified").replace("{reason}", reason) });
1154
+ }
1155
+ } catch (error) {
1156
+ const reason = error instanceof Error ? error.message : String(error);
1157
+ setExtensionVerdict({ kind: "error", text: t("settings.verifyUnverified").replace("{reason}", reason) });
1158
+ } finally {
1159
+ setVerifyingExtension(false);
1160
+ }
1161
+ };
1138
1162
  (0, import_react.useEffect)(() => {
1139
1163
  if (!seeded && state.status === "ready") {
1140
1164
  setDraft(state.config ?? {});
@@ -1292,14 +1316,29 @@ function EdaMcpSettingsCard(props) {
1292
1316
  }
1293
1317
  ),
1294
1318
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { ...hintStyle, margin: "8px 0 0" }, children: t("settings.portLabel") }),
1295
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Field, { label: t("settings.downloadExtension"), hint: t("settings.installHint"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1296
- "a",
1297
- {
1298
- href: "/api/dsh-eda-mcp/extension.zip",
1299
- style: { ...secondaryButtonStyle, textDecoration: "none", display: "inline-flex", width: "fit-content" },
1300
- children: t("settings.downloadExtension")
1301
- }
1302
- ) })
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
+ ] })
1303
1342
  ] }),
1304
1343
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("footer", { style: footerStyle, children: [
1305
1344
  notice !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: noticeStyle, role: "status", children: notice.text }) : null,
@@ -1478,6 +1517,11 @@ var zh = {
1478
1517
  "settings.reconnectFailed": "\u91CD\u8FDE\u5931\u8D25",
1479
1518
  "settings.portLabel": "\u6865\u63A5 WebSocket \u7AEF\u53E3\uFF1A39009\uFF08\u56FA\u5B9A\uFF09",
1480
1519
  "settings.downloadExtension": "\u4E0B\u8F7D JLCEDA \u6269\u5C55\u5305",
1520
+ "settings.verifyExtension": "\u6821\u9A8C\u670D\u52A1\u5668\u526F\u672C",
1521
+ "settings.verifyBusy": "\u6821\u9A8C\u4E2D\u2026",
1522
+ "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
+ "settings.verifyMismatch": "\u4E0D\u4E00\u81F4\uFF1A\u670D\u52A1\u5668\u90A3\u4EFD\u4E0E\u672C\u5730\u4E0D\u540C\uFF0C\u8BF7\u91CD\u65B0\u4E0A\u4F20 {hash}",
1524
+ "settings.verifyUnverified": "\u65E0\u6CD5\u6821\u9A8C\uFF1A{reason}",
1481
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"
1482
1526
  };
1483
1527
  var en = {
@@ -1510,6 +1554,11 @@ var en = {
1510
1554
  "settings.reconnectFailed": "Reconnect failed",
1511
1555
  "settings.portLabel": "Bridge WebSocket port: 39009 (fixed)",
1512
1556
  "settings.downloadExtension": "Download JLCEDA extension zip",
1557
+ "settings.verifyExtension": "Verify server copy",
1558
+ "settings.verifyBusy": "Verifying\u2026",
1559
+ "settings.verifyMatch": "Match: the served zip is byte-identical to the local one (sha256 {hash})",
1560
+ "settings.verifyMismatch": "Mismatch: the served zip differs \u2014 re-upload {hash}",
1561
+ "settings.verifyUnverified": "Could not verify: {reason}",
1513
1562
  "settings.installHint": 'In JLCEDA Pro: Extensions \u2192 Import \u2192 select the downloaded zip \u2192 enable "Allow external interactions" for this extension.'
1514
1563
  };
1515
1564
 
package/lib/index.js CHANGED
@@ -434,7 +434,7 @@ function createEdaSkillProvider() {
434
434
  }
435
435
 
436
436
  // src/routes.ts
437
- import { createReadStream } from "node:fs";
437
+ import { readFile } from "node:fs/promises";
438
438
  import { basename } from "node:path";
439
439
  import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
440
440
 
@@ -475,11 +475,32 @@ function isLoopbackRequest(request) {
475
475
  }
476
476
  }
477
477
 
478
+ // src/bridge/extension-package.ts
479
+ import { createHash } from "node:crypto";
480
+ function sha256Of(bytes) {
481
+ return createHash("sha256").update(bytes).digest("hex");
482
+ }
483
+ function extensionZipName(version) {
484
+ return "dsh-eda-bridge-extension-" + version + ".zip";
485
+ }
486
+ function extensionDownloadUrl(base, version) {
487
+ const normalised = base.endsWith("/") ? base : base + "/";
488
+ return new URL(extensionZipName(version), normalised).toString();
489
+ }
490
+ function compareDigest(expected, actual) {
491
+ if (typeof expected !== "string" || typeof actual !== "string") return "unverified";
492
+ const left = expected.trim().toLowerCase();
493
+ const right = actual.trim().toLowerCase();
494
+ if (left === "" || right === "") return "unverified";
495
+ return left === right ? "match" : "mismatch";
496
+ }
497
+
478
498
  // src/routes.ts
479
499
  var EDA_MCP_API = {
480
500
  test: "/api/dsh-eda-mcp/test",
481
501
  reconnect: "/api/dsh-eda-mcp/reconnect",
482
502
  extensionZip: "/api/dsh-eda-mcp/extension.zip",
503
+ extensionVerify: "/api/dsh-eda-mcp/extension/verify",
483
504
  diagnostics: "/api/dsh-eda-mcp/diagnostics",
484
505
  selfTest: "/api/dsh-eda-mcp/self-test",
485
506
  settingsDescribe: "/api/dsh-eda-mcp/settings/describe",
@@ -607,20 +628,68 @@ function makeEdaMcpRoutes(deps) {
607
628
  handler: async (req, res) => {
608
629
  if (req.method !== "GET") return json(res, 405, { ok: false, error: "method-not-allowed" });
609
630
  if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: "forbidden" });
631
+ let bytes;
632
+ try {
633
+ bytes = await readFile(deps.extensionZipPath);
634
+ } catch {
635
+ return json(res, 404, { ok: false, error: "extension zip missing next to the plugin" });
636
+ }
610
637
  res.writeHead(200, {
611
638
  "content-type": "application/zip",
612
639
  // Keep the versioned file name the browser saves it as.
613
640
  "content-disposition": 'attachment; filename="' + basename(deps.extensionZipPath) + '"',
614
- "cache-control": "no-store"
641
+ "cache-control": "no-store",
642
+ "x-extension-version": deps.extensionVersion,
643
+ "x-extension-sha256": sha256Of(bytes),
644
+ "content-length": String(bytes.byteLength)
615
645
  });
616
- const stream = createReadStream(deps.extensionZipPath);
617
- stream.on("error", () => {
618
- res.destroy();
646
+ res.end(bytes);
647
+ }
648
+ };
649
+ const extensionVerify = {
650
+ kind: "exact",
651
+ path: EDA_MCP_API.extensionVerify,
652
+ handler: async (req, res) => {
653
+ if (req.method !== "GET") return json(res, 405, { ok: false, error: "method-not-allowed" });
654
+ 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
+ };
669
+ let response;
670
+ try {
671
+ response = await fetch(url, { signal: AbortSignal.timeout(2e4) });
672
+ } catch (error) {
673
+ return json(res, 200, { ...report, verified: "unverified", error: "download failed: " + (error instanceof Error ? error.message : String(error)) });
674
+ }
675
+ if (!response.ok) {
676
+ return json(res, 200, {
677
+ ...report,
678
+ verified: "unverified",
679
+ httpStatus: response.status,
680
+ 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
+ });
682
+ }
683
+ const remoteBytes = new Uint8Array(await response.arrayBuffer());
684
+ const remoteSha256 = sha256Of(remoteBytes);
685
+ return json(res, 200, {
686
+ ...report,
687
+ remote: { bytes: remoteBytes.byteLength, sha256: remoteSha256 },
688
+ verified: compareDigest(localSha256, remoteSha256)
619
689
  });
620
- stream.pipe(res);
621
690
  }
622
691
  };
623
- return [test, reconnect, extensionZip, diagnostics, selfTest];
692
+ return [test, reconnect, extensionZip, extensionVerify, diagnostics, selfTest];
624
693
  }
625
694
  function makeEdaMcpSettingsRoutes(settings) {
626
695
  const ns = "eda-mcp";
@@ -951,6 +1020,14 @@ function installSettingsSection(ctx, scoped, ns, schema, entry, hooks) {
951
1020
  }
952
1021
  var inject = ["webServer", "systemPrompt"];
953
1022
  var BRIDGE_URL = new URL("../lib/bridge.mjs", import.meta.url);
1023
+ function resolveExtensionVersion() {
1024
+ try {
1025
+ const manifest = JSON.parse(readFileSync(fileURLToPath(new URL("../jlc-extension/extension.json", import.meta.url)), "utf8"));
1026
+ return typeof manifest.version === "string" ? manifest.version : "";
1027
+ } catch {
1028
+ return "";
1029
+ }
1030
+ }
954
1031
  function resolveExtensionZipPath() {
955
1032
  const plain = fileURLToPath(new URL("../dsh-eda-bridge-extension.zip", import.meta.url));
956
1033
  try {
@@ -1054,6 +1131,8 @@ function apply(ctx, config) {
1054
1131
  const disposers = makeEdaMcpRoutes({
1055
1132
  getConfig: () => current(),
1056
1133
  extensionZipPath: resolveExtensionZipPath(),
1134
+ extensionVersion: resolveExtensionVersion(),
1135
+ extensionDownloadBase: process.env.DSH_EDA_EXTENSION_BASE ?? "http://159.75.130.207/eda/",
1057
1136
  reconnect: remountClient,
1058
1137
  bridgeLaunch: describeBridgeLaunch,
1059
1138
  // Surface the skill registration so "did it register?" is answerable from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-eda-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
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": {
@@ -72,7 +72,7 @@
72
72
  "scripts": {
73
73
  "build": "node build.mjs",
74
74
  "typecheck": "tsc --noEmit",
75
- "test": "node --experimental-strip-types test/node-runtime.test.ts && node --experimental-strip-types test/config-values.test.ts && node --experimental-strip-types test/design-rules.test.ts && node --experimental-strip-types test/seams.test.ts && node --experimental-strip-types test/eda-launch.test.ts && node --experimental-strip-types test/schematic-audit.test.ts && node --experimental-strip-types test/design-intake.test.ts && node --experimental-strip-types test/extension-package.test.ts && node --experimental-strip-types test/netlist-erc.test.ts && node --experimental-strip-types test/skills.test.ts",
75
+ "test": "node --experimental-strip-types test/node-runtime.test.ts && node --experimental-strip-types test/config-values.test.ts && node --experimental-strip-types test/design-rules.test.ts && node --experimental-strip-types test/seams.test.ts && node --experimental-strip-types test/eda-launch.test.ts && node --experimental-strip-types test/schematic-audit.test.ts && node --experimental-strip-types test/design-intake.test.ts && node --experimental-strip-types test/extension-package.test.ts && node --experimental-strip-types test/extension-routes.test.ts && node --experimental-strip-types test/netlist-erc.test.ts && node --experimental-strip-types test/skills.test.ts",
76
76
  "seam-check": "node scripts/seam-check.mjs",
77
77
  "contract-check": "node scripts/contract-check.mjs",
78
78
  "guard:bundle": "node scripts/guard-bundle-paths.mjs",
@@ -327,6 +327,36 @@ export function EdaMcpSettingsCard(props: EdaMcpSettingsCardProps) {
327
327
  const [testing, setTesting] = useState(false)
328
328
  const [reconnecting, setReconnecting] = useState(false)
329
329
  const [notice, setNotice] = useState<{ kind: 'ok' | 'error'; text: string } | undefined>()
330
+ const [verifyingExtension, setVerifyingExtension] = useState(false)
331
+ const [extensionVerdict, setExtensionVerdict] = useState<{ kind: 'ok' | 'error'; text: string } | undefined>()
332
+
333
+ const verifyExtension = async (): Promise<void> => {
334
+ if (verifyingExtension) return
335
+ setVerifyingExtension(true)
336
+ try {
337
+ const response = await fetch('/api/dsh-eda-mcp/extension/verify')
338
+ const body = await response.json() as {
339
+ verified?: 'match' | 'mismatch' | 'unverified'
340
+ file?: string
341
+ local?: { sha256?: string }
342
+ error?: string
343
+ }
344
+ if (body.verified === 'match') {
345
+ const short = String(body.local?.sha256 ?? '').slice(0, 16) + '…'
346
+ setExtensionVerdict({ kind: 'ok', text: t('settings.verifyMatch').replace('{hash}', short) })
347
+ } else if (body.verified === 'mismatch') {
348
+ setExtensionVerdict({ kind: 'error', text: t('settings.verifyMismatch').replace('{hash}', String(body.file ?? '')) })
349
+ } else {
350
+ const reason = String(body.error ?? 'unknown')
351
+ setExtensionVerdict({ kind: 'error', text: t('settings.verifyUnverified').replace('{reason}', reason) })
352
+ }
353
+ } catch (error) {
354
+ const reason = error instanceof Error ? error.message : String(error)
355
+ setExtensionVerdict({ kind: 'error', text: t('settings.verifyUnverified').replace('{reason}', reason) })
356
+ } finally {
357
+ setVerifyingExtension(false)
358
+ }
359
+ }
330
360
 
331
361
  useEffect(() => {
332
362
  if (!seeded && state.status === 'ready') {
@@ -470,6 +500,17 @@ export function EdaMcpSettingsCard(props: EdaMcpSettingsCardProps) {
470
500
  >
471
501
  {t('settings.downloadExtension')}
472
502
  </a>
503
+ <button
504
+ type="button"
505
+ onClick={() => { void verifyExtension() }}
506
+ disabled={verifyingExtension}
507
+ style={verifyingExtension ? { ...secondaryButtonStyle, ...disabledButtonStyle, marginLeft: 8 } : { ...secondaryButtonStyle, marginLeft: 8 }}
508
+ >
509
+ {verifyingExtension ? t('settings.verifyBusy') : t('settings.verifyExtension')}
510
+ </button>
511
+ {extensionVerdict !== undefined
512
+ ? <p role="status" style={{ ...hintStyle, margin: '6px 0 0', color: extensionVerdict.kind === 'ok' ? undefined : '#c0392b' }}>{extensionVerdict.text}</p>
513
+ : null}
473
514
  </Field>
474
515
  </div>
475
516
 
@@ -28,6 +28,11 @@ export const zh = {
28
28
  'settings.reconnectFailed': '重连失败',
29
29
  'settings.portLabel': '桥接 WebSocket 端口:39009(固定)',
30
30
  'settings.downloadExtension': '下载 JLCEDA 扩展包',
31
+ 'settings.verifyExtension': '校验服务器副本',
32
+ 'settings.verifyBusy': '校验中…',
33
+ 'settings.verifyMatch': '一致:服务器上的 zip 与本地这份逐字节相同(sha256 {hash})',
34
+ 'settings.verifyMismatch': '不一致:服务器那份与本地不同,请重新上传 {hash}',
35
+ 'settings.verifyUnverified': '无法校验:{reason}',
31
36
  'settings.installHint': '在嘉立创EDA专业版中:扩展 → 导入 → 选择下载的 zip → 在扩展列表开启本扩展的「允许外部交互」。',
32
37
  } as const
33
38
 
@@ -61,6 +66,11 @@ export const en = {
61
66
  'settings.reconnectFailed': 'Reconnect failed',
62
67
  'settings.portLabel': 'Bridge WebSocket port: 39009 (fixed)',
63
68
  'settings.downloadExtension': 'Download JLCEDA extension zip',
69
+ 'settings.verifyExtension': 'Verify server copy',
70
+ 'settings.verifyBusy': 'Verifying…',
71
+ 'settings.verifyMatch': 'Match: the served zip is byte-identical to the local one (sha256 {hash})',
72
+ 'settings.verifyMismatch': 'Mismatch: the served zip differs — re-upload {hash}',
73
+ 'settings.verifyUnverified': 'Could not verify: {reason}',
64
74
  'settings.installHint': 'In JLCEDA Pro: Extensions → Import → select the downloaded zip → enable "Allow external interactions" for this extension.',
65
75
  } as const
66
76
 
package/src/index.ts CHANGED
@@ -157,6 +157,15 @@ const BRIDGE_URL = new URL('../lib/bridge.mjs', import.meta.url)
157
157
  * that manifest instead of hard-coding a file name; older layouts without a
158
158
  * versioned file keep working through the plain-name fallback.
159
159
  */
160
+ function resolveExtensionVersion(): string {
161
+ try {
162
+ const manifest = JSON.parse(readFileSync(fileURLToPath(new URL('../jlc-extension/extension.json', import.meta.url)), 'utf8')) as { version?: string }
163
+ return typeof manifest.version === 'string' ? manifest.version : ''
164
+ } catch {
165
+ return ''
166
+ }
167
+ }
168
+
160
169
  function resolveExtensionZipPath(): string {
161
170
  const plain = fileURLToPath(new URL('../dsh-eda-bridge-extension.zip', import.meta.url))
162
171
  try {
@@ -292,6 +301,8 @@ let current: () => EdaMcpConfig = () => unwrapConfig(config ?? {})
292
301
  const disposers = makeEdaMcpRoutes({
293
302
  getConfig: () => current(),
294
303
  extensionZipPath: resolveExtensionZipPath(),
304
+ extensionVersion: resolveExtensionVersion(),
305
+ extensionDownloadBase: process.env.DSH_EDA_EXTENSION_BASE ?? 'http://159.75.130.207/eda/',
295
306
  reconnect: remountClient,
296
307
  bridgeLaunch: describeBridgeLaunch,
297
308
  // Surface the skill registration so "did it register?" is answerable from
package/src/routes.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createReadStream } from 'node:fs'
1
+ import { readFile } from 'node:fs/promises'
2
2
  import { basename } from 'node:path'
3
3
  import type { ServerResponse } from 'node:http'
4
4
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
@@ -27,11 +27,13 @@ export interface SettingsBridge {
27
27
  }
28
28
  import type { EdaMcpConfig } from './index.ts'
29
29
  import { isLoopbackRequest } from './loopback.ts'
30
+ import { compareDigest, extensionDownloadUrl, sha256Of } from './bridge/extension-package.ts'
30
31
 
31
32
  export const EDA_MCP_API = {
32
33
  test: '/api/dsh-eda-mcp/test',
33
34
  reconnect: '/api/dsh-eda-mcp/reconnect',
34
35
  extensionZip: '/api/dsh-eda-mcp/extension.zip',
36
+ extensionVerify: '/api/dsh-eda-mcp/extension/verify',
35
37
  diagnostics: '/api/dsh-eda-mcp/diagnostics',
36
38
  selfTest: '/api/dsh-eda-mcp/self-test',
37
39
  settingsDescribe: '/api/dsh-eda-mcp/settings/describe',
@@ -87,6 +89,10 @@ function toView(descriptor: SettingsBridgeView): unknown {
87
89
  export interface EdaMcpRouteDeps {
88
90
  getConfig: () => EdaMcpConfig
89
91
  extensionZipPath: string
92
+ /** Wire version of the bundled extension zip (from jlc-extension/extension.json). */
93
+ extensionVersion: string
94
+ /** Base URL of the download server; override with DSH_EDA_EXTENSION_BASE. */
95
+ extensionDownloadBase: string
90
96
  /** Dispose and recreate the MCP bridge child, then return immediately. */
91
97
  reconnect: () => void
92
98
  /** Which executable and script the bridge child is started with (diagnostics). */
@@ -179,25 +185,82 @@ export function makeEdaMcpRoutes(deps: EdaMcpRouteDeps): WebRoute[] {
179
185
  },
180
186
  }
181
187
 
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.
182
191
  const extensionZip: WebRoute = {
183
192
  kind: 'exact',
184
193
  path: EDA_MCP_API.extensionZip,
185
194
  handler: async (req, res): Promise<void> => {
186
195
  if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'method-not-allowed' })
187
196
  if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: 'forbidden' })
197
+ let bytes: Buffer
198
+ try {
199
+ bytes = await readFile(deps.extensionZipPath)
200
+ } catch {
201
+ return json(res, 404, { ok: false, error: 'extension zip missing next to the plugin' })
202
+ }
188
203
  res.writeHead(200, {
189
204
  'content-type': 'application/zip',
190
205
  // Keep the versioned file name the browser saves it as.
191
206
  'content-disposition': 'attachment; filename="' + basename(deps.extensionZipPath) + '"',
192
207
  'cache-control': 'no-store',
208
+ 'x-extension-version': deps.extensionVersion,
209
+ 'x-extension-sha256': sha256Of(bytes),
210
+ 'content-length': String(bytes.byteLength),
211
+ })
212
+ res.end(bytes)
213
+ },
214
+ }
215
+
216
+ const extensionVerify: WebRoute = {
217
+ kind: 'exact',
218
+ path: EDA_MCP_API.extensionVerify,
219
+ handler: async (req, res): Promise<void> => {
220
+ if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'method-not-allowed' })
221
+ 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
+ }
236
+ let response: Response
237
+ try {
238
+ response = await fetch(url, { signal: AbortSignal.timeout(20000) })
239
+ } catch (error) {
240
+ return json(res, 200, { ...report, verified: 'unverified', error: 'download failed: ' + (error instanceof Error ? error.message : String(error)) })
241
+ }
242
+ if (!response.ok) {
243
+ // 429 = the server throttles an IP to 5 downloads/min; that is not a checksum problem.
244
+ return json(res, 200, {
245
+ ...report,
246
+ verified: 'unverified',
247
+ httpStatus: response.status,
248
+ error: response.status === 429
249
+ ? 'the download server throttled this IP (HTTP 429, 5 downloads/min). Wait about a minute and verify again.'
250
+ : 'download failed: HTTP ' + response.status,
251
+ })
252
+ }
253
+ const remoteBytes = new Uint8Array(await response.arrayBuffer())
254
+ const remoteSha256 = sha256Of(remoteBytes)
255
+ return json(res, 200, {
256
+ ...report,
257
+ remote: { bytes: remoteBytes.byteLength, sha256: remoteSha256 },
258
+ verified: compareDigest(localSha256, remoteSha256),
193
259
  })
194
- const stream = createReadStream(deps.extensionZipPath)
195
- stream.on('error', () => { res.destroy() })
196
- stream.pipe(res)
197
260
  },
198
261
  }
199
262
 
200
- return [test, reconnect, extensionZip, diagnostics, selfTest]
263
+ return [test, reconnect, extensionZip, extensionVerify, diagnostics, selfTest]
201
264
  }
202
265
 
203
266
  export function makeEdaMcpSettingsRoutes(settings: SettingsBridge): WebRoute[] {
@@ -0,0 +1,95 @@
1
+ import { strict as assert } from 'node:assert'
2
+ import { createServer } from 'node:http'
3
+ import { readFileSync } from 'node:fs'
4
+ import { makeEdaMcpRoutes, EDA_MCP_API } from '../src/routes.ts'
5
+ import { sha256Of } from '../src/bridge/extension-package.ts'
6
+
7
+ const zipPath = 'dsh-eda-bridge-extension-0.4.9.zip'
8
+ const localBytes = readFileSync(zipPath)
9
+ const localSha = sha256Of(localBytes)
10
+
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' } }
15
+ }
16
+ function fakeRes() {
17
+ const captured: { status?: number; headers?: Record<string, string>; body?: unknown; bytes?: Buffer } = {}
18
+ const res = {
19
+ writeHead(status: number, headers: Record<string, string>) { captured.status = status; captured.headers = headers; return res },
20
+ 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 },
24
+ }
25
+ return { res, captured }
26
+ }
27
+
28
+ function deps(base: string) {
29
+ return {
30
+ getConfig: () => ({}),
31
+ extensionZipPath: zipPath,
32
+ extensionVersion: '0.4.9',
33
+ extensionDownloadBase: base,
34
+ reconnect: () => {},
35
+ bridgeLaunch: () => ({ command: 'node', args: [], source: 'test' }),
36
+ diagnostics: () => ({}),
37
+ launchConfig: () => ({}),
38
+ selfTest: async () => ({}),
39
+ }
40
+ }
41
+
42
+ function routeOf(routes: ReturnType<typeof makeEdaMcpRoutes>, path: string) {
43
+ const route = routes.find(r => r.path === path)
44
+ assert.ok(route, 'route ' + path + ' must exist')
45
+ return route
46
+ }
47
+
48
+ async function withServer(handler: Parameters<typeof createServer>[1], run: (base: string) => Promise<void>) {
49
+ const server = createServer(handler)
50
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
51
+ const port = (server.address() as { port: number }).port
52
+ try { await run('http://127.0.0.1:' + port + '/eda/') } finally { await new Promise<void>(resolve => server.close(() => resolve())) }
53
+ }
54
+
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/'))
58
+ const { res, captured } = fakeRes()
59
+ await routeOf(routes, EDA_MCP_API.extensionZip).handler(fakeReq('GET') as never, res as never)
60
+ 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
+ }
66
+
67
+ // 2. identical remote copy -> match
68
+ await withServer((_req, res) => { res.writeHead(200, { 'content-type': 'application/zip' }); res.end(localBytes) }, async (base) => {
69
+ const { res, captured } = fakeRes()
70
+ await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionVerify).handler(fakeReq('GET') as never, res as never)
71
+ 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
+ })
76
+
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) => {
79
+ const { res, captured } = fakeRes()
80
+ await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionVerify).handler(fakeReq('GET') as never, res as never)
81
+ const body = JSON.parse(String(captured.body))
82
+ assert.equal(body.verified, 'mismatch')
83
+ })
84
+
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) => {
87
+ const { res, captured } = fakeRes()
88
+ await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionVerify).handler(fakeReq('GET') as never, res as never)
89
+ 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)
93
+ })
94
+
95
+ console.log('ALL PASS extension-routes')