dsh-agy-link 0.4.77 → 0.4.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.79 (2026-10-29)
4
+
5
+ ### English
6
+
7
+ - **Fix(mcp): never silently drop tools whose names collide after MCP normalization.**
8
+ - `toMcpName()` maps any non-`[A-Za-z0-9_-]` character to `_`, so distinct DSH tools like `a.b` and `a_b` normalized to the same MCP name and the second was silently removed from `/tools` — hiding a real, possibly allowlisted, tool from the agent. Colliding names are now disambiguated with a stable `__<n>` suffix; `bridge.mjs` still calls back by the exact `_dshName`, so execution is unaffected. Adds a regression test.
9
+
10
+ ### 中文
11
+
12
+ - **修复(mcp):名称经 MCP 归一化后撞名的工具不再被静默丢弃。**
13
+ - `toMcpName()` 会把非 `[A-Za-z0-9_-]` 字符替换成 `_`,于是 `a.b` 与 `a_b` 这类不同工具会归一化成同一个 MCP 名,第二个被从 `/tools` 静默删除,导致一个真实(且可能已加入 allowlist 的)工具对 agent 不可见。现在撞名会加稳定的 `__<n>` 后缀消歧;`bridge.mjs` 仍按精确 `_dshName` 回调,执行不受影响。补充回归测试。
14
+
15
+ **Full Changelog**: https://github.com/amlyczz/dsh-agy-link/compare/v0.4.78...v0.4.79
16
+
17
+ ## 0.4.78 (2026-10-29)
18
+
19
+ ### English
20
+
21
+ - **Refactor(mcp/http): one bounded JSON body reader for every HTTP surface.**
22
+ - Extracted `src/host/read-json-body.ts` and routed both the MCP bridge `/call` and all `/plugins/agy-link/*` web routes through it. Plugin payloads are tiny config JSON; the old unbounded buffer let any local client (including a prompt-injected agy child holding the bridge token) exhaust host memory by streaming a huge POST body. Bodies are now capped (1 MiB for plugin routes, 8 MiB for `/call`) and oversized ones are discarded. Adds focused unit tests for the reader (valid/malformed/array/oversized/stream-error).
23
+
24
+ ### 中文
25
+
26
+ - **重构(mcp/http):所有 HTTP 面共用同一个有界 JSON body 读取器。**
27
+ - 抽出 `src/host/read-json-body.ts`,MCP 桥接 `/call` 与全部 `/plugins/agy-link/*` Web 路由统一走它。插件请求体都是很小的配置 JSON,此前的无界缓冲会让任意本地客户端(包括持有桥接 token、被提示词注入的 agy 子进程)用超大 POST body 打爆宿主内存。现在缓冲有上限(插件路由 1 MiB、`/call` 8 MiB),超限直接丢弃。为该读取器补充了针对性单测(正常/非法/数组/超限/流错误)。
28
+
29
+ **Full Changelog**: https://github.com/amlyczz/dsh-agy-link/compare/v0.4.77...v0.4.78
30
+
3
31
  ## 0.4.77 (2026-10-29)
4
32
 
5
33
  ### English
package/dist/index.js CHANGED
@@ -34639,6 +34639,57 @@ var WarmupService = class {
34639
34639
  return this.warmupAllAccounts({ force: false });
34640
34640
  }
34641
34641
  };
34642
+ function readJsonBody(req, opts = {}) {
34643
+ const maxBytes = opts.maxBytes ?? 1048576;
34644
+ const emitter = req;
34645
+ return new Promise((resolve) => {
34646
+ const chunks = [];
34647
+ let size = 0;
34648
+ let tooLarge = false;
34649
+ const done = (result) => resolve(result);
34650
+ emitter.on?.("data", (chunk) => {
34651
+ const buf = chunk ?? Buffer.alloc(0);
34652
+ size += buf.length;
34653
+ if (size > maxBytes) {
34654
+ tooLarge = true;
34655
+ chunks.length = 0;
34656
+ return;
34657
+ }
34658
+ chunks.push(buf);
34659
+ });
34660
+ emitter.on?.("end", () => {
34661
+ if (tooLarge) return done({
34662
+ value: {},
34663
+ tooLarge: true,
34664
+ invalid: false
34665
+ });
34666
+ try {
34667
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
34668
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) return done({
34669
+ value: parsed,
34670
+ tooLarge: false,
34671
+ invalid: false
34672
+ });
34673
+ return done({
34674
+ value: {},
34675
+ tooLarge: false,
34676
+ invalid: true
34677
+ });
34678
+ } catch {
34679
+ return done({
34680
+ value: {},
34681
+ tooLarge: false,
34682
+ invalid: true
34683
+ });
34684
+ }
34685
+ });
34686
+ emitter.on?.("error", () => done({
34687
+ value: {},
34688
+ tooLarge: false,
34689
+ invalid: true
34690
+ }));
34691
+ });
34692
+ }
34642
34693
  //#endregion
