dsh-plugin-wsl-env 0.1.10 → 0.2.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/CHANGELOG.md +103 -0
- package/README.md +19 -2
- package/agent/wsl-agent.sh +156 -0
- package/docs/CONFIGURATION.md +8 -0
- package/docs/LIMITATIONS.md +10 -5
- package/docs/PARITY.md +70 -0
- package/docs/README.zh.md +29 -20
- package/lib/agent-errors.js +33 -0
- package/lib/agent-exec.js +194 -0
- package/lib/agent-protocol.js +116 -0
- package/lib/agent-shared.js +32 -0
- package/lib/agent.js +414 -0
- package/lib/fs-publish.js +42 -0
- package/lib/index.js +192 -7
- package/lib/ports.js +61 -0
- package/lib/sandbox.js +3 -1
- package/lib/shell-env.js +28 -2
- package/lib/subprocess.js +15 -0
- package/lib/terminal-route.js +31 -0
- package/lib/watcher.js +127 -0
- package/package.json +10 -4
package/lib/index.js
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
39
|
import { rename } from "node:fs/promises";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
40
41
|
import z from "@deepseek-ai/schemastery";
|
|
41
42
|
import { LocalBashExecutor } from "@deepseek-ai/dsh-bash-local";
|
|
42
43
|
import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
|
|
@@ -44,6 +45,12 @@ import { FsError } from "@deepseek-ai/dsh-fs";
|
|
|
44
45
|
import { canonicalPath, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature, SandboxUnavailableError, writableRoots } from "@deepseek-ai/dsh-sandbox";
|
|
45
46
|
import { isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixToUnc, toDisplayPath, toLinuxPath, toWorldPath, uncToPosix } from "./paths.js";
|
|
46
47
|
import { WslSandbox } from "./sandbox.js";
|
|
48
|
+
import { armDistroWatcher } from "./watcher.js";
|
|
49
|
+
import { WslAgent } from "./agent.js";
|
|
50
|
+
import { AgentUnavailableError } from "./agent-errors.js";
|
|
51
|
+
import { publicationArgv } from "./fs-publish.js";
|
|
52
|
+
import { agentExecutionHandle } from "./agent-exec.js";
|
|
53
|
+
import { sharedAgent } from "./agent-shared.js";
|
|
47
54
|
// `runCapture` and distro discovery live in ./wsl.js so the directory picker
|
|
48
55
|
// can use them without pulling this file's executor and filesystem peers in.
|
|
49
56
|
import { canonicalLinuxPath, copyModeInDistro, defaultDistro, defaultShell, linuxHomePath, shellArgs, workdirFailure, wslErrorCode } from "./wsl.js";
|
|
@@ -180,6 +187,14 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
180
187
|
maxOutputBytes: z.number().default(64e3).volatile(),
|
|
181
188
|
maxSpillBytes: z.number().default(64 * 1024 * 1024).volatile(),
|
|
182
189
|
graceMs: z.number().default(3e3).volatile(),
|
|
190
|
+
/**
|
|
191
|
+
* Run commands through the resident agent when it is up (one long-lived
|
|
192
|
+
* in-distro process; in-distro timeout semantics), falling back to the
|
|
193
|
+
* one-shot `wsl.exe` path whenever the agent is out. `false` never uses
|
|
194
|
+
* the agent. The confinement, the output budgets' shapes and the result
|
|
195
|
+
* decoration are identical on both paths.
|
|
196
|
+
*/
|
|
197
|
+
agent: z.boolean().default(true),
|
|
183
198
|
});
|
|
184
199
|
|
|
185
200
|
constructor(ctx, config) {
|
|
@@ -249,6 +264,15 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
249
264
|
return this.config.hostCwd.get() ?? process.env.SystemRoot ?? process.cwd();
|
|
250
265
|
}
|
|
251
266
|
|
|
267
|
+
/**
|
|
268
|
+
* The shared resident agent serving this executor's distro.
|
|
269
|
+
* @param distro - the resolved distro name.
|
|
270
|
+
* @returns {WslAgent} the shared agent.
|
|
271
|
+
*/
|
|
272
|
+
executionAgent(distro) {
|
|
273
|
+
return sharedAgent(distro);
|
|
274
|
+
}
|
|
275
|
+
|
|
252
276
|
/**
|
|
253
277
|
* The argv that runs INSIDE the distro for one spec: the resolved shell, its
|
|
254
278
|
* login/command flags, and the command string. This is the argv a
|
|
@@ -492,6 +516,88 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
492
516
|
const distro = await this.distro();
|
|
493
517
|
const shell = await this.shell();
|
|
494
518
|
const resolved = await this.withDefaultWorkdir(spec);
|
|
519
|
+
if (this.config.agent) {
|
|
520
|
+
try {
|
|
521
|
+
return await this.executeViaAgent(resolved, distro, shell);
|
|
522
|
+
} catch (error) {
|
|
523
|
+
// Only an out-of-service agent falls back; anything else (a cwd
|
|
524
|
+
// refusal, a sandbox refusal) is the command's own verdict.
|
|
525
|
+
if (!(error instanceof AgentUnavailableError)) throw error;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return this.executeOneShot(resolved, distro, shell);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The agent-backed execution path: one long-lived in-distro process, the
|
|
533
|
+
* confinement argv unchanged, the handle shape unchanged. Any
|
|
534
|
+
* {@link AgentUnavailableError} propagates for the caller's fallback.
|
|
535
|
+
* @param resolved - the resolved spec from {@link WslShellExecutor.execute}.
|
|
536
|
+
* @param distro - the resolved distro name.
|
|
537
|
+
* @param shell - the resolved distro shell.
|
|
538
|
+
* @returns the decorated execution result.
|
|
539
|
+
*/
|
|
540
|
+
async executeViaAgent(resolved, distro, shell) {
|
|
541
|
+
const policy = resolved.sandboxPolicy;
|
|
542
|
+
const confined = this.sandbox !== undefined && policy !== undefined && policy.mode !== "danger-full-access"
|
|
543
|
+
? await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode: policy.mode }, { distro })
|
|
544
|
+
: undefined;
|
|
545
|
+
const mode = policy?.mode;
|
|
546
|
+
const execution = agentExecutionHandle({
|
|
547
|
+
agent: await this.executionAgent(distro),
|
|
548
|
+
// The one-shot path converts the spec's workdir into the distro's
|
|
549
|
+
// coordinate system inside argv(); the agent carries the cwd as a frame
|
|
550
|
+
// field, so the SAME conversion happens here — a raw UNC must never
|
|
551
|
+
// reach `chdir` (that is the bug this line fixes).
|
|
552
|
+
cwd: toLinuxPath(resolved.workdir, { distro }),
|
|
553
|
+
argv: confined !== undefined ? confined.argv : this.distroArgv(resolved, shell),
|
|
554
|
+
timeoutMs: resolved.timeoutMs,
|
|
555
|
+
signal: resolved.signal,
|
|
556
|
+
onStarted: confined !== undefined
|
|
557
|
+
? (proc) => {
|
|
558
|
+
this.processFacts.set(proc, {
|
|
559
|
+
mode,
|
|
560
|
+
enforcement: confined.enforcement,
|
|
561
|
+
denialSignatures: confined.denialSignatures,
|
|
562
|
+
runnerFailureRules: confined.runnerFailureRules,
|
|
563
|
+
runnerProgram: confined.argv[0],
|
|
564
|
+
workdir: resolved.workdir,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
: undefined,
|
|
568
|
+
});
|
|
569
|
+
return WslShellExecutor.decorateResult(execution, (result) => {
|
|
570
|
+
this.throwIfDistroUnavailable(result, distro);
|
|
571
|
+
this.throwIfWorkdirMissing(result, resolved.workdir);
|
|
572
|
+
if (this.sandbox === undefined) return result;
|
|
573
|
+
if (confined === undefined) return { ...result, sandbox: { mode, denied: false } };
|
|
574
|
+
const { enforcement, denialSignatures, runnerFailureRules } = confined;
|
|
575
|
+
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
|
|
576
|
+
if (runnerFailure !== undefined) throw new SandboxUnavailableError(mode, runnerFailure.detail);
|
|
577
|
+
return {
|
|
578
|
+
...result,
|
|
579
|
+
sandbox: {
|
|
580
|
+
mode,
|
|
581
|
+
denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
|
|
582
|
+
enforcement,
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
}, (error) => {
|
|
586
|
+
if (resolved.signal?.aborted === true) resolved.signal.throwIfAborted();
|
|
587
|
+
throw error;
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* The pre-agent execution path, verbatim: one `wsl.exe` per command through
|
|
593
|
+
* the inherited `executeArgv` machinery. Kept as the permanent fallback for
|
|
594
|
+
* an out-of-service agent and as the `agent: false` behaviour.
|
|
595
|
+
* @param resolved - the resolved spec.
|
|
596
|
+
* @param distro - the resolved distro name.
|
|
597
|
+
* @param shell - the resolved distro shell.
|
|
598
|
+
* @returns the decorated execution result.
|
|
599
|
+
*/
|
|
600
|
+
async executeOneShot(resolved, distro, shell) {
|
|
495
601
|
const policy = resolved.sandboxPolicy;
|
|
496
602
|
if (this.sandbox === undefined || policy === undefined || policy.mode === "danger-full-access") {
|
|
497
603
|
// `executeArgv` resolves to the execution handle, so it has to be awaited
|
|
@@ -626,6 +732,15 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
626
732
|
* distinct failing path, cached.
|
|
627
733
|
*/
|
|
628
734
|
resolveSymlinks: z.boolean().default(true),
|
|
735
|
+
/**
|
|
736
|
+
* Run the in-distro side of publication (mode copy + atomic rename) through
|
|
737
|
+
* the resident agent (`agent/wsl-agent.sh`), one round trip instead of a
|
|
738
|
+
* `wsl.exe` chmod plus a 9p rename. Any agent failure falls back to the
|
|
739
|
+
* legacy two-step path for that call and, after the agent's own rebuild
|
|
740
|
+
* budget is spent, permanently — this flag never changes what is published,
|
|
741
|
+
* only how many round trips it costs.
|
|
742
|
+
*/
|
|
743
|
+
agent: z.boolean().default(true),
|
|
629
744
|
});
|
|
630
745
|
|
|
631
746
|
constructor(ctx, config) {
|
|
@@ -692,11 +807,45 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
692
807
|
const source = uncToPosix(replaced);
|
|
693
808
|
const staged = uncToPosix(replacement);
|
|
694
809
|
if (source !== undefined && staged !== undefined) {
|
|
810
|
+
if (this.config.agent) {
|
|
811
|
+
try {
|
|
812
|
+
// One in-distro round trip: mode copy + atomic rename. The staged
|
|
813
|
+
// temp is on the share, but the agent reaches it by its Linux path,
|
|
814
|
+
// where the kernel does the rename — no 9p publication involved.
|
|
815
|
+
const agent = await this.publicationAgent();
|
|
816
|
+
const result = await agent.exec({
|
|
817
|
+
cwd: "/",
|
|
818
|
+
argv: publicationArgv(source.linuxPath, staged.linuxPath),
|
|
819
|
+
timeoutMs: 30000,
|
|
820
|
+
});
|
|
821
|
+
if (result.exitCode === 0) return;
|
|
822
|
+
// A nonzero from the script is a real publication failure (chmod or
|
|
823
|
+
// rename); report it rather than retrying through the legacy path,
|
|
824
|
+
// which would fail the same way but slower.
|
|
825
|
+
throw new FsError(`publishing "${replaced}" failed inside the distro (exit ${result.exitCode})`, "FS_IO_ERROR");
|
|
826
|
+
} catch (error) {
|
|
827
|
+
// The agent being out is the ONLY thing the legacy path exists for.
|
|
828
|
+
if (!(error instanceof AgentUnavailableError)) throw error;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
695
831
|
await copyModeInDistro(source.distro, source.linuxPath, staged.linuxPath, { wslPath: this.config.wslPath });
|
|
696
832
|
}
|
|
697
833
|
await rename(replacement, replaced);
|
|
698
834
|
}
|
|
699
835
|
|
|
836
|
+
/**
|
|
837
|
+
* The resident agent serving this filesystem's in-distro side work, created
|
|
838
|
+
* on first use and kept for the provider's lifetime (its own idle timer
|
|
839
|
+
* retires the process; a later call starts a fresh one).
|
|
840
|
+
* @returns {Promise<WslAgent>} the agent for the pinned distro.
|
|
841
|
+
*/
|
|
842
|
+
async publicationAgent() {
|
|
843
|
+
if (this.agentInstance === undefined) {
|
|
844
|
+
this.agentInstance = sharedAgent(await this.distro());
|
|
845
|
+
}
|
|
846
|
+
return this.agentInstance;
|
|
847
|
+
}
|
|
848
|
+
|
|
700
849
|
/**
|
|
701
850
|
* The pinned distro, resolving WSL's default on first use.
|
|
702
851
|
* @returns the distro name.
|
|
@@ -796,7 +945,23 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
796
945
|
if (cached !== undefined) return cached;
|
|
797
946
|
const parsed = uncToPosix(target.targetKey);
|
|
798
947
|
if (parsed === undefined) return undefined;
|
|
799
|
-
|
|
948
|
+
// The agent serves the canonical path when it is up (no extra `wsl.exe`
|
|
949
|
+
// per distinct failing path); the one-shot helper is the fallback and the
|
|
950
|
+
// `agent: false` path.
|
|
951
|
+
let canonical;
|
|
952
|
+
if (this.config.agent) {
|
|
953
|
+
try {
|
|
954
|
+
const agent = await this.publicationAgent();
|
|
955
|
+
const result = await agent.exec({ cwd: "/", argv: ["readlink", "-f", parsed.linuxPath], timeoutMs: 10000, signal });
|
|
956
|
+
const resolved = result.stdout.toString("utf8").trim();
|
|
957
|
+
canonical = resolved.startsWith("/") ? resolved : undefined;
|
|
958
|
+
} catch (error) {
|
|
959
|
+
if (!(error instanceof AgentUnavailableError)) throw error;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (canonical === undefined) {
|
|
963
|
+
canonical = await canonicalLinuxPath(parsed.distro, parsed.linuxPath, { wslPath: this.config.wslPath, signal });
|
|
964
|
+
}
|
|
800
965
|
if (canonical === undefined || canonical === parsed.linuxPath) return undefined;
|
|
801
966
|
const replacement = { targetKey: posixToUnc(parsed.distro, canonical), displayPath: target.displayPath };
|
|
802
967
|
this.canonicalTargets.set(target.targetKey, replacement);
|
|
@@ -885,13 +1050,33 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
885
1050
|
}
|
|
886
1051
|
|
|
887
1052
|
/**
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
*
|
|
1053
|
+
* Watch from inside the distro, where the kernel can actually report change:
|
|
1054
|
+
* a long-lived `wsl.exe` poll loop (`lib/watcher.js`) fires the seam's
|
|
1055
|
+
* invalidation callback. The seam asks for coarse invalidation, not events,
|
|
1056
|
+
* so a poll is honest — and creations and deletions are both seen, because
|
|
1057
|
+
* they bump the parent directory's mtime. Replaces the previous flat
|
|
1058
|
+
* refusal (`FS_IO_ERROR`), which was the correct answer over 9p only.
|
|
1059
|
+
*
|
|
1060
|
+
* @param target - the resolved file or directory to observe.
|
|
1061
|
+
* @param changed - the invalidation callback.
|
|
1062
|
+
* @param signal - cancels initialization; the caller closes an armed watcher.
|
|
1063
|
+
* @returns resolves once active, with the async close function.
|
|
892
1064
|
*/
|
|
893
|
-
watch() {
|
|
894
|
-
|
|
1065
|
+
async watch(target, changed, signal) {
|
|
1066
|
+
signal.throwIfAborted();
|
|
1067
|
+
const parsed = uncToPosix(target.targetKey);
|
|
1068
|
+
if (parsed === undefined) {
|
|
1069
|
+
throw new FsError(`cannot watch "${target.displayPath}": not a path inside the distro`, "FS_IO_ERROR");
|
|
1070
|
+
}
|
|
1071
|
+
const distro = await this.distro();
|
|
1072
|
+
return armDistroWatcher({
|
|
1073
|
+
wslPath: this.config.wslPath,
|
|
1074
|
+
distro,
|
|
1075
|
+
linuxPath: parsed.linuxPath,
|
|
1076
|
+
onChange: changed,
|
|
1077
|
+
onError: (error) => changed(error),
|
|
1078
|
+
signal,
|
|
1079
|
+
});
|
|
895
1080
|
}
|
|
896
1081
|
|
|
897
1082
|
/**
|
package/lib/ports.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Listening-port visibility for the distro, the Remote-WSL port-forwarding
|
|
3
|
+
* parity row's DSH-side half.
|
|
4
|
+
*
|
|
5
|
+
* There is no DSH channel that streams live facts to the model; the sanctioned
|
|
6
|
+
* one is the managed `DSH_*` namespace (`ctx.shellEnv`), whose `resolve` is
|
|
7
|
+
* SYNCHRONOUS. So the snapshot is refreshed on a timer and served stale-but-
|
|
8
|
+
* recent: `DSH_WSL_PORTS` names the ports with a listener inside the distro,
|
|
9
|
+
* at most `refreshMs` old. A dev server the model starts shows up on the next
|
|
10
|
+
* refresh, which is the granularity that matters at conversational pace.
|
|
11
|
+
*
|
|
12
|
+
* The scan reads `/proc/net/tcp` and `/proc/net/tcp6` directly — no `ss`, no
|
|
13
|
+
* `netstat`, nothing to install — and keeps sockets in state `0A` (LISTEN).
|
|
14
|
+
* Pure parser, unit-testable without a distro.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-plugin-wsl/ports
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse one `/proc/net/tcp{,6}` body into listening ports.
|
|
21
|
+
* @param {string} text - the file's content.
|
|
22
|
+
* @returns {number[]} deduplicated listening ports, ascending.
|
|
23
|
+
*/
|
|
24
|
+
export function parseListeningPorts(text) {
|
|
25
|
+
const ports = new Set();
|
|
26
|
+
for (const line of text.split("\n").slice(1)) {
|
|
27
|
+
const columns = line.trim().split(/\s+/);
|
|
28
|
+
// sl, local_address, rem_address, st, ... — st 0A is LISTEN.
|
|
29
|
+
if (columns.length < 4 || columns[3] !== "0A") continue;
|
|
30
|
+
const portHex = columns[1].split(":")[1];
|
|
31
|
+
if (portHex === undefined) continue;
|
|
32
|
+
const port = Number.parseInt(portHex, 16);
|
|
33
|
+
if (Number.isFinite(port) && port > 0) ports.add(port);
|
|
34
|
+
}
|
|
35
|
+
return [...ports].sort((a, b) => a - b);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The shell script that prints both proc files' contents.
|
|
40
|
+
* @returns {string} the `sh -c` body.
|
|
41
|
+
*/
|
|
42
|
+
export function procNetScript() {
|
|
43
|
+
return "cat /proc/net/tcp /proc/net/tcp6 2>/dev/null";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Snapshot the distro's listening ports through the agent.
|
|
48
|
+
* @param {object} runner - the {@link WslAgent}-like runner.
|
|
49
|
+
* @param {number} [timeoutMs] - the exec budget.
|
|
50
|
+
* @returns {Promise<number[]>} listening ports, ascending; [] on any failure —
|
|
51
|
+
* a snapshot is a convenience, never an error the session should see.
|
|
52
|
+
*/
|
|
53
|
+
export async function listeningPorts(runner, timeoutMs = 10000) {
|
|
54
|
+
try {
|
|
55
|
+
const result = await runner.exec({ cwd: "/", argv: ["sh", "-c", procNetScript(), "ports"], timeoutMs });
|
|
56
|
+
if (result.exitCode !== 0) return [];
|
|
57
|
+
return parseListeningPorts(result.stdout.toString("utf8"));
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
package/lib/sandbox.js
CHANGED
|
@@ -146,7 +146,9 @@ export class WslSandbox {
|
|
|
146
146
|
if (!(await this.usable(distro, signal))) {
|
|
147
147
|
throw new SandboxUnavailableError(
|
|
148
148
|
policy.mode,
|
|
149
|
-
`bwrap is not usable inside distro "${distro}"
|
|
149
|
+
`bwrap is not usable inside distro "${distro}". Install it with ` +
|
|
150
|
+
`"scripts/bootstrap.sh ${distro} --install" (from the package), or directly: ` +
|
|
151
|
+
`wsl.exe -d ${distro} -u root -- apt-get install -y bubblewrap`,
|
|
150
152
|
);
|
|
151
153
|
}
|
|
152
154
|
const workspaceRoot = toLinuxPath(policy.workspaceRoot, { distro });
|
package/lib/shell-env.js
CHANGED
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
import z from "@deepseek-ai/schemastery";
|
|
27
27
|
import { isWslUnc } from "./paths.js";
|
|
28
28
|
import { defaultDistro, defaultShell, linuxHomePath } from "./wsl.js";
|
|
29
|
+
import { sharedAgent } from "./agent-shared.js";
|
|
30
|
+
import { listeningPorts } from "./ports.js";
|
|
29
31
|
|
|
30
32
|
export const name = "wsl-shell-env";
|
|
31
33
|
|
|
@@ -34,6 +36,8 @@ export const inject = ["shellEnv"];
|
|
|
34
36
|
export const Config = z.object({
|
|
35
37
|
/** Distro these facts describe; empty means this machine's default distro. */
|
|
36
38
|
distro: z.string().default(""),
|
|
39
|
+
/** How often the listening-port snapshot refreshes, in milliseconds. */
|
|
40
|
+
portsRefreshMs: z.number().default(10 * 1000),
|
|
37
41
|
});
|
|
38
42
|
|
|
39
43
|
export async function apply(ctx, config = {}) {
|
|
@@ -56,7 +60,21 @@ export async function apply(ctx, config = {}) {
|
|
|
56
60
|
DSH_WSL_HOME: {
|
|
57
61
|
description: `Home directory of the user model shell calls run as inside ${distro}.`,
|
|
58
62
|
},
|
|
63
|
+
DSH_WSL_PORTS: {
|
|
64
|
+
description: `Ports with a listener inside ${distro} (comma-separated, ascending), as of the last snapshot — refreshed every ${Math.round(config.portsRefreshMs / 1000)}s, so a dev server started moments ago may not be listed yet. Empty means none were listening at the last scan.`,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// The port snapshot refreshes on a timer and is served synchronously: the
|
|
69
|
+
// registry's resolve is sync by contract, so live reads are impossible —
|
|
70
|
+
// the timer bounds staleness instead (see lib/ports.js).
|
|
71
|
+
let ports = [];
|
|
72
|
+
const refreshPorts = async () => {
|
|
73
|
+
ports = await listeningPorts(sharedAgent(distro), 8000);
|
|
59
74
|
};
|
|
75
|
+
void refreshPorts();
|
|
76
|
+
const portsTimer = setInterval(() => void refreshPorts(), config.portsRefreshMs);
|
|
77
|
+
portsTimer.unref?.();
|
|
60
78
|
|
|
61
79
|
const dispose = ctx.shellEnv.register({
|
|
62
80
|
name: "dsh-plugin-wsl-env",
|
|
@@ -64,11 +82,19 @@ export async function apply(ctx, config = {}) {
|
|
|
64
82
|
resolve: (execution) => {
|
|
65
83
|
const cwd = execution?.agent?.session?.header?.cwd;
|
|
66
84
|
if (!isWslUnc(cwd)) return {};
|
|
67
|
-
return {
|
|
85
|
+
return {
|
|
86
|
+
DSH_WSL_DISTRO: distro,
|
|
87
|
+
DSH_WSL_SHELL: shell,
|
|
88
|
+
DSH_WSL_HOME: home,
|
|
89
|
+
DSH_WSL_PORTS: ports.join(","),
|
|
90
|
+
};
|
|
68
91
|
},
|
|
69
92
|
});
|
|
70
93
|
|
|
71
94
|
// Disposing matters: a config hot-reload re-runs apply(), and the registry
|
|
72
95
|
// rejects a second contributor claiming the same keys.
|
|
73
|
-
ctx.effect(() =>
|
|
96
|
+
ctx.effect(() => {
|
|
97
|
+
clearInterval(portsTimer);
|
|
98
|
+
dispose();
|
|
99
|
+
});
|
|
74
100
|
}
|
package/lib/subprocess.js
CHANGED
|
@@ -74,6 +74,7 @@ import z from "@deepseek-ai/schemastery";
|
|
|
74
74
|
import { LocalSubprocessRuntime } from "@deepseek-ai/dsh-subprocess-local";
|
|
75
75
|
import { isPosixAbsolute, toLinuxPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
|
|
76
76
|
import { DEFAULT_WSL_PATH, defaultDistro, wslEnvValue, wslTerminalArgv } from "./wsl.js";
|
|
77
|
+
import { terminalRoute } from "./terminal-route.js";
|
|
77
78
|
|
|
78
79
|
/**
|
|
79
80
|
* Env names a terminal launch keeps for the distro. `TERM` rides along because
|
|
@@ -120,6 +121,13 @@ export class WslSubprocessRuntime extends LocalSubprocessRuntime {
|
|
|
120
121
|
hostCwd: z.string().volatile(),
|
|
121
122
|
/** Extra env names to forward into the distro through WSLENV. */
|
|
122
123
|
forwardEnv: z.array(z.string()).default([]),
|
|
124
|
+
/**
|
|
125
|
+
* Give a session opened on a WINDOWS folder a host shell instead of the
|
|
126
|
+
* distro's shell started at `/mnt/<drive>/…`. WSL-folder sessions always
|
|
127
|
+
* get the distro shell — that is this provider's purpose. `false` restores
|
|
128
|
+
* the composition-owned terminal for every session.
|
|
129
|
+
*/
|
|
130
|
+
hostSessions: z.boolean().default(true),
|
|
123
131
|
});
|
|
124
132
|
|
|
125
133
|
constructor(ctx, config) {
|
|
@@ -185,6 +193,13 @@ export class WslSubprocessRuntime extends LocalSubprocessRuntime {
|
|
|
185
193
|
*/
|
|
186
194
|
async spawnTerminal(spec) {
|
|
187
195
|
if (!this.launchesDistro(spec.argv)) return super.spawnTerminal(spec);
|
|
196
|
+
// A session on a Windows folder is a HOST session: it gets the host shell
|
|
197
|
+
// in its own directory, not the distro's shell at `/mnt/<drive>/…`. The
|
|
198
|
+
// wsl.exe argv still names this provider's launch, so the substitution
|
|
199
|
+
// stays inside the override — every other consumer is untouched.
|
|
200
|
+
if (this.config.hostSessions && terminalRoute(spec.cwd) === "host") {
|
|
201
|
+
return super.spawnTerminal({ ...spec, argv: ["powershell.exe", "-NoLogo"] });
|
|
202
|
+
}
|
|
188
203
|
const target = await this.terminalTarget(spec.cwd);
|
|
189
204
|
const argv = wslTerminalArgv({
|
|
190
205
|
wslPath: this.config.wslPath,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which execution world a terminal launch belongs to.
|
|
3
|
+
*
|
|
4
|
+
* The terminal controller hands this provider the request's directory, and the
|
|
5
|
+
* directory's coordinate system is the evidence of intent: a UNC path under a
|
|
6
|
+
* distro's share names a WSL session outright, a POSIX absolute path is a
|
|
7
|
+
* distro path by construction, and a Windows drive path is a session opened on
|
|
8
|
+
* a Windows folder — which deserves a HOST shell, not the distro's shell
|
|
9
|
+
* started at `/mnt/<drive>/…`. An empty directory carries no evidence either
|
|
10
|
+
* way and defaults to the distro, which is what a profile pins.
|
|
11
|
+
*
|
|
12
|
+
* Pure by construction.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-plugin-wsl/terminal-route
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { isPosixAbsolute, uncToPosix, windowsToLinuxMount } from "./paths.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param cwd - the terminal request's working directory, any coordinate system.
|
|
21
|
+
* @returns `"distro"` when the launch belongs inside the pinned distro,
|
|
22
|
+
* `"host"` when it belongs to a Windows-folder session.
|
|
23
|
+
*/
|
|
24
|
+
export function terminalRoute(cwd) {
|
|
25
|
+
const text = String(cwd ?? "").trim();
|
|
26
|
+
if (text.length === 0) return "distro";
|
|
27
|
+
if (uncToPosix(text) !== undefined) return "distro";
|
|
28
|
+
if (isPosixAbsolute(text)) return "distro";
|
|
29
|
+
if (windowsToLinuxMount(text) !== undefined) return "host";
|
|
30
|
+
return "distro";
|
|
31
|
+
}
|
package/lib/watcher.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-distro file watching for `WslFileSystem.watch`.
|
|
3
|
+
*
|
|
4
|
+
* A 9p share gives Node no usable change notification — chokidar over
|
|
5
|
+
* `\\wsl.localhost` is the unreliability that made this provider refuse to
|
|
6
|
+
* watch at all. The seam's contract, however, asks for far less than events:
|
|
7
|
+
* `watch(target, changed, signal)` wants ONE invalidation callback, invoked
|
|
8
|
+
* whenever something under `target` may have changed. That is cheap to honor
|
|
9
|
+
* from inside the distro, where the kernel does know.
|
|
10
|
+
*
|
|
11
|
+
* The watcher is a long-lived `wsl.exe` shell loop: periodically
|
|
12
|
+
* `find <dir> -newer <stamp>` — the directory itself is in the scan, so
|
|
13
|
+
* creations AND deletions (which bump the parent's mtime) are both seen — and
|
|
14
|
+
* one `C` line per hit. No `inotifywait` dependency; the mtime walk is exact
|
|
15
|
+
* for interactive-paced edits, which is the only pace a session watch serves.
|
|
16
|
+
*
|
|
17
|
+
* Known blind spot, accepted and documented: a file created with an mtime
|
|
18
|
+
* older than the stamp (`cp -p`, `tar -x`) is not seen until something else
|
|
19
|
+
* touches the tree.
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-plugin-wsl/watcher
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { createInterface } from "node:readline";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The shell loop the watcher runs, parameterised by argv so no path can break
|
|
29
|
+
* quoting: `$1` is the Linux directory, `$2` the poll interval in seconds.
|
|
30
|
+
* Pure text, so it is unit-testable.
|
|
31
|
+
* @param {number} [intervalSeconds] - poll cadence; must be >= 1.
|
|
32
|
+
* @returns {string} the `sh -c` script body.
|
|
33
|
+
*/
|
|
34
|
+
export function watchLoopScript(intervalSeconds = 2) {
|
|
35
|
+
const seconds = Math.max(1, Math.floor(intervalSeconds));
|
|
36
|
+
// `find` lists the directory itself first: a child's creation or deletion
|
|
37
|
+
// bumps the parent's mtime, so both edges are seen, not just modifications.
|
|
38
|
+
return [
|
|
39
|
+
'D="$1"',
|
|
40
|
+
'S="${TMPDIR:-/tmp}/wsl-watch.$$.stamp"',
|
|
41
|
+
'touch "$S"',
|
|
42
|
+
// The READY line is the activation barrier: everything changed BEFORE it
|
|
43
|
+
// predates the stamp, so the host must not resolve the watch promise until
|
|
44
|
+
// it arrives — otherwise a write racing the first tick is invisible forever.
|
|
45
|
+
'printf \'R\\n\'',
|
|
46
|
+
// Check first, sleep second: a change made right after activation is seen
|
|
47
|
+
// on the first cadence, not one cadence later.
|
|
48
|
+
'while :; do',
|
|
49
|
+
' if [ -n "$(find "$D" -newer "$S" -print -quit 2>/dev/null)" ]; then',
|
|
50
|
+
' touch "$S"',
|
|
51
|
+
' printf \'C\\n\'',
|
|
52
|
+
' fi',
|
|
53
|
+
` sleep ${seconds}`,
|
|
54
|
+
'done',
|
|
55
|
+
].join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Arm one in-distro watcher over a Linux directory.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} options - the watch request.
|
|
62
|
+
* @param {string} options.wslPath - the `wsl.exe` path.
|
|
63
|
+
* @param {string} options.distro - the distro to watch inside.
|
|
64
|
+
* @param {string} options.linuxPath - the absolute Linux path to observe.
|
|
65
|
+
* @param {(error: Error) => void} [options.onError] - watcher-level failures
|
|
66
|
+
* (an exited loop); the seam reports these through its own channel.
|
|
67
|
+
* @param {() => void} options.onChange - the invalidation callback.
|
|
68
|
+
* @param {AbortSignal} options.signal - cancels initialization; the CALLER
|
|
69
|
+
* invokes the returned close to stop an armed watcher.
|
|
70
|
+
* @param {number} [options.intervalSeconds] - poll cadence, default 2.
|
|
71
|
+
* @param {(wslPath: string, args: string[]) => import("node:child_process").ChildProcess} [options.spawn]
|
|
72
|
+
* injectable process factory for tests.
|
|
73
|
+
* @returns {Promise<() => Promise<void>>} resolves once observation is active,
|
|
74
|
+
* with the async close function the seam's contract asks for.
|
|
75
|
+
*/
|
|
76
|
+
export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError, signal, intervalSeconds = 2, spawn: spawnProcess }) {
|
|
77
|
+
const doSpawn = spawnProcess ?? ((path, args) => spawn(path, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }));
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
if (signal?.aborted) {
|
|
80
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const child = doSpawn(wslPath, ["-d", distro, "--exec", "sh", "-c", watchLoopScript(intervalSeconds), "wsl-watch", linuxPath]);
|
|
84
|
+
let settled = false;
|
|
85
|
+
let closed = false;
|
|
86
|
+
const lines = createInterface({ input: child.stdout });
|
|
87
|
+
lines.on("line", (line) => {
|
|
88
|
+
if (line === "R") {
|
|
89
|
+
if (!settled && !signal?.aborted) {
|
|
90
|
+
settled = true;
|
|
91
|
+
resolve(close);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (line === "C") {
|
|
96
|
+
onChange();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
const close = async () => {
|
|
100
|
+
if (closed) return;
|
|
101
|
+
closed = true;
|
|
102
|
+
child.removeAllListeners("exit");
|
|
103
|
+
lines.close();
|
|
104
|
+
child.stdout.destroy();
|
|
105
|
+
child.kill();
|
|
106
|
+
await new Promise((resolveClose) => {
|
|
107
|
+
if (child.exitCode !== null || child.signalCode !== null) return resolveClose();
|
|
108
|
+
child.once("exit", resolveClose);
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
const fail = (error) => {
|
|
112
|
+
if (settled) {
|
|
113
|
+
onError?.(error);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
settled = true;
|
|
117
|
+
void close();
|
|
118
|
+
reject(error);
|
|
119
|
+
};
|
|
120
|
+
signal?.addEventListener("abort", () => fail(signal.reason instanceof Error ? signal.reason : new Error("aborted")), { once: true });
|
|
121
|
+
child.on("error", fail);
|
|
122
|
+
child.on("exit", (code, signalName) => {
|
|
123
|
+
if (!closed) fail(new Error(`distro watcher exited unexpectedly (code=${code} signal=${signalName ?? "none"})`));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
});
|
|
127
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-wsl-env",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "让 DeepSeek Harness 以 WSL 子系统作为会话的执行环境:shell、文件读写与命令执行均在子系统内完成,目录选择器与 GUI 终端同样定位到子系统路径,并通过子系统内的 bubblewrap 对命令进行沙箱隔离",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
7
7
|
"dsh",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"./shell-env": "./lib/shell-env.js",
|
|
23
23
|
"./wsl": "./lib/wsl.js",
|
|
24
24
|
"./paths": "./lib/paths.js",
|
|
25
|
+
"./agent": "./lib/agent.js",
|
|
25
26
|
"./package.json": "./package.json"
|
|
26
27
|
},
|
|
27
28
|
"dsh": {
|
|
@@ -31,12 +32,14 @@
|
|
|
31
32
|
},
|
|
32
33
|
"files": [
|
|
33
34
|
"lib/**/*.js",
|
|
35
|
+
"agent/*.sh",
|
|
34
36
|
"cordis.patch.yml",
|
|
35
37
|
"examples/**/*.yml",
|
|
36
38
|
"README.md",
|
|
37
39
|
"docs/README.zh.md",
|
|
38
40
|
"docs/CONFIGURATION.md",
|
|
39
41
|
"docs/LIMITATIONS.md",
|
|
42
|
+
"docs/PARITY.md",
|
|
40
43
|
"docs/ARCHITECTURE.md",
|
|
41
44
|
"CHANGELOG.md",
|
|
42
45
|
"CONTRIBUTING.md",
|
|
@@ -48,7 +51,7 @@
|
|
|
48
51
|
"test": "npm run lint:style && npm run test:syntax && npm run test:unit",
|
|
49
52
|
"test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=85 --test-coverage-functions=70 test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preset-choice.test.mjs",
|
|
50
53
|
"test:syntax": "node test/syntax.mjs",
|
|
51
|
-
"test:unit": "node --test test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preset-choice.test.mjs test/sandbox.test.mjs",
|
|
54
|
+
"test:unit": "node --test test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preset-choice.test.mjs test/sandbox.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/fs-publish.test.mjs test/agent-exec.test.mjs test/ports.test.mjs test/watcher.test.mjs",
|
|
52
55
|
"lint:style": "node test/style.mjs",
|
|
53
56
|
"prepublishOnly": "npm test",
|
|
54
57
|
"probe": "bash test/probe/run.sh",
|
|
@@ -59,7 +62,10 @@
|
|
|
59
62
|
"probe:terminal": "bash test/probe/terminal.sh",
|
|
60
63
|
"probe:sandbox": "bash test/probe/sandbox.sh",
|
|
61
64
|
"probe:sandbox-shell": "bash test/probe/sandbox-shell.sh",
|
|
62
|
-
"sync:windows": "bash test/probe/sync-to-windows.sh"
|
|
65
|
+
"sync:windows": "bash test/probe/sync-to-windows.sh",
|
|
66
|
+
"probe:watch": "bash test/probe/watch.sh",
|
|
67
|
+
"probe:exec": "bash test/probe/exec.sh",
|
|
68
|
+
"bootstrap": "bash scripts/bootstrap.sh"
|
|
63
69
|
},
|
|
64
70
|
"engines": {
|
|
65
71
|
"node": ">=20"
|