dsh-origin-bridge 0.4.7 → 0.4.8

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/README.md CHANGED
@@ -18,6 +18,7 @@
18
18
  <a href="#安装"><img alt="platform" src="https://img.shields.io/badge/platform-Windows%20%2B%20Origin%202021%2B-blue"></a>
19
19
  <img alt="tests" src="https://img.shields.io/badge/tests-1126%2F1126%20passing-brightgreen">
20
20
  <img alt="license" src="https://img.shields.io/badge/license-MIT-lightgrey">
21
+ <a href="https://github.com/caob23/dsh-origin-bridge/actions/workflows/offline-checks.yml"><img alt="offline checks" src="https://github.com/caob23/dsh-origin-bridge/actions/workflows/offline-checks.yml/badge.svg"></a>
21
22
  </p>
22
23
 
23
24
  <p align="center">
@@ -35,7 +36,7 @@
35
36
  - **数据不出本机。** 没有 API key、没有云端组件,dsh 拉起一个本地 Python 进程,通过 OriginLab 官方的 [`originpro`](https://pypi.org/project/originpro/) 驱动你机器上的 Origin。
36
37
  - **每一步都有证据。** Origin 的 X-Function 普遍"返回成功但什么都没发生"。这里画图后读回轴范围与数据跨度比对,导出后校验文件存在/体积/文件头魔数,样式逐项读回——读不回的进 `failed` 列表,不会假装成功。
37
38
  - **不是 74 个工具,是 27 个。** 每个都有实测用途,工具描述里写着实测限制,模型不会踩坑。
38
- - **1000+ 次真实测试,不是自称。** **1126 例**全链路用例跑在真实 stdio 服务器 + 本机 OriginPro 上,**1126 通过 / 0 失败**:27 个工具每个至少 15 例、最多 134 例,按副作用分五层(L0 纯解析 301、L1 只读 COM 63、L2 改会话 521、L3 落盘 163、L4 进程级 78)。提示词全部按人话写,跑之前先用在线 `tools/list` schema 校验用例本身,所以失败只会是产品问题,不会是题目写错。另有 **364 项**离线/单元/边界检查(asciiio 84、binio 30、lifecycle 37、smoke 129、edge 26、paths 28、json 18、readfile 12)**随包发布,你可以直接复跑**;那 1126 例题库含提示词与模拟数据,是开发侧资产,不随包分发。originpro 的十几个文档陷阱全部记录在案并绕开。
39
+ - **1000+ 次真实测试,不是自称。** **1126 例**全链路用例跑在真实 stdio 服务器 + 本机 OriginPro 上,**1126 通过 / 0 失败**:27 个工具每个至少 15 例、最多 134 例,按副作用分五层(L0 纯解析 301、L1 只读 COM 63、L2 改会话 521、L3 落盘 163、L4 进程级 78)。提示词全部按人话写,跑之前先用在线 `tools/list` schema 校验用例本身,所以失败只会是产品问题,不会是题目写错。另有 **417 项**离线/单元/边界检查(asciiio 84、binio 30、lifecycle 42、smoke 129、edge 26、paths 35、json 26、readfile 12、labtalk 33)**随包发布,你可以直接复跑**;那 1126 例题库含提示词与模拟数据,是开发侧资产,不随包分发。originpro 的十几个文档陷阱全部记录在案并绕开。
39
40
 
40
41
  ## 它能做什么
41
42
 
@@ -130,13 +131,14 @@ pip install -r requirements.txt # 国内网络慢就加 -i https://pypi.tun
130
131
  .\.venv\Scripts\python.exe edge_checks.py # 26 项边界
131
132
  .\.venv\Scripts\python.exe test_asciiio.py # 84 项,不需要 Origin
132
133
  .\.venv\Scripts\python.exe test_binio.py # 30 项,不需要 Origin;有真实 .bin 时会跟厂商 .txt 逐点对账
133
- .\.venv\Scripts\python.exe test_lifecycle.py # 37 项,不需要 Origin:实例分类/回收/掉线重连的安全边界
134
- .\.venv\Scripts\python.exe test_paths.py # 28 项,不需要 Origin:导出/保存目标路径的规范化与拒绝
135
- .\.venv\Scripts\python.exe test_json_safety.py # 18 项,不需要 Origin:返回值必须是严格 JSON
134
+ .\.venv\Scripts\python.exe test_lifecycle.py # 42 项,不需要 Origin:实例分类/回收/掉线重连的安全边界
135
+ .\.venv\Scripts\python.exe test_paths.py # 35 项,不需要 Origin:导出/保存目标路径的规范化与拒绝
136
+ .\.venv\Scripts\python.exe test_json_safety.py # 26 项,不需要 Origin:返回值必须是严格 JSON
136
137
  .\.venv\Scripts\python.exe test_readfile.py # 12 项,不需要 Origin:read_file 的 sample 与二进制表格拒绝
138
+ .\.venv\Scripts\python.exe test_labtalk_guard.py # 33 项,不需要 Origin:LabTalk 黑名单匹配
137
139
  ```
138
140
 
139
- 上面这 364 项随包发布,你可以自己复跑。另外那 **1126 例全链路题库是开发侧资产**(`cases/` + `run_cases.py`),跑的是真实 stdio 服务器 + 本机 Origin,用它的提示词和你的原始数据都不适合公开,所以没有进仓库、也没有进 npm 包;本文里的覆盖数字都来自它的实际运行结果。
141
+ 上面这 417 项随包发布,你可以自己复跑。另外那 **1126 例全链路题库是开发侧资产**(`cases/` + `run_cases.py`),跑的是真实 stdio 服务器 + 本机 Origin,用它的提示词和你的原始数据都不适合公开,所以没有进仓库、也没有进 npm 包;本文里的覆盖数字都来自它的实际运行结果。
140
142
 
141
143
  ### 不是 dsh,是别的 MCP 客户端
142
144
 
@@ -275,6 +277,9 @@ Origin Bridge 的做法:
275
277
  | 装饰过的数值格子 | `<0.001`、`2.5±0.1`、`25 mV`、`1,234.5`、`2.5*`、`(500)`、`1e400` 这些形态此前让整列被判"全为空"直接丢弃——用户看到的是少一列数据,不是报错 | `cell_number` 逐形态解开并回一条 `warnings` 说明代入了什么;溢出/Infinity 一律按缺值,绝不把 inf 送出桥 |
276
278
  | 分隔符嗅探 | 欧陆 `Zeit;Signal / 0,0;1,5` 会被逗号拆开,而且错的拆法因为"列更多"在评分里胜出 | 评分改成:数值列数 → 行宽一致性 → 列数更少者优先;表头行不参与数值比 |
277
279
  | `origin_open` 与句柄 | 开工程会替换所有页面,之前发出去的 `ws-/gr-` 句柄全部指向已销毁的对象;死对象上的 `save_fig` 会"成功"但什么都不写(1124 例跑里正是它让连续三个导出失败) | `open_project` 成功与失败都清空句柄表并在返回里给 `handles_reset: true`;旧句柄再用会得到 `worksheet_not_found`/`graph_not_found`,不会静默操作死页面 |
280
+ | LabTalk 黑名单 | 原来按子串匹配,2026-10-20 用 45 条高危语句实测 **37 条能过**:`win -cd` 销毁页面、`doc -n` 新建工程丢当前项目、`del/delete` 删数据集、`run -p`/`run.file` 执行外部程序、`exit`、以及能绕过句柄重置的 `doc -o` | 改成按语句(`;`/换行/`{}` 切分)+ 词边界规则:`doc -`、`win -c`、`del/delete`、`layer -d`、`page -d`、`quit/exit`、`run.`/`run -`、`system/shell`、`rmdir/mkdir`、`type -`、`close`、`saveas` 全拦;`test_labtalk_guard.py` 32 项锁住"该拦的拦、该放的放"。仍是劝告式护栏,不是沙箱 |
281
+ | dsh 新 profile 的补丁模板 | `cordis.patch.yml` 模板内容就是一行 `[]`(顶层数组占位)。往它后面**追加** `- insert:` 会得到两个 YAML 文档,dsh 直接 `failed to parse overlay` 起不来——`install_dsh.ps1` 2026-10-20 前正是这样,全新环境按 README 用脚本装会把配置写坏 | 安装器现在把"只有注释/只有一个 `[]`"的文件当空文件处理,插块成为数组本身;装完会用 PyYAML 校验一次,解析失败就提示从备份恢复 |
282
+ | `save_fig` 的 `width` | 传了也不生效:`save_fig(path)` 只有 path 时用页面尺寸,**必须同时给 type** 才认 width (实测 400 和 12000 导出都是 3216×2461、字节数相同)。我们的 `origin_export` 以前恰好只传 path | `save_fig(path, fmt, width=N)` + 缺文件时回退纯 path;PNG 导完比对 `dimensions.0` 与请求值,不一致就回 warning。题库宽度用例从"有 dimensions"改成**断言等于请求宽度** |
278
283
  | 非标准 JSON | `json.dumps` 默认把 inf/nan 写成裸 `Infinity`/`NaN`,那不是合法 JSON——dsh 侧整条响应解析失败,看起来像"桥挂了"而不是"一个数不对" | `sanitize()` 把 ±inf/nan 归 None;`server.py` 写出走 `allow_nan=False`,兜不住就降级成带原 id 的 JSON-RPC 错误;`smoke_test` 客户端用 `strict_loads` 拒绝,测试不会替产品掩盖它(`test_json_safety.py` 18 项) |
279
284
 
280
285
  ## 已知边界
@@ -286,7 +291,7 @@ Origin Bridge 的做法:
286
291
  - 交付路径:中文目录、中文文件名、含空格、200+ 字符长路径、自动建多级目录、`tiff→tif`/`jpeg→jpg` 改名——2026-10-12 在真机上逐条实测可用(PNG/TIF/PDF/SVG/EMF 和 `.opju` 都试过)。**不行的是网络路径**:UNC 与未映射盘返回 `bad_path`,不去猜认证。另有两条 Origin 自己的怪脾气已经兜住:文件名里的 `#` 会被导出滤镜换成编号(桥发现后会改名回你要的名字并在 `warnings` 里说明),路径组件尾部的空格/点会被 Windows 悄悄截掉(桥先截一遍再比较,不会报"文件不存在")。
287
292
  - 千分位和逗号分隔符同形的文件(不加引号的 `0,1,234.5`)没法机械还原:桥按最宽行补齐并报"行长度不一致",不会假装列数对得上。欧陆小数点(`0,5`)只在分隔符是 `;` 或 tab 时才认。
288
293
  - Origin 以管理员运行而 Python 不是(或反之)时 COM 连不上,两侧权限要一致。
289
- - 长会话攒太多页面会让 COM 桥报"无效指针"。`origin_close_pages` 能清,根治是 `origin_exit` 后重连。
294
+ - 长会话攒太多页面会让 COM 桥报"无效指针"。`origin_close_pages` 能清,根治是 `origin_exit` 后重连。2026-10-20 实测到 300 页仍不崩,但每张图的耗时从 0.7 秒(前 100 页)涨到 2.8 秒(200–300 页)——所以 `origin_plot`/`origin_matrix` 每新建 50 个页面会在 `warnings` 里提醒一次(计数不花额外 COM 调用,`origin_close_pages`/`origin_exit`/`origin_open` 会清零)。
290
295
  - **Origin 实例不会跟着客户端退出**:originpro 需要时自己拉一个 Origin,Python 进程结束那个实例还在。攒够就会报"实例超过上限"。现在插件在进程退出时回收**自己起的、且没有窗口的**实例(`atexit`),历史残留用 `origin_reclaim` 清;有窗口的实例永远不动,因为那可能是你正开着的项目。
291
296
  - 统计批处理(t 检验 / ANOVA / PCA / 生存分析)没有,originpro 本身也不提供。
292
297
  - **无并发保护**:两个会话同时驱动 Origin 会互相踩。
@@ -323,7 +328,7 @@ dsh-origin-bridge/
323
328
  ├── icon.svg # 卡片图标(scripts/make_icon.py 生成)
324
329
  ├── locale/*.json # dsh 卡片显示名与描述
325
330
  ├── scripts/make_preview.py # 重新生成 docs/preview.png(关掉图例后才导出)
326
- └── test_*.py / smoke_test.py / edge_checks.py # 364 项离线与单元检查(随包发布)
331
+ └── test_*.py / smoke_test.py / edge_checks.py # 417 项离线与单元检查(随包发布)
327
332
  ```
328
333
 
329
334
  开发侧另有 `cases/` + `run_cases.py` 的 1126 例全链路题库和 `render_realdata.py` 的真实数据逐文件出图脚本,两者都不随包发布(见上文)。
@@ -22,10 +22,24 @@ const candidates = [
22
22
  'python',
23
23
  ].filter(Boolean);
24
24
 
25
+ // Same environment the dsh patch sets, so logs are UTF-8 instead of the console
26
+ // code page and the child flushes line by line.
27
+ const env = { ...process.env };
28
+ if (!env.PYTHONIOENCODING) env.PYTHONIOENCODING = 'utf-8';
29
+ if (!env.PYTHONUNBUFFERED) env.PYTHONUNBUFFERED = '1';
30
+
25
31
  const child = spawn(
26
32
  candidates.find((c) => c === 'python' || existsSync(c)) || 'python',
27
- ['-u', server],
28
- { stdio: 'inherit', cwd: root },
33
+ ['-u', '-X', 'utf8', server],
34
+ { stdio: 'inherit', cwd: root, env },
29
35
  );
30
36
 
31
- child.on('exit', (code) => process.exit(code ?? 0));
37
+ // A child killed by a signal is a crash, not a clean exit: reporting 0 here would
38
+ // make an MCP client treat a dead server as "finished normally".
39
+ child.on('exit', (code, signal) => {
40
+ if (signal) {
41
+ console.error(`[origin-bridge] python exited on ${signal}`);
42
+ process.exit(1);
43
+ }
44
+ process.exit(code ?? 1);
45
+ });
package/install_dsh.ps1 CHANGED
@@ -90,18 +90,25 @@ $block += " toolCallTimeoutMs: 180000`r`n"
90
90
  # second copy of the server to drift out of sync with C:\...\origin-bridge.
91
91
  $pkgName = 'dsh-origin-bridge'
92
92
  $pkgDir = Join-Path $profileDir "node_modules\$pkgName"
93
+ # Read the real version instead of hard-coding it: the card in dsh shows this
94
+ # value, and a stale 0.4.0 makes the installed plugin look ancient.
95
+ $pkgVersion = '0.0.0'
96
+ $manifest = Join-Path $src 'package.json'
97
+ if (Test-Path $manifest) {
98
+ try { $pkgVersion = (Get-Content $manifest -Raw -Encoding UTF8 | ConvertFrom-Json).version } catch { }
99
+ }
93
100
  if (-not (Test-Path (Join-Path $pkgDir 'index.js'))) {
94
101
  New-Item -ItemType Directory -Force -Path $pkgDir | Out-Null
95
- @'
102
+ @"
96
103
  {
97
104
  "name": "dsh-origin-bridge",
98
- "version": "0.4.0",
105
+ "version": "$pkgVersion",
99
106
  "description": "Origin Bridge: natural language" -> local Origin/OriginPro -> editable .opju + publication images",
100
107
  "main": "./index.js",
101
108
  "type": "module",
102
109
  "license": "MIT"
103
110
  }
104
- '@ | Set-Content -Path (Join-Path $pkgDir 'package.json') -Encoding ASCII
111
+ "@ | Set-Content -Path (Join-Path $pkgDir 'package.json') -Encoding ASCII
105
112
  @'
106
113
  // Registration-only bundle. The Origin tools come from server.py in this
107
114
  // package's own folder, registered by the mcp-origin-bridge insert in the
@@ -126,6 +133,13 @@ $block += " name: $pkgName`r`n"
126
133
 
127
134
  $existing = if (Test-Path $patch) { Get-Content $patch -Raw -Encoding UTF8 } else { '' }
128
135
 
136
+ # A fresh dsh profile ships `cordis.patch.yml` containing just `[]` (plus comments).
137
+ # Appending a `- insert:` entry after that produces TWO YAML documents and dsh
138
+ # refuses to parse the overlay — the installer used to break a brand-new profile.
139
+ # Treat a comments-only / empty-array file as empty so the block becomes the array.
140
+ $meaningful = ($existing -split "`r?`n" | Where-Object { $_ -notmatch '^\s*#' -and $_.Trim() -ne '' }) -join "`n"
141
+ if ($meaningful.Trim() -eq '[]' -or $meaningful.Trim() -eq '') { $existing = '' }
142
+
129
143
  # -RemoveOld: drop the previous plugin's insert block. Both entries pointed at
130
144
  # C:\...\originlab-bridge, and a dead stdio server still shows up as a tool
131
145
  # provider, so leaving it in place would mean two overlapping tool sets.
package/originlab.py CHANGED
@@ -351,6 +351,28 @@ atexit.register(_release_on_exit)
351
351
 
352
352
  _handles = {}
353
353
  _names = {}
354
+
355
+ # Measured 2026-10-20: 300 graph pages do not break the bridge, but each plot slows
356
+ # from ~0.7 s (first 100) to ~2.8 s (200-300), because Origin's COM surface grows with
357
+ # the project. Counting pages we create costs nothing (no extra COM call), so we can
358
+ # tell the caller before it becomes painful.
359
+ _page_growth = [0]
360
+ _GROWTH_HINT_EVERY = 50
361
+
362
+
363
+ def _growth_warning(created=True):
364
+ if not created:
365
+ return None
366
+ _page_growth[0] += 1
367
+ n = _page_growth[0]
368
+ if n % _GROWTH_HINT_EVERY:
369
+ return None
370
+ return ("本次会话已新建约 %d 个图页且未清理:实测 300 页时每张图约 2.8 秒(100 页时约 0.7 秒)。"
371
+ "长会话建议 origin_close_pages(kind='graph'),或 origin_exit 后重开" % n)
372
+
373
+
374
+ def _reset_growth():
375
+ _page_growth[0] = 0
354
376
  _seq = [0]
355
377
  _project_file = ""
356
378
 
@@ -1031,6 +1053,9 @@ def plot(worksheet, x=1, y=2, plot_type="line", graph_name="", title="",
1031
1053
  if review:
1032
1054
  span["warnings"] = [review]
1033
1055
  span["next_actions"] = list(REVIEW_OPTIONS)
1056
+ grown = _growth_warning(created=(int(layer) == 0))
1057
+ if grown:
1058
+ span.setdefault("warnings", []).append(grown)
1034
1059
  return sanitize({"graph": _bind("gr", gr, gr.name), "plot_type": plot_type,
1035
1060
  "channel": channel, "template": tpl,
1036
1061
  "source_sheet": _safe(lambda: ws.name, ""),
@@ -1370,8 +1395,25 @@ def _layer_count(gr):
1370
1395
  return n
1371
1396
 
1372
1397
 
1398
+ def _image_width(value, default, hi):
1399
+ """Validate a rendering width. Both ends matter: 0 or negative is nonsense, and
1400
+ a huge number makes Origin render a file too big to hand back."""
1401
+ try:
1402
+ width = int(value)
1403
+ except (TypeError, ValueError):
1404
+ width = default
1405
+ if width <= 0:
1406
+ raise OriginError("bad_arguments", "width 必须是正整数,收到 %r" % (value,))
1407
+ if width > hi:
1408
+ raise OriginError("bad_arguments", "width 太大:上限 %d,收到 %d" % (hi, width),
1409
+ ["出版图给 1600–3000 就够;预览图给 900"])
1410
+ return max(100, width)
1411
+
1412
+
1373
1413
  def view(graph, width=900):
1374
1414
  """Render the graph to a small PNG and hand the bytes back for visual check."""
1415
+ width = _image_width(width, 900, 2400)
1416
+
1375
1417
  def run():
1376
1418
  import base64
1377
1419
  import tempfile
@@ -1404,8 +1446,42 @@ def view(graph, width=900):
1404
1446
  return call(run)
1405
1447
 
1406
1448
 
1407
- LABTALK_DENY = ("doc -s", "doc -c", "saveas", "quit", "exitloop", "type -m", "openb", "run.x",
1408
- "close;", "close ", "system ", "shell ", "run.section", "run.open", "fopen")
1449
+ # Substring matching let 37 of 45 dangerous statements through (measured 2026-10-20):
1450
+ # `win -cd` destroyed a page, `doc -n` replaced the project, `run -p`/`run.file` run
1451
+ # external code, and `doc -o` bypassed the handle reset that origin_open performs.
1452
+ # These are anchored per statement (split on ';', newline and '{') with word
1453
+ # boundaries, so option spelling and whitespace no longer decide the outcome.
1454
+ LABTALK_DENY = ("saveas", "openb", "fopen", "exitloop")
1455
+ LABTALK_DENY_STATEMENTS = (
1456
+ # Only `doc -a` (activate) is harmless; every other switch can save, replace,
1457
+ # open or close the project. Inverting the match keeps unknown switches blocked.
1458
+ (r"doc\s+-(?![aA](?:\s|;|$))", "doc 开关(-s 存、-n 新建、-o 打开都会丢当前工程;只放 -a)"),
1459
+ (r"win\s+-c", "关闭/销毁页面(-c、-cd)"),
1460
+ (r"(?:del|delete)(?:\s|$)", "删除数据集"),
1461
+ (r"layer\s+-d", "删除图层"),
1462
+ (r"page\s+-d", "删除页面"),
1463
+ (r"(?:quit|exit)(?:\s|$)", "退出 Origin"),
1464
+ (r"run(?:\.|\s+-)", "执行外部脚本或程序"),
1465
+ (r"(?:system|shell)(?:\s|$)", "调用系统命令"),
1466
+ (r"(?:rmdir|mkdir)(?:\s|$)", "创建/删除目录"),
1467
+ (r"type\s+-", "type 开关(-a 会用外部程序打开文件)"),
1468
+ (r"close(?:\s|$)", "关闭窗口"),
1469
+ )
1470
+
1471
+
1472
+ def labtalk_denied(script):
1473
+ """Return the reasons a LabTalk script is refused (empty list = allowed).
1474
+
1475
+ Exposed as a function so the test suite exercises the same matcher the tool uses
1476
+ instead of a copy that silently drifts.
1477
+ """
1478
+ low = re.sub(r"\s+", " ", str(script).lower())
1479
+ blocked = [p for p in LABTALK_DENY if p in low]
1480
+ statements = [st.strip() for st in re.split('[;\\n{}]', low) if st.strip()]
1481
+ for rule, why in LABTALK_DENY_STATEMENTS:
1482
+ if any(re.search(r"(?:^|[\s,(=])" + rule, st) for st in statements):
1483
+ blocked.append(why)
1484
+ return blocked
1409
1485
 
1410
1486
 
1411
1487
  def labtalk(script, readback=(), numeric=()):
@@ -1420,11 +1496,11 @@ def labtalk(script, readback=(), numeric=()):
1420
1496
  # Advisory guardrail, not a sandbox: it stops the model from wiping the
1421
1497
  # session by accident, not from trying hard. Whitespace is collapsed so
1422
1498
  # 'doc -s' does not slip past the literal match.
1423
- low = re.sub(r"\s+", " ", str(script).lower())
1424
- blocked = [p for p in LABTALK_DENY if p in low]
1499
+ blocked = labtalk_denied(script)
1425
1500
  if blocked:
1426
1501
  raise OriginError("labtalk_denied", "这些命令不允许通过 origin_labtalk 执行:%s" % blocked,
1427
- ["关工程/退 Origin 请用 origin_exit 或 origin_save_project"])
1502
+ ["关工程/退 Origin 请用 origin_exit 或 origin_save_project",
1503
+ "句柄/页面管理用 origin_close_pages、保存用 origin_save_project"])
1428
1504
 
1429
1505
  def run():
1430
1506
  op = origin()
@@ -1569,6 +1645,7 @@ def fit(worksheet, x=1, y=2, kind="linear", func="", fixed=None, starts=None,
1569
1645
 
1570
1646
 
1571
1647
  def export(graph, path="", fmt="png", width=1600):
1648
+ width = _image_width(width, 1600, 12000)
1572
1649
  fmt = str(fmt).lstrip(".").lower()
1573
1650
  # Origin's export filter only recognises the short names: asking for a.tiff
1574
1651
  # leaves no file behind, which reads as a failed export.
@@ -1588,7 +1665,13 @@ def export(graph, path="", fmt="png", width=1600):
1588
1665
  raw_path = str(path or "").strip()
1589
1666
  target_path = _norm_target(raw_path or os.path.join(folder, "%s.%s" % (name, fmt)), fmt)
1590
1667
  _prepare_write(target_path)
1591
- _safe(lambda: page.save_fig(target_path))
1668
+ # width only applies when a type is passed as well; the old call passed
1669
+ # neither, so `width` never did anything (measured: 400 and 12000 both came
1670
+ # back as the page size, 3216 px). Fall back to the plain call if the typed
1671
+ # one does not produce a file.
1672
+ _safe(lambda: page.save_fig(target_path, fmt, width=int(width)))
1673
+ if not os.path.isfile(target_path):
1674
+ _safe(lambda: page.save_fig(target_path))
1592
1675
  written, note = _written_or_mangled(target_path)
1593
1676
  if not written:
1594
1677
  raise OriginError("export_failed", "Origin 说导出了但文件不存在:%s" % target_path,
@@ -1600,6 +1683,11 @@ def export(graph, path="", fmt="png", width=1600):
1600
1683
  ["图页可能是空的:origin_inspect 看 plots 数"])
1601
1684
  if note:
1602
1685
  info["warnings"] = [note]
1686
+ dims = info.get("dimensions")
1687
+ if dims and fmt == "png" and abs(int(dims[0]) - int(width)) > max(2, int(width) * 0.02):
1688
+ # Tell the caller instead of pretending the request was honoured.
1689
+ info["warnings"] = (info.get("warnings") or []) + [
1690
+ "width=%d 没有生效:实际导出 %dx%d(Origin 对该图/滤镜接受了别的尺寸)" % (width, dims[0], dims[1])]
1603
1691
  rel = _rel_note(path)
1604
1692
  if rel:
1605
1693
  info["warnings"] = (info.get("warnings") or []) + [rel]
@@ -1694,6 +1782,7 @@ def open_project(path):
1694
1782
  "Origin 版本低于该工程保存版本时也打不开",
1695
1783
  "打开失败后旧句柄已作废,请重新 origin_inspect 看当前页面"])
1696
1784
  _project_file = path
1785
+ _reset_growth()
1697
1786
  return sanitize({"opened": path, "open_pages": [p.name for p in op.pages()],
1698
1787
  "handles_reset": True,
1699
1788
  "note": "之前拿到的 ws-/gr- 句柄全部作废(工程换了页面),请用这里的页面名或重新导入"})
@@ -1741,6 +1830,7 @@ def exit_origin(save_to=""):
1741
1830
  _names.clear()
1742
1831
  _project_file = ""
1743
1832
  _reset_connection()
1833
+ _reset_growth()
1744
1834
  return sanitize({"exited": True, "saved": saved})
1745
1835
 
1746
1836
  return call(run)
@@ -1873,6 +1963,9 @@ def matrix_plot(rows, template="heat_map", book_name="", graph_name="",
1873
1963
  "xlim": _safe(lambda: list(lay.xlim)), "ylim": _safe(lambda: list(lay.ylim)),
1874
1964
  "zlim": _safe(lambda: list(lay.zlim)),
1875
1965
  "proof_level": "verified"}
1966
+ grown = _growth_warning()
1967
+ if grown:
1968
+ out.setdefault("warnings", []).append(grown)
1876
1969
  if export_path:
1877
1970
  out["export"] = _export_on_com_thread(gr, export_path)
1878
1971
  if project_path:
@@ -1979,6 +2072,8 @@ def close_pages(kind="graph", names=None, dry_run=False):
1979
2072
  for ref in [r for r, nm in _names.items() if nm and nm in closed]:
1980
2073
  _handles.pop(ref, None)
1981
2074
  _names.pop(ref, None)
2075
+ if closed:
2076
+ _reset_growth()
1982
2077
  return sanitize({"asked": wanted, "closed": closed,
1983
2078
  "refused": [n for n in wanted if n not in closed],
1984
2079
  "errors": errors,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-origin-bridge",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "在对话里驱动本机 Origin / OriginPro:导入 .dat/.csv/.txt 与电化学工作站 .bin,绘图、拟合、改样式,导出图片并保存可在 Origin 里继续编辑的 .opju 工程。dsh 插件,27 个 MCP 工具。第三方工具,与 OriginLab Corporation 无关联、未获认可。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -53,6 +53,7 @@
53
53
  "test_paths.py",
54
54
  "test_json_safety.py",
55
55
  "test_readfile.py",
56
+ "test_labtalk_guard.py",
56
57
  "test_asciiio.py",
57
58
  "test_binio.py",
58
59
  "test_lifecycle.py",
package/server.py CHANGED
@@ -20,7 +20,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
20
20
  import originlab # noqa: E402
21
21
 
22
22
  SERVER_NAME = "origin-bridge"
23
- SERVER_VERSION = "0.4.7"
23
+ SERVER_VERSION = "0.4.8"
24
24
  PROTOCOL = "2024-11-05"
25
25
 
26
26
 
@@ -145,7 +145,7 @@ TOOLS = [
145
145
  "symbol_interior": {"type": "integer", "enum": [0, 1, 2, 3],
146
146
  "description": "0 无符号 1 实心 2 空心 3 点心"},
147
147
  "transparency": {"type": "number", "description": "0-100 百分比"},
148
- "fill_area": {"type": "boolean", "description": "线下填充(面积图效果)"},
148
+ "fill_area": {"type": ["boolean", "integer"], "description": "线下填充(面积图效果,1/true 都行)"},
149
149
  },
150
150
  "required": ["graph"],
151
151
  },
@@ -168,7 +168,7 @@ TOOLS = [
168
168
  "properties": {
169
169
  "worksheet": {"type": "string"},
170
170
  "x": {"type": ["integer", "string"], "default": 1},
171
- "y": {"type": ["integer", "string"], "default": 2},
171
+ "y": {"type": ["integer", "string", "array"], "default": 2},
172
172
  "kind": {"type": "string", "enum": ["linear", "nlfitsing", "gauss", "lorentz",
173
173
  "voigt", "expdec1", "expdec2", "sine", "power",
174
174
  "logistic", "boltzmann", "doseresp", "cubic",
@@ -200,7 +200,7 @@ TOOLS = [
200
200
  "path": {"type": "string", "description": "输出文件绝对路径;省略则用图页名放到当前目录"},
201
201
  "fmt": {"type": "string", "enum": ["png", "tif", "jpg", "jpeg", "svg", "pdf", "emf"],
202
202
  "default": "png"},
203
- "width": {"type": "integer", "default": 1600},
203
+ "width": {"type": "integer", "minimum": 100, "maximum": 12000, "default": 1600},
204
204
  },
205
205
  },
206
206
  "run": lambda a: originlab.export(a.get("graph", ""), a.get("path", ""),
@@ -243,14 +243,14 @@ TOOLS = [
243
243
  "source": {"type": "string", "description": "数据文件路径(与 columns 二选一)"},
244
244
  "columns": {"type": "object", "description": "内联数据 {列名: [值...]},优先用 source"},
245
245
  "x": {"type": ["integer", "string"], "default": 1},
246
- "y": {"type": ["integer", "string"], "default": 2},
246
+ "y": {"type": ["integer", "string", "array"], "default": 2},
247
247
  "plot_type": {"type": "string", "enum": ["line", "scatter", "line_symbol", "column"], "default": "line"},
248
248
  "title": {"type": "string"},
249
249
  "x_title": {"type": "string"},
250
250
  "y_title": {"type": "string"},
251
251
  "fmt": {"type": "string", "enum": ["png", "tif", "jpg", "jpeg", "svg", "pdf", "emf"],
252
252
  "default": "png"},
253
- "width": {"type": "integer", "default": 1600},
253
+ "width": {"type": "integer", "minimum": 100, "maximum": 12000, "default": 1600},
254
254
  "output_dir": {"type": "string", "description": "交付目录:图片 + opju 都写到这。不给则只建图、不落盘"},
255
255
  "export_path": {"type": "string"},
256
256
  "project_path": {"type": "string"},
@@ -394,7 +394,7 @@ TOOLS = [
394
394
  "type": "object",
395
395
  "properties": {
396
396
  "name": {"type": "string"},
397
- "data": {"type": "string", "description": "数据文件路径,会先 origin_read_file 解析列名"},
397
+ "data": {"type": ["string", "object", "array"], "description": "数据文件路径;也接受内联的 {列名:[值]} 或列名列表"},
398
398
  "columns": {"type": "array", "description": "或者直接给列名列表"},
399
399
  },
400
400
  "required": ["name"],
@@ -421,7 +421,7 @@ TOOLS = [
421
421
  "with_legend": {"type": "boolean", "default": True},
422
422
  "fmt": {"type": "string", "enum": ["png", "tif", "jpg", "jpeg", "svg", "pdf", "emf"],
423
423
  "default": "png"},
424
- "width": {"type": "integer", "default": 1600},
424
+ "width": {"type": "integer", "minimum": 100, "maximum": 12000, "default": 1600},
425
425
  },
426
426
  "required": ["name"],
427
427
  },
@@ -452,14 +452,14 @@ TOOLS = [
452
452
  "type": "object",
453
453
  "properties": {
454
454
  "graph": {"type": "string", "description": "省略则用当前活动图页"},
455
- "width": {"type": "integer", "default": 900},
455
+ "width": {"type": "integer", "minimum": 100, "maximum": 2400, "default": 900},
456
456
  },
457
457
  },
458
458
  "run": lambda a: originlab.view(a.get("graph", ""), a.get("width", 900)),
459
459
  },
460
460
  {
461
461
  "name": "origin_labtalk",
462
- "description": "逃生舱:执行原始 LabTalk,并读回你点名的变量(numeric 读数值表达式,readback 读字符串变量)。实测限制:数值/表达式类语句可用(如 layer.x.type=2、win -a Graph1),但字符串赋值类语句(layer.x.title$=... 、set c1 -l ...)在这台机器上 lt_exec 会返回 False,轴标题等请用 origin_style。returned=false 就是真失败,别重试同一条。doc -s / saveas / exitloop 等破坏性命令被禁。",
462
+ "description": "逃生舱:执行原始 LabTalk,并读回你点名的变量(numeric 读数值表达式,readback 读字符串变量)。实测限制:数值/表达式类语句可用(如 layer.x.type=2、win -a Graph1),但字符串赋值类语句(layer.x.title$=... 、set c1 -l ...)在这台机器上 lt_exec 会返回 False,轴标题等请用 origin_style。returned=false 就是真失败,别重试同一条。破坏性命令会被拦下并返回 labtalk_denied:doc -s/-n/-o(存/新建/打开工程)、win -c/-cd(关/销毁页面)、del/delete、layer -d、page -d、run./run -、system/shell、type -、rmdir/mkdir、quit/exit、close、saveas。这是劝告式护栏、不是沙箱(变量拼接仍可能绕过),别拿它做破坏性操作;页面/句柄管理用 origin_close_pages,保存用 origin_save_project。",
463
463
  "inputSchema": {
464
464
  "type": "object",
465
465
  "properties": {
@@ -494,6 +494,44 @@ def _tool_result(call_id, payload, is_error=False):
494
494
  return {"jsonrpc": "2.0", "id": call_id, "result": {"content": content, "isError": is_error}}
495
495
 
496
496
 
497
+ _JSON_TYPES = {"string": str, "integer": int, "number": (int, float), "boolean": bool,
498
+ "array": list, "object": dict}
499
+
500
+
501
+ def _validate_args(name, args, schema):
502
+ """Reject wrong-shaped arguments as bad_arguments before the tool runs.
503
+
504
+ Only *types* are checked here. Enums and ranges stay with the engine, which
505
+ answers with a more specific code (`bad_position`, `bad_format`, `bad_layer_type`)
506
+ than a generic bad_arguments would. What this fixes is the type confusion: a
507
+ `path: 123` used to reach the engine and surface as `origin_unavailable` with a
508
+ raw TypeError, i.e. the model was told Origin was missing. Extra keys are fine.
509
+ """
510
+ props = (schema or {}).get("properties") or {}
511
+ problems = []
512
+ for key, spec in props.items():
513
+ if key not in args or args[key] is None:
514
+ continue
515
+ value = args[key]
516
+ types = spec.get("type")
517
+ types = types if isinstance(types, list) else ([types] if types else [])
518
+ if types:
519
+ def fits(t):
520
+ py = _JSON_TYPES.get(t)
521
+ if py is None:
522
+ return True
523
+ if t in ("integer", "number") and isinstance(value, bool):
524
+ return False
525
+ return isinstance(value, py)
526
+ if not any(fits(t) for t in types):
527
+ problems.append("%s 应为 %s,收到 %s"
528
+ % (key, "/".join(types), type(value).__name__))
529
+ continue
530
+ if problems:
531
+ raise originlab.OriginError("bad_arguments", "%s 参数不合法:%s" % (name, ";".join(problems)),
532
+ ["按工具的 inputSchema 修正参数类型/范围后再调用"])
533
+
534
+
497
535
  def _handle(msg):
498
536
  if not isinstance(msg, dict):
499
537
  return {"jsonrpc": "2.0", "id": None,
@@ -532,6 +570,7 @@ def _handle(msg):
532
570
  return _tool_result(call_id, {"ok": False, "code": "bad_arguments",
533
571
  "message": "arguments 必须是对象"}, True)
534
572
  try:
573
+ _validate_args(name, args, tool.get("inputSchema"))
535
574
  out = tool["run"](args)
536
575
  if isinstance(out, dict):
537
576
  out = {"ok": True, **out}
@@ -579,13 +618,31 @@ def main():
579
618
 
580
619
  stdin = sys.stdin.buffer
581
620
  stdout = sys.stdout.buffer
582
- _log("ready; python=%s" % sys.executable)
621
+ # One JSON line is one request; an unbounded readline let a single huge
622
+ # `columns` payload allocate whatever the client sent. The cap only needs to be
623
+ # far above any real dataset (a 1M-point two-column write is ~30 MB of JSON).
624
+ max_line = int(os.environ.get("ORIGIN_BRIDGE_MAX_LINE", 64 * 1024 * 1024))
625
+ _log("ready; python=%s | max line %d" % (sys.executable, max_line))
583
626
 
584
627
  while True:
585
- raw = stdin.readline()
628
+ raw = stdin.readline(max_line + 1)
586
629
  if not raw:
587
630
  _log("stdin closed; exiting")
588
631
  return 0
632
+ if len(raw) > max_line and not raw.endswith(b"\n"):
633
+ # Drain the rest of the oversized line so the next request is not read
634
+ # from the middle of this one, then answer with a clean error.
635
+ while True:
636
+ chunk = stdin.readline(max_line + 1)
637
+ if not chunk or chunk.endswith(b"\n"):
638
+ break
639
+ _log("request over %d bytes refused" % max_line)
640
+ stdout.write((json.dumps({"jsonrpc": "2.0", "id": None,
641
+ "error": {"code": -32600,
642
+ "message": "请求太大:单行上限 %d 字节" % max_line}},
643
+ ensure_ascii=False) + "\n").encode("utf-8"))
644
+ stdout.flush()
645
+ continue
589
646
  raw = raw.strip()
590
647
  if not raw:
591
648
  continue
@@ -91,6 +91,60 @@ def main():
91
91
  ok("answered with a JSON-RPC error", payload.get("error", {}).get("code") == -32603, payload)
92
92
  ok("error keeps the request id", payload.get("id") == 7, payload)
93
93
 
94
+ # ------------------------------------------------ oversized request line
95
+ # A single enormous request must be refused without allocating it whole, and the
96
+ # stream has to resync so the NEXT request still works.
97
+ child2 = (
98
+ "import io, json, os, sys\n"
99
+ "sys.path.insert(0, os.environ['BRIDGE_DIR'])\n"
100
+ "import server\n"
101
+ "big = json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list',"
102
+ " 'pad': 'x' * 9000}) + chr(10)\n"
103
+ "good = json.dumps({'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'}) + chr(10)\n"
104
+ "sys.stdin = io.TextIOWrapper(io.BytesIO((big + good).encode('utf-8')), encoding='utf-8')\n"
105
+ "sys.exit(server.main() or 0)\n"
106
+ )
107
+ env2 = dict(env, ORIGIN_BRIDGE_MAX_LINE="4096")
108
+ proc2 = subprocess.run([sys.executable, "-c", child2], capture_output=True, env=env2, timeout=120)
109
+ lines2 = proc2.stdout.decode("utf-8", "replace").strip().splitlines()
110
+ ok("oversized request produced two replies", len(lines2) == 2, len(lines2))
111
+ first = {}
112
+ try:
113
+ first = strict(lines2[0])
114
+ except Exception as exc: # noqa: BLE001
115
+ ok("refusal is parseable", False, exc)
116
+ ok("oversized request refused with -32600",
117
+ first.get("error", {}).get("code") == -32600, first)
118
+ second = {}
119
+ try:
120
+ second = strict(lines2[1])
121
+ except Exception: # noqa: BLE001
122
+ pass
123
+ ok("stream resynced: the next request is answered",
124
+ bool((second.get("result") or {}).get("tools")), second)
125
+
126
+ # ------------------------------------------------ argument validation
127
+ # A wrong-typed argument used to reach the engine and surface as
128
+ # "origin_unavailable: TypeError", i.e. it blamed a missing Origin install.
129
+ import server as srv
130
+
131
+ def code_for(tool, args):
132
+ try:
133
+ srv._validate_args(tool, args, srv._BY_NAME[tool]["inputSchema"])
134
+ except srv.originlab.OriginError as exc:
135
+ return exc.code
136
+ return "no-raise"
137
+
138
+ ok("wrong-typed path is bad_arguments",
139
+ code_for("origin_import", {"path": 123}) == "bad_arguments")
140
+ ok("wrong-typed worksheet is bad_arguments",
141
+ code_for("origin_plot", {"worksheet": ["ws-1"]}) == "bad_arguments")
142
+ ok("boolean is not an integer",
143
+ code_for("origin_export", {"width": True}) == "bad_arguments")
144
+ ok("valid args pass", code_for("origin_import", {"path": "x.csv"}) == "no-raise")
145
+ ok("extra keys are tolerated",
146
+ code_for("origin_import", {"path": "x.csv", "bogus": 1}) == "no-raise")
147
+
94
148
  print("\n%d/%d json-safety checks passed" % (PASSED, PASSED + len(FAILED)))
95
149
  if FAILED:
96
150
  for f in FAILED:
@@ -0,0 +1,88 @@
1
+ """Checks for the LabTalk deny matcher (no Origin needed).
2
+
3
+ The guard is advisory — a variable can still assemble a blocked command at runtime —
4
+ but everything a model would plausibly type, including option variants and extra
5
+ whitespace, has to be refused. Measured before the rewrite: 37 of 45 destructive
6
+ statements went straight through.
7
+ """
8
+
9
+ import os
10
+ import sys
11
+
12
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13
+ import originlab as ol # noqa: E402
14
+
15
+ PASSED = 0
16
+ FAILED = []
17
+
18
+
19
+ def ok(label, cond, detail=""):
20
+ global PASSED
21
+ if cond:
22
+ PASSED += 1
23
+ else:
24
+ FAILED.append("%s %s" % (label, detail))
25
+ print("%s %s%s" % ("PASS" if cond else "FAIL", label,
26
+ (" | " + str(detail)[:100]) if detail else ""))
27
+
28
+
29
+ BLOCKED = [
30
+ ("doc -s", "保存工程"),
31
+ ("doc -n", "新建工程会丢当前项目"),
32
+ ("doc -o C:\\x.opju", "打开工程绕过句柄重置"),
33
+ ("win -c Graph1", "关窗口"),
34
+ ("win -cd Graph1", "销毁窗口"),
35
+ ("del Book1", "删数据集"),
36
+ ("delete Book1", "删数据集"),
37
+ ("layer -d", "删图层"),
38
+ ("page -d", "删页面"),
39
+ ("rmdir C:\\temp\\x", "删目录"),
40
+ ("mkdir C:\\temp\\x", "建目录"),
41
+ ("run -p", "执行外部程序"),
42
+ ("run.file x", "执行外部脚本"),
43
+ ("type -a", "用外部程序打开文件"),
44
+ ("exit", "退出"),
45
+ ("quit", "退出"),
46
+ ("system dir", "系统命令"),
47
+ ("shell dir", "系统命令"),
48
+ ("close", "裸 close 以前漏过"),
49
+ ("DOC -N", "大小写与多空格"),
50
+ ("win\t-cd", "制表符"),
51
+ ("xx = 1; win -cd Graph1;", "分号后的危险命令"),
52
+ ("if(1) del Book1;", "条件语句里的危险命令"),
53
+ ]
54
+
55
+ ALLOWED = [
56
+ "xx = 1;",
57
+ "layer.x.title$ = \"Time (s)\";",
58
+ "win -a Graph1;",
59
+ "layer.x.type = 2;",
60
+ "doc.echo = 0;",
61
+ "doc -a;",
62
+ "layer.x.label$ = \"delivery time\";",
63
+ "label$ = \"exit\";",
64
+ "legend -r;",
65
+ ]
66
+
67
+
68
+ def main():
69
+ for script, why in BLOCKED:
70
+ reasons = ol.labtalk_denied(script)
71
+ ok("blocked: %s(%s)" % (script, why), bool(reasons), reasons)
72
+ for script in ALLOWED:
73
+ reasons = ol.labtalk_denied(script)
74
+ ok("allowed: %s" % script, not reasons, reasons)
75
+ # the same matcher the tool calls
76
+ import inspect
77
+ src = inspect.getsource(ol.labtalk)
78
+ ok("tool uses the tested matcher", "labtalk_denied(script)" in src)
79
+ print("\n%d/%d deny-matcher checks passed" % (PASSED, PASSED + len(FAILED)))
80
+ if FAILED:
81
+ for f in FAILED:
82
+ print(" FAILED:", f)
83
+ return 1
84
+ return 0
85
+
86
+
87
+ if __name__ == "__main__":
88
+ sys.exit(main())
package/test_lifecycle.py CHANGED
@@ -225,6 +225,20 @@ for tool in server.TOOLS:
225
225
  check("%s 有描述和 schema" % tool["name"],
226
226
  bool(tool.get("description")) and isinstance(tool.get("inputSchema"), dict))
227
227
 
228
+ print()
229
+ print("page-growth hint(不碰 COM):")
230
+ originlab._reset_growth()
231
+ early = [originlab._growth_warning() for _ in range(49)]
232
+ check("前 49 个页面不出提示", all(h is None for h in early))
233
+ h50 = originlab._growth_warning()
234
+ check("第 50 个页面给出提示", bool(h50) and "50" in h50, h50)
235
+ check("提示带实测耗时", bool(h50) and "2.8" in h50, h50)
236
+ originlab._reset_growth()
237
+ check("清理后计数归零", originlab._page_growth[0] == 0, originlab._page_growth[0])
238
+ check("非新建不计数", originlab._growth_warning(created=False) is None
239
+ and originlab._page_growth[0] == 0)
240
+ originlab._reset_growth()
241
+
228
242
  print()
229
243
  if fails:
230
244
  print("%d 项失败: %s" % (len(fails), ", ".join(fails)))
package/test_paths.py CHANGED
@@ -129,6 +129,17 @@ def main():
129
129
  ok("blank path silent", ol._rel_note(" ") is None)
130
130
  ok("no file written into the package dir by these checks",
131
131
  set(os.listdir(HERE)) == before, sorted(set(os.listdir(HERE)) - before))
132
+ print()
133
+ print("width guards (shared by export and view):")
134
+ ok("0 is refused", code_of(lambda: ol._image_width(0, 900, 2400)) == "bad_arguments")
135
+ ok("negative is refused", code_of(lambda: ol._image_width(-5, 900, 2400)) == "bad_arguments")
136
+ ok("absurd is refused", code_of(lambda: ol._image_width(100000, 900, 2400)) == "bad_arguments")
137
+ ok("the ceiling itself is allowed", ol._image_width(2400, 900, 2400) == 2400)
138
+ ok("a tiny value is lifted to the floor", ol._image_width(20, 900, 2400) == 100)
139
+ ok("garbage falls back to the default", ol._image_width("wide", 900, 2400) == 900)
140
+ ok("export ceiling is higher than view",
141
+ ol._image_width(12000, 1600, 12000) == 12000)
142
+
132
143
 
133
144
  print("\n%d/%d path checks passed" % (PASSED, PASSED + len(FAILED)))
134
145
  if FAILED: