dsh-origin-bridge 0.4.6 → 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
@@ -11,13 +11,14 @@
11
11
  </p>
12
12
 
13
13
  <p align="center">
14
- <sub>27 个工具 · <b>1118 例全链路测试全部通过</b> · 数据不出本机 · 支持工作站 <code>.txt</code> / <code>.bin</code></sub>
14
+ <sub>27 个工具 · <b>1126 例全链路测试全部通过</b> · 数据不出本机 · 支持工作站 <code>.txt</code> / <code>.bin</code></sub>
15
15
  </p>
16
16
 
17
17
  <p align="center">
18
18
  <a href="#安装"><img alt="platform" src="https://img.shields.io/badge/platform-Windows%20%2B%20Origin%202021%2B-blue"></a>
19
- <img alt="tests" src="https://img.shields.io/badge/tests-1118%2F1118%20passing-brightgreen">
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+ 次真实测试,不是自称。** **1118 例**全链路用例跑在真实 stdio 服务器 + 本机 OriginPro 上,**1118 通过 / 0 失败**:27 个工具每个至少 15 例、最多 134 例,按副作用分五层(L0 纯解析 296、L1 只读 COM 61、L2 改会话 520、L3 落盘 163、L4 进程级 78)。提示词全部按人话写,跑之前先用在线 `tools/list` schema 校验用例本身,所以失败只会是产品问题,不会是题目写错。另有 **352 项**离线/单元/边界检查(asciiio 84、binio 30、lifecycle 37、smoke 129、edge 26、paths 28、json 18)**随包发布,你可以直接复跑**;那 1118 例题库含提示词与模拟数据,是开发侧资产,不随包分发。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
 
@@ -50,7 +51,7 @@
50
51
  | "做一张 XPS 图" | `origin_chart_render`(40 个科研预设:轴标题/图类型/建议拟合) |
51
52
  | "热力图显示这个矩阵" | `origin_matrix`(heat_map / cmap / mesh / contline,Origin 自带图模) |
52
53
 
53
- 数据源支持:`.dat` `.csv` `.txt` `.tsv`(自动嗅探分隔符;编码覆盖 UTF-8 / GBK / **UTF-16(有无 BOM 都行)** / cp1252;多行表头、Fortran 指数、`NA`/`-`;还有 `<0.001` 检出限、`2.5±0.1`、`25 mV`、`1,234.5`、`(500)`、`2.5*` 这类被装饰过的数值——每解一种都会在 `warnings` 里写清代入了什么)、`.xls` `.xlsx`、电化学工作站(CH Instruments 等)导出的 `.txt`(几十行仪器说明自动收进 `metadata`,不会把首行日期当成列名),以及头部为 `80 F2 1B 00` 的电化学工作站二进制 `.bin`。
54
+ 数据源支持:`.dat` `.csv` `.txt` `.tsv`(`origin_read_file` 只读这些文本类;`.xls/.xlsx/.wks` 会明确报 `unsupported_file_type` 让你改用 `origin_import`。自动嗅探分隔符;编码覆盖 UTF-8 / GBK / **UTF-16(有无 BOM 都行)** / cp1252;多行表头、Fortran 指数、`NA`/`-`;还有 `<0.001` 检出限、`2.5±0.1`、`25 mV`、`1,234.5`、`(500)`、`2.5*` 这类被装饰过的数值——每解一种都会在 `warnings` 里写清代入了什么)、`.xls` `.xlsx`、电化学工作站(CH Instruments 等)导出的 `.txt`(几十行仪器说明自动收进 `metadata`,不会把首行日期当成列名),以及头部为 `80 F2 1B 00` 的电化学工作站二进制 `.bin`。
54
55
 
55
56
  ## 安装
56
57
 
@@ -87,6 +88,12 @@ dsh plugin --profile desktop add @caob23/dsh-origin-bridge # 转发壳,依赖
87
88
  pip install originpro numpy # 国内网络慢就加 -i https://pypi.tuna.tsinghua.edu.cn/simple