34643
34694
  //#region src/host/mcp-bridge.ts
34644
34695
  const MCP_SERVER_KEY = "dsh-tools";
@@ -34660,34 +34711,11 @@ const INTERNAL_TOOL_NAMES = /* @__PURE__ */ new Set([
34660
34711
  function toMcpName(name) {
34661
34712
  return name.replace(/[^a-zA-Z0-9_-]/g, "_");
34662
34713
  }
34663
- /** Loopback request-body cap. Tool arguments are tiny; this only exists so a
34664
- * token-holding (possibly prompt-injected) agy child cannot OOM the host by
34665
- * streaming an unbounded body at /call. */
34666
- const DEFAULT_MAX_BODY_BYTES = 8388608;
34667
- function readBody(req, maxBytes) {
34668
- return new Promise((resolve) => {
34669
- const chunks = [];
34670
- let size = 0;
34671
- let tooLarge = false;
34672
- req.on("data", (c) => {
34673
- size += c.length;
34674
- if (size > maxBytes) {
34675
- tooLarge = true;
34676
- chunks.length = 0;
34677
- return;
34678
- }
34679
- chunks.push(c);
34680
- });
34681
- req.on("end", () => resolve({
34682
- tooLarge,
34683
- text: tooLarge ? "" : Buffer.concat(chunks).toString("utf8")
34684
- }));
34685
- req.on("error", () => resolve({
34686
- tooLarge,
34687
- text: ""
34688
- }));
34689
- });
34690
- }
34714
+ /** Loopback request-body cap for /call. Tool arguments are tiny; this only
34715
+ * exists so a token-holding (possibly prompt-injected) agy child cannot OOM the
34716
+ * host by streaming an unbounded body. The bounded reader itself is shared with
34717
+ * the plugin's web routes (see ./read-json-body.ts). */
34718
+ const MCP_MAX_BODY_BYTES = 8388608;
34691
34719
  /** Constant-time bearer comparison (the token is a shared secret). */
34692
34720
  function bearerMatches(provided, expected) {
34693
34721
  const a = Buffer.from(provided, "utf8");
@@ -34745,37 +34773,35 @@ function startMcpBridge(opts) {
34745
34773
  }
34746
34774
  const allow = opts.allowlist().split(",").map((s) => s.trim()).filter(Boolean);
34747
34775
  const allowSet = new Set(allow);
34748
- const seen = /* @__PURE__ */ new Set();
34749
- sendJson(res, 200, { tools: svc.schemas().filter((t) => allow.length === 0 || allowSet.has(t.name)).filter((t) => {
34750
- if (INTERNAL_TOOL_NAMES.has(t.name)) return false;
34751
- const mapped = toMcpName(t.name);
34752
- if (seen.has(mapped)) return false;
34753
- seen.add(mapped);
34754
- return true;
34755
- }).map((t) => ({
34756
- name: toMcpName(t.name),
34757
- dshName: t.name,
34758
- description: t.description,
34759
- inputSchema: {
34760
- type: "object",
34761
- ...t.parameters
34762
- }
34763
- })) });
34776
+ const used = /* @__PURE__ */ new Set();
34777
+ sendJson(res, 200, { tools: svc.schemas().filter((t) => allow.length === 0 || allowSet.has(t.name)).filter((t) => !INTERNAL_TOOL_NAMES.has(t.name)).map((t) => {
34778
+ const base = toMcpName(t.name);
34779
+ let name = base;
34780
+ for (let i = 2; used.has(name); i++) name = base + "__" + i;
34781
+ used.add(name);
34782
+ return {
34783
+ name,
34784
+ dshName: t.name,
34785
+ description: t.description,
34786
+ inputSchema: {
34787
+ type: "object",
34788
+ ...t.parameters
34789
+ }
34790
+ };
34791
+ }) });
34764
34792
  return;
34765
34793
  }
34766
34794
  if (req.method === "POST" && (url === "/call" || url === "/mcp/call")) {
34767
- const body = await readBody(req, opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
34795
+ const body = await readJsonBody(req, { maxBytes: opts.maxBodyBytes ?? MCP_MAX_BODY_BYTES });
34768
34796
  if (body.tooLarge) {
34769
34797
  sendJson(res, 413, { error: "request body too large" });
34770
34798
  return;
34771
34799
  }
34772
- let parsed = {};
34773
- try {
34774
- parsed = JSON.parse(body.text);
34775
- } catch {
34800
+ if (body.invalid) {
34776
34801
  sendJson(res, 400, { error: "invalid JSON" });
34777
34802
  return;
34778
34803
  }
34804
+ const parsed = body.value;
34779
34805
  const svc = opts.tools();
34780
34806
  if (!svc) {
34781
34807
  sendJson(res, 503, { error: "tools service unavailable" });
@@ -35493,20 +35519,10 @@ function apply(ctx, entryConfig = {}) {
35493
35519
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
35494
35520
  res.end(JSON.stringify(body));
35495
35521
  };
35496
- const readBody = (req, strict = false) => {
35497
- const r = req;
35498
- return new Promise((resolve) => {
35499
- const chunks = [];
35500
- r.on?.("data", (c) => chunks.push(c));
35501
- r.on?.("end", () => {
35502
- try {
35503
- const v = JSON.parse(Buffer.concat(chunks).toString("utf8"));
35504
- resolve(v && typeof v === "object" ? v : {});
35505
- } catch {
35506
- resolve(strict ? { __invalidJson: true } : {});
35507
- }
35508
- });
35509
- });
35522
+ const readBody = async (req, strict = false) => {
35523
+ const result = await readJsonBody(req);
35524
+ if (result.invalid || result.tooLarge) return strict ? { __invalidJson: true } : {};
35525
+ return result.value;
35510
35526
  };
35511
35527
  const methodOf = (req) => {
35512
35528
  const m = req.method;
@@ -120,6 +120,9 @@ reference-counted slot is the strongest isolation the plugin can provide.
120
120
  loopback endpoint also caps request bodies (8 MiB → HTTP 413) and compares the
121
121
  bearer token in constant time, so a token-holding but prompt-injected child
122
122
  cannot OOM the host with an unbounded body or probe the token via timing.
123
+ Distinct DSH tool names that normalize to the same MCP name (e.g. `a.b`
124
+ and `a_b`) are disambiguated with a stable suffix instead of being silently
125
+ dropped, so every real tool stays reachable.
123
126
  Every bridged call is audit-logged as `mcp call <tool> [<outcome>] <ms>` with
124
127
  the caller class and the failure text (never the arguments), so a failed run
125
128
  is visible in journald instead of only in transcripts.
@@ -44,3 +44,15 @@ choice, not a fallback applied by this plugin.
44
44
  `/agy doctor` writes its report with redaction: Google auth URLs,
45
45
  `4/...` authorization codes, `ya29.*` tokens, and `Bearer` headers are
46
46
  stripped before the file hits disk. Still read it before attaching.
47
+
48
+ ## HTTP surfaces and request-body caps
49
+
50
+ - The plugin's web routes (`/plugins/agy-link/*`) and the experimental loopback
51
+ MCP bridge (`/call`) share one bounded JSON body reader
52
+ (`src/host/read-json-body.ts`): bodies are capped (1 MiB for plugin routes,
53
+ 8 MiB for `/call`) and oversized bodies are discarded rather than buffered, so
54
+ a local client — including a prompt-injected agy child holding the bridge token
55
+ — cannot exhaust host memory by streaming a huge POST body.
56
+ - The MCP bridge binds to `127.0.0.1` only, requires a per-process random bearer
57
+ token compared in constant time, enforces `mcpToolAllowlist` at execution time,
58
+ and never exposes the internal `run_code`/`agy_ask`/`agy_tool` tools.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-agy-link",
3
- "version": "0.4.77",
3
+ "version": "0.4.79",
4
4
  "description": "Google Antigravity (agy CLI) models for DeepSeek Harness — stream Gemini/Claude/GPT-OSS subscriptions into DSH with thinking, tool activity, token usage and in-GUI Google OAuth login.",
5
5
  "type": "module",
6
6
  "license": "MIT",