dsh-wsl-tool 1.8.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.
@@ -0,0 +1,15 @@
1
+ # This bundle layer registers the wsl tool plugin in an agent preset.
2
+ # Presets can also add the row manually instead of relying on this patch.
3
+
4
+ # The entry name is PATH-RELATIVE on purpose. DSH anchors a relative `name:` to
5
+ # the directory of the patch file that declared it (`anchorInsertedPluginNames`),
6
+ # so this row always loads this bundle's own `index.js` — whatever the package
7
+ # folder happens to be called. A bare package name would instead have to match
8
+ # the installed folder name, which couples this file to the npm package name:
9
+ # the registry refuses `dsh-wsl` as too similar to `is-wsl`, so the published
10
+ # name is `dsh-wsl-tool` while a local `file:` install may sit in
11
+ # `node_modules/dsh-wsl`. Keep this relative.
12
+
13
+ - insert:
14
+ - id: tool-wsl
15
+ name: './index.js'
package/index.js ADDED
@@ -0,0 +1,39 @@
1
+ // dsh-wsl: model-facing WSL tools for DeepSeek Harness (DSH).
2
+ //
3
+ // Registers three tools:
4
+ // - `wsl` : run a Linux command through wsl.exe, returning stdout/stderr
5
+ // with exit-code / signal / timeout / truncation markers.
6
+ // - `wsl-path` : convert between Windows and WSL paths via `wslpath`.
7
+ // - `wsl-env` : summarize the WSL environment (distros, kernel, cpu, mem,
8
+ // disk) so an agent knows what it is running on.
9
+ //
10
+ // Each call runs in a fresh shell, so no state persists between calls. This
11
+ // plugin publishes nothing and only consumes the host-plane `subprocess` and
12
+ // `tools` registries, so it sits loose in an agent preset without a realm.
13
+ //
14
+ // The implementation lives in `lib/`: `config` (defaults and environment
15
+ // overrides), `paths` (path translation and shell quoting), `guard` (the
16
+ // destructive-command rules), `result` (markers and truncation), `runner` (the
17
+ // one spawn path) and `tools/` (the three tool definitions).
18
+
19
+ import { resolveConfig } from './lib/config.js'
20
+ import { createRunner } from './lib/runner.js'
21
+ import { createWslTool } from './lib/tools/wsl.js'
22
+ import { createWslPathTool } from './lib/tools/wsl-path.js'
23
+ import { createWslEnvTool } from './lib/tools/wsl-env.js'
24
+
25
+ export const name = 'tool-wsl'
26
+ export const inject = ['tools', 'subprocess']
27
+
28
+ export function apply(ctx) {
29
+ // Read the environment once per mount: a configuration change is a mount
30
+ // (i.e. a DSH restart), not something a running session re-reads.
31
+ const config = resolveConfig()
32
+ const runner = createRunner(ctx, config)
33
+ // `ctx` is passed for the optional `jobs` service only (background commands);
34
+ // it is read with ctx.get at call time, never injected, so a preset without
35
+ // tool-jobs still mounts this plugin.
36
+ ctx.tools.register(createWslTool({ ctx, config, runner }))
37
+ ctx.tools.register(createWslPathTool({ config, runner }))
38
+ ctx.tools.register(createWslEnvTool({ config, runner }))
39
+ }
package/lib/config.js ADDED
@@ -0,0 +1,130 @@
1
+ // Resolved settings for one mounted plugin instance.
2
+ //
3
+ // Everything tunable lives here. The numeric knobs are environment-overridable
4
+ // so a deployment can tune them without editing this package; an unparsable or
5
+ // out-of-range value falls back to the default instead of failing the mount,
6
+ // because one bad environment variable must not take all three tools down.
7
+
8
+ import { windowsPathToWsl } from './paths.js'
9
+
10
+ export const DEFAULTS = {
11
+ /** Linux-side directory used when the caller passes no `workdir`. */
12
+ workdir: '~',
13
+ /** Deadline for a model-issued command; `timeoutMs` overrides it per call. */
14
+ commandTimeoutMs: 10 * 60 * 1000,
15
+ /** Ceiling for a per-call `timeoutMs`, mirroring the platform shell tools'
16
+ * `maxTimeoutMs`: a slip of the keyboard must not mean "never time out". */
17
+ maxCommandTimeoutMs: 24 * 60 * 60 * 1000,
18
+ /** Hard ceiling for the plugin's OWN probes (`wsl -l -v`, `wslpath`, `wsl-env`). */
19
+ internalTimeoutMs: 30 * 1000,
20
+ /** Grace period handed to the provider's termination procedure. */
21
+ graceMs: 3000,
22
+ /** Per-stream in-memory output window; overflow keeps the tail. */
23
+ maxOutputBytes: 64 * 1024,
24
+ /** Per-stream spill-file cap; a larger stream loses its complete-stream file. */
25
+ maxSpillBytes: 64 * 1024 * 1024,
26
+ /** Windows caps a whole command line at 32767 chars; above this the script
27
+ * is fed to `bash -ls` on stdin instead. */
28
+ maxCommandChars: 30_000,
29
+ /** `setTimeout` stores its delay in a signed 32-bit int; larger fires at once. */
30
+ maxTimerDelayMs: 2 ** 31 - 1,
31
+ }
32
+
33
+ /**
34
+ * Host shell facts forwarded into the Linux side.
35
+ *
36
+ * WSL does not pass Windows environment variables into a distribution (that is
37
+ * what `WSLENV` is for), so without this the Linux side sees none of the facts
38
+ * the platform's own shell tools inject, and a script reading `$DSH_SESSION_ID`
39
+ * silently gets nothing.
40
+ *
41
+ * Only the KEYS live here; the values are per-execution — `DSH_SESSION_ID` cannot
42
+ * be a host constant, since one host serves many sessions — so they are collected
43
+ * by `runner.collectForwardEnv` from the platform's own `ctx.shellEnv` registry,
44
+ * the same source the model's other shell tools use.
45
+ *
46
+ * `DSH_WEB_URL` is deliberately NOT in this list: it is a `127.0.0.1` URL for the
47
+ * Windows-side server, and in the default NAT networking mode WSL cannot reach
48
+ * Windows loopback (measured: HTTP 000 both on `127.0.0.1` and on the host IP,
49
+ * which the server does not bind either). Forwarding it would hand out a URL
50
+ * that cannot be opened. `DSH_HOME` is a Windows path, so it is translated to
51
+ * the same directory's `/mnt/...` view.
52
+ */
53
+ export const FORWARDED_ENV_KEYS = ['DSH_SESSION_ID', 'DSH_SHELL', 'DSH_HOME']
54
+
55
+ // Bounds for the environment-overridable knobs, so a typo cannot silently
56
+ // produce an absurd window or a deadline that fires instantly.
57
+ const OUTPUT_BYTES_MIN = 1024
58
+ const OUTPUT_BYTES_MAX = 8 * 1024 * 1024
59
+ const TIMEOUT_MS_MIN = 100
60
+
61
+ const ENV_INT_RE = /^\d+$/
62
+
63
+ function envInt(env, name, fallback, min, max) {
64
+ const raw = env[name]
65
+ if (typeof raw !== 'string') return fallback
66
+ const trimmed = raw.trim()
67
+ if (!ENV_INT_RE.test(trimmed)) return fallback
68
+ const value = Number(trimmed)
69
+ return value >= min && value <= max ? value : fallback
70
+ }
71
+
72
+ /** @returns a distribution name to pin, or null to use the system default. */
73
+ function envDistro(env) {
74
+ const raw = env.DSH_WSL_DISTRO
75
+ return typeof raw === 'string' && raw.trim() !== '' ? raw.trim() : null
76
+ }
77
+
78
+ /**
79
+ * Where a call starts when the caller passes no `workdir`.
80
+ *
81
+ * `home` (the default) keeps the documented `~`. `session` starts in the
82
+ * session's working directory — the plugin's own process cwd, the same source
83
+ * `dsh-pwsh-local` uses by default — which is what an agent working on a
84
+ * Windows checkout usually wants, since its files live at `/mnt/<drive>/...`
85
+ * rather than in the Linux home. Anything else is an explicit default path.
86
+ *
87
+ * @returns a path, or null meaning "the process working directory".
88
+ */
89
+ function envWorkdir(env) {
90
+ const raw = env.DSH_WSL_WORKDIR
91
+ if (typeof raw !== 'string' || raw.trim() === '') return DEFAULTS.workdir
92
+ const value = raw.trim()
93
+ if (value === 'home') return DEFAULTS.workdir
94
+ if (value === 'session') return null
95
+ return value
96
+ }
97
+
98
+ /**
99
+ * Resolve the effective configuration for one mount.
100
+ *
101
+ * `DSH_WSL_MAX_OUTPUT_BYTES` also raises the spill ceiling when needed, since a
102
+ * spill file smaller than the in-memory window could never hold the complete
103
+ * stream and would be discarded exactly when it is most useful.
104
+ *
105
+ * @param env - environment to read (injectable for tests).
106
+ */
107
+ export function resolveConfig(env = process.env) {
108
+ const maxOutputBytes = envInt(
109
+ env, 'DSH_WSL_MAX_OUTPUT_BYTES', DEFAULTS.maxOutputBytes, OUTPUT_BYTES_MIN, OUTPUT_BYTES_MAX,
110
+ )
111
+ const maxCommandTimeoutMs = envInt(
112
+ env, 'DSH_WSL_MAX_TIMEOUT_MS', DEFAULTS.maxCommandTimeoutMs, TIMEOUT_MS_MIN, DEFAULTS.maxTimerDelayMs,
113
+ )
114
+ return {
115
+ ...DEFAULTS,
116
+ // null means "whatever wsl.exe uses by default", which is what makes the
117
+ // package portable: `Ubuntu-22.04` exists on the author's machine, not
118
+ // necessarily on a storefront user's.
119
+ distro: envDistro(env),
120
+ defaultWorkdir: envWorkdir(env),
121
+ maxOutputBytes,
122
+ maxSpillBytes: Math.max(DEFAULTS.maxSpillBytes, maxOutputBytes),
123
+ maxCommandTimeoutMs,
124
+ // The default deadline obeys the cap too, so the two knobs cannot disagree.
125
+ commandTimeoutMs: Math.min(
126
+ envInt(env, 'DSH_WSL_TIMEOUT_MS', DEFAULTS.commandTimeoutMs, TIMEOUT_MS_MIN, DEFAULTS.maxTimerDelayMs),
127
+ maxCommandTimeoutMs,
128
+ ),
129
+ }
130
+ }
@@ -0,0 +1,150 @@
1
+ // Capability diagnostics for `wsl-env`: one probe script, its parser, and the
2
+ // compact lines composed from it.
3
+ //
4
+ // The question this answers is "can this machine run X?" — WSL1 or 2, systemd,
5
+ // cgroup version, GPU passthrough, docker, which drives are mounted, and how WSL
6
+ // itself is configured. Every fact is optional: a missing tool or a disabled
7
+ // interop must degrade to a label, never fail the whole summary.
8
+
9
+ import { windowsPathToWsl } from './paths.js'
10
+
11
+ const WINDOWS_MOUNT_RE = /^\/mnt\/([a-z])(?:\/|$)/
12
+
13
+ /**
14
+ * Describe where the session's own working directory lands inside WSL.
15
+ *
16
+ * This is the one fact that changes what the model should DO rather than what it
17
+ * can do: a checkout under `/mnt/<drive>` goes through the Windows filesystem
18
+ * bridge, where metadata-heavy work (builds, installs, git) is dramatically
19
+ * slower than on the Linux filesystem — measured on the author's machine at ~8x
20
+ * for a 128 MB sequential write and far worse for many small files. Saying so
21
+ * here is cheap; discovering it as a mystery slowdown is not.
22
+ *
23
+ * @param hostCwd - the plugin's own working directory (a Windows path here).
24
+ * @returns a summary line, or null when the directory cannot be expressed.
25
+ */
26
+ export function workspaceLine(hostCwd) {
27
+ const path = windowsPathToWsl(String(hostCwd ?? ''))
28
+ if (!path.startsWith('/')) return null
29
+ const mount = WINDOWS_MOUNT_RE.exec(path)
30
+ const where = mount === null
31
+ ? 'Linux filesystem'
32
+ : `Windows drive mount /mnt/${mount[1]} — builds, installs and git are much slower here; prefer a path under /home when it matters`
33
+ return `workspace: ${path} (${where})`
34
+ }
35
+
36
+ /**
37
+ * One shell round trip collecting every fact that does not need its own
38
+ * `wsl.exe` launch. Each line is `key=value`; a fact that cannot be read prints
39
+ * an explicit marker (`absent`, `none`, empty) rather than disappearing.
40
+ *
41
+ * `cmd.exe` is reached through interop to read the WINDOWS-side `.wslconfig`
42
+ * (networking mode, memory and processor limits live there, not in the distro).
43
+ * It runs from `/mnt/c` so cmd.exe does not inherit a `\\wsl.localhost\...`
44
+ * working directory and complain about a UNC path.
45
+ */
46
+ export const CAPABILITY_PROBE = [
47
+ String.raw`echo "os=$(. /etc/os-release 2>/dev/null && printf '%s' "$PRETTY_NAME")"`,
48
+ String.raw`echo "wsl=$(uname -r | grep -q WSL2 && echo 2 || echo 1)"`,
49
+ String.raw`echo "init=$(ps -p 1 -o comm= 2>/dev/null | tr -d ' ')"`,
50
+ String.raw`echo "cgroup=$(stat -fc %T /sys/fs/cgroup 2>/dev/null)"`,
51
+ String.raw`[ -e /dev/dxg ] && echo "gpu=dxg" || echo "gpu=none"`,
52
+ String.raw`if command -v nvidia-smi >/dev/null 2>&1; then echo "nvidia=$(nvidia-smi -L 2>/dev/null | head -1)"; else echo "nvidia=absent"; fi`,
53
+ String.raw`if command -v docker >/dev/null 2>&1; then echo "docker=$(timeout 3 docker info --format '{{.ServerVersion}}' 2>/dev/null || echo cli-only)"; else echo "docker=absent"; fi`,
54
+ String.raw`echo "drives=$(ls /mnt 2>/dev/null | grep -E '^[a-z]$' | tr '\n' ',')"`,
55
+ String.raw`echo "wslconf=$(grep -vE '^[[:space:]]*(#|$)' /etc/wsl.conf 2>/dev/null | tr '\n' ';')"`,
56
+ String.raw`echo "winconf=$(cd /mnt/c 2>/dev/null && cmd.exe /c 'type "%USERPROFILE%\.wslconfig"' 2>/dev/null | tr -d '\r' | grep -vE '^[[:space:]]*(#|$)' | tr '\n' ';')"`,
57
+ ].join('\n')
58
+
59
+ /**
60
+ * Parse `key=value` probe output into a fact map.
61
+ * Lines without `=`, and keys repeated later, are ignored/overwritten; values
62
+ * keep their internal spaces and lose only the trailing newline.
63
+ */
64
+ export function parseFacts(text) {
65
+ const facts = {}
66
+ for (const line of String(text ?? '').split(/\r?\n/)) {
67
+ const separator = line.indexOf('=')
68
+ if (separator <= 0) continue
69
+ const key = line.slice(0, separator).trim()
70
+ if (key === '' || /\s/.test(key)) continue
71
+ facts[key] = line.slice(separator + 1).trim()
72
+ }
73
+ return facts
74
+ }
75
+
76
+ function cgroupLabel(value) {
77
+ if (value === 'cgroup2fs') return 'cgroup v2'
78
+ if (value === 'tmpfs') return 'cgroup v1'
79
+ return value === undefined || value === '' ? null : `cgroup ${value}`
80
+ }
81
+
82
+ /**
83
+ * Compose the compact capability lines from a parsed probe.
84
+ * @returns lines for the facts that were readable; unreadable ones are omitted
85
+ * rather than guessed at, so an absent line means "could not tell".
86
+ */
87
+ export function capabilityLines(facts) {
88
+ const lines = []
89
+
90
+ // Identity and kernel-level facts.
91
+ const identity = []
92
+ if (facts.os) identity.push(facts.os)
93
+ if (facts.wsl === '2') identity.push('WSL2')
94
+ else if (facts.wsl === '1') identity.push('WSL1 (no systemd, no docker, slow /mnt)')
95
+ const cgroup = cgroupLabel(facts.cgroup)
96
+ if (cgroup !== null) identity.push(cgroup)
97
+ if (identity.length > 0) lines.push(identity.join(' · '))
98
+
99
+ // Init system and containers.
100
+ const runtime = []
101
+ if (facts.init) {
102
+ runtime.push(facts.init === 'systemd' ? 'systemd: yes' : `systemd: no (PID 1 is ${facts.init})`)
103
+ }
104
+ if (facts.docker === 'absent') runtime.push('docker: not installed')
105
+ else if (facts.docker === 'cli-only') runtime.push('docker: cli only, daemon unreachable')
106
+ else if (facts.docker) runtime.push(`docker: daemon ${facts.docker}`)
107
+ if (runtime.length > 0) lines.push(runtime.join(' · '))
108
+
109
+ // GPU passthrough. The adapter UUID adds length without answering
110
+ // "can this machine do GPU work", so it is dropped.
111
+ const gpu = []
112
+ if (facts.gpu === 'dxg') gpu.push('/dev/dxg present (GPU passthrough enabled)')
113
+ else if (facts.gpu === 'none') gpu.push('no /dev/dxg (no GPU passthrough)')
114
+ if (facts.nvidia && facts.nvidia !== 'absent') {
115
+ gpu.push(`nvidia-smi: ${facts.nvidia.replace(/\s*\(UUID:[^)]*\)/, '')}`)
116
+ } else if (facts.nvidia === 'absent') gpu.push('nvidia-smi: not installed')
117
+ if (gpu.length > 0) lines.push(`GPU: ${gpu.join(' · ')}`)
118
+
119
+ // Mounted Windows drives.
120
+ const drives = (facts.drives ?? '').split(',').filter((drive) => drive !== '')
121
+ if (drives.length > 0) lines.push(`drives: ${drives.map((drive) => `/mnt/${drive}`).join(' ')}`)
122
+
123
+ // Configuration, from both sides of the boundary.
124
+ const config = []
125
+ if (facts.wslconf) config.push(`/etc/wsl.conf: ${facts.wslconf}`)
126
+ if (facts.winconf) config.push(`.wslconfig: ${facts.winconf}`)
127
+ else if (facts.winconf === '') config.push('.wslconfig: not set')
128
+ if (config.length > 0) lines.push(config.join(' · '))
129
+
130
+ return lines
131
+ }
132
+
133
+ /**
134
+ * Summarize `wsl --version`, whose labels are LOCALIZED, so nothing is parsed
135
+ * by name: the first three lines are WSL/kernel/WSLg in a fixed order, and the
136
+ * Windows build line is the one naming Windows. The Direct3D/MSRDC/DXCore
137
+ * versions are dropped as noise.
138
+ * @returns one line, or null when the output was not the expected table.
139
+ */
140
+ export function launcherSummary(text) {
141
+ const lines = String(text ?? '')
142
+ .split(/\r?\n/)
143
+ .map((line) => line.trim())
144
+ .filter((line) => line.includes(':') && line.length > 0)
145
+ if (lines.length === 0) return null
146
+ const keep = lines.slice(0, 3)
147
+ const windows = lines.find((line) => /windows/i.test(line))
148
+ if (windows !== undefined && !keep.includes(windows)) keep.push(windows)
149
+ return keep.join(' · ')
150
+ }
package/lib/guard.js ADDED
@@ -0,0 +1,107 @@
1
+ // Destructive-command guard.
2
+ //
3
+ // The guard is a safety net for a model that is about to do something
4
+ // irreversible — not a security boundary (the caller may always pass
5
+ // `allowDangerous: true`). It is therefore tuned to be deterministic and to
6
+ // err toward refusing, while still letting a read-only INVESTIGATION of the
7
+ // same tools run: `man fdisk` and `grep reboot /var/log/syslog` must work.
8
+
9
+ // `rm` is the one command whose flag spelling is genuinely open-ended:
10
+ // `rm -rf`, `rm -fr`, `rm -r -f`, `rm -R --force`, `rm --recursive --force`.
11
+ // A regex over the whole command missed the separated and long forms, so scan
12
+ // each `rm` invocation and collect its flags individually. The prefix and the
13
+ // trailing lookahead tolerate everything that can wrap a command word: quotes,
14
+ // backticks, `$(`/`)` command substitution, and a `\rm` escape.
15
+ const RM_INVOCATION = /(?:^|[\s;&|"'`(\\])(?:\S*\/)?rm(?=[\s"'`)}]|$)/g
16
+ const QUOTE_STRIP_RE = /^["'`]+|["'`]+$/g
17
+
18
+ // `$IFS` (and `${IFS}`) expands to whitespace, so `rm$IFS-rf` is the same
19
+ // command as `rm -rf`. Normalize it for MATCHING only; the command that runs is
20
+ // untouched, so the worst case is refusing an exotic but harmless literal.
21
+ const IFS_ESCAPE_RE = /\$\{?IFS\}?/g
22
+
23
+ // A RECURSIVE delete is refused whether or not `-f` is present: with stdin on
24
+ // /dev/null nothing prompts, so `rm -r tree` deletes a whole tree silently —
25
+ // exactly what this guard exists to prevent. Requiring `-f` as well let
26
+ // `rm a -f; rm b -r` through, and `-f` only suppresses prompts anyway.
27
+ export function rmIsDestructive(segment) {
28
+ RM_INVOCATION.lastIndex = 0
29
+ let match
30
+ while ((match = RM_INVOCATION.exec(segment)) !== null) {
31
+ for (const rawToken of segment.slice(match.index + match[0].length).split(/\s+/)) {
32
+ const token = rawToken.replace(QUOTE_STRIP_RE, '')
33
+ if (token === '--') break
34
+ if (token === '--recursive') return true
35
+ else if (/^-[A-Za-z]+$/.test(token) && (token.includes('r') || token.includes('R'))) return true
36
+ }
37
+ }
38
+ return false
39
+ }
40
+
41
+ // A command line is a sequence of segments separated by `;`, `&`, `|` or a
42
+ // newline. Scanning the WHOLE line for dangerous keywords refused `man fdisk`
43
+ // and `grep reboot /var/log/syslog`, so each segment is evaluated separately
44
+ // and the device/power tools are matched at COMMAND POSITION — the first word,
45
+ // past the wrappers and `VAR=value` assignments that can precede it.
46
+ const SEGMENT_SPLIT_RE = /[;&|\n]+/
47
+ const COMMAND_WRAPPERS = new Set([
48
+ 'sudo', 'doas', 'command', 'exec', 'nohup', 'nice', 'ionice', 'time', 'stdbuf', 'setsid', 'env',
49
+ ])
50
+ const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/
51
+
52
+ /** The command word a segment invokes, with wrappers skipped and the path removed. */
53
+ export function commandWord(segment) {
54
+ const tokens = segment.trim().split(/\s+/).filter((token) => token.length > 0)
55
+ let index = 0
56
+ while (index < tokens.length) {
57
+ const bare = tokens[index].replace(QUOTE_STRIP_RE, '')
58
+ if (COMMAND_WRAPPERS.has(bare) || ENV_ASSIGNMENT_RE.test(bare) || bare.startsWith('-')) {
59
+ index += 1
60
+ continue
61
+ }
62
+ break
63
+ }
64
+ const word = tokens[index]
65
+ if (word === undefined) return null
66
+ const bare = word.replace(QUOTE_STRIP_RE, '')
67
+ return bare.slice(bare.lastIndexOf('/') + 1)
68
+ }
69
+
70
+ const POWER_TOOLS = new Set(['shutdown', 'poweroff', 'reboot', 'halt'])
71
+ const DISK_TOOLS = new Set(['mkswap', 'wipefs', 'fdisk', 'sfdisk', 'gdisk', 'sgdisk', 'parted', 'blkdiscard', 'shred'])
72
+
73
+ // Patterns that are dangerous wherever they appear, because they WRITE to a
74
+ // block device or fork-bomb the machine regardless of the command word.
75
+ const DESTRUCTIVE_PATTERNS = [
76
+ [/[^>]\s*>>?\s*\/dev\/(sd|hd|nvme|mmcblk|vd|xvd|disk)/, 'redirect onto a block device'],
77
+ [/:\s*\(\s*\)\s*\{[^\n]*\|[^\n]*&[^\n]*\}\s*;\s*:/, 'fork bomb'],
78
+ ]
79
+
80
+ function segmentReason(segment) {
81
+ if (rmIsDestructive(segment)) return 'recursive delete (`rm -r`)'
82
+
83
+ const word = commandWord(segment)
84
+ if (word === null) return null
85
+ if (POWER_TOOLS.has(word)) return `power control (\`${word}\`)`
86
+ if (/^mkfs(\.\w+)?$/.test(word)) return 'mkfs (format a filesystem)'
87
+ if (DISK_TOOLS.has(word)) return `disk tool (\`${word}\`)`
88
+ if (word === 'dd' && /\bof=\s*\/dev\//.test(segment)) return 'dd onto a block device'
89
+ if (word === 'systemctl' && /\b(poweroff|reboot|halt)\b/.test(segment)) return 'power control (systemctl)'
90
+ return null
91
+ }
92
+
93
+ /**
94
+ * @param command - the final (post-translation) command string.
95
+ * @returns a human-readable reason when the command is destructive, else null.
96
+ */
97
+ export function destructiveReason(command) {
98
+ const scanned = command.replace(IFS_ESCAPE_RE, ' ')
99
+ for (const [pattern, reason] of DESTRUCTIVE_PATTERNS) {
100
+ if (pattern.test(scanned)) return reason
101
+ }
102
+ for (const segment of scanned.split(SEGMENT_SPLIT_RE)) {
103
+ const reason = segmentReason(segment)
104
+ if (reason !== null) return reason
105
+ }
106
+ return null
107
+ }
package/lib/paths.js ADDED
@@ -0,0 +1,85 @@
1
+ // Shell quoting and Windows -> WSL path translation.
2
+ //
3
+ // Both concerns are "turn caller text into something bash and wsl.exe read the
4
+ // same way", and every rule here exists because the naive version was wrong:
5
+ // see the comments on each pattern.
6
+
7
+ // Shell-quote a value for a single-quoted `export KEY='value'` fragment.
8
+ export function shellQuote(value) {
9
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
10
+ }
11
+
12
+ // `~` must stay OUTSIDE the quotes or bash never expands it, so a `~`-rooted
13
+ // path is split: the tilde stays bare and only the remainder is quoted.
14
+ // `~/my dir` -> `~/'my dir'`; anything else is quoted whole. A path that is
15
+ // literally named `~foo` therefore cannot be expressed — acceptable, and the
16
+ // safe direction: an unexpanded tilde fails loudly instead of silently.
17
+ const TILDE_PATH_RE = /^(~[A-Za-z0-9._-]*)((?:\/.*)?)$/
18
+
19
+ export function quotePath(path) {
20
+ const match = TILDE_PATH_RE.exec(path)
21
+ if (match === null) return shellQuote(path)
22
+ const [, tilde, rest] = match
23
+ return rest.length <= 1 ? tilde : `${tilde}/${shellQuote(rest.slice(1))}`
24
+ }
25
+
26
+ export function buildCdCommand(workdir) {
27
+ return `cd ${quotePath(workdir)}`
28
+ }
29
+
30
+ // Characters that may appear inside a Windows path segment but cannot be part
31
+ // of one: whitespace ends the segment, and a shell operator ends the token.
32
+ // Parentheses are deliberately ALLOWED — `C:\Program Files (x86)\Steam` is an
33
+ // ordinary Windows path, and a trailing `$(...)` is harmless because a
34
+ // continuation chunk only ever changes if it contains a backslash.
35
+ const PATH_CHAR = `[^\\s"'` + '`' + `|&;<>]`
36
+
37
+ // One Windows drive-absolute path: `C:\foo`, `C:/foo`, and — the case the
38
+ // first version got wrong — a path whose LATER segments contain spaces, e.g.
39
+ // `C:\Program Files\Git` or `C:\Program Files (x86)\Steam`.
40
+ //
41
+ // A space continues the match only when the next chunk does NOT itself start a
42
+ // new drive path, so `cp C:\a.txt D:\b.txt` translates BOTH paths instead of
43
+ // letting ` D:\b.txt` be absorbed into the first. An unconsumed chunk is left
44
+ // verbatim, so `C:\Program Files` (no backslash after the space) still becomes
45
+ // `/mnt/c/Program Files`, and `echo C:\x && ls` still stops at the `&&`.
46
+ //
47
+ // The lookbehind replaces the first version's `\b`: a drive letter preceded by
48
+ // `/`, `\` or `:` is not a drive path but path-like TEXT inside another
49
+ // expression — `sed "s/C:\x/y/"` and `http://x/C:/y` must be left alone.
50
+ const DRIVE_PATH_RE = new RegExp(
51
+ `(?<![\\w/\\\\:])([A-Za-z]):([\\\\/])(${PATH_CHAR}*(?:[ \\t]+(?![A-Za-z]:[\\\\/])${PATH_CHAR}*)*)`,
52
+ 'g',
53
+ )
54
+
55
+ // Windows reaches a WSL filesystem as a UNC path: `\\wsl.localhost\<distro>\home\x`
56
+ // or the legacy `\\wsl$\<distro>\home\x`. Inside a Linux command both mean the
57
+ // Linux path, so translate them too.
58
+ const WSL_UNC_RE = new RegExp(
59
+ `\\\\\\\\wsl(?:\\.localhost|\\$)(?:\\\\+([^\\\\/\\s"'` + '`' + `|&;<>()]+))?((?:[\\\\/]${PATH_CHAR}*)*)`,
60
+ 'g',
61
+ )
62
+
63
+ /**
64
+ * Translate literal Windows paths into their WSL/Linux form.
65
+ * C:\Users\me\a.txt -> /mnt/c/Users/me/a.txt
66
+ * C:\Program Files\Git\cmd -> /mnt/c/Program Files/Git/cmd
67
+ * \\wsl.localhost\Ubuntu\home -> /home
68
+ * A single lowercase letter followed by `/` is NOT rewritten: `a:/b` is
69
+ * ordinary text far more often than it is a drive path, and the backslash form
70
+ * (`a:\b`, or an uppercase `C:/...`) still is.
71
+ */
72
+ export function windowsPathToWsl(text) {
73
+ if (typeof text !== 'string' || text.length === 0) return text
74
+
75
+ const withDrives = text.replace(DRIVE_PATH_RE, (match, letter, separator, rest) => {
76
+ if (separator === '/' && letter !== letter.toUpperCase()) return match
77
+ const tail = rest.replace(/\\/g, '/')
78
+ return `/mnt/${letter.toLowerCase()}/${tail}`.replace(/\/{2,}/g, '/')
79
+ })
80
+
81
+ return withDrives.replace(WSL_UNC_RE, (_match, _distro, tail) => {
82
+ const path = String(tail ?? '').replace(/\\/g, '/').replace(/\/{2,}/g, '/')
83
+ return path.startsWith('/') ? path : `/${path}`
84
+ })
85
+ }
package/lib/result.js ADDED
@@ -0,0 +1,104 @@
1
+ // Turning one settled subprocess into the value the model sees, plus the
2
+ // launcher noise filters and the truncation arithmetic.
3
+
4
+ // wsl.exe emits this locale-dependent launcher warning to stderr whenever
5
+ // Windows has a localhost proxy configured and WSL runs in NAT mode. It
6
+ // repeats on every call, so drop it; the tokens "localhost" and "proxy"
7
+ // ("代理") stay stable across locales.
8
+ const LOCALHOST_PROXY_WARNING = /^\s*wsl:\s.*(localhost|127\.0\.0\.1).*(proxy|代理)/i
9
+
10
+ // procps (`ps`, `top`, `free`, `w`) probes the console for a window size; a
11
+ // redirected wsl.exe stream has no real terminal, so it reports a bogus
12
+ // 131072x1 and warns on stderr. Pure noise for every call, so drop it.
13
+ const BOGUS_SCREEN_SIZE_WARNING = /^\s*your \d+x\d+ screen size is bogus\.?\s*expect trouble\.?\s*$/i
14
+
15
+ /** Strip wsl.exe launcher / procps noise lines from stderr. */
16
+ export function cleanStderr(text) {
17
+ return text
18
+ .split(/\r?\n/)
19
+ .filter((line) => !LOCALHOST_PROXY_WARNING.test(line) && !BOGUS_SCREEN_SIZE_WARNING.test(line))
20
+ .join('\n')
21
+ }
22
+
23
+ // Windows exit codes are unsigned 32-bit; wsl.exe reports its own failures as
24
+ // -1, which reaches us as 4294967295. Show the signed value a human expects.
25
+ export function normalizeExitCode(exitCode) {
26
+ if (exitCode === 0xFFFFFFFF) return -1
27
+ return exitCode
28
+ }
29
+
30
+ export function byteLength(text) {
31
+ return Buffer.byteLength(text, 'utf8')
32
+ }
33
+
34
+ /**
35
+ * One collected stream -> text plus the truncation facts needed to recover
36
+ * whatever the in-memory tail window dropped.
37
+ *
38
+ * `droppedBytes` is derived from the decoded text and can be off by a byte or
39
+ * two when the byte-trimmed window starts inside a multi-byte character, so the
40
+ * model-facing marker quotes the window SIZE instead of this number.
41
+ */
42
+ export function streamFacts(read) {
43
+ if (read === undefined || read === null) {
44
+ return { text: '', totalBytes: 0, droppedBytes: 0, lossy: false, spillPath: null }
45
+ }
46
+ const text = read.text ?? ''
47
+ const totalBytes = typeof read.nextOffset === 'number' ? read.nextOffset : byteLength(text)
48
+ return {
49
+ text,
50
+ totalBytes,
51
+ droppedBytes: read.lossy ? Math.max(0, totalBytes - byteLength(text)) : 0,
52
+ lossy: read.lossy === true,
53
+ spillPath: read.spillPath ?? null,
54
+ }
55
+ }
56
+
57
+ export function truncationMarkers(value, maxOutputBytes) {
58
+ const markers = []
59
+ for (const stream of ['stdout', 'stderr']) {
60
+ const dropped = value[`${stream}DroppedBytes`] ?? 0
61
+ if (dropped <= 0) continue
62
+ const total = value[`${stream}TotalBytes`] ?? 0
63
+ const spill = value[`${stream}SpillPath`]
64
+ const recovery = spill === null || spill === undefined
65
+ ? 'earlier bytes were dropped'
66
+ : `full stream: ${spill}`
67
+ markers.push(`[${stream} truncated: at most the last ${maxOutputBytes} of ${total} bytes were kept; ${recovery}]`)
68
+ }
69
+ return markers.length > 0 ? markers : ['[output truncated]']
70
+ }
71
+
72
+ /**
73
+ * Format one result value as the model-facing text: body first, then one marker
74
+ * per line. An aborted call reports the timeout rather than the exit code the
75
+ * kill produced, since that code is an artifact and not the command's answer.
76
+ */
77
+ export function formatResult(value, maxOutputBytes) {
78
+ // A background start has no output yet: the whole answer is the job handle,
79
+ // which the model then drives with the generic job tools.
80
+ if (value.jobId !== null && value.jobId !== undefined) {
81
+ return `[started in the background as job ${value.jobId}; read it with job_output, stop it with job_kill]`
82
+ }
83
+
84
+ let body = value.stdout || ''
85
+ if (value.stderr && value.stderr.length > 0) {
86
+ if (body.length > 0 && !body.endsWith('\n')) body += '\n'
87
+ body += `[stderr]\n${value.stderr}`
88
+ }
89
+ if (body.length === 0) body = '(no output)'
90
+
91
+ const markers = []
92
+ if (value.truncated) markers.push(...truncationMarkers(value, maxOutputBytes))
93
+ if (value.timedOut) {
94
+ const after = typeof value.timeoutMs === 'number' ? `${value.timeoutMs}ms` : 'the configured timeout'
95
+ markers.push(`[timed out after ${after}; the command was killed]`)
96
+ } else if (value.signal !== null && value.signal !== undefined) {
97
+ markers.push(`[killed by signal: ${value.signal}]`)
98
+ } else if (value.exitCode !== 0 && value.exitCode !== null) {
99
+ markers.push(`[exit code: ${value.exitCode}]`)
100
+ }
101
+ if (markers.length === 0) return body
102
+ if (!body.endsWith('\n')) body += '\n'
103
+ return body + markers.join('\n')
104
+ }