88
89
  ```
89
90
 
91
+ > **前置缺了会怎样,两种情况不一样**(2026-10-19 实测,别猜):
92
+ > - **解释器在、`originpro` 不在**:27 个工具**照常注册**,每一次调用都返回 `code=missing_originpro` 和原因(`No module named 'originpro'`),`isError: true`——看得见、知道修什么。
93
+ > - **PATH 上没有 `python`**(补丁默认命令就是 `python`):桥进程起不来,`failOnStartupError: false` 保证 dsh 照常启动,代价是那一轮**工具完全不注册**、也没有报错。这时先设 `ORIGIN_BRIDGE_PYTHON`。
94
+ >
95
+ > Origin 没开**不影响**注册:COM 连接是第一次工具调用时才建立的。
96
+
90
97
  包内补丁的默认取值是 `python`(PATH 上那个)和 `profiles/desktop`。依赖装在 venv 里、或者你用的是别的 profile,就设这两个用户环境变量(设完要重开终端、再彻底重启 dsh):
91
98
 
92
99
  ```powershell
@@ -124,12 +131,14 @@ pip install -r requirements.txt # 国内网络慢就加 -i https://pypi.tun
124
131
  .\.venv\Scripts\python.exe edge_checks.py # 26 项边界
125
132
  .\.venv\Scripts\python.exe test_asciiio.py # 84 项,不需要 Origin
126
133
  .\.venv\Scripts\python.exe test_binio.py # 30 项,不需要 Origin;有真实 .bin 时会跟厂商 .txt 逐点对账
