dsh-wsl-desktop 0.3.7 → 0.4.0
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.en.md +4 -4
- package/README.md +4 -4
- package/lib/index.js +92 -0
- package/lib/wsl/preset.js +18 -3
- package/lib/wsl/search-tool.js +436 -0
- package/lib/wsl/search.js +288 -0
- package/lib/wsl/watch.js +14 -6
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -20,7 +20,7 @@ Turns a WSL distribution into a **first-class execution world** inside DSH Deskt
|
|
|
20
20
|
|
|
21
21
|
## Target capabilities
|
|
22
22
|
|
|
23
|
-
**Honest state after live acceptance** (`verify-post-restart.mjs
|
|
23
|
+
**Honest state after live acceptance** (`verify-post-restart.mjs`: its rows that have run are green, but **the two live search rows have not run yet** and await the operator's live acceptance this round, plus 20 offline suites; each item below notes its evidence strength):
|
|
24
24
|
|
|
25
25
|
| # | Capability | Status |
|
|
26
26
|
|---|---|---|
|
|
@@ -29,7 +29,7 @@ Turns a WSL distribution into a **first-class execution world** inside DSH Deskt
|
|
|
29
29
|
| ③ | Windows and WSL workspaces coexist in the same instance | **Live acceptance PASS**: Windows workspaces stay on the host preset (PowerShell available, no bash); WSL sessions run the confined realm in parallel |
|
|
30
30
|
| ④ | Linux-side sandbox constraints on the WSL side | **Shell side fixed and live-verified**: at runtime, all `rw` mounts are enumerated and remounted read-only one by one (observed `/mnt/c`, `/dev/shm`, `/run/user/<uid>` going WRITABLE → READONLY); any failure rejects (exit code 97 preserved); `enforcement` honestly reports `partial`. **fs-side fence implemented and live-verified** (out-of-bounds write rejected, PASS — see [The fs tool fence](docs/FS-FENCE.md)) |
|
|
31
31
|
|
|
32
|
-
**Which capabilities are qualified**: the evidence strength is the wording of the status column above. The
|
|
32
|
+
**Which capabilities are qualified**: the evidence strength is the wording of the status column above. The terminal has no `stdio.control` — it is stated under "Known limitations" rather than left out.
|
|
33
33
|
|
|
34
34
|
## Install / Update / Uninstall
|
|
35
35
|
|
|
@@ -69,7 +69,7 @@ After every DSH Desktop update, the plugin may break because of harness internal
|
|
|
69
69
|
|
|
70
70
|
## Known limitations
|
|
71
71
|
|
|
72
|
-
**The grep / glob tools
|
|
72
|
+
**The grep / glob tools are available in a WSL session, backed by the distribution's own `grep` / `find`.** The host's two rows are still removed, for the two measured facts: `tool-fs-search` is launched via `ctx.subprocess.spawn()` (`search-core.ts:238`), but its `argv[0]` is the **bundled Windows rg.exe** from `@vscode/ripgrep` (`search-core.ts:174-178`) — it does not exist inside the distro and cannot work with Linux arguments; the distro, meanwhile, ships `/usr/bin/grep` and `/usr/bin/find` (measured: `command -v rg` inside the distro answers nothing). So the `wsl-world` group mounts the two names back onto this plugin's own implementation (`lib/wsl/search-tool.js`), and the model calls `grep` / `glob` in a WSL session as usual — **with the distribution's semantics**: POSIX ERE (no `\d`, no `(?i)`) · `.gitignore` not parsed (only the six VCS directories are skipped) · dotted directories searched · `include` matching the base name only and without brace expansion · patterns interpreted relative to the search root, with `*` and `**/` crossing `/` · a record whose path contains `:` possibly dropped or silently mis-attributed · binary files never matched · a capped result not saved anywhere. **That list is a summary; the document is authoritative for the eight gaps** (gaps 5 and 6 are merged into one item here, and the last item, "a capped result not saved anywhere", is a limit rather than a gap). The eight gaps, the limits, what was not measured and how to verify it yourself are in [Search inside the distribution](docs/SEARCH.en.md).
|
|
73
73
|
|
|
74
74
|
**The terminal goes through the PTY bridge, and PTC's `stdio.control` (fd channel) stays explicitly rejected** — `wsl.exe` cannot forward arbitrary descriptors. How the bridge is driven, and its measured preconditions, are in [docs/PTY-BRIDGE.md](docs/PTY-BRIDGE.md).
|
|
75
75
|
|
|
@@ -80,7 +80,7 @@ After every DSH Desktop update, the plugin may break because of harness internal
|
|
|
80
80
|
## Verification
|
|
81
81
|
|
|
82
82
|
```powershell
|
|
83
|
-
node scripts/verify-all.mjs # all offline suites (
|
|
83
|
+
node scripts/verify-all.mjs # all offline suites (20)
|
|
84
84
|
node scripts/verify-all.mjs --live # additionally the suites needing the installed plugin + a running host
|
|
85
85
|
```
|
|
86
86
|
|
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
## 目标能力
|
|
20
20
|
|
|
21
|
-
**活体验收通过后的诚实状态**(`verify-post-restart.mjs`
|
|
21
|
+
**活体验收通过后的诚实状态**(`verify-post-restart.mjs` 已跑过的行全绿,但**搜索的两条活体行尚未运行**、待操作者本轮活体验收,加离线 20 套件;下面每条都注明证据强度):
|
|
22
22
|
|
|
23
23
|
| # | 能力 | 状态 |
|
|
24
24
|
|---|---|---|
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
| ③ | 同一实例里 Windows 与 WSL 工作区并存 | **活体验收 PASS**:Windows 工作区停留在宿主 preset(PowerShell 可用、无 bash),WSL 会话并行运行 confined realm |
|
|
28
28
|
| ④ | WSL 侧的 Linux 沙箱约束 | **shell 侧已修并活体验证**:运行时枚举所有 `rw` 挂载逐个改只读(实测 `/mnt/c`、`/dev/shm`、`/run/user/<uid>` 从 WRITABLE 变 READONLY)、失败即拒(保留退出码 97)、`enforcement` 如实报 `partial`。**fs 侧围栏已实现并活体验证**(越界写拒绝 PASS,见 [fs 工具的围栏](docs/FS-FENCE.md))|
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
**哪些能力有保留**(证据强度就是上表状态列的措辞):终端不支持 `stdio.control`——写在「已知限制」里,不藏。
|
|
31
31
|
|
|
32
32
|
## 安装 / 更新 / 卸载
|
|
33
33
|
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
|
|
68
68
|
## 已知限制
|
|
69
69
|
|
|
70
|
-
**grep / glob 工具在 WSL
|
|
70
|
+
**grep / glob 工具在 WSL 会话里可用,后端是发行版自己的 `grep` / `find`。** 宿主那两行依然被移除,理由是实测的两件事:`tool-fs-search` 虽然通过 `ctx.subprocess.spawn()` 启动(`search-core.ts:238`),但它的 `argv[0]` 是 `@vscode/ripgrep` 提供的**打包 Windows rg.exe**(`search-core.ts:174-178`)——在发行版里既不存在、也无法用 Linux 参数工作;而发行版自带 `/usr/bin/grep` 与 `/usr/bin/find`(实测:发行版里 `command -v rg` 回答为空)。所以 `wsl-world` 组把这两个名字挂回本插件自己的实现(`lib/wsl/search-tool.js`),模型在 WSL 会话里照常调用 `grep` / `glob`——**但语义是发行版工具的**:POSIX ERE(没有 `\d`、`(?i)`)· 不解析 `.gitignore`(只排除六个 VCS 目录)· 点目录会被搜到 · `include` 只匹配 basename 且没有花括号 · 模式按搜索根相对解释、`*` 与 `**/` 跨 `/` · 路径含 `:` 的记录可能被丢弃或被静默错配 · 二进制文件永不被匹配 · 达到上限的截断不落盘。**这串要点是摘要,缺口的权威清单在文档里**(文档八条中第 5、6 条在这里并成了一条;末条「截断不落盘」是限额,不是缺口)。八条缺口、限额、未测项与怎么自己验,见 [发行版内搜索](docs/SEARCH.md)。
|
|
71
71
|
|
|
72
72
|
**终端走 PTY 桥,PTC 的 `stdio.control`(fd 通道)明确拒绝**——`wsl.exe` 无法转发任意描述符。桥的驱动方式与实测前提见 [docs/PTY-BRIDGE.md](docs/PTY-BRIDGE.md)。
|
|
73
73
|
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
## 校验
|
|
79
79
|
|
|
80
80
|
```powershell
|
|
81
|
-
node scripts/verify-all.mjs # 全部离线套件(
|
|
81
|
+
node scripts/verify-all.mjs # 全部离线套件(20 个)
|
|
82
82
|
node scripts/verify-all.mjs --live # 追加需要已安装插件 + 运行中宿主的套件
|
|
83
83
|
```
|
|
84
84
|
|
package/lib/index.js
CHANGED
|
@@ -107,6 +107,15 @@ function variantIdFor(preset) {
|
|
|
107
107
|
const SUBPROCESS_MODULE = pathToFileURL(join(HERE, 'wsl', 'subprocess.js')).href
|
|
108
108
|
const SHELL_MODULE = pathToFileURL(join(HERE, 'wsl', 'shell.js')).href
|
|
109
109
|
const FS_MODULE = pathToFileURL(join(HERE, 'wsl', 'fs.js')).href
|
|
110
|
+
/**
|
|
111
|
+
* The realm's own `grep`/`glob` module. Named here, beside the three providers, for the same
|
|
112
|
+
* reason they are: a row must name a module the LOADER can import, and a `file://` URL is the
|
|
113
|
+
* one spelling that survives every generation. The two search rows both name this module (the
|
|
114
|
+
* row's `config.tool` picks which of the two tools it registers), because the search names the
|
|
115
|
+
* model already knows have to be re-mounted by something this plugin owns — the host's search
|
|
116
|
+
* package is removed inside a WSL realm, not reused.
|
|
117
|
+
*/
|
|
118
|
+
const SEARCH_MODULE = pathToFileURL(join(HERE, 'wsl', 'search-tool.js')).href
|
|
110
119
|
|
|
111
120
|
/** Last preset materialization outcome, reported through the route. */
|
|
112
121
|
let presetState = { status: 'pending' }
|
|
@@ -300,6 +309,7 @@ async function materializePresets(ctx, distro, generation, previous) {
|
|
|
300
309
|
subprocessPath: SUBPROCESS_MODULE,
|
|
301
310
|
shellPath: SHELL_MODULE,
|
|
302
311
|
fsPath: FS_MODULE,
|
|
312
|
+
searchPath: SEARCH_MODULE,
|
|
303
313
|
...(distro !== undefined ? { distro } : {}),
|
|
304
314
|
...(sourceDir !== undefined ? { sourceDir } : {}),
|
|
305
315
|
})
|
|
@@ -823,6 +833,88 @@ const METHODS = {
|
|
|
823
833
|
text: JSON.stringify(pwshTool.result ?? null).slice(0, 200),
|
|
824
834
|
})
|
|
825
835
|
|
|
836
|
+
// The realm's own `grep`/`glob`: the host removes both names from a WSL world
|
|
837
|
+
// (their argv[0] is a packaged Windows ripgrep) and this plugin mounts them back
|
|
838
|
+
// on the distribution's own grep/find. NO offline suite can drive them — the row
|
|
839
|
+
// module imports harness packages — so these two rows are the only observation of
|
|
840
|
+
// the chain end to end, and `scripts/verify-post-restart.mjs` reads them.
|
|
841
|
+
//
|
|
842
|
+
// The step names are `tool.search.*` (singular `tool`, as the plan spells them),
|
|
843
|
+
// NOT the `tools.*` the sibling rows use: the suite pairs with these exact strings,
|
|
844
|
+
// and both files are the only two places they appear.
|
|
845
|
+
//
|
|
846
|
+
// The fixture names, the token and the line number are spelled AGAIN in
|
|
847
|
+
// `scripts/verify-post-restart.mjs`, which asserts on their Linux paths — the same
|
|
848
|
+
// pairing `dsh-wsl-selftest.txt` already has between the two files. Three fixture
|
|
849
|
+
// facts, each one earning its place:
|
|
850
|
+
//
|
|
851
|
+
// - the probe carries the token on LINE 3, so a hit pins the line NUMBER rather
|
|
852
|
+
// than merely "some line of the right file";
|
|
853
|
+
// - `.git/x/hidden.ts` carries the same token and sits where the glob pattern
|
|
854
|
+
// reaches, so a search that stopped excluding the six VCS names prints an extra
|
|
855
|
+
// path (the shape Task 1's measurement removed) instead of passing quietly;
|
|
856
|
+
// - `src/a/b/deep.ts` is TWO directories below the pattern's own prefix, which is
|
|
857
|
+
// what pins "the pattern is anchored to the search root": an unanchored
|
|
858
|
+
// `src/*.ts` matches NOTHING (measured), so this file either appears or the
|
|
859
|
+
// anchoring broke.
|
|
860
|
+
//
|
|
861
|
+
// Written through the shell executor, not `fs`, and that is deliberate: the
|
|
862
|
+
// fixtures must exist in the world the search PROCESS runs in. A file written
|
|
863
|
+
// through the Windows-side share and a `grep` started through wsl.exe are only
|
|
864
|
+
// guaranteed to agree while the share serves the same running distribution —
|
|
865
|
+
// measured on this machine, after a host restart the share kept serving a stale
|
|
866
|
+
// view that the distribution could not see at all.
|
|
867
|
+
let searchFixtureError = null
|
|
868
|
+
const searchToken = 'dsh-wsl-search-needle'
|
|
869
|
+
if (shell === undefined || typeof workspaceCwd !== 'string' || !workspaceCwd.startsWith('\\\\wsl.localhost\\')) {
|
|
870
|
+
// A host (Windows) session has no distribution shell and no search tools; the
|
|
871
|
+
// rows still record, so the suite's WSL run can tell "no fixture" from "no row".
|
|
872
|
+
searchFixtureError = 'the WSL shell executor is unavailable, so the search fixtures were not created'
|
|
873
|
+
} else {
|
|
874
|
+
const searchFixtureCommand = [
|
|
875
|
+
'mkdir -p src/a/b src/.git/x',
|
|
876
|
+
`printf '%s\\n' 'the search probe, written by the selftest' 'second line, so the match is not on line one' '${searchToken}' > src/dsh-wsl-search-probe.ts`,
|
|
877
|
+
`printf '%s\\n' '${searchToken}' > src/.git/x/hidden.ts`,
|
|
878
|
+
`printf '%s\\n' 'the nested file a root-relative pattern must find' > src/a/b/deep.ts`,
|
|
879
|
+
].join(' && ')
|
|
880
|
+
// The policy is mandatory, not decorative: a `shell.resolve` with none gets the
|
|
881
|
+
// session-less default root, which does NOT contain this scratch workspace —
|
|
882
|
+
// measured, every write then failed EROFS. Same object the watch mutations and
|
|
883
|
+
// the fs roundtrip above stamp, for the same reason.
|
|
884
|
+
const searchFixturePolicy = { mode: 'workspace-write', workspaceRoot: workspaceCwd }
|
|
885
|
+
const searchFixture = await runShellSpec(shell, shell.resolve({
|
|
886
|
+
command: searchFixtureCommand,
|
|
887
|
+
workdir: workspaceCwd,
|
|
888
|
+
sandboxPolicy: searchFixturePolicy,
|
|
889
|
+
}))
|
|
890
|
+
.then((execution) => execution.result())
|
|
891
|
+
.catch((error) => ({ exitCode: null, stderr: { text: String(error?.message ?? error) } }))
|
|
892
|
+
if (searchFixture.exitCode !== 0) {
|
|
893
|
+
searchFixtureError = `the search fixtures could not be written: exit ${searchFixture.exitCode ?? 'null'} ${(searchFixture.stderr?.text ?? '').slice(0, 200)}`.trim()
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
// Both rows fold the fixture outcome into `ok`: a row that could not be given its
|
|
897
|
+
// fixture must not read as a passing observation of the tool.
|
|
898
|
+
//
|
|
899
|
+
// Both also record at 800 bytes, where their siblings use 400: this page carries
|
|
900
|
+
// the probe AND the nested path (each an absolute path under the scratch) plus the
|
|
901
|
+
// value those paths came from, and a cut that removed the `lineNumber` would red
|
|
902
|
+
// the assertion for a reason that is not the tool's. Measured on the real
|
|
903
|
+
// distribution output with a real scratch spelling: 309 (grep) and 356 (glob)
|
|
904
|
+
// bytes — 444 bytes of headroom, which is the point of the larger budget.
|
|
905
|
+
const grepSearch = await invoke('grep', { pattern: searchToken })
|
|
906
|
+
record('tool.search.grep', {
|
|
907
|
+
ok: grepSearch.ok && searchFixtureError === null,
|
|
908
|
+
error: grepSearch.error ?? searchFixtureError,
|
|
909
|
+
text: JSON.stringify(grepSearch.result ?? null).slice(0, 800),
|
|
910
|
+
})
|
|
911
|
+
const globSearch = await invoke('glob', { pattern: 'src/**/*.ts' })
|
|
912
|
+
record('tool.search.glob', {
|
|
913
|
+
ok: globSearch.ok && searchFixtureError === null,
|
|
914
|
+
error: globSearch.error ?? searchFixtureError,
|
|
915
|
+
text: JSON.stringify(globSearch.result ?? null).slice(0, 800),
|
|
916
|
+
})
|
|
917
|
+
|
|
826
918
|
// A tool resolves its policy from the session's logged permission state
|
|
827
919
|
// (`tool-bash/src/index.ts:199`), which a programmatically created
|
|
828
920
|
// session does not have. Recording one is what makes the tool layer's
|
package/lib/wsl/preset.js
CHANGED
|
@@ -202,12 +202,13 @@ export function hasClassifierVerdict(name) {
|
|
|
202
202
|
* @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
|
|
203
203
|
* @param {string} options.shellPath - module specifier of the WSL shell executor.
|
|
204
204
|
* @param {string} options.fsPath - module specifier of the WSL filesystem provider.
|
|
205
|
+
* @param {string} options.searchPath - module specifier of this plugin's WSL search tools.
|
|
205
206
|
* @param {string | undefined} options.distro - distribution for paths that do not name one.
|
|
206
207
|
* @param {boolean} options.includeEditor - whether the source preset mounted the string-replace editor.
|
|
207
208
|
* @param {Map<string, object>} [options.toolConfig] - config the pruned host-world rows carried, keyed by row id.
|
|
208
209
|
* @returns {object} the group entry.
|
|
209
210
|
*/
|
|
210
|
-
export function buildWorldGroup({ subprocessPath, shellPath, fsPath, distro, includeEditor, toolConfig }) {
|
|
211
|
+
export function buildWorldGroup({ subprocessPath, shellPath, fsPath, searchPath, distro, includeEditor, toolConfig }) {
|
|
211
212
|
// The pin belongs under `config`, NOT as a sibling row key. The loader hands a
|
|
212
213
|
// plugin ONLY `options.config` (`registry.plugin(plugin, this.options.config, …)`),
|
|
213
214
|
// and nothing relocates row keys into it — so as a bare `distro` key every
|
|
@@ -236,6 +237,19 @@ export function buildWorldGroup({ subprocessPath, shellPath, fsPath, distro, inc
|
|
|
236
237
|
toolRow('tool-bash', '@deepseek-ai/dsh-tool-bash'),
|
|
237
238
|
toolRow('tool-fs', '@deepseek-ai/dsh-tool-fs'),
|
|
238
239
|
...(includeEditor ? [toolRow('str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor')] : []),
|
|
240
|
+
// Searching is re-mounted the same way bash and fs are: the two NAMES the model already
|
|
241
|
+
// knows, over a module of THIS plugin. It cannot be a carried host row — the host's
|
|
242
|
+
// search package is removed by WORLD_MODULE_NAMES, so naming it back would recreate the
|
|
243
|
+
// row the transform exists to drop (its argv[0] is a packaged Windows ripgrep binary).
|
|
244
|
+
//
|
|
245
|
+
// Both rows name ONE module, so the row's config is what tells that instance which tool
|
|
246
|
+
// to register: toolRow() is deliberately NOT used here — it would reapply the pruned
|
|
247
|
+
// host search row's config (a `sampleOverCapGlobResults`/caps shape this module does not
|
|
248
|
+
// read, and whose `tool` field would be absent), failing the row at mount time instead of
|
|
249
|
+
// mounting it. The tool name is also exactly `grep`/`glob`: callers invoke tools by name,
|
|
250
|
+
// and the row id is a separate thing that happens to spell the same.
|
|
251
|
+
{ id: 'grep', name: searchPath, config: { tool: 'grep' } },
|
|
252
|
+
{ id: 'glob', name: searchPath, config: { tool: 'glob' } },
|
|
239
253
|
]
|
|
240
254
|
return {
|
|
241
255
|
id: 'wsl-world',
|
|
@@ -306,11 +320,12 @@ function cloneRow(row) {
|
|
|
306
320
|
* @param {string} options.subprocessPath - module specifier of the WSL subprocess provider.
|
|
307
321
|
* @param {string} options.shellPath - module specifier of the WSL shell executor.
|
|
308
322
|
* @param {string} options.fsPath - module specifier of the WSL filesystem provider.
|
|
323
|
+
* @param {string} options.searchPath - module specifier of this plugin's WSL search tools.
|
|
309
324
|
* @param {string | undefined} options.distro - distribution for paths that do not name one.
|
|
310
325
|
* @param {string | undefined} options.sourceDir - directory the source preset lives in.
|
|
311
326
|
* @returns {{ plugins: object[], removed: string[], unclassified: { id: string | null, name: unknown }[] }} the variant rows, what was dropped, and the rows kept without a classifier verdict — an unreadable specifier, or a tool-family module that is neither removed nor verdicted-kept.
|
|
312
327
|
*/
|
|
313
|
-
export function buildVariantPlugins(basePlugins, { subprocessPath, shellPath, fsPath, distro, sourceDir }) {
|
|
328
|
+
export function buildVariantPlugins(basePlugins, { subprocessPath, shellPath, fsPath, searchPath, distro, sourceDir }) {
|
|
314
329
|
const kept = []
|
|
315
330
|
const removed = []
|
|
316
331
|
/** Rows kept without a classifier verdict — both shapes the prune() branches record. */
|
|
@@ -392,6 +407,6 @@ export function buildVariantPlugins(basePlugins, { subprocessPath, shellPath, fs
|
|
|
392
407
|
const pruned = prune(row)
|
|
393
408
|
if (pruned !== null) kept.push(pruned)
|
|
394
409
|
}
|
|
395
|
-
kept.push(buildWorldGroup({ subprocessPath, shellPath, fsPath, distro, includeEditor, toolConfig }))
|
|
410
|
+
kept.push(buildWorldGroup({ subprocessPath, shellPath, fsPath, searchPath, distro, includeEditor, toolConfig }))
|
|
396
411
|
return { plugins: kept, removed, unclassified }
|
|
397
412
|
}
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The realm's `grep` and `glob`: the host's two names, run by the distribution's own tools.
|
|
3
|
+
*
|
|
4
|
+
* A WSL realm removes the host's search tools ON PURPOSE (their `argv[0]` is a packaged
|
|
5
|
+
* WINDOWS ripgrep binary), and this module is what puts the two NAMES back — one row per
|
|
6
|
+
* tool inside the `wsl-world` group, exactly the way `bash` and `fs` are re-mounted there.
|
|
7
|
+
* Both rows name this ONE module, and `config.tool` is what says which of the two tools
|
|
8
|
+
* that instance registers: two rows naming one module would otherwise register the same
|
|
9
|
+
* tool twice, and a row that names neither is refused at mount time rather than mounting
|
|
10
|
+
* nothing in silence.
|
|
11
|
+
*
|
|
12
|
+
* What is deliberately NOT the host's, and each one is declared in the tool's own
|
|
13
|
+
* description as well (the wording lives THERE, where the model reads it before calling —
|
|
14
|
+
* repeating the list here would be a second contract to keep in sync):
|
|
15
|
+
*
|
|
16
|
+
* - **the backend**: `/usr/bin/grep -E` and `/usr/bin/find` as the distribution ships them;
|
|
17
|
+
* - **the pattern dialect**: POSIX extended for `grep`; for `find` the search root anchors
|
|
18
|
+
* the glob and `*` crosses `/` (measured, findutils 4.10.0);
|
|
19
|
+
* - **the ignore behaviour**: `.gitignore` is not read, only the six VCS directories are
|
|
20
|
+
* skipped, and — unlike the host, whose ripgrep skips every hidden entry — dotted
|
|
21
|
+
* directories ARE searched;
|
|
22
|
+
* - **`include`**: fnmatch over each file's BASE NAME, so braces and directory parts never
|
|
23
|
+
* match anything (`grep --include` semantics, measured in the distribution);
|
|
24
|
+
* - **the cap**: a result at the limit is TRUNCATED and says so, and nothing is spilled to
|
|
25
|
+
* a file (the host reports where it saved the complete result; this world keeps none).
|
|
26
|
+
*
|
|
27
|
+
* The VALUE of each tool is the host's own shape (`{matches:[{path,lineNumber,line}]}`,
|
|
28
|
+
* `{root,paths}`) — the parsers' `text` field is mapped to `line` here, and `truncated` is
|
|
29
|
+
* NOT a field: it is a fact about what the text withheld, so it is stated in the text, which
|
|
30
|
+
* is where the host states it too.
|
|
31
|
+
*
|
|
32
|
+
* Failures are classified by `searchFailure` (the host's two names) and raised as a
|
|
33
|
+
* {@link HarnessError} subclass, so the registry exposes `{ name, code }` on the result the
|
|
34
|
+
* same way it does for the host's own `SearchError`.
|
|
35
|
+
* @module dsh-wsl-desktop/wsl/search-tool
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
|
39
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
40
|
+
import { cutToBytes, globArgv, grepArgv, parseGlobOutput, parseGrepOutput, searchFailure } from './search.js'
|
|
41
|
+
|
|
42
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
43
|
+
export const name = 'wsl-search'
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Services these tools use. `fs` is the REALM's filesystem provider — the `wsl-world`
|
|
47
|
+
* group isolates `fs` — which is what turns the model's path into the Linux absolute one
|
|
48
|
+
* the distribution's own tools can open; `workingDirectory` is the session workspace that
|
|
49
|
+
* a relative path resolves against.
|
|
50
|
+
*/
|
|
51
|
+
export const inject = ['tools', 'systemPrompt', 'subprocess', 'fs', 'workingDirectory']
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The caps, mirroring `@deepseek-ai/dsh-tool-fs-search`'s defaults field for field, so the
|
|
55
|
+
* two worlds' searches are the same SIZE even though their engines differ. They are module
|
|
56
|
+
* constants rather than a plugin config because this module is mounted twice in one group
|
|
57
|
+
* (one instance per tool) and a config that had to be repeated on both rows would be a
|
|
58
|
+
* second place to drift; a deployment that needs different numbers adds a config then.
|
|
59
|
+
*/
|
|
60
|
+
const MAX_MATCHES = 250
|
|
61
|
+
const MAX_LINE_BYTES = 2000
|
|
62
|
+
const MAX_RESULTS = 100
|
|
63
|
+
const RAW_OUTPUT_MAX_BYTES = 20_000_000
|
|
64
|
+
const GRACE_MS = 3_000
|
|
65
|
+
const STDERR_MAX_BYTES = 64 * 1024
|
|
66
|
+
const TIMEOUT_MS = 30_000
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The parsers are asked to RETAIN EVERYTHING; the cap is applied where the model's text is
|
|
70
|
+
* written ({@link renderGrep} / {@link renderGlob}).
|
|
71
|
+
*
|
|
72
|
+
* That split is forced, not preferred: `output.render(args, value)` is the only place a tool
|
|
73
|
+
* writes the text, the `value` it receives is the frozen snapshot the registry validated, and
|
|
74
|
+
* a cap applied any earlier would leave the text unable to tell "exactly at the cap" from
|
|
75
|
+
* "the cap withheld the rest" — the silent-cut shape this whole capability refuses. The host
|
|
76
|
+
* splits it in exactly the same place (its parsers return every record and its
|
|
77
|
+
* `retainGrepMatches`/`globCardPage` cut inside `render`).
|
|
78
|
+
*
|
|
79
|
+
* What bounds "keep everything" is therefore the stdout budget at the spawn seam
|
|
80
|
+
* ({@link RAW_OUTPUT_MAX_BYTES}): a run that produced more than that is refused loudly below
|
|
81
|
+
* rather than parsed partially, so "all the records" is always a number the seam retained.
|
|
82
|
+
*/
|
|
83
|
+
const RETAIN_ALL_MATCHES = { maxMatches: Number.POSITIVE_INFINITY, maxLineBytes: Number.POSITIVE_INFINITY }
|
|
84
|
+
const RETAIN_ALL_PATHS = { maxResults: Number.POSITIVE_INFINITY }
|
|
85
|
+
|
|
86
|
+
/** One failed search, carrying the host's classification code on the result. */
|
|
87
|
+
class SearchError extends HarnessError {}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Raise the classified failure for a failed run, a failed launch, or a print that could not
|
|
91
|
+
* be read. `searchFailure` owns WHICH class this is; this function only carries it.
|
|
92
|
+
* @param {unknown} error - the failure, with `exitCode` attached when the run exited.
|
|
93
|
+
* @param {string} toolName - `'grep'` or `'glob'`.
|
|
94
|
+
*/
|
|
95
|
+
function raise(error, toolName) {
|
|
96
|
+
const { code, message } = searchFailure(error, toolName)
|
|
97
|
+
throw new SearchError(message, code)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Raise for a run that COMPLETED with an exit code neither tool accepts, handing the code
|
|
102
|
+
* and the diagnostic tail to {@link searchFailure} — the exit code is what tells a rejected
|
|
103
|
+
* grep pattern apart from every other failure.
|
|
104
|
+
* @param {string} toolName - `'grep'` or `'glob'`.
|
|
105
|
+
* @param {number} exitCode - the process's exit code.
|
|
106
|
+
* @param {string} stderr - the retained stderr tail.
|
|
107
|
+
*/
|
|
108
|
+
function raiseExit(toolName, exitCode, stderr) {
|
|
109
|
+
const detail = stderr.trim()
|
|
110
|
+
const error = new Error(detail.length === 0 ? `${toolName} exited ${exitCode}` : detail)
|
|
111
|
+
error.exitCode = exitCode
|
|
112
|
+
raise(error, toolName)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the model's path to the search root both tools run against.
|
|
117
|
+
*
|
|
118
|
+
* The root is a LINUX ABSOLUTE path, and this is the site where that happens — the reason
|
|
119
|
+
* the vectors never meet a dash-leading path. MEASURED (findutils 4.10.0): `find -- -dashdir`
|
|
120
|
+
* reports "unknown predicate `-dashdir'" and exits 1, a LOUD failure, which is why the vector
|
|
121
|
+
* layer accepts such a path instead of guarding against it; and it cannot arrive here, because
|
|
122
|
+
* the resolution below turns the model's path into an absolute one and an absolute path never
|
|
123
|
+
* begins with `-`. (Task 2 left those two facts in `globArgv`'s comment and named this site —
|
|
124
|
+
* this is that site.)
|
|
125
|
+
*
|
|
126
|
+
* The resolution is the realm's OWN filesystem provider rather than a second path grammar
|
|
127
|
+
* grown here: `ctx.fs` is the service the `fs` row mounts into this group, and it is what
|
|
128
|
+
* knows the Windows/UNC spellings of the distribution's files.
|
|
129
|
+
* @param {object} ctx - the realm's plugin context.
|
|
130
|
+
* @param {object} exec - the tool-execution context (owner agent and abort signal).
|
|
131
|
+
* @param {{ path?: string }} input - the model's arguments.
|
|
132
|
+
* @returns {Promise<{ workdir: string, root: string }>} the session workspace (the spawn cwd)
|
|
133
|
+
* and the absolute Linux search root.
|
|
134
|
+
*/
|
|
135
|
+
async function resolveRoot(ctx, exec, input) {
|
|
136
|
+
const workdir = await ctx.workingDirectory.ensure(exec.agent, exec.signal)
|
|
137
|
+
const target = await ctx.fs.resolve(input.path ?? '.', { cwd: workdir, signal: exec.signal })
|
|
138
|
+
return { workdir, root: ctx.fs.processPath(target) }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Run one search command inside the distribution and return what it printed.
|
|
143
|
+
*
|
|
144
|
+
* Only the TRANSPORT failures are raised here (a launch that never happened, a kill, a print
|
|
145
|
+
* the seam could not retain whole): which exit codes are acceptable is the calling tool's own
|
|
146
|
+
* business, because `grep` and `find` disagree about it. A nonzero exit is returned to the
|
|
147
|
+
* caller, which raises it with its code attached — that code is the whole classification.
|
|
148
|
+
* @param {object} ctx - the realm's plugin context.
|
|
149
|
+
* @param {object} exec - the tool-execution context (abort signal).
|
|
150
|
+
* @param {string} toolName - `'grep'` or `'glob'`; named in every failure message.
|
|
151
|
+
* @param {string[]} argv - the vector from `grepArgv`/`globArgv`.
|
|
152
|
+
* @param {string} workdir - the session workspace, as the subprocess provider maps it.
|
|
153
|
+
* @returns {Promise<{ stdout: string, stderr: string, exitCode: number }>} the run's output.
|
|
154
|
+
*/
|
|
155
|
+
async function runCommand(ctx, exec, toolName, argv, workdir) {
|
|
156
|
+
let handle
|
|
157
|
+
try {
|
|
158
|
+
handle = ctx.subprocess.spawn({
|
|
159
|
+
argv,
|
|
160
|
+
cwd: workdir,
|
|
161
|
+
stdio: {
|
|
162
|
+
stdin: 'ignore',
|
|
163
|
+
stdout: { maxBytes: RAW_OUTPUT_MAX_BYTES },
|
|
164
|
+
stderr: { maxBytes: STDERR_MAX_BYTES },
|
|
165
|
+
},
|
|
166
|
+
graceMs: GRACE_MS,
|
|
167
|
+
signal: exec.signal,
|
|
168
|
+
})
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// A launch that never happened (a NUL in argv, a signal already aborted, a provider that
|
|
171
|
+
// refused) is reported without claiming execution began — and it is SEARCH_FAILED, the
|
|
172
|
+
// same class as the `ENOENT` of a distribution without grep/find.
|
|
173
|
+
raise(error, toolName)
|
|
174
|
+
}
|
|
175
|
+
let outcome
|
|
176
|
+
try {
|
|
177
|
+
outcome = await handle.done
|
|
178
|
+
} catch (error) {
|
|
179
|
+
raise(error, toolName)
|
|
180
|
+
}
|
|
181
|
+
const stdout = handle.collected.stdout?.readFrom(0)
|
|
182
|
+
const stderr = handle.collected.stderr?.readFrom(0)
|
|
183
|
+
if (stdout === undefined || stderr === undefined) raise(new Error('the run produced no collected output streams'), toolName)
|
|
184
|
+
if (stdout.lossy) {
|
|
185
|
+
// The seam retained less than the tool printed, and a partial print parses into a short
|
|
186
|
+
// answer that reads exactly like a complete one — the one outcome a search must never
|
|
187
|
+
// report quietly.
|
|
188
|
+
raise(new Error(`it printed more than the ${RAW_OUTPUT_MAX_BYTES}-byte output budget; narrow pattern, path, or include and retry`), toolName)
|
|
189
|
+
}
|
|
190
|
+
if (outcome.signal !== null || outcome.exitCode === null) {
|
|
191
|
+
raise(new Error(`it was killed by signal ${outcome.signal ?? '(unknown)'}`), toolName)
|
|
192
|
+
}
|
|
193
|
+
return { stdout: stdout.text, stderr: stderr.text, exitCode: outcome.exitCode }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Bound one matched line to {@link MAX_LINE_BYTES} and mark the cut.
|
|
198
|
+
*
|
|
199
|
+
* The marker is the host's own (` (line truncated)`, `search-core.ts:325-330`), and it is
|
|
200
|
+
* exact rather than a guess because the VALUE keeps the complete line: a cut line is a strict
|
|
201
|
+
* prefix of what the search read, so "was anything cut" is answered by comparing the two, not
|
|
202
|
+
* by comparing a length against a cap that a line may meet without being cut.
|
|
203
|
+
* @param {string} line - the complete matched line.
|
|
204
|
+
* @returns {string} the preview, suffixed when bytes were cut.
|
|
205
|
+
*/
|
|
206
|
+
function previewLine(line) {
|
|
207
|
+
const kept = cutToBytes(line, MAX_LINE_BYTES)
|
|
208
|
+
return kept === line ? kept : `${kept} (line truncated)`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The model-facing `grep` page: a found-count header, the retained matches grouped by file,
|
|
213
|
+
* and — when the cap cut the list — a footer that says so AND says the rest was not saved.
|
|
214
|
+
* The host's footer carries a recovery locator there; this world has no spill file, so the
|
|
215
|
+
* honest footer is the one that says so instead of pointing at a file that does not exist.
|
|
216
|
+
* @param {{ matches: Array<{path: string, lineNumber: number, line: string}> }} value - the tool's value.
|
|
217
|
+
* @returns {{ type: 'text', text: string }[]} the content blocks.
|
|
218
|
+
*/
|
|
219
|
+
function renderGrep(value) {
|
|
220
|
+
const seen = value.matches.length
|
|
221
|
+
if (seen === 0) return [{ type: 'text', text: 'No matches found' }]
|
|
222
|
+
const kept = value.matches.slice(0, MAX_MATCHES)
|
|
223
|
+
const truncated = seen > MAX_MATCHES
|
|
224
|
+
const header = truncated
|
|
225
|
+
? `Found ${kept.length} of ${seen} matches`
|
|
226
|
+
: `Found ${seen} ${seen === 1 ? 'match' : 'matches'}`
|
|
227
|
+
const byFile = new Map()
|
|
228
|
+
for (const match of kept) {
|
|
229
|
+
const group = byFile.get(match.path)
|
|
230
|
+
if (group !== undefined) group.push(match)
|
|
231
|
+
else byFile.set(match.path, [match])
|
|
232
|
+
}
|
|
233
|
+
const sections = [...byFile].map(([path, group]) => (
|
|
234
|
+
`${path}\n${group.map((match) => `Line ${match.lineNumber}: ${previewLine(match.line)}`).join('\n')}`
|
|
235
|
+
))
|
|
236
|
+
const footer = truncated
|
|
237
|
+
? '\n\n(The complete result was not saved; narrow pattern, path, or include to see more.)'
|
|
238
|
+
: ''
|
|
239
|
+
return [{ type: 'text', text: `${header}\n\n${sections.join('\n\n')}${footer}` }]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The model-facing `glob` page: the paths themselves when they fit, and the first page plus a
|
|
244
|
+
* footer that says how many were withheld and that the rest was not saved.
|
|
245
|
+
* @param {{ root: string, paths: string[] }} value - the tool's value.
|
|
246
|
+
* @returns {{ type: 'text', text: string }[]} the content blocks.
|
|
247
|
+
*/
|
|
248
|
+
function renderGlob(value) {
|
|
249
|
+
if (value.paths.length === 0) return [{ type: 'text', text: 'No files found' }]
|
|
250
|
+
if (value.paths.length <= MAX_RESULTS) return [{ type: 'text', text: value.paths.join('\n') }]
|
|
251
|
+
const page = value.paths.slice(0, MAX_RESULTS).join('\n')
|
|
252
|
+
return [{
|
|
253
|
+
type: 'text',
|
|
254
|
+
text: `${page}\n\n(Showing ${MAX_RESULTS} of ${value.paths.length} paths, oldest first. The complete list was not saved; narrow pattern or path to see more.)`,
|
|
255
|
+
}]
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The `grep` tool: which line of which file matches, with line numbers, grouped by file.
|
|
260
|
+
* @param {object} ctx - the realm's plugin context (the services `execute` reaches).
|
|
261
|
+
* @returns {object} the registry-ready tool definition.
|
|
262
|
+
*/
|
|
263
|
+
function grepTool(ctx) {
|
|
264
|
+
return defineTool({
|
|
265
|
+
name: 'grep',
|
|
266
|
+
description: 'Search file contents inside the WSL distribution, with the distribution\'s own grep. '
|
|
267
|
+
+ 'Returns matching lines with line numbers, grouped by file, each path exactly as grep printed it (absolute). '
|
|
268
|
+
+ '.gitignore is not read — only the six VCS directories are skipped — and dotted directories ARE searched. '
|
|
269
|
+
+ 'Binary files are never matched: the distribution\'s grep treats a binary file as non-matching, so its bytes never enter a result line — the host\'s rg reports a match for the same file. '
|
|
270
|
+
+ 'A path containing a colon can make a match wrong or missing: `path:line:text` is printed with no escaping, so a colon inside a path can be taken for a field separator, and the match then names the wrong file and line number with nothing in the result to mark it wrong — or the record is dropped. Narrow the path, or use `read`, when a name contains one. '
|
|
271
|
+
+ `Up to ${MAX_MATCHES} matches; a capped result says how many were withheld, and the complete list is not saved anywhere.`,
|
|
272
|
+
parameters: {
|
|
273
|
+
pattern: {
|
|
274
|
+
type: 'string',
|
|
275
|
+
required: true,
|
|
276
|
+
description: 'Regular expression to search for, in POSIX extended form — not ripgrep\'s: \\d and (?i) are not available (write [0-9], or spell out the case alternation). '
|
|
277
|
+
+ 'A pattern that matches nothing returns no matches, not an error.',
|
|
278
|
+
},
|
|
279
|
+
path: {
|
|
280
|
+
type: 'string',
|
|
281
|
+
description: 'File or directory to search. Defaults to the session workspace; a relative path resolves against it.',
|
|
282
|
+
},
|
|
283
|
+
include: {
|
|
284
|
+
type: 'string',
|
|
285
|
+
description: 'One glob filter for which files to search (e.g. "*.ts"). It is matched against each file\'s BASE NAME, so a glob carrying a directory ("src/*.ts") matches nothing — narrow with path instead — '
|
|
286
|
+
+ 'and there is no brace expansion, so "*.{ts,tsx}" matches nothing either. Not a list; negation is not supported.',
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
timeoutMs: TIMEOUT_MS,
|
|
290
|
+
output: {
|
|
291
|
+
schema: {
|
|
292
|
+
type: 'object',
|
|
293
|
+
additionalProperties: false,
|
|
294
|
+
properties: {
|
|
295
|
+
matches: {
|
|
296
|
+
type: 'array',
|
|
297
|
+
required: true,
|
|
298
|
+
items: {
|
|
299
|
+
type: 'object',
|
|
300
|
+
additionalProperties: false,
|
|
301
|
+
properties: {
|
|
302
|
+
path: { type: 'string', required: true },
|
|
303
|
+
lineNumber: { type: 'integer', required: true },
|
|
304
|
+
line: { type: 'string', required: true },
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
render: (_args, value) => renderGrep(value),
|
|
311
|
+
},
|
|
312
|
+
async execute(args, exec) {
|
|
313
|
+
if (args.pattern.length === 0) throw new Error('pattern must be a non-empty string')
|
|
314
|
+
if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given')
|
|
315
|
+
const { workdir, root } = await resolveRoot(ctx, exec, args)
|
|
316
|
+
const argv = grepArgv(
|
|
317
|
+
{ pattern: args.pattern, path: root, ...args.include !== undefined ? { include: args.include } : {} },
|
|
318
|
+
{ maxMatches: MAX_MATCHES },
|
|
319
|
+
)
|
|
320
|
+
const run = await runCommand(ctx, exec, 'grep', argv, workdir)
|
|
321
|
+
if (run.exitCode === 1) return { matches: [] }
|
|
322
|
+
if (run.exitCode !== 0) raiseExit('grep', run.exitCode, run.stderr)
|
|
323
|
+
const parsed = parseGrepOutput(run.stdout, RETAIN_ALL_MATCHES)
|
|
324
|
+
// grep prints `path:line:text` for every match, so output that parses to NOTHING is
|
|
325
|
+
// output the print reader could not read at all — a colon inside a path is the known
|
|
326
|
+
// cause. That must not answer as "No matches found", which is what a silent drop would
|
|
327
|
+
// look like: the two are distinguishable precisely because a genuine empty result
|
|
328
|
+
// prints not a single byte.
|
|
329
|
+
//
|
|
330
|
+
// That premise leans on `--binary-files=without-match` in `grepArgv` (`search.js`), and
|
|
331
|
+
// this coupling is the part no assertion can see. The flag's VALUE is the gap the tool
|
|
332
|
+
// description declares — a binary file is treated as non-matching, so its bytes never
|
|
333
|
+
// enter a line — and the measurement below is what makes this guard's premise provable:
|
|
334
|
+
// WITH the flag a binary file's match yields no line, no stdout byte and exit 1
|
|
335
|
+
// (measured, GNU grep 3.11), so a non-empty print here really is unreadable output.
|
|
336
|
+
// Measured without it: exit 0, an EMPTY stdout, and the `Binary file … matches` notice
|
|
337
|
+
// on STDERR — this guard reads stdout, so it would not fire; the call would answer
|
|
338
|
+
// "No matches found" and drop the notice, and the loud failure this guard exists for
|
|
339
|
+
// would need a grep that put that notice on stdout. The flag's presence is pinned
|
|
340
|
+
// byte-for-byte by the golden vector in `scripts/verify-search.mjs`; the reason it must
|
|
341
|
+
// stay is here. (Carried ruling 38, task 4; its wording states the result first under
|
|
342
|
+
// ruling 47's minor, alongside the declared gap.)
|
|
343
|
+
if (run.stdout.length > 0 && parsed.matches.length === 0) {
|
|
344
|
+
raise(new Error(`its output could not be read as match records (${run.stdout.length} bytes; a path containing a colon is the known cause) — narrow the path and retry`), 'grep')
|
|
345
|
+
}
|
|
346
|
+
return { matches: parsed.matches.map((match) => ({ path: match.path, lineNumber: match.lineNumber, line: match.text })) }
|
|
347
|
+
},
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The `glob` tool: files, not directories, whose paths match a glob pattern.
|
|
353
|
+
* @param {object} ctx - the realm's plugin context (the services `execute` reaches).
|
|
354
|
+
* @returns {object} the registry-ready tool definition.
|
|
355
|
+
*/
|
|
356
|
+
function globTool(ctx) {
|
|
357
|
+
return defineTool({
|
|
358
|
+
name: 'glob',
|
|
359
|
+
description: 'Find files (not directories) inside the WSL distribution, with the distribution\'s own find. '
|
|
360
|
+
+ 'Returns absolute paths exactly as find printed them, in modification-time order. '
|
|
361
|
+
+ '.gitignore is not read — only the six VCS directories are skipped — and dotted directories ARE searched. '
|
|
362
|
+
+ `Up to ${MAX_RESULTS} paths; a capped result says how many were withheld, and the complete list is not saved anywhere.`,
|
|
363
|
+
parameters: {
|
|
364
|
+
pattern: {
|
|
365
|
+
type: 'string',
|
|
366
|
+
required: true,
|
|
367
|
+
description: 'Glob pattern to match file paths against (e.g. "**/*.ts", "src/**/*.test.js"). Interpreted relative to the search root, and "*" crosses "/" as "*" and "**/" alike, '
|
|
368
|
+
+ 'so "src/*.ts" also matches files deeper than one level, while a pattern with no "/" matches at any depth and a leading "/" means the same as none. '
|
|
369
|
+
+ 'A pattern that matches nothing returns an empty list, not an error.',
|
|
370
|
+
},
|
|
371
|
+
path: {
|
|
372
|
+
type: 'string',
|
|
373
|
+
description: 'Directory to search in. Defaults to the session workspace; a relative path resolves against it.',
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
timeoutMs: TIMEOUT_MS,
|
|
377
|
+
output: {
|
|
378
|
+
schema: {
|
|
379
|
+
type: 'object',
|
|
380
|
+
additionalProperties: false,
|
|
381
|
+
properties: {
|
|
382
|
+
root: { type: 'string', required: true },
|
|
383
|
+
paths: { type: 'array', required: true, items: { type: 'string' } },
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
render: (_args, value) => renderGlob(value),
|
|
387
|
+
},
|
|
388
|
+
async execute(args, exec) {
|
|
389
|
+
if (args.pattern.trim().length === 0) throw new Error('pattern must be a non-empty string')
|
|
390
|
+
if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given')
|
|
391
|
+
const { workdir, root } = await resolveRoot(ctx, exec, args)
|
|
392
|
+
const argv = globArgv({ pattern: args.pattern, path: root }, { maxResults: MAX_RESULTS })
|
|
393
|
+
const run = await runCommand(ctx, exec, 'glob', argv, workdir)
|
|
394
|
+
if (run.exitCode !== 0) raiseExit('glob', run.exitCode, run.stderr)
|
|
395
|
+
const parsed = parseGlobOutput(run.stdout, RETAIN_ALL_PATHS)
|
|
396
|
+
// The `-printf` emits one `%T@ %p` record per hit, so a non-empty print that parsed to
|
|
397
|
+
// NO path is damaged output (the reader drops a record it cannot read, silently by
|
|
398
|
+
// design) — and silence here would be indistinguishable from "no files match", which
|
|
399
|
+
// find prints as nothing at all.
|
|
400
|
+
if (run.stdout.length > 0 && parsed.paths.length === 0) {
|
|
401
|
+
raise(new Error(`its output could not be read as path records (${run.stdout.length} bytes)`), 'glob')
|
|
402
|
+
}
|
|
403
|
+
return { root, paths: parsed.paths }
|
|
404
|
+
},
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The sentence each tool adds to the system prompt while that tool exists in the scope. */
|
|
409
|
+
const PROMPTS = {
|
|
410
|
+
grep: 'Use the grep tool — not shell grep — to search file contents in this WSL session; it is the distribution\'s own grep.',
|
|
411
|
+
glob: 'Use the glob tool — not shell find — to discover files by path pattern in this WSL session; it is the distribution\'s own find.',
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Register the ONE tool this row names, plus its scope-aware prompt guidance.
|
|
416
|
+
*
|
|
417
|
+
* The section is registered the way the host registers its own (`grep.ts:275-282`): its text
|
|
418
|
+
* is a function of the scope and is EMPTY while the tool is not resolvable there, so the
|
|
419
|
+
* guidance cannot outlive the tool it describes. The section name is `wsl:`-prefixed rather
|
|
420
|
+
* than the host's `tool:` — a composition that mounted both would otherwise have two
|
|
421
|
+
* sections claiming one name.
|
|
422
|
+
* @param {object} ctx - the realm's plugin context; registrations are effects scoped to it.
|
|
423
|
+
* @param {{ tool?: string } | undefined} config - the row's config: which tool this is.
|
|
424
|
+
*/
|
|
425
|
+
export function apply(ctx, config) {
|
|
426
|
+
const tool = config?.tool
|
|
427
|
+
if (tool !== 'grep' && tool !== 'glob') {
|
|
428
|
+
throw new Error(`wsl-search: this row must name the tool it registers as config.tool ("grep" or "glob"), got ${JSON.stringify(tool ?? null)}`)
|
|
429
|
+
}
|
|
430
|
+
ctx.systemPrompt.section({
|
|
431
|
+
name: `wsl:${tool}`,
|
|
432
|
+
order: ctx.systemPrompt.getSectionOrder(tool === 'grep' ? 'TOOL_GREP' : 'TOOL_GLOB'),
|
|
433
|
+
text: ({ scope }) => ctx.tools.get(tool, scope) === undefined ? '' : PROMPTS[tool],
|
|
434
|
+
})
|
|
435
|
+
ctx.tools.register(tool === 'grep' ? grepTool(ctx) : globTool(ctx))
|
|
436
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The realm's own search: the argv vectors for the distribution's grep and find.
|
|
3
|
+
*
|
|
4
|
+
* The host's grep/glob are removed inside a WSL realm on purpose — their argv[0] is a
|
|
5
|
+
* packaged WINDOWS ripgrep binary — so without this module the realm has no search tool
|
|
6
|
+
* at all. The distribution carries its own `/usr/bin/grep` and `/usr/bin/find`, and what
|
|
7
|
+
* this module decides is only ever the ARGV VECTOR that runs them.
|
|
8
|
+
*
|
|
9
|
+
* No shell exists on that path, by construction: `sh -c`, a pipe, a redirect and a glob
|
|
10
|
+
* expanded by a shell are all impossible here, because every model value (pattern, path,
|
|
11
|
+
* include) is pushed as exactly ONE element. A value that reads like shell syntax is
|
|
12
|
+
* therefore inert — `a b; rm -rf /` is one argument and nothing else. Sorting and
|
|
13
|
+
* filtering happen in Node, on the parsed output.
|
|
14
|
+
*
|
|
15
|
+
* The other half of the job is what comes BACK. The distribution's tools print formats of
|
|
16
|
+
* their own (`path:line:text` with no escaping, `%T@ %p` NUL-separated), and the parsers
|
|
17
|
+
* below turn that print into the host tool's shape, applying the caps there — in Node,
|
|
18
|
+
* because the vectors ask for everything and only the consumer can stop. The sort is Node's
|
|
19
|
+
* too: a realm has no shell to pipe into `sort`.
|
|
20
|
+
*
|
|
21
|
+
* What a FAILED run means is decided here as well (`searchFailure`), in the host's own two
|
|
22
|
+
* names, so the tool that raises the failure does not get to invent a vocabulary of its
|
|
23
|
+
* own — and so that the two classes stay apart in an offline row rather than in a `catch`
|
|
24
|
+
* block nobody can test.
|
|
25
|
+
*
|
|
26
|
+
* The glob translation is MEASURED rather than assumed. GNU find's `-path` matches the
|
|
27
|
+
* WHOLE printed path and its `*` crosses `/` (findutils 4.10.0, measured in the distribution),
|
|
28
|
+
* so a leading `**` + `/` in a glob is exactly `*`: the translation COLLAPSES that prefix, it
|
|
29
|
+
* does not expand it into a repeated segment, and the caller anchors the result to the
|
|
30
|
+
* search root — an unanchored tail matches NOTHING, because `-path` sees the printed
|
|
31
|
+
* absolute path and never the root-relative one the model wrote (also measured).
|
|
32
|
+
* @module dsh-wsl-desktop/wsl/search
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** VCS directories the listing must never descend into (host parity: glob.ts:37). */
|
|
36
|
+
export const VCS_EXCLUDES = ['.git', '.svn', '.hg', '.bzr', '.jj', '.sl']
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Translate a model-facing path glob into the tail of a `find -path` pattern.
|
|
40
|
+
*
|
|
41
|
+
* MEASURED: GNU find's `-path` matches the WHOLE printed path and its `*` crosses `/`, so a
|
|
42
|
+
* `**` + `/` prefix is exactly `*` — a collapse, not an expansion. The caller anchors the
|
|
43
|
+
* result to the search root: an unanchored tail matches nothing (measured).
|
|
44
|
+
*
|
|
45
|
+
* A leading `/` is ripgrep's ANCHORED glob form; EVERY leading slash is therefore stripped, so
|
|
46
|
+
* that `/**` + `/x.ts`, `**` + `/x.ts` and `//**` + `/*.ts` all mean the same thing here. One
|
|
47
|
+
* slash is not enough: a doubled one would leave `/*.ts`, and the anchor would then build a
|
|
48
|
+
* `//`-bearing -path pattern, which matches NOTHING (measured: the same fixture answers 2
|
|
49
|
+
* records for the single-slash form and 0 for the doubled one — and the doubled-leading-slash
|
|
50
|
+
* input family was found in review, after the first two were fixed).
|
|
51
|
+
* @param {string} pattern - the model's glob.
|
|
52
|
+
* @returns {string} the tail of the `-path` pattern; globArgv anchors it to the root.
|
|
53
|
+
*/
|
|
54
|
+
export function translateGlob(pattern) {
|
|
55
|
+
return pattern.replace(/^\/+/, '').replace(/\*\*\//g, '').replace(/^\.\//, '')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One `grep -E` argv vector. Every model value is ONE element; no shell exists.
|
|
60
|
+
*
|
|
61
|
+
* The VCS exclusion is not optional here: `grep -r` has no prune concept, and the host's
|
|
62
|
+
* grep skips those directories because ripgrep does. Without the exclusions this vector
|
|
63
|
+
* answers with `.git`'s own files (measured — see task-1-report.md).
|
|
64
|
+
* @param {{ pattern: string, path: string, include?: string }} input - the model's request.
|
|
65
|
+
* @param {{ maxMatches: number }} caps - the caller's cap; not consumed by the vector
|
|
66
|
+
* (the cap is applied where the output is parsed, not on the command line).
|
|
67
|
+
* @returns {string[]} the argv, ready for the distribution's grep.
|
|
68
|
+
*/
|
|
69
|
+
export function grepArgv(input, caps) {
|
|
70
|
+
void caps
|
|
71
|
+
const argv = ['/usr/bin/grep', '-r', '-n', '-H', '-E', '--binary-files=without-match']
|
|
72
|
+
// Measured: without these, the vector returns .git/x/hidden.ts — grep has no prune concept,
|
|
73
|
+
// so the VCS exclusion rides on --exclude-dir (still one inert element per name). They are
|
|
74
|
+
// OPTIONS, so they stand before --include= and before the `--`: after that separator grep
|
|
75
|
+
// would read one of them as just another path operand.
|
|
76
|
+
for (const name of VCS_EXCLUDES) argv.push(`--exclude-dir=${name}`)
|
|
77
|
+
if (input.include !== undefined) argv.push(`--include=${input.include}`)
|
|
78
|
+
argv.push('--regexp', input.pattern, '--', input.path)
|
|
79
|
+
return argv
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One `find` argv vector: VCS pruned by -prune, the pattern anchored to the root, mtime
|
|
84
|
+
* printed for the sort in Node.
|
|
85
|
+
* @param {{ pattern: string, path: string }} input - the model's request.
|
|
86
|
+
* @param {{ maxResults: number }} caps - the caller's cap; not consumed by the vector
|
|
87
|
+
* (the cap is applied where the output is parsed, not on the command line).
|
|
88
|
+
* @returns {string[]} the argv, ready for the distribution's find.
|
|
89
|
+
*/
|
|
90
|
+
export function globArgv(input, caps) {
|
|
91
|
+
void caps
|
|
92
|
+
// Strip ONLY the trailing slashes: the formula is <path-without-trailing-slashes>/<tail>, so a
|
|
93
|
+
// root path '/' deliberately yields '' and the anchored pattern keeps exactly ONE leading slash.
|
|
94
|
+
// MEASURED, twice in the distribution: a '//'-prefixed -path pattern matches NOTHING, so
|
|
95
|
+
// special-casing the root back to '/' would make a full-root search silently return empty.
|
|
96
|
+
const root = input.path.replace(/\/+$/, '')
|
|
97
|
+
// The separator is MEASURED, not assumed (findutils 4.10.0): the vector answers the SAME records
|
|
98
|
+
// with and without it (exit 0 both ways), and a `--` in the expression position is rejected as
|
|
99
|
+
// an unknown predicate — so all it ends is find's OWN option parsing, and nothing else moves.
|
|
100
|
+
// It is NOT a licence to pass a path that begins with `-`; measured, `find -- -dashdir …`
|
|
101
|
+
// reports "unknown predicate `-dashdir'" and exits 1 — a LOUD failure, which is why it is
|
|
102
|
+
// accepted rather than guarded against here. Such a path cannot reach this vector anyway:
|
|
103
|
+
// Task 3's path resolution turns the model's relative path into an ABSOLUTE one, so the first
|
|
104
|
+
// dash-leading argument find could meet never is one. (Repeat these two facts where that
|
|
105
|
+
// resolution happens: Task 3 owns that site, and this comment cannot stand in for it.)
|
|
106
|
+
const argv = ['/usr/bin/find', '--', input.path]
|
|
107
|
+
for (const name of VCS_EXCLUDES) argv.push('-name', name, '-prune', '-o')
|
|
108
|
+
// -path matches the whole printed path, so the model's root-relative pattern is anchored here.
|
|
109
|
+
argv.push('-path', `${root}/${translateGlob(input.pattern)}`, '-type', 'f', '-printf', '%T@ %p\\0')
|
|
110
|
+
return argv
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Cut one line to `maxBytes` UTF-8 BYTES without ever splitting a character.
|
|
115
|
+
*
|
|
116
|
+
* The cap's name is its contract, and a UTF-16 `length` is not that number: '中文中' is 3 units
|
|
117
|
+
* but 9 bytes (measured), so a unit-counted cap of 3 passed the WHOLE line through at three times
|
|
118
|
+
* the caller's number. This walks the string's CODE POINTS and adds each one's `Buffer.byteLength`
|
|
119
|
+
* until the next would cross the cap, so the cut always lands between characters — a byte-wise slice
|
|
120
|
+
* of the buffer instead answers one whole character plus the first byte of the next, printed as a
|
|
121
|
+
* replacement character. (Code points, not grapheme clusters: a combining mark is a code point of
|
|
122
|
+
* its own, which is as far as this cap's name claims to go.)
|
|
123
|
+
*
|
|
124
|
+
* It is exported because the parser is not the only place the cut happens: a tool that keeps the
|
|
125
|
+
* complete records in its VALUE and cuts the model-facing text at the render boundary (the host's
|
|
126
|
+
* split — its `previewLine` cuts there) must make the same cut, and a second implementation of a
|
|
127
|
+
* byte-correct cut is how the two would come to disagree.
|
|
128
|
+
* @param {string} text - the line to cut.
|
|
129
|
+
* @param {number} maxBytes - the cap, in UTF-8 bytes.
|
|
130
|
+
* @returns {string} the longest prefix that fits, ending on a character boundary.
|
|
131
|
+
*/
|
|
132
|
+
export function cutToBytes(text, maxBytes) {
|
|
133
|
+
let bytes = 0
|
|
134
|
+
let end = 0
|
|
135
|
+
for (const ch of text) {
|
|
136
|
+
const size = Buffer.byteLength(ch, 'utf8')
|
|
137
|
+
if (bytes + size > maxBytes) break
|
|
138
|
+
bytes += size
|
|
139
|
+
end += ch.length
|
|
140
|
+
}
|
|
141
|
+
return text.slice(0, end)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Parse `grep -H -n` output into the host's match shape.
|
|
146
|
+
*
|
|
147
|
+
* grep prints `path:line:text` with NO escaping, so the split is the fragile part and it is
|
|
148
|
+
* fixed to the FIRST two colons: the path ends at the first, the number runs to the second,
|
|
149
|
+
* and everything after the second is text — which is what lets a matching line carry colons
|
|
150
|
+
* of its own. A line without a second colon is not a record and is skipped.
|
|
151
|
+
*
|
|
152
|
+
* A colon inside the PATH is the same ambiguity read the other way, and the print cannot settle
|
|
153
|
+
* it: the field boundaries are gone before this function is ever called. What is left to choose
|
|
154
|
+
* is which failure to take, and this function takes the recoverable one. The line-number field is
|
|
155
|
+
* the test — it is a COUNT, so a field that is not a finite integer (`/ws/a:b.ts:7:x` leaves
|
|
156
|
+
* `b.ts` where the number belongs) means the split landed inside the damage, and the record is
|
|
157
|
+
* SKIPPED, exactly as an unreadable find record is. Skipping costs one RECOVERABLE false negative:
|
|
158
|
+
* the model can re-run with `read`, a narrower path, or bash. Keeping it costs the whole CALL —
|
|
159
|
+
* NaN is not lossless JSON, so the host's value snapshot rejects the value (packages/util/values/
|
|
160
|
+
* src/index.ts:128-129), its schema declares this field an INTEGER (tool-fs-search/src/grep.ts:307),
|
|
161
|
+
* and the tool answers with a ToolOutputError that takes every other match in the batch down with
|
|
162
|
+
* it, behind an error that never mentions a colon.
|
|
163
|
+
*
|
|
164
|
+
* That guard is NOT a solution to the ambiguity and must not be read as one: it covers only the
|
|
165
|
+
* fields that do not read as a line number at all (NaN, Infinity, a fraction like `9.30`), and it
|
|
166
|
+
* cannot cover the ones that do. `/ws/2024:12:00.log:7:needle` mis-reads to a FINITE
|
|
167
|
+
* `{path: '/ws/2024', lineNumber: 12, text: '00.log:7:needle'}` (measured) — a number no guard can
|
|
168
|
+
* tell from a real one — and `''` or `' 7'` read as 0 and 7 just as silently, so a colon-bearing
|
|
169
|
+
* path may be DROPPED or silently MIS-ATTRIBUTED, and only the print format could settle which.
|
|
170
|
+
* The gap is listed with the realm's other differences from the host (Task 5, docs/SEARCH.md).
|
|
171
|
+
*
|
|
172
|
+
* The caps are applied HERE, in Node: the vector asks the tool for everything, so this is the
|
|
173
|
+
* only place a limit can be applied — and a cap that reported nothing would make a cut answer
|
|
174
|
+
* read as a complete one. A line over `maxLineBytes` is cut to the cap but is NOT a truncated
|
|
175
|
+
* answer: the record survives, shortened, so `truncated` stays about what the CAP left out. That
|
|
176
|
+
* cap is in BYTES, because that is the name's promise — see `cutToBytes` for the unit that makes
|
|
177
|
+
* it so.
|
|
178
|
+
* @param {string} stdout - grep's stdout, one `path:line:text` record per line.
|
|
179
|
+
* @param {{ maxMatches: number, maxLineBytes: number }} caps - the caller's caps.
|
|
180
|
+
* @returns {{ matches: Array<{path: string, lineNumber: number, text: string}>, truncated: boolean }}
|
|
181
|
+
* the records, and whether the CAP left at least one further record out. (A record skipped for
|
|
182
|
+
* being unreadable is not a truncation: the flag is about the cap, not about damage.)
|
|
183
|
+
*/
|
|
184
|
+
export function parseGrepOutput(stdout, caps) {
|
|
185
|
+
const matches = []
|
|
186
|
+
let truncated = false
|
|
187
|
+
for (const rawLine of stdout.split('\n')) {
|
|
188
|
+
if (rawLine === '') continue
|
|
189
|
+
const first = rawLine.indexOf(':')
|
|
190
|
+
const second = first === -1 ? -1 : rawLine.indexOf(':', first + 1)
|
|
191
|
+
if (second === -1) continue
|
|
192
|
+
// A line number is a COUNT, so anything else here means the split landed in the damage (the
|
|
193
|
+
// docblock has the reason this is a SKIP rather than a `NaN` handed to the caller). It is
|
|
194
|
+
// skipped BEFORE the cap is consulted, so an unreadable record neither fills the cap nor sets
|
|
195
|
+
// `truncated` (which is about the cap), and with `continue`, never `break` — a broken record
|
|
196
|
+
// must not end the scan and quietly shorten the answer.
|
|
197
|
+
const lineNumber = Number(rawLine.slice(first + 1, second))
|
|
198
|
+
if (!Number.isInteger(lineNumber)) continue
|
|
199
|
+
if (matches.length >= caps.maxMatches) { truncated = true; break }
|
|
200
|
+
const text = rawLine.slice(second + 1).replace(/\r$/, '')
|
|
201
|
+
matches.push({
|
|
202
|
+
path: rawLine.slice(0, first),
|
|
203
|
+
lineNumber,
|
|
204
|
+
text: cutToBytes(text, caps.maxLineBytes),
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
return { matches, truncated }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Parse `find -printf '%T@ %p\0'` and order by mtime ASCENDING, in Node (no shell sort).
|
|
212
|
+
*
|
|
213
|
+
* The record format is the vector's, and both halves of it are load-bearing: the mtime ends at
|
|
214
|
+
* the FIRST space (a path may contain one), and the NUL is the separator because a path may
|
|
215
|
+
* contain a newline — a line-based record would split one path in two. The order is the host's
|
|
216
|
+
* MEASURED one rather than the natural-looking one: the host sorts by mtime ascending (oldest
|
|
217
|
+
* first), so a descender here would be a silent disagreement with it. The cap is applied AFTER
|
|
218
|
+
* the sort, which is what makes "the first N" mean the N oldest rather than whichever N find
|
|
219
|
+
* happened to print first.
|
|
220
|
+
*
|
|
221
|
+
* A record that cannot be read at all is DROPPED, symmetrically with the grep side (which drops a
|
|
222
|
+
* line without a second colon, and one whose line-number field is not a finite integer — the two
|
|
223
|
+
* tests differ only because the fields do: a count, an mtime). The vector's own `-printf` always
|
|
224
|
+
* emits the space, so a record without one means the print was cut short, and a non-finite mtime
|
|
225
|
+
* means a value this parser cannot order by — a phantom path, or an order that depends on where a
|
|
226
|
+
* NaN landed in V8's sort, would be an answer nobody decided. The drop is deliberately silent
|
|
227
|
+
* (`truncated` is about the CAP, not about damage): for a broken record, no path is the better
|
|
228
|
+
* answer.
|
|
229
|
+
* @param {string} stdout - find's stdout, `%T@ %p` records separated by NUL.
|
|
230
|
+
* @param {{ maxResults: number }} caps - the caller's cap.
|
|
231
|
+
* @returns {{ paths: string[], truncated: boolean }} the paths, oldest first, and whether the CAP
|
|
232
|
+
* left at least one further record out. (The caller's `root` is the tool's own: it fills it
|
|
233
|
+
* from the path the model asked for — this parser never guesses one.)
|
|
234
|
+
*/
|
|
235
|
+
export function parseGlobOutput(stdout, caps) {
|
|
236
|
+
const entries = stdout.split('\0').filter(entry => entry !== '')
|
|
237
|
+
.map((entry) => {
|
|
238
|
+
const space = entry.indexOf(' ')
|
|
239
|
+
// No space means this is not a record the vector could have printed: skip it (never a path
|
|
240
|
+
// built out of the damage). Not seen: a finite-looking prefix — a record cut before its space
|
|
241
|
+
// — would pass the mtime check below, so the space itself is what says the record is whole.
|
|
242
|
+
if (space === -1) return null
|
|
243
|
+
const mtime = Number(entry.slice(0, space))
|
|
244
|
+
return Number.isFinite(mtime) ? { mtime, path: entry.slice(space + 1) } : null
|
|
245
|
+
})
|
|
246
|
+
.filter(entry => entry !== null)
|
|
247
|
+
.sort((a, b) => a.mtime - b.mtime)
|
|
248
|
+
const truncated = entries.length > caps.maxResults
|
|
249
|
+
return { paths: entries.slice(0, caps.maxResults).map(entry => entry.path), truncated }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Classify ONE failed search into the host's failure vocabulary.
|
|
254
|
+
*
|
|
255
|
+
* Two names, because the model's next move differs: `SEARCH_INVALID_PATTERN` means rewrite the
|
|
256
|
+
* regex, `SEARCH_FAILED` means the search never ran as asked (a launch that never happened —
|
|
257
|
+
* including the `ENOENT` of a distribution without `grep`/`find` — a kill, a timeout, or a print
|
|
258
|
+
* that could not be read). The names are the host's own (`tool-fs-search/src/search-core.ts:67-97`).
|
|
259
|
+
*
|
|
260
|
+
* The rule is grep's EXIT CODE, and the honest boundary of that rule belongs here rather than in a
|
|
261
|
+
* comment nobody reads: GNU grep exits 2 for every error it has, not only for a bad pattern, and its
|
|
262
|
+
* stderr is the only thing that separates a rejected pattern from an unreadable target. What settles
|
|
263
|
+
* the mapping is the caller's action — and for an exit 2 the excerpt is what actually says which one
|
|
264
|
+
* happened — so the class is the exit code and the message carries the diagnostic verbatim. The
|
|
265
|
+
* class is grep's ALONE: find has no such exit, so an exit 2 from it is an ordinary failure.
|
|
266
|
+
*
|
|
267
|
+
* What this function deliberately does NOT do is decide the one combination a caller can detect
|
|
268
|
+
* better than it can: output that was printed but parsed to nothing. That is a failure of the PRINT
|
|
269
|
+
* reader, it is raised loudly by the tool that sees it (a silent drop of a malformed record must not
|
|
270
|
+
* become indistinguishable from a genuine "no matches"), and it arrives here as an ordinary error
|
|
271
|
+
* with no exit code — `SEARCH_FAILED`.
|
|
272
|
+
* @param {unknown} error - the failure as it surfaced: an `Error` (or Error-shaped value) whose
|
|
273
|
+
* `exitCode` is the run's exit code when there was one.
|
|
274
|
+
* @param {string} toolName - `'grep'` or `'glob'`, the tool the failure is reported for.
|
|
275
|
+
* @returns {{ code: 'SEARCH_INVALID_PATTERN' | 'SEARCH_FAILED', message: string }} the class and the
|
|
276
|
+
* model-facing message (the class name leads it: the message is what the model sees, and the code
|
|
277
|
+
* rides beside it for the layers that route on it).
|
|
278
|
+
*/
|
|
279
|
+
export function searchFailure(error, toolName) {
|
|
280
|
+
const detail = error instanceof Error ? error.message : String(error?.message ?? error)
|
|
281
|
+
const exitCode = typeof error === 'object' && error !== null ? error.exitCode : undefined
|
|
282
|
+
const suffix = detail.length === 0 ? '' : `: ${detail}`
|
|
283
|
+
if (toolName === 'grep' && exitCode === 2) {
|
|
284
|
+
return { code: 'SEARCH_INVALID_PATTERN', message: `SEARCH_INVALID_PATTERN: grep rejected the pattern (exit 2)${suffix}` }
|
|
285
|
+
}
|
|
286
|
+
const where = typeof exitCode === 'number' ? ` (exit ${exitCode})` : ''
|
|
287
|
+
return { code: 'SEARCH_FAILED', message: `SEARCH_FAILED: ${toolName} search failed${where}${suffix}` }
|
|
288
|
+
}
|
package/lib/wsl/watch.js
CHANGED
|
@@ -86,13 +86,21 @@ const NODE_IO = {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
|
-
*
|
|
89
|
+
* The key an absent directory answers with.
|
|
90
|
+
*
|
|
91
|
+
* Unreachable by construction, not by convention: POSIX file names cannot contain NUL and
|
|
92
|
+
* `entrySetKey` only uses NUL BETWEEN names, so every real `readdir` reading produces a key
|
|
93
|
+
* that starts with a non-empty name. This one needs a name that is empty, which cannot exist.
|
|
94
|
+
*/
|
|
95
|
+
const ABSENT_KEY = '\0'
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Read one directory reading as a key, answering an absent directory with {@link ABSENT_KEY}.
|
|
90
99
|
*
|
|
91
100
|
* The contract observes a path for CREATION as well as for change, so a directory that
|
|
92
|
-
* is not there is
|
|
93
|
-
* reappearance are both changes
|
|
94
|
-
*
|
|
95
|
-
* `docs/WATCH.md` states). Every other error belongs to the caller.
|
|
101
|
+
* is not there is a key of its own rather than a failure: its deletion and its
|
|
102
|
+
* reappearance are both changes, and so is an EMPTY directory's, because absence and an
|
|
103
|
+
* empty entry set are different keys. Every other error belongs to the caller.
|
|
96
104
|
* @param {string} hostPath - the target's host path.
|
|
97
105
|
* @param {object} io - the io seam.
|
|
98
106
|
* @returns {Promise<string>} the entry-set key.
|
|
@@ -101,7 +109,7 @@ async function readEntryKey(hostPath, io) {
|
|
|
101
109
|
try {
|
|
102
110
|
return entrySetKey(await io.readdir(hostPath))
|
|
103
111
|
} catch (error) {
|
|
104
|
-
if (error?.code === 'ENOENT') return
|
|
112
|
+
if (error?.code === 'ENOENT') return ABSENT_KEY
|
|
105
113
|
throw error
|
|
106
114
|
}
|
|
107
115
|
}
|