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/lib/tools/wsl.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// The `wsl` tool: run one Linux command and return its output with markers.
|
|
2
|
+
//
|
|
3
|
+
// The description is deliberately lean: everything it says is either something
|
|
4
|
+
// the parameter schemas cannot express, or something that saves the model a
|
|
5
|
+
// wasted call. Both are injected into every request, so repetition between the
|
|
6
|
+
// description and the parameter docs is pure cost.
|
|
7
|
+
|
|
8
|
+
import { destructiveReason } from '../guard.js'
|
|
9
|
+
import { windowsPathToWsl } from '../paths.js'
|
|
10
|
+
import { formatResult } from '../result.js'
|
|
11
|
+
import { assertLauncherReachable } from '../runner.js'
|
|
12
|
+
|
|
13
|
+
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
14
|
+
const LABEL_MAX_CHARS = 120
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One-line, bounded label for a background job (`JobStart.label`).
|
|
18
|
+
*
|
|
19
|
+
* It is just the command: `job_list` and the completion notice already frame it
|
|
20
|
+
* with the job id and the `wsl` kind, so a `wsl:` prefix here reads as
|
|
21
|
+
* "wsl-1 [wsl] completed — wsl: …".
|
|
22
|
+
*/
|
|
23
|
+
function jobLabel(command) {
|
|
24
|
+
const text = String(command)
|
|
25
|
+
const line = text.split('\n').map((part) => part.trim()).find((part) => part !== '') ?? text.trim()
|
|
26
|
+
return line.length > LABEL_MAX_CHARS ? `${line.slice(0, LABEL_MAX_CHARS - 1)}…` : line
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createWslTool({ ctx, config, runner }) {
|
|
30
|
+
/** The value returned when a command is handed to the background. */
|
|
31
|
+
function backgroundResult(jobId) {
|
|
32
|
+
return {
|
|
33
|
+
exitCode: null,
|
|
34
|
+
signal: null,
|
|
35
|
+
timedOut: false,
|
|
36
|
+
timeoutMs: null,
|
|
37
|
+
truncated: false,
|
|
38
|
+
stdout: '',
|
|
39
|
+
stderr: '',
|
|
40
|
+
stdoutTotalBytes: 0,
|
|
41
|
+
stdoutDroppedBytes: 0,
|
|
42
|
+
stderrTotalBytes: 0,
|
|
43
|
+
stderrDroppedBytes: 0,
|
|
44
|
+
stdoutSpillPath: null,
|
|
45
|
+
stderrSpillPath: null,
|
|
46
|
+
jobId: String(jobId),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Start `command` as a job in the host's registry, so the model drives it with
|
|
52
|
+
* the generic `job_output`/`job_kill` tools it already has.
|
|
53
|
+
*
|
|
54
|
+
* The jobs service is OPTIONAL (`ctx.get`, never `inject`): a preset without
|
|
55
|
+
* `tool-jobs` must still mount this plugin for foreground use.
|
|
56
|
+
*/
|
|
57
|
+
function startInBackground(command, opts, exec) {
|
|
58
|
+
const jobs = ctx.get?.('jobs')
|
|
59
|
+
if (jobs === undefined || jobs === null) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'wsl: runInBackground needs the background-job service, which this preset does not provide ' +
|
|
62
|
+
'(load the `tool-jobs` plugin). Run the command in the foreground with a larger `timeoutMs` instead.',
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let launched
|
|
67
|
+
let cancelReason = null
|
|
68
|
+
let jobId
|
|
69
|
+
try {
|
|
70
|
+
jobId = jobs.start({
|
|
71
|
+
kind: 'wsl',
|
|
72
|
+
label: jobLabel(command),
|
|
73
|
+
outputLimitBytes: config.maxOutputBytes,
|
|
74
|
+
...(exec?.agent === undefined ? {} : { owner: exec.agent }),
|
|
75
|
+
// The spawn happens INSIDE run(): the contract says a throw leaves
|
|
76
|
+
// nothing registered, so spawning first and registering second could
|
|
77
|
+
// leak a process if registration failed.
|
|
78
|
+
run() {
|
|
79
|
+
const started = runner.startWsl(command, opts)
|
|
80
|
+
launched = started.launched
|
|
81
|
+
const { distro } = started.plan
|
|
82
|
+
return {
|
|
83
|
+
cancel(reason) {
|
|
84
|
+
cancelReason = reason ?? null
|
|
85
|
+
launched.cancel()
|
|
86
|
+
},
|
|
87
|
+
done: launched.settle().then(
|
|
88
|
+
(value) => {
|
|
89
|
+
const output = formatResult(value, config.maxOutputBytes)
|
|
90
|
+
try {
|
|
91
|
+
// A launcher failure is not the command's result, even here.
|
|
92
|
+
assertLauncherReachable(distro, value)
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return { status: 'failed', detail: error.message, output }
|
|
95
|
+
}
|
|
96
|
+
if (launched.state.cancelled) {
|
|
97
|
+
// The kill's exit code is an artifact, not the command's
|
|
98
|
+
// answer — the job's own status line already says `killed`,
|
|
99
|
+
// so do not also render a contradictory `[exit code: 1]`.
|
|
100
|
+
const clean = formatResult({ ...value, exitCode: null, signal: null }, config.maxOutputBytes)
|
|
101
|
+
return { status: 'killed', detail: cancelReason === null ? 'cancelled' : `cancelled: ${cancelReason}`, output: clean }
|
|
102
|
+
}
|
|
103
|
+
if (value.timedOut) return { status: 'killed', detail: 'timed out', output }
|
|
104
|
+
if (value.signal !== null) return { status: 'killed', detail: `signal ${value.signal}`, output }
|
|
105
|
+
return { status: 'completed', detail: `exit code ${value.exitCode}`, output }
|
|
106
|
+
},
|
|
107
|
+
// `JobHooks.done` must never reject. Cancelling a job before its
|
|
108
|
+
// target starts makes the provider reject the handle ("terminated
|
|
109
|
+
// before target start"), which is a cancellation — not a failure
|
|
110
|
+
// of work that never ran.
|
|
111
|
+
(error) => {
|
|
112
|
+
const detail = error?.message ?? String(error)
|
|
113
|
+
return launched.state.cancelled
|
|
114
|
+
? { status: 'killed', detail: cancelReason === null ? 'cancelled' : `cancelled: ${cancelReason}`, output: '' }
|
|
115
|
+
: { status: 'failed', detail, output: '' }
|
|
116
|
+
},
|
|
117
|
+
),
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// The registry refuses when no controller serves this composition, and a
|
|
123
|
+
// producer failure inside run() lands here too. Either way the caller
|
|
124
|
+
// still has a way forward, so say so.
|
|
125
|
+
throw new Error(
|
|
126
|
+
`wsl: could not start a background job (${error?.message ?? String(error)}). ` +
|
|
127
|
+
'Run the command in the foreground instead, with a larger `timeoutMs`.',
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
return backgroundResult(jobId)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
name: 'wsl',
|
|
135
|
+
description:
|
|
136
|
+
'Run a Linux command through WSL and return its stdout/stderr, with trailing markers for a ' +
|
|
137
|
+
'non-zero exit, a timeout, or truncated output. Each call is a fresh shell — nothing persists, ' +
|
|
138
|
+
'so pass `workdir` or `cd` inside the command. stdin is /dev/null unless you pass `stdin`, so an ' +
|
|
139
|
+
'interactive command (`read`, a password prompt) gets EOF immediately. Windows paths in ' +
|
|
140
|
+
'`command`/`workdir` are rewritten to their /mnt/... form automatically. Output is capped per ' +
|
|
141
|
+
'stream; when it is truncated the marker names a file holding the complete output, which you can ' +
|
|
142
|
+
'read. Set `runInBackground` for work that outlives the call. A destructive command is refused ' +
|
|
143
|
+
'unless `allowDangerous` is true.',
|
|
144
|
+
parameters: {
|
|
145
|
+
type: 'object',
|
|
146
|
+
properties: {
|
|
147
|
+
command: {
|
|
148
|
+
type: 'string',
|
|
149
|
+
description: 'The Linux command to execute inside WSL.',
|
|
150
|
+
},
|
|
151
|
+
description: {
|
|
152
|
+
type: 'string',
|
|
153
|
+
description: 'Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI).',
|
|
154
|
+
},
|
|
155
|
+
workdir: {
|
|
156
|
+
type: 'string',
|
|
157
|
+
description: 'Working directory inside WSL: a Linux path (`~`, `/home/me`) or a Windows path, which is translated. Default `~`.',
|
|
158
|
+
},
|
|
159
|
+
timeoutMs: {
|
|
160
|
+
type: 'number',
|
|
161
|
+
description: `Timeout in milliseconds; on expiry the process is killed and the result is marked as timed out. Defaults to ${config.commandTimeoutMs}.`,
|
|
162
|
+
},
|
|
163
|
+
distro: {
|
|
164
|
+
type: 'string',
|
|
165
|
+
description: 'WSL distribution to run in. Defaults to the system default distribution; overrides the DSH_WSL_DISTRO environment variable.',
|
|
166
|
+
},
|
|
167
|
+
env: {
|
|
168
|
+
type: 'object',
|
|
169
|
+
// DSH's supported schema subset requires a BOOLEAN here (an object
|
|
170
|
+
// value schema is rejected by assertSupportedJsonSchema), so the
|
|
171
|
+
// value type lives in the description and is enforced at runtime by
|
|
172
|
+
// execute(), which rejects a non-string value or a bad key name.
|
|
173
|
+
additionalProperties: true,
|
|
174
|
+
description: 'Extra environment variables to export before the command. Keys must be valid shell names and values must be strings.',
|
|
175
|
+
},
|
|
176
|
+
stdin: {
|
|
177
|
+
type: 'string',
|
|
178
|
+
description: 'Text written to the command stdin (UTF-8) before it runs. A `sudo -S` password passed here is recorded in the transcript.',
|
|
179
|
+
},
|
|
180
|
+
runInBackground: {
|
|
181
|
+
type: 'boolean',
|
|
182
|
+
description: 'Run as a background job and return its id immediately: read it with job_output, stop it with job_kill. No default deadline applies.',
|
|
183
|
+
},
|
|
184
|
+
allowDangerous: {
|
|
185
|
+
type: 'boolean',
|
|
186
|
+
description: 'Must be true to run a destructive command: a recursive delete, dd onto a device, mkfs/partitioning, power control, or a fork bomb.',
|
|
187
|
+
},
|
|
188
|
+
translatePaths: {
|
|
189
|
+
type: 'boolean',
|
|
190
|
+
description: 'Default true: rewrite Windows paths in `command` to /mnt/... . Set false to pass `command` verbatim, e.g. a native path for a Windows program launched through interop. `workdir` is always translated.',
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
required: ['command', 'description'],
|
|
194
|
+
},
|
|
195
|
+
output: {
|
|
196
|
+
schema: {
|
|
197
|
+
type: 'object',
|
|
198
|
+
additionalProperties: false,
|
|
199
|
+
properties: {
|
|
200
|
+
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
201
|
+
signal: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
202
|
+
timedOut: { type: 'boolean' },
|
|
203
|
+
timeoutMs: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
204
|
+
truncated: { type: 'boolean' },
|
|
205
|
+
stdout: { type: 'string' },
|
|
206
|
+
stderr: { type: 'string' },
|
|
207
|
+
stdoutTotalBytes: { type: 'integer' },
|
|
208
|
+
stdoutDroppedBytes: { type: 'integer' },
|
|
209
|
+
stderrTotalBytes: { type: 'integer' },
|
|
210
|
+
stderrDroppedBytes: { type: 'integer' },
|
|
211
|
+
stdoutSpillPath: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
212
|
+
stderrSpillPath: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
213
|
+
jobId: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
214
|
+
},
|
|
215
|
+
required: [
|
|
216
|
+
'exitCode', 'signal', 'timedOut', 'timeoutMs', 'truncated', 'stdout', 'stderr',
|
|
217
|
+
'stdoutTotalBytes', 'stdoutDroppedBytes', 'stderrTotalBytes', 'stderrDroppedBytes',
|
|
218
|
+
'stdoutSpillPath', 'stderrSpillPath', 'jobId',
|
|
219
|
+
],
|
|
220
|
+
},
|
|
221
|
+
render: (_args, value) => [{ type: 'text', text: formatResult(value, config.maxOutputBytes) }],
|
|
222
|
+
},
|
|
223
|
+
async execute(args, exec) {
|
|
224
|
+
if (typeof args.command !== 'string' || args.command.trim().length === 0) {
|
|
225
|
+
throw new Error('wsl: command must be a non-empty string')
|
|
226
|
+
}
|
|
227
|
+
if (typeof args.description !== 'string' || args.description.trim().length === 0) {
|
|
228
|
+
throw new Error('wsl: description must be a non-empty string')
|
|
229
|
+
}
|
|
230
|
+
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
231
|
+
throw new Error('wsl: timeoutMs must be a positive number')
|
|
232
|
+
}
|
|
233
|
+
if (args.translatePaths !== undefined && typeof args.translatePaths !== 'boolean') {
|
|
234
|
+
throw new Error('wsl: translatePaths must be a boolean')
|
|
235
|
+
}
|
|
236
|
+
if (args.runInBackground !== undefined && typeof args.runInBackground !== 'boolean') {
|
|
237
|
+
throw new Error('wsl: runInBackground must be a boolean')
|
|
238
|
+
}
|
|
239
|
+
if (args.stdin !== undefined && typeof args.stdin !== 'string') {
|
|
240
|
+
throw new Error('wsl: stdin must be a string')
|
|
241
|
+
}
|
|
242
|
+
if (args.env !== undefined && (typeof args.env !== 'object' || args.env === null || Array.isArray(args.env))) {
|
|
243
|
+
throw new Error('wsl: env must be an object of string values')
|
|
244
|
+
}
|
|
245
|
+
if (args.env !== undefined) {
|
|
246
|
+
// Silently dropping a bad key would run the command with the variable
|
|
247
|
+
// missing, which is worse than refusing it.
|
|
248
|
+
for (const [key, value] of Object.entries(args.env)) {
|
|
249
|
+
if (!ENV_KEY_RE.test(key)) {
|
|
250
|
+
throw new Error(`wsl: env key ${JSON.stringify(key)} is not a valid shell variable name`)
|
|
251
|
+
}
|
|
252
|
+
if (value !== undefined && typeof value !== 'string') {
|
|
253
|
+
throw new Error(`wsl: env["${key}"] must be a string`)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const command = args.translatePaths === false ? args.command : windowsPathToWsl(args.command)
|
|
259
|
+
const reason = destructiveReason(command)
|
|
260
|
+
if (reason !== null && args.allowDangerous !== true) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`wsl: refused a destructive command (${reason}). ` +
|
|
263
|
+
'If this is intended, re-issue it with `allowDangerous: true`.',
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const opts = {
|
|
268
|
+
distro: args.distro,
|
|
269
|
+
workdir: args.workdir,
|
|
270
|
+
env: args.env,
|
|
271
|
+
stdin: args.stdin,
|
|
272
|
+
// Carries the calling session: its workspace and its `DSH_*` facts are
|
|
273
|
+
// per-call, not host constants (see runner.sessionCwdOf/collectForwardEnv).
|
|
274
|
+
exec,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (args.runInBackground === true) {
|
|
278
|
+
// A background job is meant to outlive the foreground deadline, so the
|
|
279
|
+
// configured default does NOT apply; an explicit timeoutMs still does.
|
|
280
|
+
return startInBackground(command, { ...opts, timeoutMs: args.timeoutMs }, exec)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return await runner.runWsl(command, {
|
|
284
|
+
...opts,
|
|
285
|
+
timeoutMs: args.timeoutMs ?? config.commandTimeoutMs,
|
|
286
|
+
})
|
|
287
|
+
},
|
|
288
|
+
presentCall: (args) => ({
|
|
289
|
+
card: 'terminal',
|
|
290
|
+
title: args.command,
|
|
291
|
+
description: args.description,
|
|
292
|
+
// The card must show the directory the command really runs in, not the
|
|
293
|
+
// Windows spelling the caller passed.
|
|
294
|
+
...(args.workdir !== undefined ? { cwd: windowsPathToWsl(args.workdir) } : {}),
|
|
295
|
+
}),
|
|
296
|
+
}
|
|
297
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-wsl-tool",
|
|
3
|
+
"version": "1.8.0",
|
|
4
|
+
"description": "Model-facing WSL tools for DeepSeek Harness: run Linux commands from Windows with background jobs, stdin, path translation, destructive-command guard and WSL capability diagnostics.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/XINY11451/dsh-wsl.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/XINY11451/dsh-wsl#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/XINY11451/dsh-wsl/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"dsh",
|
|
18
|
+
"dsh-plugin",
|
|
19
|
+
"deepseek-harness",
|
|
20
|
+
"wsl",
|
|
21
|
+
"windows",
|
|
22
|
+
"linux",
|
|
23
|
+
"shell",
|
|
24
|
+
"interop"
|
|
25
|
+
],
|
|
26
|
+
"files": [
|
|
27
|
+
"index.js",
|
|
28
|
+
"lib",
|
|
29
|
+
"cordis.patch.yml",
|
|
30
|
+
"README.md",
|
|
31
|
+
"README.zh-CN.md",
|
|
32
|
+
"screenshots.json",
|
|
33
|
+
"assets",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "node test/smoke.mjs",
|
|
38
|
+
"test:real": "node test/smoke.mjs --real && node test/real-seam.mjs",
|
|
39
|
+
"sync": "node scripts/sync-profile.mjs"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public",
|
|
43
|
+
"registry": "https://registry.npmjs.org/"
|
|
44
|
+
},
|
|
45
|
+
"dsh": {
|
|
46
|
+
"bundle": {
|
|
47
|
+
"patch": "./cordis.patch.yml"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
package/screenshots.json
ADDED