127
- .\.venv\Scripts\python.exe test_lifecycle.py # 37 项,不需要 Origin:实例分类/回收/掉线重连的安全边界
128
- .\.venv\Scripts\python.exe test_paths.py # 28 项,不需要 Origin:导出/保存目标路径的规范化与拒绝
129
- .\.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
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 黑名单匹配
130
139
  ```
131
140
 
132
- 上面这 352 项随包发布,你可以自己复跑。另外那 **1118 例全链路题库是开发侧资产**(`cases/` + `run_cases.py`),跑的是真实 stdio 服务器 + 本机 Origin,用它的提示词和你的原始数据都不适合公开,所以没有进仓库、也没有进 npm 包;本文里的覆盖数字都来自它的实际运行结果。
141
+ 上面这 417 项随包发布,你可以自己复跑。另外那 **1126 例全链路题库是开发侧资产**(`cases/` + `run_cases.py`),跑的是真实 stdio 服务器 + 本机 Origin,用它的提示词和你的原始数据都不适合公开,所以没有进仓库、也没有进 npm 包;本文里的覆盖数字都来自它的实际运行结果。
133
142
 
134
143
  ### 不是 dsh,是别的 MCP 客户端
135
144
 
@@ -179,7 +188,7 @@ powershell -ExecutionPolicy Bypass -File .\install_dsh.ps1 -Profile desktop
179
188
  | 工具 | 用途 |
180
189
  |---|---|
181
190
  | `origin_status` | 连接检查、Origin 版本、已开页面。**动手前先调它** |
182
- | `origin_read_file` | 只解析文件不碰 Origin:编码、分隔符、表头行、每列长名/单位/有效行数;`.bin` 走另一条路线给出技术/点数/方法参数/轴来源 |
191
+ | `origin_read_file` | 只解析文件不碰 Origin:编码、分隔符、表头行、每列长名/单位/有效行数、**前 3 个原始值(`sample`,缺值为 null,用来抽查没串列)**;`.xls/.xlsx/.wks` 明确拒绝并让你改用 `origin_import`;`.bin` 走另一条路线给出技术/点数/方法参数/轴来源 |
183
192
  | `origin_import` | 导入 `.dat/.csv/.txt/.tsv/.xls/.xlsx/.wks` 和工作站二进制 `.bin`,返回 `ws-N` 句柄和列清单 |
184
193
  | `origin_write` | 内联数据建表:窄表 `columns={列名:[值…]}`,宽表 `headers`+`rows`(一次写完) |
185
194
  | `origin_formula` | 给列设计算公式(Fx)做派生列,读回算出的行数与首末值 |
@@ -267,6 +276,10 @@ Origin Bridge 的做法:
267
276
  | 编码候选顺序 | 仪器"另存为 Unicode 文本"导出的 UTF-16(有/无 BOM)此前直接报 `找不到数值数据行`;而 cp1252 写的 `µ`/`°` 会被 gb18030 **抢先**解成 `祍`/`癈`——它不报错,所以错误一路静默到列名 | `read_text` 先认 BOM,无 BOM 时按 NUL 占比猜 utf-16 并在 `encoding` 里写明是猜的;再用"高位字节成对=GBK、孤立单字节=cp1252"决定候选顺序(`high_byte_runs`) |
268
277
  | 装饰过的数值格子 | `<0.001`、`2.5±0.1`、`25 mV`、`1,234.5`、`2.5*`、`(500)`、`1e400` 这些形态此前让整列被判"全为空"直接丢弃——用户看到的是少一列数据,不是报错 | `cell_number` 逐形态解开并回一条 `warnings` 说明代入了什么;溢出/Infinity 一律按缺值,绝不把 inf 送出桥 |
269
278
  | 分隔符嗅探 | 欧陆 `Zeit;Signal / 0,0;1,5` 会被逗号拆开,而且错的拆法因为"列更多"在评分里胜出 | 评分改成:数值列数 → 行宽一致性 → 列数更少者优先;表头行不参与数值比 |
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"改成**断言等于请求宽度** |
270
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 项) |
271
284
 
272
285
  ## 已知边界
@@ -278,7 +291,7 @@ Origin Bridge 的做法:
278
291
  - 交付路径:中文目录、中文文件名、含空格、200+ 字符长路径、自动建多级目录、`tiff→tif`/`jpeg→jpg` 改名——2026-10-12 在真机上逐条实测可用(PNG/TIF/PDF/SVG/EMF 和 `.opju` 都试过)。**不行的是网络路径**:UNC 与未映射盘返回 `bad_path`,不去猜认证。另有两条 Origin 自己的怪脾气已经兜住:文件名里的 `#` 会被导出滤镜换成编号(桥发现后会改名回你要的名字并在 `warnings` 里说明),路径组件尾部的空格/点会被 Windows 悄悄截掉(桥先截一遍再比较,不会报"文件不存在")。
279
292
  - 千分位和逗号分隔符同形的文件(不加引号的 `0,1,234.5`)没法机械还原:桥按最宽行补齐并报"行长度不一致",不会假装列数对得上。欧陆小数点(`0,5`)只在分隔符是 `;` 或 tab 时才认。
280
293
  - Origin 以管理员运行而 Python 不是(或反之)时 COM 连不上,两侧权限要一致。
281
- - 长会话攒太多页面会让 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` 会清零)。
282
295
  - **Origin 实例不会跟着客户端退出**:originpro 需要时自己拉一个 Origin,Python 进程结束那个实例还在。攒够就会报"实例超过上限"。现在插件在进程退出时回收**自己起的、且没有窗口的**实例(`atexit`),历史残留用 `origin_reclaim` 清;有窗口的实例永远不动,因为那可能是你正开着的项目。
283
296
  - 统计批处理(t 检验 / ANOVA / PCA / 生存分析)没有,originpro 本身也不提供。
284
297
  - **无并发保护**:两个会话同时驱动 Origin 会互相踩。
@@ -315,10 +328,10 @@ dsh-origin-bridge/
315
328
  ├── icon.svg # 卡片图标(scripts/make_icon.py 生成)
316
329
  ├── locale/*.json # dsh 卡片显示名与描述
317
330
  ├── scripts/make_preview.py # 重新生成 docs/preview.png(关掉图例后才导出)
318
- └── test_*.py / smoke_test.py / edge_checks.py # 352 项离线与单元检查(随包发布)
331
+ └── test_*.py / smoke_test.py / edge_checks.py # 417 项离线与单元检查(随包发布)
319
332
  ```
