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