dsh-plugin-wsl-env 0.3.0 → 0.5.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 +175 -0
- package/README.md +40 -17
- package/agent/wsl-agent.sh +63 -19
- package/cordis.patch.yml +6 -4
- package/docs/ARCHITECTURE.md +7 -3
- package/docs/CONFIGURATION.md +1 -0
- package/docs/LIMITATIONS.md +17 -1
- package/docs/PARITY.md +5 -4
- package/docs/README.zh.md +32 -10
- package/docs/UPSTREAM-TERMINAL-TITLE.md +81 -0
- package/lib/agent-confined.js +9 -3
- package/lib/agent.js +69 -4
- package/lib/bwrap.js +18 -1
- package/lib/fs-decisions.js +132 -0
- package/lib/fsio-agent.js +2 -2
- package/lib/index.js +36 -24
- package/lib/paths.js +14 -0
- package/lib/shell-env.js +6 -1
- package/locale/auto-preset/en.json +6 -0
- package/locale/auto-preset/zh.json +6 -0
- package/locale/en.json +6 -0
- package/locale/picker/en.json +6 -0
- package/locale/picker/zh.json +6 -0
- package/locale/shell-env/en.json +6 -0
- package/locale/shell-env/zh.json +6 -0
- package/locale/subprocess/en.json +6 -0
- package/locale/subprocess/zh.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +16 -4
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Upstream proposal: a terminal tab title that follows the session's shell
|
|
2
|
+
|
|
3
|
+
Status: proposed upstream as
|
|
4
|
+
[deepseek-ai/deepseek-harness#8769](https://github.com/deepseek-ai/deepseek-harness/discussions/8769)
|
|
5
|
+
(targeting `packages/api/terminal-controller`, version line `0.2.0-rc.x`). This
|
|
6
|
+
is the record of a gap this plugin worked around, could not work around, and
|
|
7
|
+
now documents: the GUI terminal titles every tab from the composition's single
|
|
8
|
+
shell profile, so a per-session shell choice is invisible in the title.
|
|
9
|
+
|
|
10
|
+
## The gap
|
|
11
|
+
|
|
12
|
+
`TerminalController.spawn` fixes a terminal's display title from the resolved
|
|
13
|
+
shell profile before the provider is ever consulted:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
const info: WebTerminalInfo = {
|
|
17
|
+
id: request.id, shell, title: shell.name, cwd: environment.cwd, ...
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`shell` is `resolveShell(subprocess, this.config.shell, signal)` — the
|
|
22
|
+
composition's configured profile (`shell.name: WSL` in this plugin's layer),
|
|
23
|
+
kept verbatim; `resolveExecutable` swaps only the `path`. Two properties of the
|
|
24
|
+
seam make the title unable to follow a per-session shell:
|
|
25
|
+
|
|
26
|
+
- `SubprocessTerminalSpawnSpec` carries no display-name field, so a provider
|
|
27
|
+
that rewrites the launch (this plugin routes a Windows-folder session's
|
|
28
|
+
`wsl.exe` launch to `powershell.exe` — `subprocess-wsl.hostSessions`) cannot
|
|
29
|
+
say what it actually started.
|
|
30
|
+
- The controller never reads the effective program back from the
|
|
31
|
+
`SubprocessTerminalHandle`, so the rewrite is invisible to `info.title`.
|
|
32
|
+
|
|
33
|
+
The result on this plugin's default configuration: a terminal that runs
|
|
34
|
+
PowerShell under a tab that reads `WSL`.
|
|
35
|
+
|
|
36
|
+
## Why a plugin cannot bridge it
|
|
37
|
+
|
|
38
|
+
Three escape hatches were evaluated against `0.2.0-rc.2` and each is closed:
|
|
39
|
+
|
|
40
|
+
1. **Replace the controller row's module with a subclass** that corrects the
|
|
41
|
+
title after `super.spawn` (the class exports itself; `BrowserTerminal.rename`
|
|
42
|
+
is public and lands before `create()` publishes the terminal). Closed by the
|
|
43
|
+
patch-layer contract: *"A truthy name asserts the existing plugin name
|
|
44
|
+
rather than renaming it"* (`packages/boot/app-boot/src/config-schema/document.ts`)
|
|
45
|
+
— an id-targeted patch can replace `config`, but not the module.
|
|
46
|
+
2. **Disable the shipped row and insert the subclass under a new id.** Closed
|
|
47
|
+
by the web composition: the row id is load-bearing. The web-app bundle
|
|
48
|
+
inserts `terminal-controller`, and the client model that the
|
|
49
|
+
`dsh-client-ui-sidebar-terminal` entry waits for mounts with
|
|
50
|
+
`inject = ['remote', 'remote.terminal']` — the `terminal` namespace the host
|
|
51
|
+
controller registers in its constructor. Disabling the row fails web boot:
|
|
52
|
+
`1 entry did not activate: @deepseek-ai/dsh-client-ui-sidebar-terminal:
|
|
53
|
+
pending (waiting for service: webTerminals)`. Verified live against
|
|
54
|
+
`dsh-desktop 0.2.0-rc.2`.
|
|
55
|
+
3. **Per-session config.** The `shell` profile is composition state; `!!js`
|
|
56
|
+
expressions evaluate once at config resolution and reach no Session
|
|
57
|
+
identity.
|
|
58
|
+
|
|
59
|
+
## The proposal
|
|
60
|
+
|
|
61
|
+
Any one of these closes the gap; the first is the smallest and keeps the
|
|
62
|
+
profile name as the fallback:
|
|
63
|
+
|
|
64
|
+
- `SubprocessTerminalSpawnSpec` gains an optional `displayTitle` (or the handle
|
|
65
|
+
exposes the launched `argv`), and `create()` uses it for `info.title` when
|
|
66
|
+
present — falling back to `shell.name`. A provider that rewrites a launch
|
|
67
|
+
states the program it started; every other launch keeps the profile name.
|
|
68
|
+
- Alternatively, the controller derives the title from the effective program
|
|
69
|
+
itself: title `executableName(effectiveArgv[0])` when it differs from
|
|
70
|
+
`shell.path`, else `shell.name`.
|
|
71
|
+
|
|
72
|
+
With either, this plugin deletes the limitation paragraph in
|
|
73
|
+
[LIMITATIONS.md](LIMITATIONS.md) and the `WSL`-over-PowerShell title, for any
|
|
74
|
+
provider that rewrites launches — not just WSL.
|
|
75
|
+
|
|
76
|
+
## Workarounds until then
|
|
77
|
+
|
|
78
|
+
- Double-click a tab's title to rename it (a shipped client feature; the
|
|
79
|
+
controller's `rename` Remote API accepts 1–120 characters).
|
|
80
|
+
- Add `shellCandidates` to the `terminal-controller` row in a user layer: a
|
|
81
|
+
manually selected shell is titled after itself.
|
package/lib/agent-confined.js
CHANGED
|
@@ -48,10 +48,16 @@ const confinedAgents = new Map();
|
|
|
48
48
|
* @param {boolean} [request.maskWindowsDrive] - shadow `/mnt` with an empty
|
|
49
49
|
* tmpfs in the resident's profile; part of the cache key, since it changes
|
|
50
50
|
* the mount table the resident lives under.
|
|
51
|
+
* @param {boolean} [request.realTmp] - default TRUE: workspace-write binds the
|
|
52
|
+
* distro's real `/tmp` read-write into the resident's profile, so a write
|
|
53
|
+
* the fence approved lands where every reader reads. `false` restores the
|
|
54
|
+
* ephemeral tmpfs — for tests and probes, not production: a `/tmp` write
|
|
55
|
+
* behind an ephemeral mount reports success into a directory no reader can
|
|
56
|
+
* see, which is the bug this default exists to prevent.
|
|
51
57
|
* @returns {WslAgent} the confined resident for this key.
|
|
52
58
|
*/
|
|
53
|
-
export function confinedAgent({ distro, wslPath = "wsl.exe", policy, maskWindowsDrive = false }) {
|
|
54
|
-
const key = `${wslPath}\0${distro}\0${policy.mode}\0${policy.workspaceRoot}\0${maskWindowsDrive ? "masked" : "visible"}`;
|
|
59
|
+
export function confinedAgent({ distro, wslPath = "wsl.exe", policy, maskWindowsDrive = false, realTmp = true }) {
|
|
60
|
+
const key = `${wslPath}\0${distro}\0${policy.mode}\0${policy.workspaceRoot}\0${maskWindowsDrive ? "masked" : "visible"}\0${realTmp ? "real" : "ephemeral"}`;
|
|
55
61
|
let agent = confinedAgents.get(key);
|
|
56
62
|
if (agent === undefined) {
|
|
57
63
|
agent = new WslAgent({
|
|
@@ -65,7 +71,7 @@ export function confinedAgent({ distro, wslPath = "wsl.exe", policy, maskWindows
|
|
|
65
71
|
// `lib/bwrap.js`, whose module doc records what happened when they did not.
|
|
66
72
|
argvPrefix: bwrapArgvPrefix(
|
|
67
73
|
{ mode: policy.mode, workspaceRoot: toLinuxPath(policy.workspaceRoot, { distro }) },
|
|
68
|
-
{ maskWindowsDrive },
|
|
74
|
+
{ maskWindowsDrive, realTmp },
|
|
69
75
|
),
|
|
70
76
|
});
|
|
71
77
|
confinedAgents.set(key, agent);
|
package/lib/agent.js
CHANGED
|
@@ -44,6 +44,50 @@ const HANDSHAKE_TIMEOUT_MS = 15000;
|
|
|
44
44
|
/** Extra time the host watchdog waits past the request's own in-distro timeout. */
|
|
45
45
|
const WATCHDOG_GRACE_MS = 13000;
|
|
46
46
|
|
|
47
|
+
/** The Windows essentials `wsl.exe` itself needs to start and report cleanly. */
|
|
48
|
+
const WINDOWS_ESSENTIALS = /^(systemroot|systemdrive|windir|path|pathext|comspec|os|computername)$/i;
|
|
49
|
+
|
|
50
|
+
/** The managed facts prefix whose names (and only theirs) may be forwarded. */
|
|
51
|
+
const MANAGED_ENV_PREFIX = "DSH_";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The Windows environment the transport starts from, pinned.
|
|
55
|
+
*
|
|
56
|
+
* Spawned bare, `wsl.exe` would inherit this process's whole environment — and
|
|
57
|
+
* the `WSLENV` variable riding along decides which of those names are
|
|
58
|
+
* FORWARDED into the distro, where every model command inherits them. Dev
|
|
59
|
+
* machines routinely route secrets that way (`WSLENV=GITHUB_TOKEN/u` is the
|
|
60
|
+
* documented example), so the resident's env is built from scratch instead:
|
|
61
|
+
* the Windows essentials `wsl.exe` itself needs, the managed `DSH_*` facts,
|
|
62
|
+
* and a `WSLENV` that names only those managed entries — preserving their
|
|
63
|
+
* original forwarding flags. Nothing else on the Windows side can reach a
|
|
64
|
+
* model command.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately NOT here: the executor's `forwardEnv` names. They ride the
|
|
67
|
+
* one-shot path's WSLENV (`spawnSpec` builds it per call); the resident path
|
|
68
|
+
* has never forwarded them, and this pin keeps that posture instead of
|
|
69
|
+
* widening it silently.
|
|
70
|
+
*
|
|
71
|
+
* @param {Record<string, string|undefined>} parent - this process's environment.
|
|
72
|
+
* @returns {Record<string, string>} the pinned environment for `wsl.exe`.
|
|
73
|
+
*/
|
|
74
|
+
export function pinnedWindowsEnv(parent) {
|
|
75
|
+
const env = {};
|
|
76
|
+
for (const [name, value] of Object.entries(parent)) {
|
|
77
|
+
if (value === undefined) continue;
|
|
78
|
+
if (WINDOWS_ESSENTIALS.test(name) || name.startsWith(MANAGED_ENV_PREFIX)) env[name] = value;
|
|
79
|
+
}
|
|
80
|
+
env.WSLENV = String(parent.WSLENV ?? "")
|
|
81
|
+
.split(":")
|
|
82
|
+
.filter((entry) => entry.startsWith(MANAGED_ENV_PREFIX))
|
|
83
|
+
.join(":");
|
|
84
|
+
// wsl.exe writes its own diagnostics as UTF-16LE without this, and a UTF-16
|
|
85
|
+
// message read as UTF-8 is mojibake — including the `WSL_E_*` codes. Same
|
|
86
|
+
// pin the one-shot path applies to every spawn.
|
|
87
|
+
env.WSL_UTF8 = "1";
|
|
88
|
+
return env;
|
|
89
|
+
}
|
|
90
|
+
|
|
47
91
|
/**
|
|
48
92
|
* @param {object} options - the spawn options.
|
|
49
93
|
* @param {string} options.wslPath - the `wsl.exe` path.
|
|
@@ -55,6 +99,7 @@ const WATCHDOG_GRACE_MS = 13000;
|
|
|
55
99
|
*/
|
|
56
100
|
function spawnDefault({ wslPath, distro, scriptPath, argvPrefix = [] }) {
|
|
57
101
|
return spawn(wslPath, ["-d", distro, "--exec", ...argvPrefix, "sh", scriptPath], {
|
|
102
|
+
env: pinnedWindowsEnv(process.env),
|
|
58
103
|
stdio: ["pipe", "pipe", "pipe"],
|
|
59
104
|
windowsHide: true,
|
|
60
105
|
});
|
|
@@ -309,7 +354,13 @@ export class WslAgent {
|
|
|
309
354
|
const id = `r${this.nextRequestId++}`;
|
|
310
355
|
pending.id = id;
|
|
311
356
|
this.pending.set(id, pending);
|
|
312
|
-
|
|
357
|
+
// The rebuilt transport can itself be gone by now; a frame that
|
|
358
|
+
// cannot be written must fail its caller, not strand the entry.
|
|
359
|
+
if (!this.writeLines(relabelFrame(pending.lines, id))) {
|
|
360
|
+
this.clearPending(pending);
|
|
361
|
+
this.pending.delete(id);
|
|
362
|
+
pending.reject(new AgentUnavailableError(`agent exited before request ${id} could be re-sent; its state is unknown`));
|
|
363
|
+
}
|
|
313
364
|
}
|
|
314
365
|
})
|
|
315
366
|
.catch((error) => {
|
|
@@ -381,7 +432,16 @@ export class WslAgent {
|
|
|
381
432
|
};
|
|
382
433
|
signal.addEventListener("abort", pending.onAbort, { once: true });
|
|
383
434
|
}
|
|
384
|
-
|
|
435
|
+
// A frame that cannot be WRITTEN must fail its caller now. Silently
|
|
436
|
+
// dropping it used to strand the request: nothing would ever answer, the
|
|
437
|
+
// watchdog (armed on an ACK that will not come) had no process to kill,
|
|
438
|
+
// and with no timeout the caller waited forever.
|
|
439
|
+
if (!this.writeLines(lines)) {
|
|
440
|
+
this.clearPending(pending);
|
|
441
|
+
this.pending.delete(pending.id);
|
|
442
|
+
reject(new AgentUnavailableError("the agent exited before the request could be sent; it never ran"));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
385
445
|
this.rearmIdleTimer();
|
|
386
446
|
}
|
|
387
447
|
|
|
@@ -491,15 +551,20 @@ export class WslAgent {
|
|
|
491
551
|
/** @private */
|
|
492
552
|
writeLines(lines) {
|
|
493
553
|
const child = this.process;
|
|
494
|
-
if (!child || !child.stdin.writable) return;
|
|
554
|
+
if (!child || !child.stdin.writable) return false;
|
|
495
555
|
child.stdin.write(`${lines.join("\n")}\n`);
|
|
496
556
|
this.rearmIdleTimer();
|
|
557
|
+
return true;
|
|
497
558
|
}
|
|
498
559
|
|
|
499
560
|
/** @private */
|
|
500
561
|
writeLinesSafe(child, lines) {
|
|
562
|
+
// The writable check matters beyond politeness: a write to an ENDED stdin
|
|
563
|
+
// does not throw here — it emits an async 'error' event the caller never
|
|
564
|
+
// listens for, which crashes the process during its own shutdown.
|
|
565
|
+
if (!child.stdin || !child.stdin.writable) return;
|
|
501
566
|
try {
|
|
502
|
-
child.stdin
|
|
567
|
+
child.stdin.write(`${lines.join("\n")}\n`);
|
|
503
568
|
} catch {
|
|
504
569
|
// A dead pipe is what close() is cleaning up after.
|
|
505
570
|
}
|
package/lib/bwrap.js
CHANGED
|
@@ -40,15 +40,32 @@
|
|
|
40
40
|
* (binfmt interop dispatches on file content, not location), so `enforcement`
|
|
41
41
|
* stays `partial` — see `lib/sandbox.js`.
|
|
42
42
|
*
|
|
43
|
+
* `options.realTmp` changes what workspace-write does at `/tmp`. The default
|
|
44
|
+
* (`--tmpfs /tmp`) gives the sandbox an ephemeral, empty temp area — the right
|
|
45
|
+
* shape for a COMMAND: fresh per run, cannot touch the distro's real `/tmp`,
|
|
46
|
+
* cannot be poisoned by a previous run. `realTmp: true` binds the REAL `/tmp`
|
|
47
|
+
* read-write instead — the right shape for the FILE-WRITES resident: the
|
|
48
|
+
* fence grants `/tmp` as a writable root, so a write the fence approved must
|
|
49
|
+
* land where every reader (the plain agent, the user's own shell) reads — not
|
|
50
|
+
* inside a private tmpfs that reads cannot see and that dies with the
|
|
51
|
+
* resident. Read-only mode mounts no `/tmp` at all, so the option is inert
|
|
52
|
+
* there.
|
|
53
|
+
*
|
|
43
54
|
* @param policy - the policy to express as bwrap mounts.
|
|
44
55
|
* @param options - profile options.
|
|
45
56
|
* @param options.maskWindowsDrive - shadow `/mnt` with an empty tmpfs.
|
|
57
|
+
* @param options.realTmp - workspace-write binds the real `/tmp` read-write
|
|
58
|
+
* instead of mounting an ephemeral tmpfs over it.
|
|
46
59
|
* @returns profile arguments, before the `--` separator and the command argv.
|
|
47
60
|
*/
|
|
48
61
|
export function bwrapProfileArgs(policy, options = {}) {
|
|
49
62
|
const args = ["--ro-bind", "/", "/", "--dev", "/dev", "--unshare-pid", "--proc", "/proc", "--die-with-parent"];
|
|
50
63
|
if (policy.mode === "workspace-write") {
|
|
51
|
-
|
|
64
|
+
if (options.realTmp) {
|
|
65
|
+
args.push("--bind", "/tmp", "/tmp");
|
|
66
|
+
} else {
|
|
67
|
+
args.push("--tmpfs", "/tmp");
|
|
68
|
+
}
|
|
52
69
|
args.push("--bind", policy.workspaceRoot, policy.workspaceRoot);
|
|
53
70
|
}
|
|
54
71
|
if (options.maskWindowsDrive) {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The filesystem provider's POLICY DECISIONS, extracted from
|
|
3
|
+
* `lib/index.js` so they are unit-testable in a bare checkout: which paths are
|
|
4
|
+
* refused for naming another distro, which mode refuses a mutation outright,
|
|
5
|
+
* which write guards refuse which intents, and how a substrate failure maps
|
|
6
|
+
* onto the refusal the tool layer surfaces. Pure in, descriptor out — no I/O,
|
|
7
|
+
* no peers (the provider wraps these descriptors in `FsError` at one choke
|
|
8
|
+
* point, keeping the peer import out of this module and the decisions honest).
|
|
9
|
+
*
|
|
10
|
+
* Every message here is model-facing and must stay byte-identical to what the
|
|
11
|
+
* provider produced before the extraction — the tool layer and the model match
|
|
12
|
+
* on these strings.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-plugin-wsl/fs-decisions
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { isAnotherDistrosUnc } from "./paths.js";
|
|
18
|
+
|
|
19
|
+
/** A refusal descriptor the provider wraps in `FsError` at one choke point. */
|
|
20
|
+
const refusal = (message, code) => ({ message, code });
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The worldPath decision: a path that resolves into another WSL distro is
|
|
24
|
+
* refused while `restrictToDistro` holds — deliberately NOT a sandbox denial,
|
|
25
|
+
* because no wider permission can lift a configuration choice.
|
|
26
|
+
* @param {object} input - the decision's inputs.
|
|
27
|
+
* @param {string} input.path - the path as the caller spelled it.
|
|
28
|
+
* @param {string} input.world - the resolved world path (a UNC for distro paths).
|
|
29
|
+
* @param {string} input.distro - the pinned distro name.
|
|
30
|
+
* @param {boolean} input.restrictToDistro - the configuration fence.
|
|
31
|
+
* @returns {{message: string, code: string}|null} the refusal, or null to proceed.
|
|
32
|
+
*/
|
|
33
|
+
export function outsideDistroRefusal({ path, world, distro, restrictToDistro }) {
|
|
34
|
+
if (!restrictToDistro || !isAnotherDistrosUnc(world, distro)) return null;
|
|
35
|
+
return refusal(
|
|
36
|
+
`"${path}" names a path in another WSL distro, and this profile pins its filesystem to "${distro}" ` +
|
|
37
|
+
`(restrictToDistro: true). Open a session in that distro, or set restrictToDistro: false.`,
|
|
38
|
+
"FS_OUTSIDE_DISTRO",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The mutationAgent decision: does THIS mutation route to the confined
|
|
44
|
+
* resident? An absent policy (caller pre-checked), a disabled sandbox, and an
|
|
45
|
+
* approved escalation all address the plain resident — which is what those
|
|
46
|
+
* mean.
|
|
47
|
+
* @param {object} input - the decision's inputs.
|
|
48
|
+
* @param {{mode: string, workspaceRoot: string}|undefined} input.policy - the
|
|
49
|
+
* resolved file-effect policy.
|
|
50
|
+
* @param {boolean} input.sandboxEnabled - the provider's `sandbox` configuration.
|
|
51
|
+
* @returns {boolean} true when the mutation belongs to the confined resident.
|
|
52
|
+
*/
|
|
53
|
+
export function isConfinedMutation({ policy, sandboxEnabled }) {
|
|
54
|
+
return Boolean(policy) && Boolean(sandboxEnabled) && policy.mode !== "danger-full-access";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The mode-level mutation decision: `read-only` refuses every write outright;
|
|
59
|
+
* `danger-full-access` is the approved escalation and refuses nothing.
|
|
60
|
+
* `workspace-write` refuses nothing HERE — its containment check needs the
|
|
61
|
+
* fresh target and runs in the provider.
|
|
62
|
+
* @param {string} mode - the resolved policy mode.
|
|
63
|
+
* @param {string} displayPath - the caller-facing path for the message.
|
|
64
|
+
* @returns {{message: string, code: string}|null} the refusal, or null to proceed.
|
|
65
|
+
*/
|
|
66
|
+
export function mutationModeRefusal(mode, displayPath) {
|
|
67
|
+
if (mode !== "read-only") return null;
|
|
68
|
+
return refusal(`cannot write "${displayPath}": file access denied under read-only mode`, "FS_SANDBOX_DENIED");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The containment denial for a `workspace-write` mutation whose fresh target
|
|
73
|
+
* sat under no writable root.
|
|
74
|
+
* @param {string} displayPath - the caller-facing path for the message.
|
|
75
|
+
* @returns {{message: string, code: string}} the refusal descriptor.
|
|
76
|
+
*/
|
|
77
|
+
export function workspaceWriteDenial(displayPath) {
|
|
78
|
+
return refusal(`cannot write "${displayPath}": file access denied under workspace-write mode`, "FS_SANDBOX_DENIED");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The write/edit guard: compare the intent (`expected`) with what is on disk
|
|
83
|
+
* NOW (`existing`, a stat row or `undefined`) and refuse the stale or
|
|
84
|
+
* unobserved intent. The peer's dialect, byte for byte.
|
|
85
|
+
* @param {object|undefined} expected - the write intent (`replaceIfVersion`
|
|
86
|
+
* with a `version`, or `createIfAbsent`), or `undefined` for an unguarded write.
|
|
87
|
+
* @param {{version: string}|undefined} existing - what stat found, or undefined.
|
|
88
|
+
* @param {string} displayPath - the caller-facing path for the message.
|
|
89
|
+
* @returns {{message: string, code: string}|null} the refusal, or null to proceed.
|
|
90
|
+
*/
|
|
91
|
+
export function guardRefusal(expected, existing, displayPath) {
|
|
92
|
+
if (expected === undefined) return null;
|
|
93
|
+
if (expected.kind === "replaceIfVersion") {
|
|
94
|
+
if (existing === undefined) return refusal(`cannot write "${displayPath}": file no longer exists`, "FS_STALE_VERSION");
|
|
95
|
+
if (existing.version !== expected.version) {
|
|
96
|
+
return refusal(`cannot write "${displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
if (expected.kind === "createIfAbsent" && existing !== undefined) {
|
|
101
|
+
return refusal(`cannot overwrite existing "${displayPath}" without reading it first`, "FS_NOT_OBSERVED");
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The substrate failure mapping: what the tool layer should surface for an
|
|
108
|
+
* error that crossed the `ctx.fs` seam. A coded refusal keeps its code and
|
|
109
|
+
* message byte-for-byte; anything else is the substrate being UNAVAILABLE —
|
|
110
|
+
* the share backend's "distro is gone" case wearing the agent substrate's
|
|
111
|
+
* wording — and the message names the documented opt-out, so an operator hit
|
|
112
|
+
* by the fail-closed posture can reach the old behaviour without a doc trip.
|
|
113
|
+
* @param {unknown} error - what the substrate raised.
|
|
114
|
+
* @returns {{message: string, code: string, cause: unknown}} the refusal the
|
|
115
|
+
* provider wraps in `FsError`.
|
|
116
|
+
*/
|
|
117
|
+
export function substrateFailure(error) {
|
|
118
|
+
if (isCoded(error)) return { message: error.message, code: error.code, cause: error };
|
|
119
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
120
|
+
return {
|
|
121
|
+
message:
|
|
122
|
+
`the distro file substrate is unavailable: ${message}. ` +
|
|
123
|
+
`Set "substrate: \\"share\\"" in the provider config to work on the Windows-side share while the agent is out.`,
|
|
124
|
+
code: "FS_IO_ERROR",
|
|
125
|
+
cause: error instanceof Error ? error : undefined,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A coded refusal: our `FsCodedError` duck (a message, a string `FS_*` code). */
|
|
130
|
+
function isCoded(error) {
|
|
131
|
+
return error instanceof Error && typeof error.code === "string" && error.code.startsWith("FS_");
|
|
132
|
+
}
|
package/lib/fsio-agent.js
CHANGED
|
@@ -68,7 +68,7 @@ export function parseStatRecord(stdout) {
|
|
|
68
68
|
const [type, mode, size, dev, ino, mtime, ctime] = stdout.toString("utf8").split("\t");
|
|
69
69
|
return {
|
|
70
70
|
type,
|
|
71
|
-
mode: Number.parseInt(mode, 8) &
|
|
71
|
+
mode: Number.parseInt(mode, 8) & 0o7777,
|
|
72
72
|
size: Number(size),
|
|
73
73
|
dev,
|
|
74
74
|
ino,
|
|
@@ -419,7 +419,7 @@ export class DistroFs {
|
|
|
419
419
|
"write",
|
|
420
420
|
[
|
|
421
421
|
linuxPath,
|
|
422
|
-
mode === undefined ? "-" : (mode &
|
|
422
|
+
mode === undefined ? "-" : (mode & 0o7777).toString(8),
|
|
423
423
|
createIfAbsent ? "no-replace" : "replace",
|
|
424
424
|
expectedVersion ?? "-",
|
|
425
425
|
content,
|
package/lib/index.js
CHANGED
|
@@ -43,7 +43,8 @@ import { LocalBashExecutor } from "@deepseek-ai/dsh-bash-local";
|
|
|
43
43
|
import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
|
|
44
44
|
import { FsError } from "@deepseek-ai/dsh-fs";
|
|
45
45
|
import { canonicalPath, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature, SandboxUnavailableError, writableRoots } from "@deepseek-ai/dsh-sandbox";
|
|
46
|
-
import { isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixToUnc, toDisplayPath, toLinuxPath, toWorldPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
|
|
46
|
+
import { isAnotherDistrosUnc, isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixToUnc, toDisplayPath, toLinuxPath, toWorldPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
|
|
47
|
+
import { guardRefusal, isConfinedMutation, mutationModeRefusal, outsideDistroRefusal, substrateFailure, workspaceWriteDenial } from "./fs-decisions.js";
|
|
47
48
|
import { WslSandbox } from "./sandbox.js";
|
|
48
49
|
import { armDistroWatcher } from "./watcher.js";
|
|
49
50
|
import { WslAgent } from "./agent.js";
|
|
@@ -534,6 +535,7 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
534
535
|
const distro = await this.distro();
|
|
535
536
|
const shell = await this.shell();
|
|
536
537
|
const resolved = await this.withDefaultWorkdir(spec);
|
|
538
|
+
this.throwIfWorkdirInAnotherDistro(resolved.workdir, distro);
|
|
537
539
|
if (this.config.agent) {
|
|
538
540
|
try {
|
|
539
541
|
return await this.executeViaAgent(resolved, distro, shell);
|
|
@@ -546,6 +548,26 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
546
548
|
return this.executeOneShot(resolved, distro, shell);
|
|
547
549
|
}
|
|
548
550
|
|
|
551
|
+
/**
|
|
552
|
+
* Refuse a workdir that names another WSL distro — the shell-side twin of
|
|
553
|
+
* the filesystem's `FS_OUTSIDE_DISTRO` fence. Left unguarded, `toLinuxPath`
|
|
554
|
+
* strips the UNC's distro name and the command runs inside THIS distro
|
|
555
|
+
* against whatever tree happens to share the path, silently, while the fs
|
|
556
|
+
* side refuses the same workspace outright. The wording mirrors the fs
|
|
557
|
+
* refusal (minus `restrictToDistro`, which is an fs-only key).
|
|
558
|
+
* @param workdir - the resolved workdir (UNC, Linux path, or Windows path).
|
|
559
|
+
* @param distro - the pinned distro name.
|
|
560
|
+
* @throws Error naming both distros and both ways out.
|
|
561
|
+
*/
|
|
562
|
+
throwIfWorkdirInAnotherDistro(workdir, distro) {
|
|
563
|
+
if (!isAnotherDistrosUnc(workdir, distro)) return;
|
|
564
|
+
throw new Error(
|
|
565
|
+
`"${workdir}" names a workspace in another WSL distro, and this profile pins its commands to "${distro}" — ` +
|
|
566
|
+
`running them here would act on a different machine's tree. Open a session in that distro, ` +
|
|
567
|
+
`or point cwd/workdir at this distro.`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
549
571
|
/**
|
|
550
572
|
* The agent-backed execution path: one long-lived in-distro process, the
|
|
551
573
|
* confinement argv unchanged, the handle shape unchanged. Any
|
|
@@ -929,7 +951,7 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
929
951
|
* @returns {Promise<WslAgent>} the agent to publish through.
|
|
930
952
|
*/
|
|
931
953
|
async mutationAgent(policy) {
|
|
932
|
-
if (!policy
|
|
954
|
+
if (!isConfinedMutation({ policy, sandboxEnabled: this.config.sandbox })) {
|
|
933
955
|
return this.publicationAgent();
|
|
934
956
|
}
|
|
935
957
|
const distro = await this.distro();
|
|
@@ -955,8 +977,8 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
955
977
|
peerError(error) {
|
|
956
978
|
if (error instanceof FsCodedError) return new FsError(error.message, error.code, { cause: error });
|
|
957
979
|
if (error instanceof SandboxUnavailableError) return new FsError(error.message, "FS_IO_ERROR", { cause: error });
|
|
958
|
-
const message
|
|
959
|
-
return new FsError(
|
|
980
|
+
const { message, code, cause } = substrateFailure(error);
|
|
981
|
+
return new FsError(message, code, cause === undefined ? {} : { cause });
|
|
960
982
|
}
|
|
961
983
|
|
|
962
984
|
/**
|
|
@@ -994,15 +1016,12 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
994
1016
|
base = await distroHome(this);
|
|
995
1017
|
}
|
|
996
1018
|
const world = toWorldPath(path, { distro, cwd: base });
|
|
997
|
-
|
|
1019
|
+
const refusal = outsideDistroRefusal({ path, world, distro, restrictToDistro: this.config.restrictToDistro });
|
|
1020
|
+
if (refusal !== null) {
|
|
998
1021
|
// Deliberately NOT `FS_SANDBOX_DENIED`. The file-tool layer turns that code
|
|
999
1022
|
// into a denial marker plus an escalation offer, and no wider permission can
|
|
1000
1023
|
// lift this fence: it is a configuration choice, not a sandbox decision.
|
|
1001
|
-
throw new FsError(
|
|
1002
|
-
`"${path}" names a path in another WSL distro, and this profile pins its filesystem to "${distro}" ` +
|
|
1003
|
-
`(restrictToDistro: true). Open a session in that distro, or set restrictToDistro: false.`,
|
|
1004
|
-
"FS_OUTSIDE_DISTRO",
|
|
1005
|
-
);
|
|
1024
|
+
throw new FsError(refusal.message, refusal.code);
|
|
1006
1025
|
}
|
|
1007
1026
|
return world;
|
|
1008
1027
|
}
|
|
@@ -1053,17 +1072,16 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
1053
1072
|
async checkedTarget(target, sandboxPolicy) {
|
|
1054
1073
|
if (!this.config.sandbox) return target;
|
|
1055
1074
|
const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
|
|
1056
|
-
const
|
|
1057
|
-
if (
|
|
1058
|
-
if (mode
|
|
1059
|
-
throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, "FS_SANDBOX_DENIED");
|
|
1060
|
-
}
|
|
1075
|
+
const modeRefusal = mutationModeRefusal(policy.mode, target.displayPath);
|
|
1076
|
+
if (modeRefusal !== null) throw new FsError(modeRefusal.message, modeRefusal.code);
|
|
1077
|
+
if (policy.mode !== "workspace-write") return target;
|
|
1061
1078
|
const fresh = await this.resolve(target.displayPath);
|
|
1062
1079
|
const distro = await this.distro();
|
|
1063
1080
|
for (const root of wslWritableRoots(policy, distro)) {
|
|
1064
1081
|
if (isWorldPathUnder(fresh.targetKey, root, { distro })) return fresh;
|
|
1065
1082
|
}
|
|
1066
|
-
|
|
1083
|
+
const denial = workspaceWriteDenial(target.displayPath);
|
|
1084
|
+
throw new FsError(denial.message, denial.code);
|
|
1067
1085
|
}
|
|
1068
1086
|
|
|
1069
1087
|
/**
|
|
@@ -1297,14 +1315,8 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
1297
1315
|
async assertGuard(target, expected, signal) {
|
|
1298
1316
|
if (expected === undefined) return;
|
|
1299
1317
|
const existing = await this.stat(target, signal);
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
if (existing.version !== expected.version) throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
1303
|
-
return;
|
|
1304
|
-
}
|
|
1305
|
-
if (expected.kind === "createIfAbsent" && existing !== undefined) {
|
|
1306
|
-
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, "FS_NOT_OBSERVED");
|
|
1307
|
-
}
|
|
1318
|
+
const refusal = guardRefusal(expected, existing, target.displayPath);
|
|
1319
|
+
if (refusal !== null) throw new FsError(refusal.message, refusal.code);
|
|
1308
1320
|
}
|
|
1309
1321
|
|
|
1310
1322
|
/**
|
package/lib/paths.js
CHANGED
|
@@ -165,6 +165,20 @@ export function isUnderDistro(uncPath, distro) {
|
|
|
165
165
|
return parsed !== undefined && parsed.distro.toLowerCase() === String(distro).toLowerCase();
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* A WSL UNC that names a distro OTHER than the pinned one — the shape both
|
|
170
|
+
* shell seams refuse: the shell executor will not run commands in it (running
|
|
171
|
+
* there would mean silently switching distros on path similarity), and the
|
|
172
|
+
* shell-env facts describe only the pinned distro, so a foreign session gets
|
|
173
|
+
* no facts rather than another distro's home and shell.
|
|
174
|
+
* @param uncPath - the candidate path (any spelling; non-UNC values are not foreign).
|
|
175
|
+
* @param distro - the pinned distro name.
|
|
176
|
+
* @returns true when the value is a WSL UNC under a different distro.
|
|
177
|
+
*/
|
|
178
|
+
export function isAnotherDistrosUnc(value, distro) {
|
|
179
|
+
return isWslUnc(value) && !isUnderDistro(value, distro);
|
|
180
|
+
}
|
|
181
|
+
|
|
168
182
|
/**
|
|
169
183
|
* Translate a Windows drive path into its WSL interop mount.
|
|
170
184
|
* @param winPath - e.g. `C:\Users\andyz\Documents`.
|
package/lib/shell-env.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import z from "@deepseek-ai/schemastery";
|
|
27
|
-
import { isWslUnc } from "./paths.js";
|
|
27
|
+
import { isAnotherDistrosUnc, isWslUnc } from "./paths.js";
|
|
28
28
|
import { defaultDistro, defaultShell, linuxHomePath } from "./wsl.js";
|
|
29
29
|
import { sharedAgent } from "./agent-shared.js";
|
|
30
30
|
import { listeningPorts } from "./ports.js";
|
|
@@ -82,6 +82,11 @@ export async function apply(ctx, config = {}) {
|
|
|
82
82
|
resolve: (execution) => {
|
|
83
83
|
const cwd = execution?.agent?.session?.header?.cwd;
|
|
84
84
|
if (!isWslUnc(cwd)) return {};
|
|
85
|
+
// The facts describe ONE distro. A workspace under another distro's UNC
|
|
86
|
+
// is not that distro — the shell refuses its commands outright (the
|
|
87
|
+
// cross-distro guard) — so the honest resolution is no facts at all,
|
|
88
|
+
// not another distro's home and shell presented as this session's.
|
|
89
|
+
if (isAnotherDistrosUnc(cwd, distro)) return {};
|
|
85
90
|
return {
|
|
86
91
|
DSH_WSL_DISTRO: distro,
|
|
87
92
|
DSH_WSL_SHELL: shell,
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "WSL execution environment",
|
|
4
|
+
"description": "Runs a DeepSeek Harness session against a WSL distro: shell, file reads/writes and command execution happen inside the distro, the directory picker and the GUI terminal target distro paths, and commands are confined by in-distro bubblewrap."
|
|
5
|
+
}
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "WSL-aware directory picker",
|
|
4
|
+
"description": "The workspace picker for both worlds: its root level lists the Windows home directory and every installed distro, so host and WSL workspaces share one dialog. The directory it returns is what auto-preset reads to choose the environment."
|
|
5
|
+
}
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "WSL shell environment facts",
|
|
4
|
+
"description": "Contributes the WSL environment's facts (distro, real login shell, home, listening ports) to the managed DSH_* namespace injected into every model shell call — for sessions whose workspace is inside a distro."
|
|
5
|
+
}
|
|
6
|
+
}
|