320
333
 
321
- 开发侧另有 `cases/` + `run_cases.py` 的 1118 例全链路题库和 `render_realdata.py` 的真实数据逐文件出图脚本,两者都不随包发布(见上文)。
334
+ 开发侧另有 `cases/` + `run_cases.py` 的 1126 例全链路题库和 `render_realdata.py` 的真实数据逐文件出图脚本,两者都不随包发布(见上文)。
322
335
 
323
336
  重新生成图标:`python scripts/make_icon.py`(只需要 numpy,PNG 由 stdlib 手写)。
324
337
 
@@ -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/cordis.patch.yml CHANGED
@@ -17,8 +17,12 @@
17
17
  # manual install -> set ORIGIN_BRIDGE_HOME to the folder holding server.py
18
18
  # interpreter -> set ORIGIN_BRIDGE_PYTHON to your venv python.exe
19
19
  #
20
- # failOnStartupError: false keeps dsh bootable when Origin is missing or a modal
21
- # dialog stalls the COM server; the tools just do not register that session.
20
+ # failOnStartupError: false keeps dsh bootable when this entry cannot start at all —
21
+ # measured (2026-10-19) that means only a missing interpreter: the default command is
22
+ # `python` from PATH. A missing originpro does NOT hide the tools: all 27 still
23
+ # register and every call answers `missing_originpro` with the fix to run. Origin
24
+ # itself being closed is irrelevant to startup — the COM connection is made lazily
25
+ # inside the first tool call, not here.
22
26
 
23
27
  - insert:
24
28
  - id: mcp-origin-bridge
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
 
@@ -669,7 +691,16 @@ def read_file(path, comment=None):
669
691
  path = os.path.abspath(os.path.expanduser(path))
670
692
  if not os.path.isfile(path):
671
693
  raise OriginError("file_not_found", "文件不存在:%s" % path)
672
- if os.path.splitext(path)[1].lower() == ".bin":
694
+ ext = os.path.splitext(path)[1].lower()
695
+ if ext in (".xls", ".xlsx", ".wks"):
696
+ # Decoding a zip/OLE container as text "succeeds" into mojibake and then reports
697
+ # "只有表头?", blaming the user's file for our mistake (measured 2026-10-19).
698
+ raise OriginError(
699
+ "unsupported_file_type",
700
+ "%s 是二进制表格,origin_read_file 只读文本类数据" % ext,
701
+ ["用 origin_import 直接导入(走 Origin 原生导入)",
702
+ "或先在 Excel 里另存为 .csv,再用 origin_read_file 预览结构"])
703
+ if ext == ".bin":
673
704
  try:
674
705
  decoded = binio.read_bin(path)
675
706
  except binio.BinFormatError as exc:
@@ -689,8 +720,14 @@ def read_file(path, comment=None):
689
720
  except ValueError as exc:
690
721
  raise OriginError("parse_failed", str(exc),
691
722
  ["确认文件里有数值行", "给 comment 指定实际的注释前缀"])
692
- parsed["columns"] = [{k: v for k, v in c.items() if k != "values"}
693
- for c in parsed["columns"]]
723
+ # Three raw values per column: enough for the model to spot a shifted column or a
724
+ # column that parsed to nothing, without putting the whole dataset in context.
725
+ parsed["columns"] = [
726
+ dict({k: v for k, v in c.items() if k != "values"},
727
+ sample=(c.get("values") or [])[:3])
728
+ for c in parsed["columns"]]
729
+ parsed["sample_note"] = ("sample 是每列前 3 个原始值(缺值为 null),只用来抽查解析是否正确;"
730
+ "完整数据用 origin_import 进 Origin")
694
731
  parsed["proof_level"] = "verified"
695
732
  return sanitize(parsed)
696
733
 
