dsh-eda-mcp 1.0.2 → 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,36 @@
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
+
13
+ ## 1.1.0 — 2026-10-21
14
+
15
+ ### `/eda/` 下载限流(服务器侧,5 次/分钟/IP)
16
+
17
+ 服务器新增 nginx 令牌桶限流:`limit_req_zone $eda_rate_key zone=eda_download:10m rate=5r/m`,`burst=5 nodelay`,超限返回 **429** 并带可读响应体;`127.0.0.1`/`::1` 用 `geo` 置空 key 白名单放行,服务器自查与上传校验不会被限流。区域配置在 `/etc/nginx/conf.d/eda-ratelimit.conf`,限流指令在 `/eda/` location 内。
18
+
19
+ - 实测:连续请求 → 前 6 次 `200`,第 7 次起 `429`;被限流期间 `/dreamspeedrun/` 仍 `200`、`/files/` 仍 `403`(未受影响)。
20
+ - `eda_extension_package` 遇 `429` 时返回 `verified: unverified` + `httpStatus: 429` 并明确说明"是限流,不是校验不一致"(真机触发验证过);`verified` 绝不会把限流误报成 `mismatch`。
21
+ - 记录一个事实:nginx **不支持 `rate=20r/h`**(单位只有 `r/s`、`r/m`,实测 `invalid rate`),所以按你的决定改为 `5r/m`。
22
+
23
+
24
+
25
+ ### 扩展 zip 的下载与校验(线协议不变 0.4.9)
26
+
27
+ 之前只有"本地那份"和"服务器那份",**没有任何东西能证明二者一致**,也没有从服务器下载的能力:
28
+
29
+ - 新增工具 `eda_extension_package`:报告插件自带 zip 的路径/字节数/sha256;`download:true` 时从下载服务器取回同一文件并**逐字节比较**,给出 `verified: match | mismatch`;一致才写入 `outDir`(默认临时目录),不一致**不落盘**并提示重新上传;`base` 可用 `DSH_EDA_EXTENSION_BASE` 覆盖。
30
+ - 新增纯函数模块 `src/bridge/extension-package.ts`(sha256、URL 拼接、"缺摘要算 unverified 而不是通过")+ `test/extension-package.test.ts`(进入 `pnpm test`,共 10 套)。
31
+ - **修掉一个真 bug**:`build.mjs` 的 `Compress-Archive` 把文件 mtime 打进 zip,同一份源码两次构建得到**不同字节**(实测同为 9827 字节、sha256 不同),校验因此永远假阴性。现在打包前把暂存目录(含子目录)mtime 固定为 2020-01-01,**构建可复现**:连续两次 build 同为 `827e9ad9…`。
32
+ - 真机验证:上传可复现 zip → 服务器 `sha256sum` 与本地一致 → `eda_extension_package({download:true})` 返回 `verified: match` 并落盘。
33
+
3
34
  ## 1.0.2 — 2026-10-20
4
35
 
5
36
  ### 覆铜调查结案(扩展 0.4.7 / 0.4.8 真机)
package/CONTRIBUTING.md CHANGED
@@ -106,6 +106,39 @@ git push origin main --follow-tags
106
106
  extension have their own version pair (they are the wire contract between the
107
107
  bridge process and JLCEDA, and `eda_status` reports a mismatch).
108
108
 
109
+ ### Uploading the extension zip to the download server
110
+
111
+ Every wire-version bump produces a new `dsh-eda-bridge-extension-<version>.zip` (it is
112
+ gitignored, so the file lives only in the npm tarball and on the download server). The
113
+ maintainer asked for the server copy to be refreshed on every update — do it in the same
114
+ session that bumps the extension, not later:
115
+
116
+ 1. upload the new zip to `/var/www/eda-download/` on `tencent` (`root@159.75.130.207`)
117
+ **without deleting older versions** — the version is in the filename, so old links keep
118
+ working;
119
+ 2. verify the transfer: the remote `sha256sum` must equal `Get-FileHash` locally, and
120
+ `curl -sI http://159.75.130.207/eda/<zip>` must answer `200` + `Content-Type:
121
+ application/zip` + the exact byte count;
122
+ 3. only then hand the link out:
123
+ `http://159.75.130.207/eda/dsh-eda-bridge-extension-<version>.zip`
124
+ (the listing at `http://159.75.130.207/eda/` shows every published zip).
125
+
126
+ The directory is served by one additive `location /eda/` block in
127
+ `/etc/nginx/conf.d/voice.conf`; adding or replacing files needs no nginx change and no
128
+ reload. Keep every other part of that server untouched.
129
+
130
+ ### Rate limit
131
+
132
+ `/eda/` is throttled to **5 requests/minute per IP with burst 5** (nginx `limit_req`,
133
+ `rate=5r/m`): six requests pass immediately, the seventh answers `429` with a readable body.
134
+ `127.0.0.1` and `::1` are exempt (geo key is empty), so server-side checks never throttle.
135
+ The zone lives in `/etc/nginx/conf.d/eda-ratelimit.conf`, the limit itself inside the `/eda/`
136
+ location; changing the rate needs `nginx -t` plus a reload.
137
+
138
+ Verifying an upload from an office IP can therefore hit the limit: `eda_extension_package`
139
+ reports that as `verified: 'unverified'` with `httpStatus: 429` — wait about a minute and
140
+ retry instead of suspecting the artifact. A `429` is never a checksum mismatch.
141
+
109
142
  Publishing to npm is documented step by step in [`docs/release.md`](docs/release.md):
