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.
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/README.zh-CN.md +304 -0
- package/assets/screenshot-1.png +0 -0
- package/cordis.patch.yml +15 -0
- package/index.js +39 -0
- package/lib/config.js +130 -0
- package/lib/diagnostics.js +150 -0
- package/lib/guard.js +107 -0
- package/lib/paths.js +85 -0
- package/lib/result.js +104 -0
- package/lib/runner.js +273 -0
- package/lib/tools/wsl-env.js +113 -0
- package/lib/tools/wsl-path.js +77 -0
- package/lib/tools/wsl.js +297 -0
- package/package.json +50 -0
- package/screenshots.json +3 -0
package/cordis.patch.yml
ADDED
|
@@ -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
|
+
}
|