@@ -1016,6 +1053,9 @@ def plot(worksheet, x=1, y=2, plot_type="line", graph_name="", title="",
1016
1053
  if review:
1017
1054
  span["warnings"] = [review]
1018
1055
  span["next_actions"] = list(REVIEW_OPTIONS)
1056
+ grown = _growth_warning(created=(int(layer) == 0))
1057
+ if grown:
1058
+ span.setdefault("warnings", []).append(grown)
1019
1059
  return sanitize({"graph": _bind("gr", gr, gr.name), "plot_type": plot_type,
1020
1060
  "channel": channel, "template": tpl,
1021
1061
  "source_sheet": _safe(lambda: ws.name, ""),
@@ -1355,8 +1395,25 @@ def _layer_count(gr):
1355
1395
  return n
1356
1396
 
1357
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
+
1358
1413
  def view(graph, width=900):
1359
1414
  """Render the graph to a small PNG and hand the bytes back for visual check."""
1415
+ width = _image_width(width, 900, 2400)
1416
+
1360
1417
  def run():
1361
1418
  import base64
1362
1419
  import tempfile
@@ -1389,8 +1446,42 @@ def view(graph, width=900):
1389
1446
  return call(run)
1390
1447
 
1391
1448
 
1392
- LABTALK_DENY = ("doc -s", "doc -c", "saveas", "quit", "exitloop", "type -m", "openb", "run.x",
1393
- "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
1394
1485
 
1395
1486
 
1396
1487
  def labtalk(script, readback=(), numeric=()):
@@ -1405,11 +1496,11 @@ def labtalk(script, readback=(), numeric=()):
1405
1496
  # Advisory guardrail, not a sandbox: it stops the model from wiping the
1406
1497
  # session by accident, not from trying hard. Whitespace is collapsed so
1407
1498
  # 'doc -s' does not slip past the literal match.
1408
- low = re.sub(r"\s+", " ", str(script).lower())
1409
- blocked = [p for p in LABTALK_DENY if p in low]
1499
+ blocked = labtalk_denied(script)
1410
1500
  if blocked:
1411
1501
  raise OriginError("labtalk_denied", "这些命令不允许通过 origin_labtalk 执行:%s" % blocked,
1412
- ["关工程/退 Origin 请用 origin_exit 或 origin_save_project"])
1502
+ ["关工程/退 Origin 请用 origin_exit 或 origin_save_project",
1503
+ "句柄/页面管理用 origin_close_pages、保存用 origin_save_project"])
1413
1504
 
1414
1505
  def run():
1415
1506
  op = origin()
@@ -1554,6 +1645,7 @@ def fit(worksheet, x=1, y=2, kind="linear", func="", fixed=None, starts=None,
1554
1645
 
1555
1646
 
1556
1647
  def export(graph, path="", fmt="png", width=1600):
1648
+ width = _image_width(width, 1600, 12000)
1557
1649
  fmt = str(fmt).lstrip(".").lower()
1558
1650
  # Origin's export filter only recognises the short names: asking for a.tiff
1559
1651
  # leaves no file behind, which reads as a failed export.
@@ -1573,7 +1665,13 @@ def export(graph, path="", fmt="png", width=1600):
1573
1665
  raw_path = str(path or "").strip()
1574
1666
  target_path = _norm_target(raw_path or os.path.join(folder, "%s.%s" % (name, fmt)), fmt)
1575
1667
  _prepare_write(target_path)
1576
- _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))
1577
1675
  written, note = _written_or_mangled(target_path)
1578
1676
  if not written:
1579
1677
  raise OriginError("export_failed", "Origin 说导出了但文件不存在:%s" % target_path,
@@ -1585,6 +1683,11 @@ def export(graph, path="", fmt="png", width=1600):
1585
1683
  ["图页可能是空的:origin_inspect 看 plots 数"])
1586
1684
  if note:
1587
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])]
1588
1691
  rel = _rel_note(path)
1589
1692
  if rel:
1590
1693
  info["warnings"] = (info.get("warnings") or []) + [rel]