110
143
  first release is manual (2FA / staged publishing), afterwards the `publish`
111
144
  workflow releases on every `v*` tag through npm trusted publishing (OIDC, no
package/README.md CHANGED
@@ -41,6 +41,32 @@ toolchain is needed. The extension still has to be imported into JLCEDA Pro by h
41
41
  sits at `node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<version>.zip` (or download it from the
42
42
  settings card).
43
43
 
44
+ Direct links (no package install needed):
45
+
46
+ - `http://159.75.130.207/eda/dsh-eda-bridge-extension-0.4.9.zip` (current wire version)
47
+ - `http://159.75.130.207/eda/` - directory listing of every uploaded extension zip
48
+
49
+ The filename always carries the wire version; check `eda_status` after importing and restart JLCEDA
50
+ Pro when the version differs from the bridge.
51
+
52
+ Verification: `eda_extension_package` reports the zip this plugin ships (path, bytes, sha256) and,
53
+ with `download: true`, fetches the served copy and compares the two byte for byte (`verified: match |
54
+ mismatch`). On a match it saves the file to `outDir` (default: the temp directory); on a mismatch it
55
+ writes nothing and tells you to re-upload. The zip build is reproducible, so equal sources always
56
+ produce the same sha256.
57
+
58
+ The download server throttles `/eda/` to 5 requests/minute per IP (burst 5, loopback exempt), so
59
+ a `429` shows up as `verified: 'unverified'` with `httpStatus: 429` — that is throttling, not a
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).
69
+
44
70
  ### 1. Install into the DSH web profile
45
71
 
46
72
  ```sh
package/README.zh.md CHANGED
@@ -50,6 +50,21 @@ pnpm add dsh-eda-mcp # 或用 Web GUI 的插件管理器安装
50
50
  发布包里已包含 `lib/` 与 `dsh-eda-bridge-extension-<线协议版本>.zip`,**不需要构建工具链**。扩展仍需手动导入
51
51
  嘉立创EDA:zip 位于 `node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<线协议版本>.zip`(也可从设置卡片下载,下载到的文件名自带版本号)。
52
52
 
53
+ 直链(不用装包也能下):
54
+
55
+ - `http://159.75.130.207/eda/dsh-eda-bridge-extension-0.4.9.zip`(当前线协议版本)
56
+ - `http://159.75.130.207/eda/` —— 所有已上传扩展 zip 的目录列表
57
+
58
+ 文件名自带线协议版本:导入后用 `eda_status` 核对,版本与桥接不一致就重启嘉立创EDA专业版。
59
+
60
+ 校验:`eda_extension_package` 报告插件自带的 zip(路径、字节数、sha256);传 `download: true` 时会把服务器那份取回来逐字节比对(`verified: match | mismatch`),一致才存到 `outDir`(默认临时目录),不一致则不落盘并提示重新上传。zip 构建已改为可复现,同样的源码永远得到同一个 sha256。
61
+
62
+ 下载服务器对 `/eda/` 限流 **5 次/分钟/IP**(burst 5,回环地址豁免):被限流时工具返回 `verified: unverified` 且 `httpStatus: 429` —— 这是限流,不是校验不一致,等一分钟重试即可。
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
+
53
68
  ### 1. 安装到 DSH Web profile
54
69
 
