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,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
+ }
@@ -0,0 +1,3 @@
1
+ [
2
+ "assets/screenshot-1.png"
3
+ ]