@@ -1662,12 +1765,27 @@ def open_project(path):
1662
1765
  def run():
1663
1766
  global _project_file
1664
1767
  op = origin()
1665
- if not op.open(path):
1768
+ # Opening a project replaces every page, so every handle handed out earlier is
1769
+ # dead — and a *refused* open can still have closed the current project. Both
1770
+ # ways the table has to go: leaving it behind makes later calls act on dead
1771
+ # pages, where originpro's wrapper "succeeds" and Origin quietly writes nothing
1772
+ # (that is how three exports failed in the 1124-case run).
1773
+ try:
1774
+ ok = bool(op.open(path))
1775
+ finally:
1776
+ _handles.clear()
1777
+ _names.clear()
1778
+ if not ok:
1779
+ _project_file = ""
1666
1780
  raise OriginError("open_failed", "Origin 拒绝打开这个工程:%s" % path,
1667
1781
  ["确认文件是 .opju/.opj 且没被另一个 Origin 实例锁住",
1668
- "Origin 版本低于该工程保存版本时也打不开"])
1782
+ "Origin 版本低于该工程保存版本时也打不开",
1783
+ "打开失败后旧句柄已作废,请重新 origin_inspect 看当前页面"])
1669
1784
  _project_file = path
1670
- return sanitize({"opened": path, "open_pages": [p.name for p in op.pages()]})
1785
+ _reset_growth()
1786
+ return sanitize({"opened": path, "open_pages": [p.name for p in op.pages()],
1787
+ "handles_reset": True,
1788
+ "note": "之前拿到的 ws-/gr- 句柄全部作废(工程换了页面),请用这里的页面名或重新导入"})
1671
1789
 
1672
1790
  return call(run)
1673
1791
 
@@ -1712,6 +1830,7 @@ def exit_origin(save_to=""):
1712
1830
  _names.clear()
1713
1831
  _project_file = ""
1714
1832
  _reset_connection()
1833
+ _reset_growth()
1715
1834
  return sanitize({"exited": True, "saved": saved})
1716
1835
 
1717
1836
  return call(run)
@@ -1844,6 +1963,9 @@ def matrix_plot(rows, template="heat_map", book_name="", graph_name="",
1844
1963
  "xlim": _safe(lambda: list(lay.xlim)), "ylim": _safe(lambda: list(lay.ylim)),
1845
1964
  "zlim": _safe(lambda: list(lay.zlim)),
1846
1965
  "proof_level": "verified"}
1966
+ grown = _growth_warning()
1967
+ if grown:
1968
+ out.setdefault("warnings", []).append(grown)
1847
1969
  if export_path:
1848
1970
  out["export"] = _export_on_com_thread(gr, export_path)
1849
1971
  if project_path:
@@ -1950,6 +2072,8 @@ def close_pages(kind="graph", names=None, dry_run=False):
1950
2072
  for ref in [r for r, nm in _names.items() if nm and nm in closed]:
1951
2073
  _handles.pop(ref, None)
1952
2074
  _names.pop(ref, None)
