dsh-plugin-wsl-env 0.8.0 → 0.10.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 +488 -12
- package/CONTRIBUTING.md +125 -62
- package/README.md +99 -68
- package/SECURITY.md +18 -2
- package/SUPPORT.md +26 -10
- package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
- package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
- package/agent/wsl-agent.sh +83 -16
- package/cordis.patch.yml +50 -35
- package/docs/ARCHITECTURE.md +203 -55
- package/docs/CONFIGURATION.md +185 -18
- package/docs/LIMITATIONS.md +125 -45
- package/docs/PARITY.md +68 -38
- package/docs/PEER-PARITY.md +103 -33
- package/docs/README.zh.md +105 -64
- package/docs/UPSTREAM-FSIO-EXPORT.md +159 -50
- package/docs/UPSTREAM-TERMINAL-TITLE.md +56 -14
- package/examples/preset-wsl-session-sandbox.cordis.patch.yml +178 -0
- package/lib/agent-protocol.d.ts +10 -4
- package/lib/agent-protocol.js +10 -3
- package/lib/agent.d.ts +84 -0
- package/lib/agent.js +167 -15
- package/lib/bwrap.d.ts +8 -0
- package/lib/bwrap.js +4 -1
- package/lib/fs-routing.d.ts +40 -4
- package/lib/fs-routing.js +80 -12
- package/lib/fsio-agent.js +1 -1
- package/lib/index.d.ts +134 -0
- package/lib/index.js +202 -9
- package/lib/native-agent.d.ts +124 -0
- package/lib/native-agent.js +198 -0
- package/lib/ports.d.ts +13 -2
- package/lib/ports.js +18 -5
- package/lib/preflight.d.ts +91 -0
- package/lib/preflight.js +117 -0
- package/lib/probe-cache.d.ts +12 -1
- package/lib/probe-cache.js +11 -3
- package/lib/sandbox-core.d.ts +70 -0
- package/lib/sandbox-core.js +187 -18
- package/lib/session-runtime.d.ts +161 -0
- package/lib/session-runtime.js +220 -0
- package/lib/session-scope.d.ts +124 -0
- package/lib/session-scope.js +191 -0
- package/lib/shell-env.js +10 -3
- package/lib/watcher.d.ts +25 -12
- package/lib/watcher.js +120 -32
- package/lib/workspace-files-route.d.ts +8 -4
- package/lib/workspace-files-route.js +42 -14
- package/lib/workspace-files-wsl.js +25 -8
- package/lib/wsl.js +10 -4
- package/locale/preflight/en.json +6 -0
- package/locale/preflight/zh.json +6 -0
- package/package.json +44 -27
- package/scripts/diagnose.sh +92 -0
package/lib/ports.js
CHANGED
|
@@ -55,17 +55,30 @@ export function procNetScript() {
|
|
|
55
55
|
*
|
|
56
56
|
* @param runner - the {@link WslAgent}-like runner.
|
|
57
57
|
* @param timeoutMs - the exec budget.
|
|
58
|
-
* @returns listening ports, ascending;
|
|
59
|
-
*
|
|
58
|
+
* @returns listening ports, ascending; undefined when the scan could not
|
|
59
|
+
* answer (agent out, non-zero exit), so a caller can keep the last snapshot.
|
|
60
60
|
*/
|
|
61
|
-
export async function
|
|
61
|
+
export async function scanListeningPorts(runner, timeoutMs = 10000) {
|
|
62
62
|
try {
|
|
63
63
|
const result = await runner.exec({ cwd: "/", argv: ["sh", "-c", procNetScript(), "ports"], timeoutMs });
|
|
64
64
|
if (result.exitCode !== 0)
|
|
65
|
-
return
|
|
65
|
+
return undefined;
|
|
66
66
|
return parseListeningPorts(result.stdout.toString("utf8"));
|
|
67
67
|
}
|
|
68
68
|
catch {
|
|
69
|
-
return
|
|
69
|
+
return undefined;
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Snapshot the distro's listening ports, collapsing a failed scan to an empty
|
|
74
|
+
* list. New callers that must distinguish "none listening" from "the scan
|
|
75
|
+
* failed" should use {@linkcode scanListeningPorts}; this wrapper exists for
|
|
76
|
+
* the probe/test shape that only needs the list.
|
|
77
|
+
*
|
|
78
|
+
* @param runner - the agent-like runner.
|
|
79
|
+
* @param timeoutMs - the exec budget.
|
|
80
|
+
* @returns listening ports, ascending; [] on a failed scan.
|
|
81
|
+
*/
|
|
82
|
+
export async function listeningPorts(runner, timeoutMs = 10000) {
|
|
83
|
+
return (await scanListeningPorts(runner, timeoutMs)) ?? [];
|
|
84
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-start sandbox preflight: surface a missing or broken `bwrap` when a
|
|
3
|
+
* distro session OPENS, not when its first command fails closed.
|
|
4
|
+
*
|
|
5
|
+
* Without this the discovery moment is the model's first command, whose
|
|
6
|
+
* `SANDBOX_UNAVAILABLE` error scrolls past as one tool failure among many; the
|
|
7
|
+
* operator may not read it until several commands have failed the same way.
|
|
8
|
+
* The probe itself is not new — it is the providers' own usability probe, run
|
|
9
|
+
* through the same shared cache (`lib/sandbox-core.js`), so a session-start
|
|
10
|
+
* check costs a real probe only once per process, and the first confined
|
|
11
|
+
* command after it pays nothing. Nothing else changes: the failure semantics
|
|
12
|
+
* stay exactly as fail-closed as before, this service only MOVES THE NEWS.
|
|
13
|
+
*
|
|
14
|
+
* Advisory by construction. A preflight that cannot run (no WSL, a wedged
|
|
15
|
+
* `wsl.exe`) degrades to a log line; it must never fail a session that has not
|
|
16
|
+
* even asked for confinement yet. It also never installs anything: the remedy
|
|
17
|
+
* it prints is the same one the refusals carry, and running an installer under
|
|
18
|
+
* root uninvited would be a posture this plugin explicitly rejects.
|
|
19
|
+
*
|
|
20
|
+
* This is a TypeScript source built to `lib/preflight.js`; edit THIS file and
|
|
21
|
+
* run `pnpm run build` — the artifact under `lib/` is generated, and
|
|
22
|
+
* `pnpm test` fails when it drifts.
|
|
23
|
+
*
|
|
24
|
+
* @module dsh-plugin-wsl/preflight
|
|
25
|
+
*/
|
|
26
|
+
import z from "@deepseek-ai/schemastery";
|
|
27
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
28
|
+
export declare const name = "wsl-preflight";
|
|
29
|
+
export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
30
|
+
/** Distro to preflight; empty means this machine's default distro. */
|
|
31
|
+
distro: z<string, string, "defined">;
|
|
32
|
+
/** The `wsl.exe` path the probe spawns; keep in step with the providers' `wslPath`. */
|
|
33
|
+
wslPath: z<string, string, "defined">;
|
|
34
|
+
/** Whether the session-start sandbox preflight runs at all. */
|
|
35
|
+
sandbox: z<boolean, boolean, "defined">;
|
|
36
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
37
|
+
/** Distro to preflight; empty means this machine's default distro. */
|
|
38
|
+
distro: z<string, string, "defined">;
|
|
39
|
+
/** The `wsl.exe` path the probe spawns; keep in step with the providers' `wslPath`. */
|
|
40
|
+
wslPath: z<string, string, "defined">;
|
|
41
|
+
/** Whether the session-start sandbox preflight runs at all. */
|
|
42
|
+
sandbox: z<boolean, boolean, "defined">;
|
|
43
|
+
}>>, "plain">;
|
|
44
|
+
/** The config schema's validated output. */
|
|
45
|
+
export interface PreflightConfig {
|
|
46
|
+
/** Distro to preflight; empty means this machine's default distro. */
|
|
47
|
+
distro?: string;
|
|
48
|
+
/** The `wsl.exe` path the probe spawns. */
|
|
49
|
+
wslPath?: string;
|
|
50
|
+
/** Whether the session-start sandbox preflight runs at all. */
|
|
51
|
+
sandbox?: boolean;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The slice of a created agent this service inspects; the harness's agent
|
|
55
|
+
* object is structural here, the same view `auto-preset` uses.
|
|
56
|
+
*/
|
|
57
|
+
export interface PreflightAgent {
|
|
58
|
+
/** The agent's id. */
|
|
59
|
+
id: string;
|
|
60
|
+
/** The session stub, when mounted. */
|
|
61
|
+
session?: {
|
|
62
|
+
header?: {
|
|
63
|
+
cwd?: string | undefined;
|
|
64
|
+
};
|
|
65
|
+
} | undefined;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The cordis context extended with the slices this service consumes. `logger`
|
|
69
|
+
* and the agent service belong to the harness, whose declarations are not
|
|
70
|
+
* visible to this plugin, so they are spelled structurally on top of the
|
|
71
|
+
* framework's {@link Context}.
|
|
72
|
+
*/
|
|
73
|
+
export type PreflightContext = Context & {
|
|
74
|
+
logger?: {
|
|
75
|
+
debug?: (message: string) => void;
|
|
76
|
+
warn?: (message: string) => void;
|
|
77
|
+
};
|
|
78
|
+
agents?: {
|
|
79
|
+
list: () => PreflightAgent[];
|
|
80
|
+
};
|
|
81
|
+
on: (event: "agent/created", listener: (payload: {
|
|
82
|
+
agent?: PreflightAgent;
|
|
83
|
+
} | undefined) => void) => () => boolean;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Mount the preflight service.
|
|
87
|
+
*
|
|
88
|
+
* @param ctx - the cordis context.
|
|
89
|
+
* @param config - the schema-validated plugin config.
|
|
90
|
+
*/
|
|
91
|
+
export declare function apply(ctx: PreflightContext, config?: PreflightConfig): Promise<void>;
|
package/lib/preflight.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-start sandbox preflight: surface a missing or broken `bwrap` when a
|
|
3
|
+
* distro session OPENS, not when its first command fails closed.
|
|
4
|
+
*
|
|
5
|
+
* Without this the discovery moment is the model's first command, whose
|
|
6
|
+
* `SANDBOX_UNAVAILABLE` error scrolls past as one tool failure among many; the
|
|
7
|
+
* operator may not read it until several commands have failed the same way.
|
|
8
|
+
* The probe itself is not new — it is the providers' own usability probe, run
|
|
9
|
+
* through the same shared cache (`lib/sandbox-core.js`), so a session-start
|
|
10
|
+
* check costs a real probe only once per process, and the first confined
|
|
11
|
+
* command after it pays nothing. Nothing else changes: the failure semantics
|
|
12
|
+
* stay exactly as fail-closed as before, this service only MOVES THE NEWS.
|
|
13
|
+
*
|
|
14
|
+
* Advisory by construction. A preflight that cannot run (no WSL, a wedged
|
|
15
|
+
* `wsl.exe`) degrades to a log line; it must never fail a session that has not
|
|
16
|
+
* even asked for confinement yet. It also never installs anything: the remedy
|
|
17
|
+
* it prints is the same one the refusals carry, and running an installer under
|
|
18
|
+
* root uninvited would be a posture this plugin explicitly rejects.
|
|
19
|
+
*
|
|
20
|
+
* This is a TypeScript source built to `lib/preflight.js`; edit THIS file and
|
|
21
|
+
* run `pnpm run build` — the artifact under `lib/` is generated, and
|
|
22
|
+
* `pnpm test` fails when it drifts.
|
|
23
|
+
*
|
|
24
|
+
* @module dsh-plugin-wsl/preflight
|
|
25
|
+
*/
|
|
26
|
+
import z from "@deepseek-ai/schemastery";
|
|
27
|
+
import { isWslUnc } from "./paths.js";
|
|
28
|
+
import { defaultDistro, runCapture } from "./wsl.js";
|
|
29
|
+
import { bwrapFailure, bwrapUsable, missingBwrapRemedy } from "./sandbox-core.js";
|
|
30
|
+
import { sessionScopeSweepScript } from "./session-scope.js";
|
|
31
|
+
export const name = "wsl-preflight";
|
|
32
|
+
export const Config = z.object({
|
|
33
|
+
/** Distro to preflight; empty means this machine's default distro. */
|
|
34
|
+
distro: z.string().default(""),
|
|
35
|
+
/** The `wsl.exe` path the probe spawns; keep in step with the providers' `wslPath`. */
|
|
36
|
+
wslPath: z.string().default("wsl.exe"),
|
|
37
|
+
/** Whether the session-start sandbox preflight runs at all. */
|
|
38
|
+
sandbox: z.boolean().default(true),
|
|
39
|
+
});
|
|
40
|
+
/** Distros whose stale-scope sweep has already run in this process. */
|
|
41
|
+
const sweptDistros = new Set();
|
|
42
|
+
/**
|
|
43
|
+
* Stop `dsh-wsl-*.scope` units a crashed app left behind. A scope is stale
|
|
44
|
+
* when its cgroup has no `wsl-agent.sh`; a live agent means another session
|
|
45
|
+
* still owns it. Advisory: a failed spawn re-arms the distro for the next
|
|
46
|
+
* session, and the once-per-distro cache keeps the cost off the normal path.
|
|
47
|
+
*
|
|
48
|
+
* @param wslPath - the `wsl.exe` path.
|
|
49
|
+
* @param distro - the resolved distro.
|
|
50
|
+
*/
|
|
51
|
+
const sweepStaleScopes = (wslPath, distro) => {
|
|
52
|
+
if (sweptDistros.has(distro))
|
|
53
|
+
return;
|
|
54
|
+
sweptDistros.add(distro);
|
|
55
|
+
void runCapture([wslPath, "-d", distro, "--exec", "sh", "-c", sessionScopeSweepScript()]).catch(() => {
|
|
56
|
+
sweptDistros.delete(distro);
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Mount the preflight service.
|
|
61
|
+
*
|
|
62
|
+
* @param ctx - the cordis context.
|
|
63
|
+
* @param config - the schema-validated plugin config.
|
|
64
|
+
*/
|
|
65
|
+
export async function apply(ctx, config = {}) {
|
|
66
|
+
if (!(config.sandbox ?? true))
|
|
67
|
+
return;
|
|
68
|
+
const wslPath = config.wslPath ?? "wsl.exe";
|
|
69
|
+
const warn = (message) => ctx.logger?.warn?.(message);
|
|
70
|
+
/**
|
|
71
|
+
* Probe the distro once and surface an unusable `bwrap` as a warning.
|
|
72
|
+
*
|
|
73
|
+
* @param agent - the session that just opened, for the workspace scope.
|
|
74
|
+
*/
|
|
75
|
+
const check = async (agent) => {
|
|
76
|
+
// Only a distro workspace has commands that confine: a host session would
|
|
77
|
+
// warn about a sandbox it never uses.
|
|
78
|
+
const cwd = agent?.session?.header?.cwd;
|
|
79
|
+
if (cwd === undefined || !isWslUnc(cwd))
|
|
80
|
+
return;
|
|
81
|
+
let distro = config.distro ?? "";
|
|
82
|
+
if (distro.length === 0) {
|
|
83
|
+
try {
|
|
84
|
+
distro = await defaultDistro({ wslPath });
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
// No WSL at all is the missing-wsl probe's territory; the first
|
|
88
|
+
// command's own error is the precise one. Stay quiet at warn level.
|
|
89
|
+
ctx.logger?.debug?.(`wsl-preflight: no default distro to preflight (${error?.message ?? error})`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// bwrapUsable answers false on a failed probe — it never rejects — so
|
|
94
|
+
// nothing here can throw except a broken context, which is the caller's
|
|
95
|
+
// bug, not the session's.
|
|
96
|
+
// Clean up what a hard crash left behind before this session creates its
|
|
97
|
+
// runtime; a live agent in another session's cgroup makes its scope safe.
|
|
98
|
+
sweepStaleScopes(wslPath, distro);
|
|
99
|
+
const usable = await bwrapUsable({ wslPath, distro });
|
|
100
|
+
if (usable) {
|
|
101
|
+
ctx.logger?.debug?.(`wsl-preflight: bwrap is usable inside distro "${distro}"`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
warn(`wsl-preflight: ${bwrapFailure(wslPath, distro) ?? missingBwrapRemedy(distro)}`);
|
|
105
|
+
};
|
|
106
|
+
ctx.on("agent/created", (payload) => void check(payload?.agent));
|
|
107
|
+
// Mount order is not a guarantee: a session created during startup can finish
|
|
108
|
+
// before this service mounts and miss the event entirely (dsh-headless does
|
|
109
|
+
// exactly that, and auto-preset sweeps for the same reason).
|
|
110
|
+
try {
|
|
111
|
+
for (const agent of ctx.agents?.list() ?? [])
|
|
112
|
+
void check(agent);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
ctx.logger?.debug?.(`wsl-preflight: could not sweep already-live agents: ${error?.message ?? error}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
package/lib/probe-cache.d.ts
CHANGED
|
@@ -25,10 +25,21 @@ export interface ProbeCache {
|
|
|
25
25
|
*
|
|
26
26
|
* @param key - the probe's cache key.
|
|
27
27
|
* @param probe - the probe to run on a miss. A throw is a failed probe, never
|
|
28
|
-
* a propagated error.
|
|
28
|
+
* a propagated error; its message is remembered for {@link failure}.
|
|
29
29
|
* @returns the verdict; a success is remembered, a failure is not.
|
|
30
30
|
*/
|
|
31
31
|
run(key: string, probe: () => Promise<boolean> | boolean): Promise<boolean>;
|
|
32
|
+
/**
|
|
33
|
+
* The message of the probe failure the key last recorded, if any. A failed
|
|
34
|
+
* verdict is not remembered as a verdict, but its reason outlives the miss:
|
|
35
|
+
* the remedy a caller prints is composed from WHY the probe failed, and the
|
|
36
|
+
* next caller — often the very next command — must still see it.
|
|
37
|
+
*
|
|
38
|
+
* @param key - the probe's cache key.
|
|
39
|
+
* @returns the thrown error's message, or undefined while no failure is
|
|
40
|
+
* recorded; cleared on the key's next success.
|
|
41
|
+
*/
|
|
42
|
+
failure(key: string): string | undefined;
|
|
32
43
|
/** Remembered successes, for tests and diagnostics. */
|
|
33
44
|
readonly size: number;
|
|
34
45
|
}
|
package/lib/probe-cache.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
export function createProbeCache() {
|
|
27
27
|
const verdicts = new Map();
|
|
28
|
+
const failures = new Map();
|
|
28
29
|
return {
|
|
29
30
|
async run(key, probe) {
|
|
30
31
|
const cached = verdicts.get(key);
|
|
@@ -34,18 +35,25 @@ export function createProbeCache() {
|
|
|
34
35
|
try {
|
|
35
36
|
return await probe();
|
|
36
37
|
}
|
|
37
|
-
catch {
|
|
38
|
+
catch (error) {
|
|
39
|
+
failures.set(key, error instanceof Error ? error.message : String(error));
|
|
38
40
|
return false;
|
|
39
41
|
}
|
|
40
42
|
})();
|
|
41
43
|
verdicts.set(key, pending);
|
|
42
44
|
const verdict = await pending;
|
|
43
|
-
if (verdict)
|
|
45
|
+
if (verdict) {
|
|
44
46
|
verdicts.set(key, true);
|
|
45
|
-
|
|
47
|
+
failures.delete(key);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
46
50
|
verdicts.delete(key);
|
|
51
|
+
}
|
|
47
52
|
return verdict;
|
|
48
53
|
},
|
|
54
|
+
failure(key) {
|
|
55
|
+
return failures.get(key);
|
|
56
|
+
},
|
|
49
57
|
/** Remembered successes, for tests and diagnostics. */
|
|
50
58
|
get size() {
|
|
51
59
|
return verdicts.size;
|
package/lib/sandbox-core.d.ts
CHANGED
|
@@ -16,6 +16,76 @@
|
|
|
16
16
|
*
|
|
17
17
|
* @module dsh-plugin-wsl/sandbox-core
|
|
18
18
|
*/
|
|
19
|
+
/**
|
|
20
|
+
* The canonical remedy for a distro whose `bwrap` is missing — also the
|
|
21
|
+
* fallback for callers that know only "the probe failed", without a classified
|
|
22
|
+
* reason. The direct command is family-aware when the caller knows the family;
|
|
23
|
+
* the bootstrap script is always the first-choice path because it detects the
|
|
24
|
+
* family itself.
|
|
25
|
+
*
|
|
26
|
+
* @param distro - the distro to install into.
|
|
27
|
+
* @param family - the detected package family, when known.
|
|
28
|
+
* @returns the operator-facing remedy text.
|
|
29
|
+
*/
|
|
30
|
+
export declare function missingBwrapRemedy(distro: string, family?: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* The remedy for a bwrap that PROBED healthy but failed a run — the runtime
|
|
33
|
+
* twin of {@link missingBwrapRemedy}. No family knowledge exists on this path
|
|
34
|
+
* (the probe succeeded, so no family probe ever ran), and reinstalling is only
|
|
35
|
+
* one of the plausible fixes, so the hint leads with diagnosis.
|
|
36
|
+
*
|
|
37
|
+
* @param distro - the distro whose bwrap failed at run time.
|
|
38
|
+
* @returns the operator-facing hint text.
|
|
39
|
+
*/
|
|
40
|
+
export declare function bwrapInstallHint(distro: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* Append {@link bwrapInstallHint} to a runner-failure detail that names bwrap.
|
|
43
|
+
* A probe-time failure composes its own classified remedy; this covers the
|
|
44
|
+
* OTHER order — bwrap probed healthy, then failed a real command — where no
|
|
45
|
+
* probe failure is on record and the raw `bwrap: …` line is all the model
|
|
46
|
+
* would see. Details that do not name bwrap pass through untouched: the
|
|
47
|
+
* runner-failure rules match the confinement runner, and the confinement
|
|
48
|
+
* runner is always bwrap here, but the wording stays the decider so a future
|
|
49
|
+
* rule change cannot attach a bwrap remedy to a foreign failure.
|
|
50
|
+
*
|
|
51
|
+
* @param detail - the classified runner-failure detail.
|
|
52
|
+
* @param distro - the distro the command ran in.
|
|
53
|
+
* @returns the detail, with the remedy appended when it names bwrap.
|
|
54
|
+
*/
|
|
55
|
+
export declare function withBwrapHint(detail: string, distro: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* Probe one distro's `bwrap` under the shared verdict cache. The probe mirrors
|
|
58
|
+
* upstream's: apply the real read-only profile around `true`, and treat a zero
|
|
59
|
+
* exit as the kernel having accepted and enforced it. A missing `bwrap` fails
|
|
60
|
+
* the spawn and probes unusable, which is what makes the providers fail closed
|
|
61
|
+
* rather than silently run unconfined.
|
|
62
|
+
*
|
|
63
|
+
* A success is remembered for the process lifetime; a failure is not, so a
|
|
64
|
+
* `bubblewrap` installed while the app is running is believed on the next
|
|
65
|
+
* command. The failure's composed remedy stays readable through
|
|
66
|
+
* {@link bwrapFailure}, which is how every refusal and the preflight surface it.
|
|
67
|
+
*
|
|
68
|
+
* @param options - the `wsl.exe` path and the distro to probe.
|
|
69
|
+
* @param options.wslPath - the `wsl.exe` path.
|
|
70
|
+
* @param options.distro - the distro to probe.
|
|
71
|
+
* @param options.signal - optional cancellation.
|
|
72
|
+
* @returns true when a confined command can run there.
|
|
73
|
+
*/
|
|
74
|
+
export declare function bwrapUsable(options: {
|
|
75
|
+
wslPath: string;
|
|
76
|
+
distro: string;
|
|
77
|
+
signal?: AbortSignal | undefined;
|
|
78
|
+
}): Promise<boolean>;
|
|
79
|
+
/**
|
|
80
|
+
* The remedy text of a key's last failed probe, as {@link bwrapUsable} composed
|
|
81
|
+
* it — the same string the providers' refusals carry. Undefined while the probe
|
|
82
|
+
* never failed (or last succeeded).
|
|
83
|
+
*
|
|
84
|
+
* @param wslPath - the `wsl.exe` path the probe used.
|
|
85
|
+
* @param distro - the distro the probe ran in.
|
|
86
|
+
* @returns the composed remedy, or undefined.
|
|
87
|
+
*/
|
|
88
|
+
export declare function bwrapFailure(wslPath: string, distro: string): string | undefined;
|
|
19
89
|
/**
|
|
20
90
|
* The seam's `ConfinedArgv` settlement facts, plus this core's `windowsDrive`
|
|
21
91
|
* mitigation flag.
|
package/lib/sandbox-core.js
CHANGED
|
@@ -65,6 +65,188 @@ const ENFORCEMENT = "partial";
|
|
|
65
65
|
* where it is unit-testable without the peers this module needs.
|
|
66
66
|
*/
|
|
67
67
|
const probeVerdicts = createProbeCache();
|
|
68
|
+
/** The cache key one (wsl.exe, distro) pair's bwrap verdict lives under. */
|
|
69
|
+
const probeKey = (wslPath, distro) => `${wslPath}\u0000${distro}`;
|
|
70
|
+
/**
|
|
71
|
+
* The direct install command each detected package family gets in the remedy.
|
|
72
|
+
* The bootstrap script remains the first-choice path — it detects the family
|
|
73
|
+
* itself — but a user pasting the message on a dnf or pacman distro must not
|
|
74
|
+
* be handed an `apt-get` line.
|
|
75
|
+
*/
|
|
76
|
+
const FAMILY_INSTALL = {
|
|
77
|
+
apt: "apt-get install -y bubblewrap",
|
|
78
|
+
dnf: "dnf install -y bubblewrap",
|
|
79
|
+
pacman: "pacman -S --noconfirm bubblewrap",
|
|
80
|
+
zypper: "zypper --non-interactive install bubblewrap",
|
|
81
|
+
};
|
|
82
|
+
/** How many characters of the failed probe's output the remedy quotes. */
|
|
83
|
+
const PROBE_OUTPUT_CAP = 300;
|
|
84
|
+
/**
|
|
85
|
+
* Classify one failed probe's output. `bwrap: ` on the output means the binary
|
|
86
|
+
* RAN and refused — a present-but-unusable bwrap (an old build, a kernel
|
|
87
|
+
* without unprivileged user namespaces), which no reinstall fixes. wsl.exe's
|
|
88
|
+
* own relay for a missing `--exec` binary names the exec instead, which is the
|
|
89
|
+
* install case. Anything else is unclassified and gets the install remedy with
|
|
90
|
+
* the raw output quoted, so neither reading is lost.
|
|
91
|
+
*
|
|
92
|
+
* @param output - the failed probe's combined stdout+stderr, NULs stripped.
|
|
93
|
+
* @returns why the probe failed, as the remedy needs it.
|
|
94
|
+
*/
|
|
95
|
+
function classifyProbeOutput(output) {
|
|
96
|
+
if (/\bbwrap: /i.test(output))
|
|
97
|
+
return "broken";
|
|
98
|
+
if (/no such file|execv|not found|cannot run|could not be started/i.test(output))
|
|
99
|
+
return "missing";
|
|
100
|
+
return "unknown";
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The package manager the distro offers, for the remedy's direct command. Only
|
|
104
|
+
* consulted on a FAILED probe, so a healthy distro never pays for it; a probe
|
|
105
|
+
* that cannot answer leaves the remedy with the package-manager-agnostic line.
|
|
106
|
+
*
|
|
107
|
+
* @param wslPath - the `wsl.exe` path the failed probe used.
|
|
108
|
+
* @param distro - the distro the failed probe ran in.
|
|
109
|
+
* @param signal - optional cancellation shared with the probe.
|
|
110
|
+
* @returns the first family whose manager exists, or undefined.
|
|
111
|
+
*/
|
|
112
|
+
async function detectPackageFamily(wslPath, distro, signal) {
|
|
113
|
+
try {
|
|
114
|
+
const script = "for m in apt-get dnf pacman zypper; do command -v $m && break; done";
|
|
115
|
+
// `command -v` answers with the PATH-resolved path, so matching on the
|
|
116
|
+
// trailing name is exact — a bare manager name never comes back.
|
|
117
|
+
const manager = (await runCapture([wslPath, "-d", distro, "--exec", "sh", "-c", script], signal)).trim().split("\n")[0]?.trim() ?? "";
|
|
118
|
+
if (manager.endsWith("/apt-get"))
|
|
119
|
+
return "apt";
|
|
120
|
+
if (manager.endsWith("/dnf"))
|
|
121
|
+
return "dnf";
|
|
122
|
+
if (manager.endsWith("/pacman"))
|
|
123
|
+
return "pacman";
|
|
124
|
+
if (manager.endsWith("/zypper"))
|
|
125
|
+
return "zypper";
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Compose the remedy one failed probe reports. Pure apart from the optional
|
|
134
|
+
* family probe it may issue (a failed probe only), so the text is unit-testable
|
|
135
|
+
* through a fake `wsl.exe`.
|
|
136
|
+
*
|
|
137
|
+
* @param wslPath - the `wsl.exe` path the probe used.
|
|
138
|
+
* @param distro - the distro the probe ran in.
|
|
139
|
+
* @param cause - the raw probe failure.
|
|
140
|
+
* @param signal - optional cancellation shared with the probe.
|
|
141
|
+
* @returns the operator-facing remedy text.
|
|
142
|
+
*/
|
|
143
|
+
async function composeBwrapFailure(wslPath, distro, cause, signal) {
|
|
144
|
+
const output = String(cause?.message ?? cause ?? "").slice(0, PROBE_OUTPUT_CAP).trim();
|
|
145
|
+
const kind = classifyProbeOutput(output);
|
|
146
|
+
if (kind === "broken") {
|
|
147
|
+
// A broken verdict requires the bwrap banner, so the output is never empty here.
|
|
148
|
+
return (`bwrap is installed inside distro "${distro}" but failed the usability probe: ${output}. ` +
|
|
149
|
+
`This is usually the kernel (unprivileged user namespaces disabled) or an unusable bwrap build rather than a missing package, so reinstalling will not help. ` +
|
|
150
|
+
`Check \`wsl.exe -d ${distro} --exec bwrap --version\` inside the distro, or set \`sandbox: false\` on the wsl-shell and wsl-fs rows to run unconfined.`);
|
|
151
|
+
}
|
|
152
|
+
const remedy = missingBwrapRemedy(distro, await detectPackageFamily(wslPath, distro, signal));
|
|
153
|
+
return kind === "unknown" && output.length > 0 ? `${remedy} The probe failed with: ${output}` : remedy;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The canonical remedy for a distro whose `bwrap` is missing — also the
|
|
157
|
+
* fallback for callers that know only "the probe failed", without a classified
|
|
158
|
+
* reason. The direct command is family-aware when the caller knows the family;
|
|
159
|
+
* the bootstrap script is always the first-choice path because it detects the
|
|
160
|
+
* family itself.
|
|
161
|
+
*
|
|
162
|
+
* @param distro - the distro to install into.
|
|
163
|
+
* @param family - the detected package family, when known.
|
|
164
|
+
* @returns the operator-facing remedy text.
|
|
165
|
+
*/
|
|
166
|
+
export function missingBwrapRemedy(distro, family) {
|
|
167
|
+
const direct = family === undefined ? undefined : FAMILY_INSTALL[family];
|
|
168
|
+
if (direct === undefined) {
|
|
169
|
+
return (`bwrap is not usable inside distro "${distro}". Install it with ` +
|
|
170
|
+
`"scripts/bootstrap.sh ${distro} --install" (from the package), or install the ` +
|
|
171
|
+
`"bubblewrap" package with the distro's package manager.`);
|
|
172
|
+
}
|
|
173
|
+
return (`bwrap is not usable inside distro "${distro}". Install it with ` +
|
|
174
|
+
`"scripts/bootstrap.sh ${distro} --install" (from the package), or directly: ` +
|
|
175
|
+
`wsl.exe -d ${distro} -u root -- ${direct}`);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The remedy for a bwrap that PROBED healthy but failed a run — the runtime
|
|
179
|
+
* twin of {@link missingBwrapRemedy}. No family knowledge exists on this path
|
|
180
|
+
* (the probe succeeded, so no family probe ever ran), and reinstalling is only
|
|
181
|
+
* one of the plausible fixes, so the hint leads with diagnosis.
|
|
182
|
+
*
|
|
183
|
+
* @param distro - the distro whose bwrap failed at run time.
|
|
184
|
+
* @returns the operator-facing hint text.
|
|
185
|
+
*/
|
|
186
|
+
export function bwrapInstallHint(distro) {
|
|
187
|
+
return (`bwrap failed at run time inside distro "${distro}". Check \`bwrap --version\` there, ` +
|
|
188
|
+
`or reinstall it: "scripts/bootstrap.sh ${distro} --install".`);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Append {@link bwrapInstallHint} to a runner-failure detail that names bwrap.
|
|
192
|
+
* A probe-time failure composes its own classified remedy; this covers the
|
|
193
|
+
* OTHER order — bwrap probed healthy, then failed a real command — where no
|
|
194
|
+
* probe failure is on record and the raw `bwrap: …` line is all the model
|
|
195
|
+
* would see. Details that do not name bwrap pass through untouched: the
|
|
196
|
+
* runner-failure rules match the confinement runner, and the confinement
|
|
197
|
+
* runner is always bwrap here, but the wording stays the decider so a future
|
|
198
|
+
* rule change cannot attach a bwrap remedy to a foreign failure.
|
|
199
|
+
*
|
|
200
|
+
* @param detail - the classified runner-failure detail.
|
|
201
|
+
* @param distro - the distro the command ran in.
|
|
202
|
+
* @returns the detail, with the remedy appended when it names bwrap.
|
|
203
|
+
*/
|
|
204
|
+
export function withBwrapHint(detail, distro) {
|
|
205
|
+
return /\bbwrap\b/i.test(detail) ? `${detail} — ${bwrapInstallHint(distro)}` : detail;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Probe one distro's `bwrap` under the shared verdict cache. The probe mirrors
|
|
209
|
+
* upstream's: apply the real read-only profile around `true`, and treat a zero
|
|
210
|
+
* exit as the kernel having accepted and enforced it. A missing `bwrap` fails
|
|
211
|
+
* the spawn and probes unusable, which is what makes the providers fail closed
|
|
212
|
+
* rather than silently run unconfined.
|
|
213
|
+
*
|
|
214
|
+
* A success is remembered for the process lifetime; a failure is not, so a
|
|
215
|
+
* `bubblewrap` installed while the app is running is believed on the next
|
|
216
|
+
* command. The failure's composed remedy stays readable through
|
|
217
|
+
* {@link bwrapFailure}, which is how every refusal and the preflight surface it.
|
|
218
|
+
*
|
|
219
|
+
* @param options - the `wsl.exe` path and the distro to probe.
|
|
220
|
+
* @param options.wslPath - the `wsl.exe` path.
|
|
221
|
+
* @param options.distro - the distro to probe.
|
|
222
|
+
* @param options.signal - optional cancellation.
|
|
223
|
+
* @returns true when a confined command can run there.
|
|
224
|
+
*/
|
|
225
|
+
export async function bwrapUsable(options) {
|
|
226
|
+
const { wslPath, distro, signal } = options;
|
|
227
|
+
return probeVerdicts.run(probeKey(wslPath, distro), async () => {
|
|
228
|
+
const prefix = bwrapArgvPrefix({ mode: "read-only", workspaceRoot: "/" });
|
|
229
|
+
try {
|
|
230
|
+
await runCapture([wslPath, "-d", distro, "--exec", ...prefix, "true"], signal);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
catch (cause) {
|
|
234
|
+
throw new Error(await composeBwrapFailure(wslPath, distro, cause, signal));
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The remedy text of a key's last failed probe, as {@link bwrapUsable} composed
|
|
240
|
+
* it — the same string the providers' refusals carry. Undefined while the probe
|
|
241
|
+
* never failed (or last succeeded).
|
|
242
|
+
*
|
|
243
|
+
* @param wslPath - the `wsl.exe` path the probe used.
|
|
244
|
+
* @param distro - the distro the probe ran in.
|
|
245
|
+
* @returns the composed remedy, or undefined.
|
|
246
|
+
*/
|
|
247
|
+
export function bwrapFailure(wslPath, distro) {
|
|
248
|
+
return probeVerdicts.failure(probeKey(wslPath, distro));
|
|
249
|
+
}
|
|
68
250
|
/**
|
|
69
251
|
* Build the sandbox class, bound to one unavailable-error implementation.
|
|
70
252
|
*
|
|
@@ -81,27 +263,16 @@ export function createSandboxCore({ SandboxUnavailableError }) {
|
|
|
81
263
|
this.maskWindowsDrive = config.maskWindowsDrive ?? false;
|
|
82
264
|
}
|
|
83
265
|
/**
|
|
84
|
-
* Whether `bwrap` can create a profile inside this distro.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* spawn and probes unusable, which is what makes the provider fail closed rather
|
|
88
|
-
* than silently run unconfined.
|
|
89
|
-
*
|
|
90
|
-
* A success is remembered for the process lifetime; a failure is not, so a
|
|
91
|
-
* `bubblewrap` installed while the app is running is believed on the next
|
|
92
|
-
* command.
|
|
266
|
+
* Whether `bwrap` can create a profile inside this distro. Delegates to the
|
|
267
|
+
* shared free probe — the same cache the preflight service and every other
|
|
268
|
+
* instance instance read, so a verdict paid once is paid once per process.
|
|
93
269
|
*
|
|
94
270
|
* @param distro - the distro to probe.
|
|
95
271
|
* @param signal - optional cancellation.
|
|
96
272
|
* @returns true when a confined command can run there.
|
|
97
273
|
*/
|
|
98
274
|
async usable(distro, signal) {
|
|
99
|
-
|
|
100
|
-
return probeVerdicts.run(key, async () => {
|
|
101
|
-
const prefix = bwrapArgvPrefix({ mode: "read-only", workspaceRoot: "/" });
|
|
102
|
-
await runCapture([this.wslPath, "-d", distro, "--exec", ...prefix, "true"], signal);
|
|
103
|
-
return true;
|
|
104
|
-
});
|
|
275
|
+
return bwrapUsable({ wslPath: this.wslPath, distro, signal });
|
|
105
276
|
}
|
|
106
277
|
/**
|
|
107
278
|
* Wrap one distro-side argv in the bwrap profile the policy asks for.
|
|
@@ -122,9 +293,7 @@ export function createSandboxCore({ SandboxUnavailableError }) {
|
|
|
122
293
|
async confine(argv, policy, options) {
|
|
123
294
|
const { distro, signal } = options;
|
|
124
295
|
if (!(await this.usable(distro, signal))) {
|
|
125
|
-
throw new SandboxUnavailableError(policy.mode,
|
|
126
|
-
`"scripts/bootstrap.sh ${distro} --install" (from the package), or directly: ` +
|
|
127
|
-
`wsl.exe -d ${distro} -u root -- apt-get install -y bubblewrap`);
|
|
296
|
+
throw new SandboxUnavailableError(policy.mode, bwrapFailure(this.wslPath, distro) ?? missingBwrapRemedy(distro));
|
|
128
297
|
}
|
|
129
298
|
const workspaceRoot = toLinuxPath(policy.workspaceRoot, { distro });
|
|
130
299
|
const prefix = bwrapArgvPrefix({ ...policy, workspaceRoot }, { maskWindowsDrive: this.maskWindowsDrive });
|