dsh-eda-mcp 1.1.0 → 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 +25 -0
- package/README.md +13 -4
- package/README.zh.md +5 -2
- package/docs/schematic-workflow.md +1 -1
- package/lib/bridge.mjs +16 -27
- package/lib/client.js +72 -8
- package/lib/extension-digest.json +6 -0
- package/lib/index.js +138 -21
- package/package.json +2 -3
- package/src/bridge/index.ts +24 -32
- package/src/client/EdaMcpSettingsCard.tsx +58 -2
- package/src/client/locales.ts +18 -2
- package/src/index.ts +6 -10
- package/src/routes.ts +129 -8
- package/src/skills-content.ts +1 -1
- package/test/extension-routes.test.ts +116 -0
- package/dsh-eda-bridge-extension-0.4.9.zip +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
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
|
+
|
|
18
|
+
## 1.1.1 — 2026-10-21
|
|
19
|
+
|
|
20
|
+
### 设置卡片:下载仍是本地那份,但**能校验服务器副本**
|
|
21
|
+
|
|
22
|
+
- 事实澄清(改前就是如此):设置卡片的「下载 JLCEDA 扩展包」是 `<a href="/api/dsh-eda-mcp/extension.zip">`,由插件读**包内自带**的 zip 直接回传,**不经过服务器**。这是有意的:离线也能装、不会被限流、不依赖服务器可用性。
|
|
23
|
+
- 现在响应头带 `x-extension-version`、`x-extension-sha256`、`content-length`,下载到的文件可自行核对。
|
|
24
|
+
- 新增路由 `GET /api/dsh-eda-mcp/extension/verify`:从下载服务器取回同一文件并逐字节比对,返回 `verified: match | mismatch | unverified`(429 会带 `httpStatus` 与"是限流不是不一致"的说明)。卡片上多一个「校验服务器副本」按钮直接显示结果。
|
|
25
|
+
- 新增第 **11** 套单测 `extension-routes`:用真实本地 HTTP 服务模拟三种服务器响应(同字节 / 不同字节 / 429),断言 `match`、`mismatch`、`unverified + 429`,并断言下载响应的 sha256 头与实际发出的字节一致。
|
|
26
|
+
|
|
27
|
+
|
|
3
28
|
## 1.1.0 — 2026-10-21
|
|
4
29
|
|
|
5
30
|
### `/eda/` 下载限流(服务器侧,5 次/分钟/IP)
|
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
|
|
|
@@ -58,6 +59,14 @@ produce the same sha256.
|
|
|
58
59
|
The download server throttles `/eda/` to 5 requests/minute per IP (burst 5, loopback exempt), so
|
|
59
60
|
a `429` shows up as `verified: 'unverified'` with `httpStatus: 429` — that is throttling, not a
|
|
60
61
|
checksum mismatch; wait a minute and retry.
|
|
62
|
+
The settings card serves the **bundled** copy (works offline, never throttled) and stamps
|
|
63
|
+
`x-extension-version` / `x-extension-sha256` on the response; its "Verify server copy" button calls
|
|
64
|
+
`/api/dsh-eda-mcp/extension/verify` and reports match / mismatch / why verification was impossible
|
|
65
|
+
(429 throttling included).
|
|
66
|
+
The settings card serves the **bundled** copy (works offline, never throttled) and stamps
|
|
67
|
+
`x-extension-version` / `x-extension-sha256` on the response; its "Verify server copy" button calls
|
|
68
|
+
`/api/dsh-eda-mcp/extension/verify` and reports match / mismatch / why verification was impossible
|
|
69
|
+
(429 throttling included).
|
|
61
70
|
|
|
62
71
|
### 1. Install into the DSH web profile
|
|
63
72
|
|
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
|
|
|
@@ -61,6 +60,10 @@ pnpm add dsh-eda-mcp # 或用 Web GUI 的插件管理器安装
|
|
|
61
60
|
|
|
62
61
|
下载服务器对 `/eda/` 限流 **5 次/分钟/IP**(burst 5,回环地址豁免):被限流时工具返回 `verified: unverified` 且 `httpStatus: 429` —— 这是限流,不是校验不一致,等一分钟重试即可。
|
|
63
62
|
|
|
63
|
+
设置卡片的下载按钮给的是**插件自带的那份**(离线可用、不被限流),响应头带 `x-extension-version` 与 `x-extension-sha256`;旁边的「校验服务器副本」按钮调用 `/api/dsh-eda-mcp/extension/verify`,逐字节比对后给出 `match / mismatch / 无法校验(含 429 限流原因)`。
|
|
64
|
+
|
|
65
|
+
设置卡片的下载按钮给的是**插件自带的那份**(离线可用、不被限流),响应头带 `x-extension-version` 与 `x-extension-sha256`;旁边的「校验服务器副本」按钮调用 `/api/dsh-eda-mcp/extension/verify`,逐字节比对后直接给出 `match / mismatch / 无法校验(含 429 限流原因)`。
|
|
66
|
+
|
|
64
67
|
### 1. 安装到 DSH Web profile
|
|
65
68
|
|
|
66
69
|
```sh
|
|
@@ -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
|
@@ -1135,6 +1135,31 @@ 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 [extensionInfo, setExtensionInfo] = (0, import_react.useState)();
|
|
1141
|
+
const verifyExtension = async () => {
|
|
1142
|
+
if (verifyingExtension) return;
|
|
1143
|
+
setVerifyingExtension(true);
|
|
1144
|
+
try {
|
|
1145
|
+
const response = await fetch("/api/dsh-eda-mcp/extension/verify");
|
|
1146
|
+
const body = await response.json();
|
|
1147
|
+
if (body.verified === "match") {
|
|
1148
|
+
const short = String(body.local?.sha256 ?? "").slice(0, 16) + "\u2026";
|
|
1149
|
+
setExtensionVerdict({ kind: "ok", text: t("settings.verifyMatch").replace("{hash}", short) });
|
|
1150
|
+
} else if (body.verified === "mismatch") {
|
|
1151
|
+
setExtensionVerdict({ kind: "error", text: t("settings.verifyMismatch").replace("{hash}", String(body.file ?? "")) });
|
|
1152
|
+
} else {
|
|
1153
|
+
const reason = String(body.error ?? "unknown");
|
|
1154
|
+
setExtensionVerdict({ kind: "error", text: t("settings.verifyUnverified").replace("{reason}", reason) });
|
|
1155
|
+
}
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1158
|
+
setExtensionVerdict({ kind: "error", text: t("settings.verifyUnverified").replace("{reason}", reason) });
|
|
1159
|
+
} finally {
|
|
1160
|
+
setVerifyingExtension(false);
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1138
1163
|
(0, import_react.useEffect)(() => {
|
|
1139
1164
|
if (!seeded && state.status === "ready") {
|
|
1140
1165
|
setDraft(state.config ?? {});
|
|
@@ -1292,14 +1317,37 @@ function EdaMcpSettingsCard(props) {
|
|
|
1292
1317
|
}
|
|
1293
1318
|
),
|
|
1294
1319
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { ...hintStyle, margin: "8px 0 0" }, children: t("settings.portLabel") }),
|
|
1295
|
-
/* @__PURE__ */ (0, import_jsx_runtime.
|
|
1296
|
-
|
|
1320
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
1321
|
+
Field,
|
|
1297
1322
|
{
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
children:
|
|
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
|
+
]
|
|
1301
1349
|
}
|
|
1302
|
-
)
|
|
1350
|
+
)
|
|
1303
1351
|
] }),
|
|
1304
1352
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("footer", { style: footerStyle, children: [
|
|
1305
1353
|
notice !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: noticeStyle, role: "status", children: notice.text }) : null,
|
|
@@ -1478,7 +1526,15 @@ var zh = {
|
|
|
1478
1526
|
"settings.reconnectFailed": "\u91CD\u8FDE\u5931\u8D25",
|
|
1479
1527
|
"settings.portLabel": "\u6865\u63A5 WebSocket \u7AEF\u53E3\uFF1A39009\uFF08\u56FA\u5B9A\uFF09",
|
|
1480
1528
|
"settings.downloadExtension": "\u4E0B\u8F7D JLCEDA \u6269\u5C55\u5305",
|
|
1481
|
-
"settings.
|
|
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",
|
|
1532
|
+
"settings.verifyExtension": "\u6821\u9A8C\u670D\u52A1\u5668\u526F\u672C",
|
|
1533
|
+
"settings.verifyBusy": "\u6821\u9A8C\u4E2D\u2026",
|
|
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",
|
|
1535
|
+
"settings.verifyMismatch": "\u4E0D\u4E00\u81F4\uFF1A\u670D\u52A1\u5668\u90A3\u4EFD\u4E0E\u672C\u5730\u4E0D\u540C\uFF0C\u8BF7\u91CD\u65B0\u4E0A\u4F20 {hash}",
|
|
1536
|
+
"settings.verifyUnverified": "\u65E0\u6CD5\u6821\u9A8C\uFF1A{reason}",
|
|
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"
|
|
1482
1538
|
};
|
|
1483
1539
|
var en = {
|
|
1484
1540
|
"settings.title": "JLCEDA MCP",
|
|
@@ -1510,7 +1566,15 @@ var en = {
|
|
|
1510
1566
|
"settings.reconnectFailed": "Reconnect failed",
|
|
1511
1567
|
"settings.portLabel": "Bridge WebSocket port: 39009 (fixed)",
|
|
1512
1568
|
"settings.downloadExtension": "Download JLCEDA extension zip",
|
|
1513
|
-
"settings.
|
|
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",
|
|
1572
|
+
"settings.verifyExtension": "Verify server copy",
|
|
1573
|
+
"settings.verifyBusy": "Verifying\u2026",
|
|
1574
|
+
"settings.verifyMatch": "Match: the served zip is byte-identical to the local one (sha256 {hash})",
|
|
1575
|
+
"settings.verifyMismatch": "Mismatch: the served zip differs \u2014 re-upload {hash}",
|
|
1576
|
+
"settings.verifyUnverified": "Could not verify: {reason}",
|
|
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".'
|
|
1514
1578
|
};
|
|
1515
1579
|
|
|
1516
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("## "));
|
|
@@ -434,8 +434,7 @@ function createEdaSkillProvider() {
|
|
|
434
434
|
}
|
|
435
435
|
|
|
436
436
|
// src/routes.ts
|
|
437
|
-
import {
|
|
438
|
-
import { basename } from "node:path";
|
|
437
|
+
import { readFile } from "node:fs/promises";
|
|
439
438
|
import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
|
|
440
439
|
|
|
441
440
|
// src/loopback.ts
|
|
@@ -475,11 +474,33 @@ function isLoopbackRequest(request) {
|
|
|
475
474
|
}
|
|
476
475
|
}
|
|
477
476
|
|
|
477
|
+
// src/bridge/extension-package.ts
|
|
478
|
+
import { createHash } from "node:crypto";
|
|
479
|
+
function sha256Of(bytes) {
|
|
480
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
481
|
+
}
|
|
482
|
+
function extensionZipName(version) {
|
|
483
|
+
return "dsh-eda-bridge-extension-" + version + ".zip";
|
|
484
|
+
}
|
|
485
|
+
function extensionDownloadUrl(base, version) {
|
|
486
|
+
const normalised = base.endsWith("/") ? base : base + "/";
|
|
487
|
+
return new URL(extensionZipName(version), normalised).toString();
|
|
488
|
+
}
|
|
489
|
+
function compareDigest(expected, actual) {
|
|
490
|
+
if (typeof expected !== "string" || typeof actual !== "string") return "unverified";
|
|
491
|
+
const left = expected.trim().toLowerCase();
|
|
492
|
+
const right = actual.trim().toLowerCase();
|
|
493
|
+
if (left === "" || right === "") return "unverified";
|
|
494
|
+
return left === right ? "match" : "mismatch";
|
|
495
|
+
}
|
|
496
|
+
|
|
478
497
|
// src/routes.ts
|
|
479
498
|
var EDA_MCP_API = {
|
|
480
499
|
test: "/api/dsh-eda-mcp/test",
|
|
481
500
|
reconnect: "/api/dsh-eda-mcp/reconnect",
|
|
482
501
|
extensionZip: "/api/dsh-eda-mcp/extension.zip",
|
|
502
|
+
extensionVerify: "/api/dsh-eda-mcp/extension/verify",
|
|
503
|
+
extensionInfo: "/api/dsh-eda-mcp/extension/info",
|
|
483
504
|
diagnostics: "/api/dsh-eda-mcp/diagnostics",
|
|
484
505
|
selfTest: "/api/dsh-eda-mcp/self-test",
|
|
485
506
|
settingsDescribe: "/api/dsh-eda-mcp/settings/describe",
|
|
@@ -601,26 +622,125 @@ function makeEdaMcpRoutes(deps) {
|
|
|
601
622
|
}
|
|
602
623
|
}
|
|
603
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
|
+
};
|
|
604
634
|
const extensionZip = {
|
|
605
635
|
kind: "exact",
|
|
606
636
|
path: EDA_MCP_API.extensionZip,
|
|
607
637
|
handler: async (req, res) => {
|
|
608
638
|
if (req.method !== "GET") return json(res, 405, { ok: false, error: "method-not-allowed" });
|
|
609
639
|
if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: "forbidden" });
|
|
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;
|
|
647
|
+
try {
|
|
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
|
+
});
|
|
677
|
+
}
|
|
610
678
|
res.writeHead(200, {
|
|
611
679
|
"content-type": "application/zip",
|
|
612
|
-
|
|
613
|
-
"
|
|
614
|
-
"
|
|
680
|
+
"content-disposition": 'attachment; filename="' + digest.file + '"',
|
|
681
|
+
"cache-control": "no-store",
|
|
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" } : {}
|
|
615
687
|
});
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
688
|
+
res.end(bytes);
|
|
689
|
+
}
|
|
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"
|
|
619
707
|
});
|
|
620
|
-
stream.pipe(res);
|
|
621
708
|
}
|
|
622
709
|
};
|
|
623
|
-
|
|
710
|
+
const extensionVerify = {
|
|
711
|
+
kind: "exact",
|
|
712
|
+
path: EDA_MCP_API.extensionVerify,
|
|
713
|
+
handler: async (req, res) => {
|
|
714
|
+
if (req.method !== "GET") return json(res, 405, { ok: false, error: "method-not-allowed" });
|
|
715
|
+
if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: "forbidden" });
|
|
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 };
|
|
720
|
+
let response;
|
|
721
|
+
try {
|
|
722
|
+
response = await fetch(url, { signal: AbortSignal.timeout(3e4) });
|
|
723
|
+
} catch (error) {
|
|
724
|
+
return json(res, 200, { ...report, verified: "unverified", error: "download failed: " + (error instanceof Error ? error.message : String(error)) });
|
|
725
|
+
}
|
|
726
|
+
if (!response.ok) {
|
|
727
|
+
return json(res, 200, {
|
|
728
|
+
...report,
|
|
729
|
+
verified: "unverified",
|
|
730
|
+
httpStatus: response.status,
|
|
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
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
const servedBytes = new Uint8Array(await response.arrayBuffer());
|
|
735
|
+
const servedSha256 = sha256Of(servedBytes);
|
|
736
|
+
return json(res, 200, {
|
|
737
|
+
...report,
|
|
738
|
+
served: { bytes: servedBytes.byteLength, sha256: servedSha256 },
|
|
739
|
+
verified: compareDigest(digest.sha256, servedSha256)
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
return [test, reconnect, extensionZip, extensionInfo, extensionVerify, diagnostics, selfTest];
|
|
624
744
|
}
|
|
625
745
|
function makeEdaMcpSettingsRoutes(settings) {
|
|
626
746
|
const ns = "eda-mcp";
|
|
@@ -951,18 +1071,13 @@ function installSettingsSection(ctx, scoped, ns, schema, entry, hooks) {
|
|
|
951
1071
|
}
|
|
952
1072
|
var inject = ["webServer", "systemPrompt"];
|
|
953
1073
|
var BRIDGE_URL = new URL("../lib/bridge.mjs", import.meta.url);
|
|
954
|
-
function
|
|
955
|
-
const plain = fileURLToPath(new URL("../dsh-eda-bridge-extension.zip", import.meta.url));
|
|
1074
|
+
function resolveExtensionVersion() {
|
|
956
1075
|
try {
|
|
957
1076
|
const manifest = JSON.parse(readFileSync(fileURLToPath(new URL("../jlc-extension/extension.json", import.meta.url)), "utf8"));
|
|
958
|
-
|
|
959
|
-
if (version !== "") {
|
|
960
|
-
const versioned = fileURLToPath(new URL("../dsh-eda-bridge-extension-" + version + ".zip", import.meta.url));
|
|
961
|
-
if (existsSync2(versioned)) return versioned;
|
|
962
|
-
}
|
|
1077
|
+
return typeof manifest.version === "string" ? manifest.version : "";
|
|
963
1078
|
} catch {
|
|
1079
|
+
return "";
|
|
964
1080
|
}
|
|
965
|
-
return plain;
|
|
966
1081
|
}
|
|
967
1082
|
var BRIDGE_PATH = fileURLToPath(BRIDGE_URL);
|
|
968
1083
|
function bridgeLaunchFor(active) {
|
|
@@ -1053,7 +1168,9 @@ function apply(ctx, config) {
|
|
|
1053
1168
|
ctx.effect(() => {
|
|
1054
1169
|
const disposers = makeEdaMcpRoutes({
|
|
1055
1170
|
getConfig: () => current(),
|
|
1056
|
-
|
|
1171
|
+
extensionDigestPath: fileURLToPath(new URL("../lib/extension-digest.json", import.meta.url)),
|
|
1172
|
+
extensionVersion: resolveExtensionVersion(),
|
|
1173
|
+
extensionDownloadBase: process.env.DSH_EDA_EXTENSION_BASE ?? "http://159.75.130.207/eda/",
|
|
1057
1174
|
reconnect: remountClient,
|
|
1058
1175
|
bridgeLaunch: describeBridgeLaunch,
|
|
1059
1176
|
// Surface the skill registration so "did it register?" is answerable from
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-eda-mcp",
|
|
3
|
-
"version": "1.1.
|
|
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",
|
|
@@ -72,7 +71,7 @@
|
|
|
72
71
|
"scripts": {
|
|
73
72
|
"build": "node build.mjs",
|
|
74
73
|
"typecheck": "tsc --noEmit",
|
|
75
|
-
"test": "node --experimental-strip-types test/node-runtime.test.ts && node --experimental-strip-types test/config-values.test.ts && node --experimental-strip-types test/design-rules.test.ts && node --experimental-strip-types test/seams.test.ts && node --experimental-strip-types test/eda-launch.test.ts && node --experimental-strip-types test/schematic-audit.test.ts && node --experimental-strip-types test/design-intake.test.ts && node --experimental-strip-types test/extension-package.test.ts && node --experimental-strip-types test/netlist-erc.test.ts && node --experimental-strip-types test/skills.test.ts",
|
|
74
|
+
"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
75
|
"seam-check": "node scripts/seam-check.mjs",
|
|
77
76
|
"contract-check": "node scripts/contract-check.mjs",
|
|
78
77
|
"guard:bundle": "node scripts/guard-bundle-paths.mjs",
|
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
|
}
|
|
@@ -327,6 +327,38 @@ 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
|
+
const [extensionInfo, setExtensionInfo] = useState<{ version: string; bytes: number; sha256: string } | undefined>()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
const verifyExtension = async (): Promise<void> => {
|
|
336
|
+
if (verifyingExtension) return
|
|
337
|
+
setVerifyingExtension(true)
|
|
338
|
+
try {
|
|
339
|
+
const response = await fetch('/api/dsh-eda-mcp/extension/verify')
|
|
340
|
+
const body = await response.json() as {
|
|
341
|
+
verified?: 'match' | 'mismatch' | 'unverified'
|
|
342
|
+
file?: string
|
|
343
|
+
local?: { sha256?: string }
|
|
344
|
+
error?: string
|
|
345
|
+
}
|
|
346
|
+
if (body.verified === 'match') {
|
|
347
|
+
const short = String(body.local?.sha256 ?? '').slice(0, 16) + '…'
|
|
348
|
+
setExtensionVerdict({ kind: 'ok', text: t('settings.verifyMatch').replace('{hash}', short) })
|
|
349
|
+
} else if (body.verified === 'mismatch') {
|
|
350
|
+
setExtensionVerdict({ kind: 'error', text: t('settings.verifyMismatch').replace('{hash}', String(body.file ?? '')) })
|
|
351
|
+
} else {
|
|
352
|
+
const reason = String(body.error ?? 'unknown')
|
|
353
|
+
setExtensionVerdict({ kind: 'error', text: t('settings.verifyUnverified').replace('{reason}', reason) })
|
|
354
|
+
}
|
|
355
|
+
} catch (error) {
|
|
356
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
357
|
+
setExtensionVerdict({ kind: 'error', text: t('settings.verifyUnverified').replace('{reason}', reason) })
|
|
358
|
+
} finally {
|
|
359
|
+
setVerifyingExtension(false)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
330
362
|
|
|
331
363
|
useEffect(() => {
|
|
332
364
|
if (!seeded && state.status === 'ready') {
|
|
@@ -461,15 +493,39 @@ export function EdaMcpSettingsCard(props: EdaMcpSettingsCardProps) {
|
|
|
461
493
|
|
|
462
494
|
<p style={{ ...hintStyle, margin: '8px 0 0' }}>{t('settings.portLabel')}</p>
|
|
463
495
|
|
|
464
|
-
<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
|
+
>
|
|
465
502
|
<a
|
|
466
503
|
href="/api/dsh-eda-mcp/extension.zip"
|
|
467
504
|
// No download name: the server sends the versioned file name, so the
|
|
468
505
|
// saved copy always states which build it is.
|
|
469
506
|
style={{ ...secondaryButtonStyle, textDecoration: 'none', display: 'inline-flex', width: 'fit-content' }}
|
|
470
507
|
>
|
|
471
|
-
{
|
|
508
|
+
{extensionInfo === undefined
|
|
509
|
+
? t('settings.downloadExtension')
|
|
510
|
+
: t('settings.downloadExtensionVersioned').replace('{version}', extensionInfo.version)}
|
|
472
511
|
</a>
|
|
512
|
+
<button
|
|
513
|
+
type="button"
|
|
514
|
+
onClick={() => { void verifyExtension() }}
|
|
515
|
+
disabled={verifyingExtension}
|
|
516
|
+
style={verifyingExtension ? { ...secondaryButtonStyle, ...disabledButtonStyle, marginLeft: 8 } : { ...secondaryButtonStyle, marginLeft: 8 }}
|
|
517
|
+
>
|
|
518
|
+
{verifyingExtension ? t('settings.verifyBusy') : t('settings.verifyExtension')}
|
|
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}
|
|
526
|
+
{extensionVerdict !== undefined
|
|
527
|
+
? <p role="status" style={{ ...hintStyle, margin: '6px 0 0', color: extensionVerdict.kind === 'ok' ? undefined : '#c0392b' }}>{extensionVerdict.text}</p>
|
|
528
|
+
: null}
|
|
473
529
|
</Field>
|
|
474
530
|
</div>
|
|
475
531
|
|
package/src/client/locales.ts
CHANGED
|
@@ -28,7 +28,15 @@ export const zh = {
|
|
|
28
28
|
'settings.reconnectFailed': '重连失败',
|
|
29
29
|
'settings.portLabel': '桥接 WebSocket 端口:39009(固定)',
|
|
30
30
|
'settings.downloadExtension': '下载 JLCEDA 扩展包',
|
|
31
|
-
'settings.
|
|
31
|
+
'settings.downloadExtensionVersioned': '下载 JLCEDA 扩展包 v{version}',
|
|
32
|
+
'settings.extensionInfo': '本地 v{version} · {bytes} 字节 · sha256 {hash}',
|
|
33
|
+
'settings.extensionInfoUnknown': '扩展包信息不可用',
|
|
34
|
+
'settings.verifyExtension': '校验服务器副本',
|
|
35
|
+
'settings.verifyBusy': '校验中…',
|
|
36
|
+
'settings.verifyMatch': '一致:服务器上的 zip 与本地这份逐字节相同(sha256 {hash})',
|
|
37
|
+
'settings.verifyMismatch': '不一致:服务器那份与本地不同,请重新上传 {hash}',
|
|
38
|
+
'settings.verifyUnverified': '无法校验:{reason}',
|
|
39
|
+
'settings.installHint': '点下载会从下载服务器取回与本插件线协议一致的 zip(发送前校验 sha256,不一致则拒绝发送);在嘉立创EDA专业版中:扩展 → 导入 → 选择该 zip → 在扩展列表开启本扩展的「允许外部交互」。',
|
|
32
40
|
} as const
|
|
33
41
|
|
|
34
42
|
export const en = {
|
|
@@ -61,7 +69,15 @@ export const en = {
|
|
|
61
69
|
'settings.reconnectFailed': 'Reconnect failed',
|
|
62
70
|
'settings.portLabel': 'Bridge WebSocket port: 39009 (fixed)',
|
|
63
71
|
'settings.downloadExtension': 'Download JLCEDA extension zip',
|
|
64
|
-
'settings.
|
|
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',
|
|
75
|
+
'settings.verifyExtension': 'Verify server copy',
|
|
76
|
+
'settings.verifyBusy': 'Verifying…',
|
|
77
|
+
'settings.verifyMatch': 'Match: the served zip is byte-identical to the local one (sha256 {hash})',
|
|
78
|
+
'settings.verifyMismatch': 'Mismatch: the served zip differs — re-upload {hash}',
|
|
79
|
+
'settings.verifyUnverified': 'Could not verify: {reason}',
|
|
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".',
|
|
65
81
|
} as const
|
|
66
82
|
|
|
67
83
|
export type EdaMcpKey = keyof typeof zh
|
package/src/index.ts
CHANGED
|
@@ -157,19 +157,13 @@ 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
|
|
161
|
-
const plain = fileURLToPath(new URL('../dsh-eda-bridge-extension.zip', import.meta.url))
|
|
160
|
+
function resolveExtensionVersion(): string {
|
|
162
161
|
try {
|
|
163
162
|
const manifest = JSON.parse(readFileSync(fileURLToPath(new URL('../jlc-extension/extension.json', import.meta.url)), 'utf8')) as { version?: string }
|
|
164
|
-
|
|
165
|
-
if (version !== '') {
|
|
166
|
-
const versioned = fileURLToPath(new URL('../dsh-eda-bridge-extension-' + version + '.zip', import.meta.url))
|
|
167
|
-
if (existsSync(versioned)) return versioned
|
|
168
|
-
}
|
|
163
|
+
return typeof manifest.version === 'string' ? manifest.version : ''
|
|
169
164
|
} catch {
|
|
170
|
-
|
|
165
|
+
return ''
|
|
171
166
|
}
|
|
172
|
-
return plain
|
|
173
167
|
}
|
|
174
168
|
|
|
175
169
|
/** Bridge script path, resolved once. */
|
|
@@ -291,7 +285,9 @@ let current: () => EdaMcpConfig = () => unwrapConfig(config ?? {})
|
|
|
291
285
|
ctx.effect(() => {
|
|
292
286
|
const disposers = makeEdaMcpRoutes({
|
|
293
287
|
getConfig: () => current(),
|
|
294
|
-
|
|
288
|
+
extensionDigestPath: fileURLToPath(new URL('../lib/extension-digest.json', import.meta.url)),
|
|
289
|
+
extensionVersion: resolveExtensionVersion(),
|
|
290
|
+
extensionDownloadBase: process.env.DSH_EDA_EXTENSION_BASE ?? 'http://159.75.130.207/eda/',
|
|
295
291
|
reconnect: remountClient,
|
|
296
292
|
bridgeLaunch: describeBridgeLaunch,
|
|
297
293
|
// Surface the skill registration so "did it register?" is answerable from
|
package/src/routes.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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,14 @@ 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',
|
|
37
|
+
extensionInfo: '/api/dsh-eda-mcp/extension/info',
|
|
35
38
|
diagnostics: '/api/dsh-eda-mcp/diagnostics',
|
|
36
39
|
selfTest: '/api/dsh-eda-mcp/self-test',
|
|
37
40
|
settingsDescribe: '/api/dsh-eda-mcp/settings/describe',
|
|
@@ -86,7 +89,11 @@ function toView(descriptor: SettingsBridgeView): unknown {
|
|
|
86
89
|
|
|
87
90
|
export interface EdaMcpRouteDeps {
|
|
88
91
|
getConfig: () => EdaMcpConfig
|
|
89
|
-
|
|
92
|
+
extensionDigestPath: string
|
|
93
|
+
/** Wire version of the bundled extension zip (from jlc-extension/extension.json). */
|
|
94
|
+
extensionVersion: string
|
|
95
|
+
/** Base URL of the download server; override with DSH_EDA_EXTENSION_BASE. */
|
|
96
|
+
extensionDownloadBase: string
|
|
90
97
|
/** Dispose and recreate the MCP bridge child, then return immediately. */
|
|
91
98
|
reconnect: () => void
|
|
92
99
|
/** Which executable and script the bridge child is started with (diagnostics). */
|
|
@@ -179,25 +186,139 @@ export function makeEdaMcpRoutes(deps: EdaMcpRouteDeps): WebRoute[] {
|
|
|
179
186
|
},
|
|
180
187
|
}
|
|
181
188
|
|
|
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
|
+
|
|
182
205
|
const extensionZip: WebRoute = {
|
|
183
206
|
kind: 'exact',
|
|
184
207
|
path: EDA_MCP_API.extensionZip,
|
|
185
208
|
handler: async (req, res): Promise<void> => {
|
|
186
209
|
if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'method-not-allowed' })
|
|
187
210
|
if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: 'forbidden' })
|
|
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
|
|
218
|
+
try {
|
|
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
|
+
})
|
|
251
|
+
}
|
|
188
252
|
res.writeHead(200, {
|
|
189
253
|
'content-type': 'application/zip',
|
|
190
|
-
|
|
191
|
-
'content-disposition': 'attachment; filename="' + basename(deps.extensionZipPath) + '"',
|
|
254
|
+
'content-disposition': 'attachment; filename="' + digest.file + '"',
|
|
192
255
|
'cache-control': 'no-store',
|
|
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' } : {}),
|
|
193
261
|
})
|
|
194
|
-
|
|
195
|
-
stream.on('error', () => { res.destroy() })
|
|
196
|
-
stream.pipe(res)
|
|
262
|
+
res.end(bytes)
|
|
197
263
|
},
|
|
198
264
|
}
|
|
199
265
|
|
|
200
|
-
|
|
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
|
+
|
|
286
|
+
const extensionVerify: WebRoute = {
|
|
287
|
+
kind: 'exact',
|
|
288
|
+
path: EDA_MCP_API.extensionVerify,
|
|
289
|
+
handler: async (req, res): Promise<void> => {
|
|
290
|
+
if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'method-not-allowed' })
|
|
291
|
+
if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: 'forbidden' })
|
|
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 }
|
|
296
|
+
let response: Response
|
|
297
|
+
try {
|
|
298
|
+
response = await fetch(url, { signal: AbortSignal.timeout(30000) })
|
|
299
|
+
} catch (error) {
|
|
300
|
+
return json(res, 200, { ...report, verified: 'unverified', error: 'download failed: ' + (error instanceof Error ? error.message : String(error)) })
|
|
301
|
+
}
|
|
302
|
+
if (!response.ok) {
|
|
303
|
+
return json(res, 200, {
|
|
304
|
+
...report,
|
|
305
|
+
verified: 'unverified',
|
|
306
|
+
httpStatus: response.status,
|
|
307
|
+
error: response.status === 429
|
|
308
|
+
? 'the download server throttled this IP (HTTP 429, 5 downloads/min). Wait about a minute and verify again.'
|
|
309
|
+
: 'download failed: HTTP ' + response.status,
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
const servedBytes = new Uint8Array(await response.arrayBuffer())
|
|
313
|
+
const servedSha256 = sha256Of(servedBytes)
|
|
314
|
+
return json(res, 200, {
|
|
315
|
+
...report,
|
|
316
|
+
served: { bytes: servedBytes.byteLength, sha256: servedSha256 },
|
|
317
|
+
verified: compareDigest(digest.sha256, servedSha256),
|
|
318
|
+
})
|
|
319
|
+
},
|
|
320
|
+
}
|
|
321
|
+
return [test, reconnect, extensionZip, extensionInfo, extensionVerify, diagnostics, selfTest]
|
|
201
322
|
}
|
|
202
323
|
|
|
203
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
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { strict as assert } from 'node:assert'
|
|
2
|
+
import { createServer } from 'node:http'
|
|
3
|
+
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { makeEdaMcpRoutes, EDA_MCP_API } from '../src/routes.ts'
|
|
7
|
+
import { sha256Of } from '../src/bridge/extension-package.ts'
|
|
8
|
+
|
|
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
|
+
}))
|
|
21
|
+
|
|
22
|
+
function fakeReq(method: string, url = '/') {
|
|
23
|
+
return { method, url, socket: { remoteAddress: '127.0.0.1' }, headers: { host: '127.0.0.1' } }
|
|
24
|
+
}
|
|
25
|
+
function fakeRes() {
|
|
26
|
+
const captured: { status?: number; headers?: Record<string, string>; body?: unknown; bytes?: Buffer } = {}
|
|
27
|
+
const res = {
|
|
28
|
+
writeHead(status: number, headers: Record<string, string>) { captured.status = status; captured.headers = headers; return res },
|
|
29
|
+
end(payload?: unknown) { if (Buffer.isBuffer(payload)) captured.bytes = payload; else if (typeof payload === 'string') captured.body = payload; return res },
|
|
30
|
+
destroy() { return res }, getHeader() { return undefined }, setHeader() { return res },
|
|
31
|
+
}
|
|
32
|
+
return { res, captured }
|
|
33
|
+
}
|
|
34
|
+
function deps(base: string) {
|
|
35
|
+
return {
|
|
36
|
+
getConfig: () => ({}),
|
|
37
|
+
extensionDigestPath: digestPath,
|
|
38
|
+
extensionVersion: '0.4.9',
|
|
39
|
+
extensionDownloadBase: base,
|
|
40
|
+
reconnect: () => {},
|
|
41
|
+
bridgeLaunch: () => ({ command: 'node', args: [], source: 'test' }),
|
|
42
|
+
diagnostics: () => ({}),
|
|
43
|
+
launchConfig: () => ({}),
|
|
44
|
+
selfTest: async () => ({}),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function routeOf(routes: ReturnType<typeof makeEdaMcpRoutes>, path: string) {
|
|
48
|
+
const route = routes.find(r => r.path === path)
|
|
49
|
+
assert.ok(route, 'route ' + path + ' must exist')
|
|
50
|
+
return route
|
|
51
|
+
}
|
|
52
|
+
async function withServer(handler: Parameters<typeof createServer>[1], run: (base: string) => Promise<void>) {
|
|
53
|
+
const server = createServer(handler)
|
|
54
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
55
|
+
const port = (server.address() as { port: number }).port
|
|
56
|
+
try { await run('http://127.0.0.1:' + port + '/eda/') } finally { await new Promise<void>(resolve => server.close(() => resolve())) }
|
|
57
|
+
}
|
|
58
|
+
|
|
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) => {
|
|
61
|
+
const { res, captured } = fakeRes()
|
|
62
|
+
await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionZip).handler(fakeReq('GET') as never, res as never)
|
|
63
|
+
assert.equal(captured.status, 200)
|
|
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
|
+
})
|
|
69
|
+
|
|
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) => {
|
|
72
|
+
const { res, captured } = fakeRes()
|
|
73
|
+
await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionZip).handler(fakeReq('GET') as never, res as never)
|
|
74
|
+
const body = JSON.parse(String(captured.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')
|
|
79
|
+
})
|
|
80
|
+
|
|
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) => {
|
|
100
|
+
const { res, captured } = fakeRes()
|
|
101
|
+
await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionVerify).handler(fakeReq('GET') as never, res as never)
|
|
102
|
+
const body = JSON.parse(String(captured.body))
|
|
103
|
+
assert.equal(body.verified, 'mismatch')
|
|
104
|
+
})
|
|
105
|
+
|
|
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) => {
|
|
108
|
+
const { res, captured } = fakeRes()
|
|
109
|
+
await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionInfo).handler(fakeReq('GET') as never, res as never)
|
|
110
|
+
const body = JSON.parse(String(captured.body))
|
|
111
|
+
assert.equal(body.version, '0.4.9')
|
|
112
|
+
assert.equal(body.sha256, goodSha)
|
|
113
|
+
assert.equal(body.source, 'server')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
console.log('ALL PASS extension-routes')
|
|
Binary file
|