2075
+ if closed:
2076
+ _reset_growth()
1953
2077
  return sanitize({"asked": wanted, "closed": closed,
1954
2078
  "refused": [n for n in wanted if n not in closed],
1955
2079
  "errors": errors,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-origin-bridge",
3
- "version": "0.4.6",
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",
@@ -52,6 +52,8 @@
52
52
  "edge_checks.py",
53
53
  "test_paths.py",
54
54
  "test_json_safety.py",
55
+ "test_readfile.py",
56
+ "test_labtalk_guard.py",
55
57
  "test_asciiio.py",
56
58
  "test_binio.py",
57
59
  "test_lifecycle.py",
package/requirements.txt CHANGED
@@ -1,2 +1,4 @@
1
+ # 实测环境:originpro 1.1.15 + OriginPro 2024 SR1 (10.100178) + Python 3.12
2
+ # originpro 要求 Origin 2021+;2.x 没有验证过,暂不设上限。
1
3
  originpro>=1.1.0
2
4
  numpy>=1.24
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.6"
23
+ SERVER_VERSION = "0.4.8"
24
24
  PROTOCOL = "2024-11-05"
25
25
 
26
26
 
@@ -106,7 +106,7 @@ TOOLS = [
106
106
  },
107
107
  {
108
108
  "name": "origin_read_file",
109
- "description": "只解析数据文件、不碰 Origin:返回编码、分隔符、表头行数、每列长名/单位/有效行数,以及仪器参数 metadata。拿到陌生 .dat/.csv/.txt 先用它看清结构,再决定 origin_import 用哪几列。支持 #、//、!、; 注释、BOM、GBK、空格对齐、Fortran 的 1.5d+00 指数、NA/- 缺失值、Origin 三行表头(Long Name/Units/Comments),也支持电化学工作站(CH Instruments 等)导出的 txt:前面几十行参数说明会被跳进 metadata,紧贴数据的 Potential/V, Current/A 这类表头按斜杠拆出列名和单位。.bin 走另一条解析路线:返回技术标签(CV/LSV/IMP/i-t)、点数、两个 float32 数组的范围和 role_confidence。",
109
+ "description": "只解析数据文件、不碰 Origin:返回编码、分隔符、表头行数、每列长名/单位/有效行数,以及仪器参数 metadata。拿到陌生 .dat/.csv/.txt 先用它看清结构,再决定 origin_import 用哪几列。支持 #、//、!、; 注释、BOM、GBK、空格对齐、Fortran 的 1.5d+00 指数、NA/- 缺失值、Origin 三行表头(Long Name/Units/Comments),也支持电化学工作站(CH Instruments 等)导出的 txt:前面几十行参数说明会被跳进 metadata,紧贴数据的 Potential/V, Current/A 这类表头按斜杠拆出列名和单位。编码覆盖 UTF-8 / GBK / UTF-16(有无 BOM)/ cp1252;<0.001、2.5±0.1、25 mV、1,234.5、(500)、2.5* 这类被装饰的数值会解出来并在 warnings 里说明代入了什么。每列还带 sample(前 3 个原始值,缺值为 null),用来抽查解析对不对——完整数据仍然只进 Origin。二进制表格 .xls/.xlsx/.wks 不在它的范围内,会明确返回 unsupported_file_type,请用 origin_import。.bin 走另一条解析路线:返回技术标签(CV/LSV/IMP/i-t)、点数、两个 float32 数组的范围和 role_confidence。",
110
110
  "inputSchema": {
111
111
  "type": "object",
112
112
  "properties": {
@@ -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", ""),
@@ -217,7 +217,7 @@ TOOLS = [
217
217
  },
218
218
  {
219
219
  "name": "origin_open",
220
- "description": "打开已有的 .opju/.opj 工程,返回页面清单。",
220
+ "description": "打开已有的 .opju/.opj 工程,返回页面清单。打开会替换工程里的所有页面,所以之前拿到的 ws-/gr- 句柄全部作废(返回 handles_reset: true)——要用就用返回的页面名,或重新 origin_import/origin_plot 拿新句柄;打开失败时旧句柄同样作废。",
221
221
  "inputSchema": {
222
222
  "type": "object",
223
223
  "properties": {"path": {"type": "string"}},
@@ -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:
@@ -0,0 +1,104 @@
1
+ """Offline checks for origin_read_file's shaping (no Origin needed).
2
+
3
+ Two behaviours that cost a real debugging cycle:
4
+ - a `.xlsx` used to be decoded as text and reported as "找不到数值数据行(只有表头?)",
5
+ which blames the user's file for our mistake;
6
+ - the response used to carry no values at all, so a model could not tell a correctly
7
+ parsed column from one whose values slid into the neighbouring column.
8
+ """
9
+
10
+ import os
11
+ import shutil
12
+ import sys
13
+ import tempfile
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
16
+ import originlab as ol # noqa: E402
17
+
18
+ PASSED = 0
19
+ FAILED = []
20
+
21
+
22
+ def ok(label, cond, detail=""):
23
+ global PASSED
24
+ if cond:
25
+ PASSED += 1
26
+ else:
27
+ FAILED.append("%s %s" % (label, detail))
28
+ print("%s %s%s" % ("PASS" if cond else "FAIL", label,
29
+ (" | " + str(detail)[:110]) if detail else ""))
30
+
31
+
32
+ def write(dirname, name, text, encoding="utf-8"):
33
+ path = os.path.join(dirname, name)
34
+ with open(path, "w", encoding=encoding, newline="") as fh:
35
+ fh.write(text)
36
+ return path
37
+
38
+
39
+ def code_of(fn):
40
+ try:
41
+ fn()
42
+ except ol.OriginError as exc:
43
+ return exc.code
44
+ except Exception as exc: # noqa: BLE001 — an unwrapped crash is the failure
45
+ return "UNWRAPPED:" + type(exc).__name__
46
+ return "no-raise"
47
+
48
+
49
+ def main():
50
+ work = tempfile.mkdtemp(prefix="readfile_checks_")
51
+ try:
52
+ csv = write(work, "a.csv", "x,y\n1,2\n3,\n5,6\n")
53
+ res = ol.read_file(csv)
54
+ ok("csv parses", res.get("rows") == 3, res.get("rows"))
55
+ ok("every column carries a 3-value sample",
56
+ [c["sample"] for c in res["columns"]] == [[1.0, 3.0, 5.0], [2.0, None, 6.0]],
57
+ [c["sample"] for c in res["columns"]])
58
+ ok("sample keeps nulls so a gap is visible", None in res["columns"][1]["sample"])
59
+ ok("sample is explained", "原始值" in (res.get("sample_note") or ""),
60
+ res.get("sample_note"))
61
+ ok("full values still stay out of the payload",
62
+ all("values" not in c for c in res["columns"]))
63
+
64
+ # the binary-spreadsheet guard
65
+ for name, head in (("b.xlsx", b"PK\x03\x04"), ("c.xls", b"\xd0\xcf\x11\xe0"),
66
+ ("d.wks", b"\x00\x00\x02\x00")):
67
+ path = os.path.join(work, name)
68
+ with open(path, "wb") as fh:
69
+ fh.write(head + b"\x00" * 64)
70
+ ok("%s is refused as a binary sheet" % name,
71
+ code_of(lambda p=path: ol.read_file(p)) == "unsupported_file_type",
72
+ code_of(lambda p=path: ol.read_file(p)))
73
+
74
+ # and the guard says what to do instead, with the file name in the message
75
+ path = os.path.join(work, "b.xlsx")
76
+ try:
77
+ ol.read_file(path)
78
+ except ol.OriginError as exc:
79
+ ok("message names the format", ".xlsx" in exc.message, exc.message)
80
+ ok("next action points at origin_import",
81
+ any("origin_import" in a for a in exc.next_actions), exc.next_actions)
82
+
83
+ # import_file must still accept Excel: only read_file refuses it
84
+ import inspect
85
+ src = inspect.getsource(ol.import_file)
86
+ ok("import_file still allows .xlsx", "二进制表格" not in src)
87
+
88
+ ok("a .bin is still routed to binio, not refused",
89
+ code_of(lambda: ol.read_file(os.path.join(
90
+ os.path.dirname(os.path.abspath(__file__)), "fixtures", "not_a_bin.bin")))
91
+ in ("parse_failed", "no-raise"), "binio path")
92
+
93
+ print("\n%d/%d read_file checks passed" % (PASSED, PASSED + len(FAILED)))
94
+ if FAILED:
95
+ for f in FAILED:
96
+ print(" FAILED:", f)
97
+ return 1
98
+ return 0
99
+ finally:
100
+ shutil.rmtree(work, ignore_errors=True)
101
+
102
+
103
+ if __name__ == "__main__":
104
+ sys.exit(main())