Repository navigation
fix(tools): argv the core can parse, and bounds before the spawn - #5
Conversation
`limit` and `hours` are plain numbers the model supplies, and the plugin
forwarded them unchecked. The host does not cover this gap: `defineTool`
compiles the property spec to JSON Schema, so it rejects a *type* mistake
(`"hours": "yesterday"` -> `invalid arguments: "hours" must be a number`) but
`-2`, `0` and `3.7` are all well-typed numbers and all reached the core.
The core does not reject them either:
* `int(limit or 20)` treats 0 as absent, so `search {limit: 0}` asked for
nothing and got 20.
* SQLite reads `LIMIT -N` as "no cap", so `search {limit: -2}` returned the
whole index: measured 1241 rows / 480,826 bytes of JSON on a real index
(94 sessions / 526,991 events), all of it into the caller's context.
`recent {limit: -3}` uncapped the overview the same way (91 rows).
* `search {limit: 3.7}` failed later inside SQLite with `slice indices must
be integers or None`, an error carrying no trace of the argument that
caused it.
* `overview` computes `cutoff = now - hours*3600`, so `hours: -5` puts the
cutoff in the future, filters out everything, and returns an empty list as
though there were no history.
This adds `intArg` / `numArg` to `src/tools/common.ts` and calls them in
`search.ts` and `recent.ts` before the op is built. Both decide in the tool
process, so a refused argument costs a message rather than a child process and
a database open.
Above the ceiling the value is clamped, not refused: `limit: 500` is an
intelligible "give me a lot" and clamping to 200 honours that intent while
keeping the bound. Below the floor it is refused, because `-2` is not a larger
request, it is a category error. `hours` is allowed to be fractional (half an
hour is a real window) and 0 is legitimate ("right now").
The refusal is thrown outside `guarded` on purpose, so it stays a
`ParameterError` naming the argument and the accepted range instead of being
flattened into the generic message every core failure produces:
`limit` must be a whole number of hits 1..200 (got -2; too small).
Verified: `npm test` -> 64 tests, 44 pass, 0 fail, 20 skipped (the skips are the
core-dependent integration tests; no voyager core on this machine). The 10 new
cases in `tests/numeric-args.test.ts` assert on an echo core's call record being
*empty*, so "refused" means nothing was spawned rather than "an error came
back". Mutation-checked each way: removing the `recent` bound turns 1 test red,
removing the `search` bound turns 3 red, restoring both returns the suite to
green. End to end against a live core, `search {limit: -2}` went from 480 KB to
a 6 ms refusal.
Two more ways a model-supplied argument could make a tool fail badly, both
measured against the real `merge` subparser rather than a mock.
**The argv was shaped so argparse could not read it.** Options were emitted in
the space form and the ids appended bare:
merge a:1 b:2 --goal g
* A goal beginning with `-` reached argparse as a flag. `--verbose` and
`---` both produced `argument --goal: expected one argument` -> SystemExit
-> `CLI_FAILED`, an error naming no argument.
* A session id beginning with `-` was parsed as a flag, not an id.
* Nothing bounded the values at all.
The core parses `--goal=VALUE` unambiguously, so options now use that form and
the ids follow a `--` separator. Interior whitespace, CJK and an embedded `=`
all survive in that form (measured). 17 shape assertions in
`tests/merge-argv.test.ts` pin it.
**One trap lives inside that fix,** and it is the reason `--json` moved:
`--json` used to be appended at the END of argv, which after the separator means
argparse stops option parsing and reads it as a positional:
merge a --goal=g -- a:1 --json -> sessions=['a:1','--json'], json=False
The core would then look for a session called `--json` and answer without JSON.
So `merge` places `--json` before the separator itself, and `runJson` gained
`jsonAlreadySet` so the bridge does not append a second one.
**An over-long argument crashed the spawn without naming itself.** A command
line has a hard ceiling - 32767 chars on Windows, where Node raises
`spawn ENAMETOOLONG` synchronously; `E2BIG` on POSIX. The bridge surfaced it as
could not start '<node>': Error: spawn ENAMETOOLONG
which names the executable and reads like a broken install. Measured here:
32500 chars spawns, 33000 throws. `flagArg` and `assertArgvFits` now bound each
value (8000) and the whole argv (24000) before the spawn, and the refusal names
the field:
`goal` is too long: 40000 characters, but at most 8000 can be carried on a
command line.
A 40000-character goal went from a 4 ms failure naming the executable to a 0 ms
refusal naming `goal`.
**`sessionflow_recent` could silently under-return when filtering.** The core's
`overview` takes no provider argument, so the filter runs in the plugin, and it
asked for `limit * 4` rows and hoped the wanted provider was at least a quarter
of them. With 190 claude rows ahead of 10 codex rows, `{provider:'codex',
limit:5}` returned an empty list - indistinguishable from "no codex sessions".
It now asks for the widest window the core honours, and marks a window-limited
answer with `truncated: true` so an incomplete list cannot read as complete.
**The echo stub was lying about the response shape.** Its subcommand branch
wrapped the reply in an `id`/`result` envelope; a real subcommand with `--json`
prints a bare payload (`_handoff_via_engine` prints `json.dumps(payload)`
directly - only the `api` op wraps). No assertion depended on it, which is
exactly why it went unnoticed, and a fixture that mismeasures its subject is
worth fixing before it hides the next mistake. `stub-overview.mjs` is new and
answers with a caller-supplied row set, so the provider filter is testable
without an index.
Verified: `npm test` -> 87 tests, 67 pass, 0 fail, 20 skipped (the skips are the
core-dependent integration tests; no voyager core on this machine).
Mutation-checked, each mutation restored afterwards:
| mutation | tests turning red |
|---|---|
| baseline | 0 |
| `--goal` back to the space form | 6 |
| `--json` moved after the separator | 3 |
| argv length guard removed | 1 |
| `recent` back to the `limit * 4` heuristic | 1 |
| `truncated` marker removed | 1 |
| restored | 0 |
`tests/plugin.test.ts` had one assertion that pinned the exact old array
`['a:1','b:2','--goal','g']` - the buggy shape. It now asserts what it was
written to protect (ids arrive as whole separate arguments, nothing is a joined
command line), because pinning the layout would have re-frozen the defect in the
one test that exists to catch it.
HarryHeYu
left a comment
There was a problem hiding this comment.
Review: 三处修复方向都对,merge/numeric 部分我在真实 Core 上验证通过;recent 的 truncated 判定还有一个反向缺口,建议先改再合
我按你 PR 里的声明逐条做了验证。环境:真实 voyager CLI(Core main 90907cc,schema 1)+ 一个 demo 索引,Node 22,Windows;CI 侧批准了 cold-start(run 42633890610,6/6 success)。
1. merge argv:确认修对了,旧形状确实是坏的
真实 merge subparser(sessions nargs="+"、--goal、--thread-title、--budget、--json)实测:
| argv | 结果 |
|---|---|
旧形状 merge demo-auth-01 --goal --verbose --json |
exit 2 argument --goal: expected one argument |
新形状 merge --goal=--verbose --json -- demo-auth-01 |
exit 0,bundle 生成 |
用 PR 里的 mergeTool 直接驱动真实 Core,6 个场景全部 action=bundle:普通 goal、--verbose、CJK + k=v、budget=compact、空 goal、两个 id。--json 在 -- 之前这一条也确认了:放到后面 argparse 会把 --json 当 positional,core 会去找一个叫 --json 的 session。
边界输入(stub,未 spawn):空数组、非数组、goal 带换行/NUL、budget 传 number 都被拒且信息指名到字段;session_ids: [1, null, 'a:1'] 会静默丢掉非字符串只留 a:1,['', ' '] 会把 ' ' 当 id 发出去(core 回 session not found)。前者建议改成拒绝而不是静默过滤——一个模型传错的 id 被丢掉,和"这个 id 不存在"是两种不同的用户问题。
2. numeric bounds:缺陷真实,且我独立复现了
search limit=-2→ 7 行(queryauth),-2时 12 行(querye)。确认是LIMIT -N去掉上限,不是报错。overview limit=0→ 0 行;limit=-3→ 1 行(切片语义)。limit=3.7:直接走Store.search是sqlite3.IntegrityError: datatype mismatch;走api.search会被int(limit or 20)静默截成 3。所以注释里"SQLite rejects it withslice indices must be integers"这句和当前 Core 不符(可能是旧版本行为),建议改掉,否则以后有人按这句话去查会找不到。
intArg/numArg 的 clamp/reject 语义是自洽的:上界 clamp、下界 reject、小数 reject。这点没问题。
3. recent:这是唯一我认为需要改的地方
PROVIDER_FILTER_WINDOW = 200 的方向是对的(从 limit*4 的猜测改成请求上限),但 truncated 的判定在目标 provider 完全落在窗口之后时不成立,而这恰好是 PR 想解决的那个"看起来像空列表"的场景。
tests/fixtures/stub-overview.mjs 不会按 limit 切片,而真实 api.overview 是 candidates[:limit]。所以现有测试测不到窗口边界。我用一个忠实切片的 stub 复现:
场景(limit=5) |
返回 | truncated |
|---|---|---|
| 200 条 claude + 10 条 codex 在窗口之后 | 0 | undefined |
| 197 claude + 3 codex 在窗口内、20 条在窗口后 | 3 | undefined |
| 正好 200 条,全部匹配 | 5 | true |
| 30 条 codex(完整) | 5 | undefined |
第一行就是"有数据但返回空列表且没有任何提示",和 CHANGELOG 里"an incomplete list cannot read as a complete one"的承诺不一致。第三行则是反向的:只有 200 条、没有更多,却标了 truncated。
当前条件是:
const truncated = matched.length > limit && want === ov.recent_sessions.length
&& ov.recent_sessions.length >= PROVIDER_FILTER_WINDOW;matched.length > limit 这一项让"0 条匹配但窗口已满"永远进不来。建议改成:只要带了 provider 过滤、且 core 返回的行数达到了请求窗口(ov.recent_sessions.length >= want),就标记"无法证明完整",不管匹配到几条——因为在没有 provider 参数的 overview 上,窗口满就意味着窗口之后可能还有。
另外两个小点:
truncated: true是一个布尔,读不出"为什么"。如果不想加字段,至少在 tool description 里写一句它表示"窗口满、不保证完整"。- 配套测试建议加"provider 完全在窗口之后"和"窗口内 3 条、窗口后 20 条"两个用例,并把
stub-overview.mjs改成按limit切片,否则这两个用例仍然测不到。
4. 测试夹具与旧断言
stub-echo.mjs从id/result信封改成 bare payload 是对的:真实merge --json顶层是action/context/thread_id/...,没有id/result。plugin.test.ts里把deepEqual(['a:1','b:2','--goal','g'])放宽为"id 是独立参数、--goal=g是一个参数",我确认旧断言钉住的正是那个在真实 Core 上 exit 2 的形状,属于"修正错误断言",不是"改测试迁就实现"。这个改动我认可。
5. CI
action_required 是 fork PR 首次贡献需要维护者批准,不是失败。我批准后:6/6 success(plugin tests + 5 个 cold-start 渠道)。workflow 是 permissions: contents: read、无 secrets、无 pull_request_target,npm ci + 测试,安全边界可接受。
一点补充:这个 workflow 只有 ubuntu-latest,而 PR 改的正是 argv 长度/Windows 命令行这类平台相关行为。Windows 部分我本地跑过(tsc 干净、87/87 测试通过),但 CI 不会覆盖,建议后续单独加一个 Windows job。
结论
REQUEST_CHANGES,只有第 3 点需要动代码(recent 的 truncated 判定 + 忠实切片 stub + 两个窗口边界用例)。第 1、2、4 点可以直接保留,第 2 点的注释文字顺手改一下即可。
改完我会再跑一遍真实 Core 的集成验证并合并。谢谢你把这几处挖出来——limit=-2 那个无上限读取,确实是合成测试很难发现的。
|
感谢继续推进 PR #5!我重新看了一遍,整体修改方向是正确的,merge 参数解析、数值边界检查以及测试替身的修复都很有价值,前面用真实 Core 验证的部分也已经通过了。 目前主要还剩三个收尾问题:
这几个问题修完不需要再扩大 PR 范围。推送修订后,我这边会重新跑真实 Core 集成测试和 cold-start CI;如果没有新的阻塞问题,就可以合并。 另外项目后续会按 Roadmap + Issue 认领方式推进,有时间就可以挑感兴趣的任务,不需要固定交付周期。感谢之前的实机复现和这次的贡献! |
…hes nothing
The `truncated` marker added with the provider-filter fix required
`matched.length > limit` — "the filter cut something". That misses the
case it most needed to catch: when other providers fill the window, the
wanted provider can sit entirely behind it, so the filter matches nothing
and cuts nothing. The caller then gets an empty list with no marker,
indistinguishable from "no sessions from that provider exist".
Measured on a real 255-session index (250 fresher `claude` rows ahead of
5 `codex` rows):
{provider:'codex', limit:5} -> 0 rows, truncated absent
sessions {platform:'codex'} -> 5 rows
The condition is now "the core filled the window", gated on the call
being filtered, so a normal unfiltered page is not relabelled as
clipped.
The fixture could not have caught this: stub-overview.mjs ignored
`limit` and always returned the whole payload, making "the core gave us
everything" and "the core gave us the window" the same answer. It now
clips as a real core does.
Mutation-checked, each restored afterwards (89 tests, 88 pass):
baseline 1 red (pre-existing, unrelated)
predicate back to matched.length > limit 2 red, incl. the new
behind-the-window test
provider guard dropped 2 red, incl. the new
unfiltered-full-page test
stub stops honouring limit 2 red, incl. the new test
restored 1 red (the same pre-existing)
The one standing red is `search round-trips a CJK query without
mangling it`, which fails identically on main in this environment
(lone-surrogate argv encoding at the core boundary) and is not touched
by this branch.
The reviewer asked for three things; this is two of them, and the third turned out to be a wrong comment rather than a wrong guard. 1. session_ids validation `session_ids` was read with `filter(isString && length > 0)`, so `['a:1', 42, ' ']` merged ONE session and reported success. The merge is the unit of consent: the caller names a set, and the WorkThread contains exactly that set. Dropping an entry is not leniency, it is a different operation performed on the caller's behalf without saying so. Every element is now checked rather than filtered, the refusal names the offending index (`session_ids[1]`) so the other ids can be kept, and a whitespace-only entry is refused as blank rather than sent to the core as an unresolvable positional. 2. The fractional-limit comment was wrong about `search` The comment claimed the core rejects `3.7` with `slice indices must be integers`. Measured on voyager 1.3.0, that is true of `overview` only: search (api.py:254, int(limit or 20)) 3.7 -> 3 rows, SILENTLY overview (api.py:77/:94, raw slice) 3.7 -> slice-indices error search limit 0 -> 20 rows (0 reads as absent) overview limit 0 -> 0 rows search limit -2 -> 33,277 rows (no cap) overview limit -3 -> 91 of 94 rows So `search` under-returns quietly, which is worse than the crash the comment described: a truncated answer looks complete. The guard was already right; its justification was not, and a wrong justification is how a later change deletes a guard for the wrong reason. Both behaviors are now recorded where they apply, and the test header states them per-op. 3. The `truncated` fix from the previous commit is unchanged Already in this branch as d46a50e; the reviewer's summary of it (let the fixture clip like a real core, and judge incompleteness by "the core filled the window" gated on the call being filtered) is what it does. Tests: 96 total, 76 pass, 0 fail, 20 skipped. The skips are the core-dependent integration tests, which need a `python` that can `import voyager`; with a real core wired in, 93 pass and the 3 remaining failures reproduce identically on main in this environment — two are tests that `delete process.env['VOYAGER_BIN']` and never restore it, and the CJK one is the core's own lone-surrogate bug: search {query: "这是绝对不存在的查询词zzz"} -> 'utf-8' codec can't encode character '\udcaf' in position 6 That is reproducible straight against the core's stdio API with no plugin involved, so it is not this branch's regression. Mutation-checked, each restored afterwards: session_ids back to the silent filter 5 red, incl. all 4 new truncated back to matched.length > limit 1 red stub stops honouring limit 1 red restored 0 red
|
三条都已在 1. 窗口截断判断 —— 你指出的正是第一版的漏判。旧判据是 判据改成"Core 把窗口填满了"而不是"我们切多了",并且只在确实带了 provider 过滤时才可能成立 —— 否则每个正常整页都会被标成截断。测试替身也按你说的改成 2. 3. 浮点注释 —— 这条实测后比我原来写的更糟。原注释只说
守卫本身没错,错的是理由;而错的理由正是后来某次改动删掉守卫的方式。 验证: 顺带报一个 Core 侧的问题,跟本 PR 无关,另外开 issue 更合适:
把 |
|
感谢你这么快把三个问题都修完,也感谢你把窗口截断测试的 Mock 一起修正了。 我已经检查了最新的 接下来我会重新检查最新 CI,并用真实 Core 跑一遍集成测试。你提到的另外 3 个失败,我会在 main 基线上逐项复现;如果确认是已有问题且与本 PR 无关,就不会因此阻塞合并。验证通过后我会直接 Squash Merge,并保留你的作者贡献归属。 另外,Windows stdin UTF-8 的发现非常重要。这个问题和 PR #5 无关,建议单独在 sessionFlow Core 仓库提交 Issue 和 PR。欢迎你继续负责这个修复:重点是 stdin 的 UTF-8 解码、中文查询和中文 repo 路径的字节级回归测试,同时保证 stdout/stderr 与其他入口不受影响。 这部分不用加到当前 PR,避免扩大改动范围。 再次感谢你持续提供真实运行证据和修复! |
HarryHeYu
left a comment
There was a problem hiding this comment.
三处收尾问题已在 63ee208 修完,复验通过。
recent改为「带 provider 过滤且 Core 填满窗口」即标记truncated;stub 现按limit裁剪。独立构造 A/B/C/D 四个窗口场景均符合预期(含「窗口未满 + 零匹配」不误报截断)。session_ids逐项校验,非法输入在 spawn 前点名session_ids[i]拒绝;测试用 echo 记录确认零 spawn。- 浮点注释已按真实 Core 改写:
search走int(limit or 20)静默截断、overview抛 slice 错误。已在真实 Core 逐项复现。
验证:npm ci / npm run build / npm test 全绿;真实 Core(v1.3.1,schema 1)下 PR HEAD 96/96 通过、0 失败、0 跳过;针对性的 42 项新增回归 42/42;真实 core overview 确认按 limit 裁剪;merge 的新 argv 形状(--goal=--verbose --json -- ids)在真实 argparse 下 exit 0,旧形状仍 exit 2;bridge schema 与 peerDependencies 均未变。
作者报告的 3 个失败已逐项复现并与 main 基线对照:在同样的 GBK-stdin + 无 PATH 回退环境下,PR HEAD 为 93/3、main 为 57/3,失败完全相同。其中 2 个是 tests/bridge.test.ts 里 delete process.env['VOYAGER_BIN'] 未恢复的顺序泄漏(VOYAGER_NOT_FOUND),1 个是 Core 侧 stdin UTF-8 解码导致的 CJK 查询失败(surrogates not allowed)。该文件在本 PR 中逐字节未改动,与本次修改路径无关,已另立 Core Issue 跟踪。
最新 CI(run 42816717952,attempt 2):plugin tests + 5 个 cold-start 渠道 6/6 success。workflow 为 pull_request、permissions: contents: read、无 secrets、无 pull_request_target,批准后运行安全。
按约定 Squash Merge,保留 SunnyRad 的作者归属。感谢实机复现与修复!
Summary
Three ways a model-supplied argument could make a tool fail badly, plus one fixture that was lying about what the core returns. Every claim below was measured against the real
mergesubparser and the realbridge, not against a mock.This PR now has two commits; the first (numeric bounds) is unchanged.
1.
sessionflow_mergebuilt an argv argparse could not parseOptions were emitted in the space form with bare ids:
fix the parser--verboseargument --goal: expected one argument→CLI_FAILED----x restA session id beginning with
-was likewise read as a flag. The core parses--goal=VALUEunambiguously, so options now use that form and ids follow a--separator. Interior whitespace, CJK and an embedded=all survive (measured).2. The trap inside that fix —
--json--jsonwas appended at the END of argv. After a--separator, argparse stops option parsing and reads it as a positional:The core would look for a session called
--jsonand answer with no JSON at all. Somergeplaces--jsonbefore the separator itself, andrunJsongainsjsonAlreadySetso the bridge does not append a second one. There is a dedicated test asserting the ordering, because getting it backwards produces a silent wrong answer rather than an error.3. An over-long argument crashed the spawn without naming itself
A command line has a hard ceiling: 32767 chars on Windows, where Node raises
spawn ENAMETOOLONGsynchronously;E2BIGon POSIX. Measured here: 32,500 chars spawns, 33,000 throws. The bridge reported it as— naming the executable, never the argument, which reads like a broken install.
flagArgandassertArgvFitsnow bound each value (8000) and the whole argv (24000) before the spawn:A 40,000-character goal went from a 4 ms failure naming the executable to a 0 ms refusal naming
goal.4. A provider-filtered
sessionflow_recentcould silently under-returnThe core's
overviewtakes no provider argument, so the filter runs in the plugin — and it asked forlimit * 4rows and hoped. With 190 claude rows ahead of 10 codex rows,{provider:'codex', limit:5}returned an empty list, indistinguishable from "no codex sessions exist". It now requests the widest window the core honours, and marks a window-limited answertruncated: trueso an incomplete list cannot read as complete.5. The echo stub was answering with a shape the real core cannot produce
Its subcommand branch wrapped the reply in an
id/resultenvelope. A real subcommand with--jsonprints a bare payload —_handoff_via_enginedoesprint(json.dumps(payload))directly, and only theapiop wraps. No assertion depended on the envelope, which is precisely why nothing noticed. Fixed, and a newstub-overview.mjsanswers with a caller-supplied row set so the provider filter is testable without an index.Test evidence
npm test: 87 tests, 67 pass, 0 fail, 20 skippeduv run ruff check .: n/a (TS-only)Mutation-checked, each restored afterwards:
--goalback to the space form--jsonmoved after the separatorrecentback to thelimit * 4heuristictruncatedmarker removedOne assertion I changed, and why
tests/plugin.test.tspinned the exact old array['a:1','b:2','--goal','g']— the buggy shape. It now asserts what it was written to protect (ids arrive as whole separate arguments; nothing is a joined command line). Pinning the layout would have re-frozen the defect in the one test that exists to catch it. Flagging this explicitly because changing an existing assertion deserves scrutiny, not a footnote.Platform impact
E2BIGvsENAMETOOLONG) but both are now refused before the spawn.Constraint self-check
mergeoroverviewParameterErrornames the field, the accepted range and the received valuemkdtempdirectory removed in afinally