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 +12 -7
- package/bin/origin-bridge.mjs +17 -3
- package/install_dsh.ps1 +17 -3
- package/originlab.py +101 -6
- package/package.json +2 -1
- package/server.py +69 -12
- package/test_json_safety.py +54 -0
- package/test_labtalk_guard.py +88 -0
- package/test_lifecycle.py +14 -0
- package/test_paths.py +11 -0
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 校验用例本身,所以失败只会是产品问题,不会是题目写错。另有 **
|
|
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 #
|
|
134
|
-
.\.venv\Scripts\python.exe test_paths.py #
|
|
135
|
-
.\.venv\Scripts\python.exe test_json_safety.py #
|
|
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
|
-
上面这
|
|
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 #
|
|
331
|
+
└── test_*.py / smoke_test.py / edge_checks.py # 417 项离线与单元检查(随包发布)
|
|
327
332
|
```
|
|
328
333
|
|
|
329
334
|
开发侧另有 `cases/` + `run_cases.py` 的 1126 例全链路题库和 `render_realdata.py` 的真实数据逐文件出图脚本,两者都不随包发布(见上文)。
|
package/bin/origin-bridge.mjs
CHANGED
|
@@ -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
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
1408
|
-
|
|
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+-(?)", "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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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": "
|
|
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
|
|
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
|
-
|
|
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
|
package/test_json_safety.py
CHANGED
|
@@ -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:
|