55
70
  ```sh
@@ -292,5 +292,6 @@ C. 只做 PCB:
292
292
  - **插件代码改动对当前会话完全不生效**:宿主在启动时把 `lib/index.js` 载入内存,之后改文件、重新 build 都不会热更新。
293
293
  - 版本纪律:扩展线协议**只递增最小位**(`0.4.1 → 0.4.2`),每次改动必须同时改 `jlc-extension/extension.json` 的 `version` 与桥接的 `VERSION`;不一致时 `eda_status.versionMismatch` 为 true。
294
294
  - 新会话开工顺序:`eda_status`(连没连、版本对不对)→ 本技能(怎么干)→ 再动手;不要先假设环境就绪。
295
+ | 需要扩展 zip(换机器/给别人) | 不清楚该给哪一份 | `eda_extension_package` 报路径+sha256;`download:true` 从服务器取回并逐字节比对(`verified`),一致才落盘 |
295
296
  - 扩展 zip 位置:`node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<版本>.zip`,或设置卡片下载(文件名自带版本号)。
296
297
  - 排查顺序:`eda_status` → `/api/dsh-eda-mcp/diagnostics`(`clientStartupError` / `skills` / `bridgeHealth`)→ `eda_list_api_methods`(白名单)→ 应用侧 DRC 面板。
package/docs/tools.md CHANGED
@@ -176,3 +176,9 @@ Design limits enforced by these tools (track widths, via sizes, net naming) are
176
176
  |---|---|---|
177
177
  | `eda_api_call` | Call an allow-listed JLCEDA Pro extension API method directly. | `method: string, args: array` |
178
178
 
179
+ ## Other
180
+
181
+ | Tool | Description | Parameters |
182
+ |---|---|---|
183
+ | `eda_extension_package` | Report the extension zip this plugin ships (absolute path, bytes, sha256) and, with download:true, fetch the copy served at the download base URL and compare the two byte for byte. On a match the served file is saved to outDir (default: the temp directory) so it can be imported into JLCEDA Pro; on a mismatch nothing is written and the tool says to re-upload. base defaults to DSH_EDA_EXTENSION_BASE or the project download server. | `download?: boolean, outDir?: string, base?: string` |
184
+
Binary file
package/lib/bridge.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  // src/bridge/index.ts
2
- import { createHash } from "node:crypto";
2
+ import { createHash as createHash2 } from "node:crypto";
3
3
  import { execFileSync, spawn } from "node:child_process";
4
- import { statSync } from "node:fs";
4
+ import { existsSync, statSync } from "node:fs";
5
5
  import { readFile } from "node:fs/promises";
6
6
  import { mkdir, unlink, writeFile } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import { basename, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
9
10
  import { createServer } from "node:http";
10
11
  import { createInterface } from "node:readline";
11
12
 
@@ -624,6 +625,26 @@ function runNetlistErc(rawNetlist) {
624
625
  return { ok: counts.fail === 0, counts, componentCount: components.length, netCount: nets.size, checks };
625
626
  }
626
627
 
628
+ // src/bridge/extension-package.ts
629
+ import { createHash } from "node:crypto";
630
+ function sha256Of(bytes) {
631
+ return createHash("sha256").update(bytes).digest("hex");
632
+ }
633
+ function extensionZipName(version) {
634
+ return "dsh-eda-bridge-extension-" + version + ".zip";
635
+ }
636
+ function extensionDownloadUrl(base, version) {
637
+ const normalised = base.endsWith("/") ? base : base + "/";
638
+ return new URL(extensionZipName(version), normalised).toString();
639
+ }
640
+ function compareDigest(expected, actual) {
641
+ if (typeof expected !== "string" || typeof actual !== "string") return "unverified";
642
+ const left = expected.trim().toLowerCase();
643
+ const right = actual.trim().toLowerCase();
644
+ if (left === "" || right === "") return "unverified";
645
+ return left === right ? "match" : "mismatch";
646
+ }
647
+
627
648
  // src/bridge/design-rules.ts
628
649
  var MIL_PER_MM = 39.37007874015748;
629
650
  function milToMm(mil) {
@@ -1100,6 +1121,7 @@ var TOOLS = [
1100
1121
  { name: "eda_pcb_canvas_origin", description: "Read (or align) the PCB canvas origin and convert points between the two coordinate spaces. JLCEDA displays CANVAS coordinates in its UI but every API takes DATA coordinates; the two differ by this offset, so a coordinate read off the screen will land somewhere else unless you convert or align. align:true calls setCanvasOrigin(0,0) so the display matches the API.", inputSchema: objectSchema({ align: { type: "boolean" }, offsetX: { type: "number" }, offsetY: { type: "number" }, point: { type: "object", additionalProperties: true } }) },
1101
1122
  { name: "eda_capture", description: "Render the active document to a PNG (dmt_EditorControl.getCurrentRenderedAreaImage) and write it to outPath or return a data URL. Use it to LOOK at the sheet/board instead of trusting coordinate math. The PNG is a scratch file: read it, then call eda_capture({cleanup:true}) to delete every capture path this session wrote.", inputSchema: objectSchema({ outPath: { type: "string" }, inline: { type: "boolean" }, cleanup: { type: "boolean" } }) },
1102
1123
  { name: "eda_workflow_guide", description: "Return the schematic working standard: grid-aligned manual placement, signal flow, when net labels/ports are required (a wire net name is overridden by connectivity), net-label fallback behaviour, batch placement and the verification order. Read it before laying out a sheet.", inputSchema: objectSchema({}) },
1124
+ { name: "eda_extension_package", description: "Report the extension zip this plugin ships (absolute path, bytes, sha256) and, with download:true, fetch the copy served at the download base URL and compare the two byte for byte. On a match the served file is saved to outDir (default: the temp directory) so it can be imported into JLCEDA Pro; on a mismatch nothing is written and the tool says to re-upload. base defaults to DSH_EDA_EXTENSION_BASE or the project download server.", inputSchema: objectSchema({ download: { type: "boolean" }, outDir: { type: "string" }, base: { type: "string" } }, []) },
1103
1125
  { name: "eda_list_api_methods", description: "Ask the CONNECTED EXTENSION for the API methods it accepts right now (dsh.internal.listApiMethods) and report source/version/counts. Falls back to the bridge copy with stale:true only when the extension is an older zip. Use refresh:true to re-query.", inputSchema: objectSchema({ refresh: { type: "boolean" } }) },
1104
1126
  { name: "eda_delete_primitive", description: 'Delete a schematic primitive by kind and primitive id. Net ports, net flags and net labels are attribute primitives and need kind "netport"/"netflag"/"netlabel"; the text fallback labels are kind "text".', inputSchema: objectSchema({ kind: { type: "string", enum: ["component", "wire", "rectangle", "circle", "arc", "polygon", "text", "netport", "netflag", "netlabel"] }, primitiveId: { type: "string" } }, ["kind", "primitiveId"]) },
1105
1127
  { name: "eda_create_board", description: "Create a board linking a schematic and a PCB (dmt_Board.createBoard).", inputSchema: objectSchema({ schematicUuid: { type: "string" }, pcbUuid: { type: "string" } }) },
@@ -1544,6 +1566,9 @@ function buildPolygonSource(points) {
1544
1566
  for (let i = 2; i < points.length; i += 2) source.push(points[i], points[i + 1]);
1545
1567
  return source;
1546
1568
  }
1569
+ function packageRoot() {
1570
+ return fileURLToPath(new URL("../", import.meta.url));
1571
+ }
1547
1572
  function requireFlatPoints(value, tool) {
1548
1573
  const points = flattenPoints(value);
1549
1574
  if (points.length < 6 || points.length % 2 !== 0 || !points.every(Number.isFinite)) {
@@ -2993,6 +3018,71 @@ async function executeTool(name, rawArgs, timeoutMs) {
2993
3018
  rotation: args.rotation === void 0 ? void 0 : Number(args.rotation),
2994
3019
  mirror: args.mirror === void 0 ? void 0 : Boolean(args.mirror)
2995
3020
  }], timeoutMs);
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;
3027
+ try {
3028
+ const manifest = JSON.parse(await readFile(manifestCandidates[0], "utf8"));
3029
+ if (typeof manifest.version === "string" && manifest.version !== "") version = manifest.version;
3030
+ } catch {
3031
+ }
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);
3048
+ const base = typeof args.base === "string" && args.base !== "" ? args.base : process.env.DSH_EDA_EXTENSION_BASE ?? "http://159.75.130.207/eda/";
3049
+ const url = extensionDownloadUrl(base, version);
3050
+ const report = {
3051
+ version,
3052
+ local: { path: localPath, bytes: localBytes.byteLength, sha256: localSha256 },
3053
+ url
3054
+ };
3055
+ if (args.download !== true) return report;
3056
+ const response = await fetch(url, { signal: AbortSignal.timeout(3e4) });
3057
+ if (!response.ok) {
3058
+ const throttled = response.status === 429;
3059
+ return {
3060
+ ...report,
3061
+ verified: "unverified",
3062
+ httpStatus: response.status,
3063
+ error: throttled ? "the download server throttled this IP (HTTP 429, 5 downloads/min per IP \u2014 burst 5). Wait about a minute and retry; nothing is wrong with the zip or the upload." : "download failed: HTTP " + response.status + " from " + url
3064
+ };
3065
+ }
3066
+ const remoteBytes = new Uint8Array(await response.arrayBuffer());
3067
+ const remoteSha256 = sha256Of(remoteBytes);
3068
+ const verdict = compareDigest(localSha256, remoteSha256);
3069
+ const result2 = {
3070
+ ...report,
3071
+ remote: { url, bytes: remoteBytes.byteLength, sha256: remoteSha256 },
3072
+ verified: verdict
3073
+ };
3074
+ if (verdict === "match") {
3075
+ const outDir = typeof args.outDir === "string" && args.outDir !== "" ? args.outDir : tmpdir();
3076
+ await mkdir(outDir, { recursive: true });
3077
+ const savedPath = join(outDir, extensionZipName(version));
3078
+ await writeFile(savedPath, remoteBytes);
3079
+ result2.savedPath = savedPath;
3080
+ result2.next = "In JLCEDA Pro: \u6269\u5C55 \u2192 \u5BFC\u5165\u6269\u5C55 \u2192 select this zip \u2192 restart the application (not a reload).";
3081
+ } 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.";
3083
+ }
3084
+ return result2;
3085
+ }
2996
3086
  case "eda_list_api_methods": {
2997
3087
  const live = await extensionApiMethods(timeoutMs, args.refresh === true);
2998
3088
  if (live !== void 0) {
@@ -4051,7 +4141,7 @@ server.on("upgrade", (req, socket) => {
4051
4141
  socket.destroy();
4052
4142
  return;
4053
4143
  }
4054
- const accept = createHash("sha1").update(key + WS_GUID).digest("base64");
4144
+ const accept = createHash2("sha1").update(key + WS_GUID).digest("base64");
4055
4145
  socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n");
4056
4146
  const client = new WsConnection(socket);
4057
4147
  clients.add(client);
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
@@ -325,6 +325,7 @@ var SKILL_PLAYBOOK_LINES = [
325
325
  "- **\u63D2\u4EF6\u4EE3\u7801\u6539\u52A8\u5BF9\u5F53\u524D\u4F1A\u8BDD\u5B8C\u5168\u4E0D\u751F\u6548**\uFF1A\u5BBF\u4E3B\u5728\u542F\u52A8\u65F6\u628A `lib/index.js` \u8F7D\u5165\u5185\u5B58\uFF0C\u4E4B\u540E\u6539\u6587\u4EF6\u3001\u91CD\u65B0 build \u90FD\u4E0D\u4F1A\u70ED\u66F4\u65B0\u3002",
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
+ "| \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 |",
328
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
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"
330
331
  ];
@@ -433,7 +434,7 @@ function createEdaSkillProvider() {
433
434
  }
434
435
 
435
436
  // src/routes.ts
436
- import { createReadStream } from "node:fs";
437
+ import { readFile } from "node:fs/promises";
437
438
  import { basename } from "node:path";
438
439
  import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
439
440
 
@@ -474,11 +475,32 @@ function isLoopbackRequest(request) {
474
475
  }
475
476
  }
476
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
+
477
498
  // src/routes.ts
478
499
  var EDA_MCP_API = {
479
500
  test: "/api/dsh-eda-mcp/test",
480
501
  reconnect: "/api/dsh-eda-mcp/reconnect",
481
502
  extensionZip: "/api/dsh-eda-mcp/extension.zip",
503
+ extensionVerify: "/api/dsh-eda-mcp/extension/verify",
482
504
  diagnostics: "/api/dsh-eda-mcp/diagnostics",
483
505
  selfTest: "/api/dsh-eda-mcp/self-test",
484
506
  settingsDescribe: "/api/dsh-eda-mcp/settings/describe",
@@ -606,20 +628,68 @@ function makeEdaMcpRoutes(deps) {
606
628
  handler: async (req, res) => {
607
629
  if (req.method !== "GET") return json(res, 405, { ok: false, error: "method-not-allowed" });
608
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
+ }
609
637
  res.writeHead(200, {
610
638
  "content-type": "application/zip",
611
639
  // Keep the versioned file name the browser saves it as.
612
640
  "content-disposition": 'attachment; filename="' + basename(deps.extensionZipPath) + '"',
613
- "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)
614
645
  });
615
- const stream = createReadStream(deps.extensionZipPath);
616
- stream.on("error", () => {
617
- 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)
618
689
  });
619
- stream.pipe(res);
620
690
  }
621
691
  };
622
- return [test, reconnect, extensionZip, diagnostics, selfTest];
692
+ return [test, reconnect, extensionZip, extensionVerify, diagnostics, selfTest];
623
693
  }
624
694
  function makeEdaMcpSettingsRoutes(settings) {
625
695
  const ns = "eda-mcp";
@@ -950,6 +1020,14 @@ function installSettingsSection(ctx, scoped, ns, schema, entry, hooks) {
950
1020
  }
951
1021
  var inject = ["webServer", "systemPrompt"];
952
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
+ }
953
1031
  function resolveExtensionZipPath() {
954
1032
  const plain = fileURLToPath(new URL("../dsh-eda-bridge-extension.zip", import.meta.url));
955
1033
  try {
@@ -1053,6 +1131,8 @@ function apply(ctx, config) {
1053
1131
  const disposers = makeEdaMcpRoutes({
1054
1132
  getConfig: () => current(),
1055
1133
  extensionZipPath: resolveExtensionZipPath(),
1134
+ extensionVersion: resolveExtensionVersion(),
1135
+ extensionDownloadBase: process.env.DSH_EDA_EXTENSION_BASE ?? "http://159.75.130.207/eda/",
1056
1136
  reconnect: remountClient,
1057
1137
  bridgeLaunch: describeBridgeLaunch,
1058
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.0.2",
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/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",
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Extension zip packaging helpers: the plugin ships
3
+ * `dsh-eda-bridge-extension-<wire version>.zip`, the download server publishes the same
4
+ * file, and a user who installs the plugin from npm must be able to tell whether the copy
5
+ * they got is byte-identical to the one the bridge expects.
6
+ */
7
+ import { createHash } from 'node:crypto'
8
+
9
+ export function sha256Of(bytes: Uint8Array): string {
10
+ return createHash('sha256').update(bytes).digest('hex')
11
+ }
12
+
13
+ export function extensionZipName(version: string): string {
14
+ return 'dsh-eda-bridge-extension-' + version + '.zip'
15
+ }
16
+
17
+ /** Normalises a base URL ('…/eda' or '…/eda/') and appends the zip name. */
18
+ export function extensionDownloadUrl(base: string, version: string): string {
19
+ const normalised = base.endsWith('/') ? base : base + '/'
20
+ return new URL(extensionZipName(version), normalised).toString()
21
+ }
22
+
23
+ export type DigestVerdict = 'match' | 'mismatch' | 'unverified'
24
+
25
+ /**
26
+ * A missing expected digest is reported as 'unverified', never as a pass: an absent
27
+ * comparison is not evidence of equality.
28
+ */
29
+ export function compareDigest(expected: string | null | undefined, actual: string | null | undefined): DigestVerdict {
30
+ if (typeof expected !== 'string' || typeof actual !== 'string') return 'unverified'
31
+ const left = expected.trim().toLowerCase()
32
+ const right = actual.trim().toLowerCase()
33
+ if (left === '' || right === '') return 'unverified'
34
+ return left === right ? 'match' : 'mismatch'
35
+ }
@@ -6,6 +6,7 @@ import { readFile } from 'node:fs/promises'
6
6
  import { mkdir, unlink, writeFile } from 'node:fs/promises'
7
7
  import { tmpdir } from 'node:os'
8
8
  import { basename, join } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
9
10
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
10
11
  import type { Socket } from 'node:net'
11
12
  import { createInterface } from 'node:readline'
@@ -13,6 +14,7 @@ import { buildLaunchArgs, buildLaunchScript, decodeRegistryOutput, resolveExecut
13
14
  import { buildIntake } from './design-intake.ts'
14
15
  import { auditSchematicGeometry } from './schematic-audit.ts'
15
16
  import { runNetlistErc } from './netlist-erc.ts'
17
+ import { compareDigest, extensionDownloadUrl, extensionZipName, sha256Of } from './extension-package.ts'
16
18
  import {
17
19
  ANNULUS_MIN_MM, BOARD_OUTLINE_WIDTH_MIL, MIN_TRACK_WIDTH_MIL, POUR_CLEARANCE_MIN_MIL,
18
20
  POUR_CLEARANCE_RECOMMENDED_MIL, SILKSCREEN_HEIGHT_MIN_MM, SILKSCREEN_WIDTH_MIN_MM,
@@ -324,6 +326,7 @@ const TOOLS = [
324
326
  { name: 'eda_pcb_canvas_origin', description: 'Read (or align) the PCB canvas origin and convert points between the two coordinate spaces. JLCEDA displays CANVAS coordinates in its UI but every API takes DATA coordinates; the two differ by this offset, so a coordinate read off the screen will land somewhere else unless you convert or align. align:true calls setCanvasOrigin(0,0) so the display matches the API.', inputSchema: objectSchema({ align: { type: 'boolean' }, offsetX: { type: 'number' }, offsetY: { type: 'number' }, point: { type: 'object', additionalProperties: true } }) },
325
327
  { name: 'eda_capture', description: 'Render the active document to a PNG (dmt_EditorControl.getCurrentRenderedAreaImage) and write it to outPath or return a data URL. Use it to LOOK at the sheet/board instead of trusting coordinate math. The PNG is a scratch file: read it, then call eda_capture({cleanup:true}) to delete every capture path this session wrote.', inputSchema: objectSchema({ outPath: { type: 'string' }, inline: { type: 'boolean' }, cleanup: { type: 'boolean' } }) },
326
328
  { name: 'eda_workflow_guide', description: 'Return the schematic working standard: grid-aligned manual placement, signal flow, when net labels/ports are required (a wire net name is overridden by connectivity), net-label fallback behaviour, batch placement and the verification order. Read it before laying out a sheet.', inputSchema: objectSchema({}) },
329
+ { name: 'eda_extension_package', description: 'Report the extension zip this plugin ships (absolute path, bytes, sha256) and, with download:true, fetch the copy served at the download base URL and compare the two byte for byte. On a match the served file is saved to outDir (default: the temp directory) so it can be imported into JLCEDA Pro; on a mismatch nothing is written and the tool says to re-upload. base defaults to DSH_EDA_EXTENSION_BASE or the project download server.', inputSchema: objectSchema({ download: { type: 'boolean' }, outDir: { type: 'string' }, base: { type: 'string' } }, []) },
327
330
  { name: 'eda_list_api_methods', description: 'Ask the CONNECTED EXTENSION for the API methods it accepts right now (dsh.internal.listApiMethods) and report source/version/counts. Falls back to the bridge copy with stale:true only when the extension is an older zip. Use refresh:true to re-query.', inputSchema: objectSchema({ refresh: { type: 'boolean' } }) },
328
331
  { name: 'eda_delete_primitive', description: 'Delete a schematic primitive by kind and primitive id. Net ports, net flags and net labels are attribute primitives and need kind "netport"/"netflag"/"netlabel"; the text fallback labels are kind "text".', inputSchema: objectSchema({ kind: { type: 'string', enum: ['component', 'wire', 'rectangle', 'circle', 'arc', 'polygon', 'text', 'netport', 'netflag', 'netlabel'] }, primitiveId: { type: 'string' } }, ['kind', 'primitiveId']) },
329
332
  { name: 'eda_create_board', description: 'Create a board linking a schematic and a PCB (dmt_Board.createBoard).', inputSchema: objectSchema({ schematicUuid: { type: 'string' }, pcbUuid: { type: 'string' } }) },
@@ -790,6 +793,15 @@ function buildPolygonSource(points) {
790
793
  return source
791
794
  }
792
795
 
796
+
797
+ /**
798
+ * Directory that holds the packaged artifacts: the bridge runs from <package>/lib/bridge.mjs,
799
+ * so its grandparent is the package root both for a published install and for the repo.
800
+ */
801
+ function packageRoot(): string {
802
+ return fileURLToPath(new URL('../', import.meta.url))
803
+ }
804
+
793
805
  function requireFlatPoints(value, tool) {
794
806
  const points = flattenPoints(value)
795
807
  if (points.length < 6 || points.length % 2 !== 0 || !points.every(Number.isFinite)) {
@@ -2363,6 +2375,79 @@ async function executeTool(name, rawArgs, timeoutMs) {
2363
2375
  mirror: args.mirror === undefined ? undefined : Boolean(args.mirror),
2364
2376
  }], timeoutMs)
2365
2377
 
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
2388
+ 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)
2405
+ const base = typeof args.base === 'string' && args.base !== ''
2406
+ ? args.base
2407
+ : (process.env.DSH_EDA_EXTENSION_BASE ?? 'http://159.75.130.207/eda/')
2408
+ const url = extensionDownloadUrl(base, version)
2409
+ const report = {
2410
+ version,
2411
+ local: { path: localPath, bytes: localBytes.byteLength, sha256: localSha256 },
2412
+ url,
2413
+ }
2414
+ if (args.download !== true) return report
2415
+ const response = await fetch(url, { signal: AbortSignal.timeout(30000) })
2416
+ 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
+ const throttled = response.status === 429
2420
+ return {
2421
+ ...report,
2422
+ verified: 'unverified',
2423
+ httpStatus: response.status,
2424
+ error: throttled
2425
+ ? 'the download server throttled this IP (HTTP 429, 5 downloads/min per IP — burst 5). Wait about a minute and retry; nothing is wrong with the zip or the upload.'
2426
+ : 'download failed: HTTP ' + response.status + ' from ' + url,
2427
+ }
2428
+ }
2429
+ const remoteBytes = new Uint8Array(await response.arrayBuffer())
2430
+ const remoteSha256 = sha256Of(remoteBytes)
2431
+ const verdict = compareDigest(localSha256, remoteSha256)
2432
+ const result: Record<string, unknown> = {
2433
+ ...report,
2434
+ remote: { url, bytes: remoteBytes.byteLength, sha256: remoteSha256 },
2435
+ verified: verdict,
2436
+ }
2437
+ if (verdict === 'match') {
2438
+ const outDir = typeof args.outDir === 'string' && args.outDir !== '' ? args.outDir : tmpdir()
2439
+ await mkdir(outDir, { recursive: true })
2440
+ const savedPath = join(outDir, extensionZipName(version))
2441
+ await writeFile(savedPath, remoteBytes)
2442
+ result.savedPath = savedPath
2443
+ result.next = 'In JLCEDA Pro: 扩展 → 导入扩展 → select this zip → restart the application (not a reload).'
2444
+ } 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.'
2447
+ }
2448
+ return result
2449
+ }
2450
+
2366
2451
  case 'eda_list_api_methods': {
2367
2452
  // Ask the running extension first: its answer is the real capability boundary.
2368
2453
  const live = await extensionApiMethods(timeoutMs, args.refresh === true)
@@ -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[] {
@@ -299,6 +299,7 @@ export const SKILL_PLAYBOOK_LINES: readonly string[] = [
299
299
  "- **插件代码改动对当前会话完全不生效**:宿主在启动时把 `lib/index.js` 载入内存,之后改文件、重新 build 都不会热更新。",
300
300
  "- 版本纪律:扩展线协议**只递增最小位**(`0.4.1 → 0.4.2`),每次改动必须同时改 `jlc-extension/extension.json` 的 `version` 与桥接的 `VERSION`;不一致时 `eda_status.versionMismatch` 为 true。",
301
301
  "- 新会话开工顺序:`eda_status`(连没连、版本对不对)→ 本技能(怎么干)→ 再动手;不要先假设环境就绪。",
302
+ "| 需要扩展 zip(换机器/给别人) | 不清楚该给哪一份 | `eda_extension_package` 报路径+sha256;`download:true` 从服务器取回并逐字节比对(`verified`),一致才落盘 |",
302
303
  "- 扩展 zip 位置:`node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<版本>.zip`,或设置卡片下载(文件名自带版本号)。",
303
304
  "- 排查顺序:`eda_status` → `/api/dsh-eda-mcp/diagnostics`(`clientStartupError` / `skills` / `bridgeHealth`)→ `eda_list_api_methods`(白名单)→ 应用侧 DRC 面板。",
304
305
  ]
@@ -0,0 +1,27 @@
1
+ import { strict as assert } from 'node:assert'
2
+ import { compareDigest, extensionDownloadUrl, extensionZipName, sha256Of } from '../src/bridge/extension-package.ts'
3
+
4
+ const bytes = new TextEncoder().encode('PK\x03\x04fake-zip-body')
5
+ assert.equal(sha256Of(bytes), sha256Of(bytes), 'hash is stable')
6
+ assert.equal(sha256Of(bytes).length, 64, 'sha256 hex length')
7
+ assert.notEqual(sha256Of(bytes), sha256Of(new TextEncoder().encode('other')), 'different bytes differ')
8
+
9
+ assert.equal(extensionZipName('0.4.9'), 'dsh-eda-bridge-extension-0.4.9.zip')
10
+ assert.equal(
11
+ extensionDownloadUrl('http://159.75.130.207/eda', '0.4.9'),
12
+ 'http://159.75.130.207/eda/dsh-eda-bridge-extension-0.4.9.zip',
13
+ 'base without a trailing slash gets one',
14
+ )
15
+ assert.equal(
16
+ extensionDownloadUrl('https://example.test/dl/', '1.2.3'),
17
+ 'https://example.test/dl/dsh-eda-bridge-extension-1.2.3.zip',
18
+ 'trailing slash is not doubled',
19
+ )
20
+
21
+ assert.equal(compareDigest('ABC', 'abc'), 'match', 'case-insensitive')
22
+ assert.equal(compareDigest('aa', 'bb'), 'mismatch')
23
+ assert.equal(compareDigest(null, 'bb'), 'unverified', 'missing expected is not a pass')
24
+ assert.equal(compareDigest('aa', undefined), 'unverified')
25
+ assert.equal(compareDigest('', ''), 'unverified')
26
+
27
+ console.log('ALL PASS extension-package')
@@ -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')