dsh-plugin-wsl-env 0.7.5 → 0.9.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +345 -8
  2. package/CONTRIBUTING.md +221 -68
  3. package/README.md +50 -35
  4. package/SECURITY.md +18 -2
  5. package/SUPPORT.md +23 -7
  6. package/cordis.patch.yml +9 -0
  7. package/docs/ARCHITECTURE.md +200 -57
  8. package/docs/CONFIGURATION.md +180 -16
  9. package/docs/LIMITATIONS.md +91 -33
  10. package/docs/PARITY.md +66 -38
  11. package/docs/PEER-PARITY.md +95 -32
  12. package/docs/README.zh.md +49 -31
  13. package/docs/UPSTREAM-FSIO-EXPORT.md +159 -50
  14. package/docs/UPSTREAM-TERMINAL-TITLE.md +49 -14
  15. package/lib/agent-confined.d.ts +42 -0
  16. package/lib/agent-confined.js +25 -41
  17. package/lib/agent-errors.d.ts +31 -0
  18. package/lib/agent-errors.js +19 -16
  19. package/lib/agent-exec.d.ts +159 -0
  20. package/lib/agent-exec.js +151 -165
  21. package/lib/agent-protocol.d.ts +192 -0
  22. package/lib/agent-protocol.js +76 -100
  23. package/lib/agent-shared.d.ts +27 -0
  24. package/lib/agent-shared.js +12 -12
  25. package/lib/agent.d.ts +353 -0
  26. package/lib/agent.js +536 -552
  27. package/lib/auto-preset.d.ts +110 -0
  28. package/lib/auto-preset.js +157 -141
  29. package/lib/bwrap.d.ts +90 -0
  30. package/lib/bwrap.js +18 -18
  31. package/lib/file-reference-wsl.d.ts +160 -0
  32. package/lib/file-reference-wsl.js +192 -173
  33. package/lib/fs-decisions.d.ts +107 -0
  34. package/lib/fs-decisions.js +64 -60
  35. package/lib/fs-routing.d.ts +182 -0
  36. package/lib/fs-routing.js +173 -116
  37. package/lib/fs-substrate.d.ts +258 -0
  38. package/lib/fs-substrate.js +248 -248
  39. package/lib/fs.d.ts +10 -0
  40. package/lib/fs.js +5 -0
  41. package/lib/fsio-agent.d.ts +307 -0
  42. package/lib/fsio-agent.js +414 -410
  43. package/lib/fsio-text.d.ts +115 -0
  44. package/lib/fsio-text.js +104 -84
  45. package/lib/git-route.d.ts +100 -0
  46. package/lib/git-route.js +46 -47
  47. package/lib/index.d.ts +1003 -0
  48. package/lib/index.js +1106 -1003
  49. package/lib/listing.d.ts +114 -0
  50. package/lib/listing.js +68 -55
  51. package/lib/paths.d.ts +182 -0
  52. package/lib/paths.js +134 -109
  53. package/lib/picker.d.ts +266 -0
  54. package/lib/picker.js +371 -351
  55. package/lib/ports.d.ts +44 -0
  56. package/lib/ports.js +39 -29
  57. package/lib/preflight.d.ts +91 -0
  58. package/lib/preflight.js +94 -0
  59. package/lib/preset-choice.d.ts +33 -0
  60. package/lib/preset-choice.js +15 -7
  61. package/lib/probe-cache.d.ts +51 -0
  62. package/lib/probe-cache.js +43 -29
  63. package/lib/sandbox-core.d.ts +149 -0
  64. package/lib/sandbox-core.js +239 -87
  65. package/lib/sandbox.d.ts +48 -0
  66. package/lib/sandbox.js +4 -4
  67. package/lib/search-exec.d.ts +96 -0
  68. package/lib/search-exec.js +52 -79
  69. package/lib/search-route.d.ts +75 -0
  70. package/lib/search-route.js +30 -31
  71. package/lib/shell-env.d.ts +88 -0
  72. package/lib/shell-env.js +78 -74
  73. package/lib/shell.d.ts +10 -0
  74. package/lib/shell.js +5 -0
  75. package/lib/subprocess.d.ts +313 -0
  76. package/lib/subprocess.js +267 -262
  77. package/lib/terminal-activity.d.ts +98 -0
  78. package/lib/terminal-activity.js +29 -39
  79. package/lib/terminal-route.d.ts +27 -0
  80. package/lib/terminal-route.js +16 -8
  81. package/lib/watcher.d.ts +77 -0
  82. package/lib/watcher.js +120 -126
  83. package/lib/workspace-files-route.d.ts +132 -0
  84. package/lib/workspace-files-route.js +73 -81
  85. package/lib/workspace-files-wsl.d.ts +257 -0
  86. package/lib/workspace-files-wsl.js +311 -251
  87. package/lib/wsl.d.ts +266 -0
  88. package/lib/wsl.js +150 -155
  89. package/locale/preflight/en.json +6 -0
  90. package/locale/preflight/zh.json +6 -0
  91. package/package.json +78 -22
  92. package/scripts/diagnose.sh +92 -0
package/lib/ports.d.ts ADDED
@@ -0,0 +1,44 @@
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
+ * This is a TypeScript source built to `lib/ports.js`; edit THIS file and run
17
+ * `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
18
+ * fails when it drifts.
19
+ *
20
+ * @module dsh-plugin-wsl/ports
21
+ */
22
+ import type { AgentExecRunner } from "./agent-exec.js";
23
+ /**
24
+ * Parse one `/proc/net/tcp{,6}` body into listening ports.
25
+ *
26
+ * @param text - the file's content.
27
+ * @returns deduplicated listening ports, ascending.
28
+ */
29
+ export declare function parseListeningPorts(text: string): number[];
30
+ /**
31
+ * The shell script that prints both proc files' contents.
32
+ *
33
+ * @returns the `sh -c` body.
34
+ */
35
+ export declare function procNetScript(): string;
36
+ /**
37
+ * Snapshot the distro's listening ports through the agent.
38
+ *
39
+ * @param runner - the {@link WslAgent}-like runner.
40
+ * @param timeoutMs - the exec budget.
41
+ * @returns listening ports, ascending; [] on any failure — a snapshot is a
42
+ * convenience, never an error the session should see.
43
+ */
44
+ export declare function listeningPorts(runner: AgentExecRunner, timeoutMs?: number): Promise<number[]>;
package/lib/ports.js CHANGED
@@ -13,49 +13,59 @@
13
13
  * `netstat`, nothing to install — and keeps sockets in state `0A` (LISTEN).
14
14
  * Pure parser, unit-testable without a distro.
15
15
  *
16
+ * This is a TypeScript source built to `lib/ports.js`; edit THIS file and run
17
+ * `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
18
+ * fails when it drifts.
19
+ *
16
20
  * @module dsh-plugin-wsl/ports
17
21
  */
18
-
19
22
  /**
20
23
  * 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.
24
+ *
25
+ * @param text - the file's content.
26
+ * @returns deduplicated listening ports, ascending.
23
27
  */
24
28
  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);
29
+ const ports = new Set();
30
+ for (const line of text.split("\n").slice(1)) {
31
+ const columns = line.trim().split(/\s+/);
32
+ // sl, local_address, rem_address, st, ... — st 0A is LISTEN.
33
+ const localAddress = columns[1];
34
+ if (columns.length < 4 || localAddress === undefined || columns[3] !== "0A")
35
+ continue;
36
+ const portHex = localAddress.split(":")[1];
37
+ if (portHex === undefined)
38
+ continue;
39
+ const port = Number.parseInt(portHex, 16);
40
+ if (Number.isFinite(port) && port > 0)
41
+ ports.add(port);
42
+ }
43
+ return [...ports].sort((a, b) => a - b);
36
44
  }
37
-
38
45
  /**
39
46
  * The shell script that prints both proc files' contents.
40
- * @returns {string} the `sh -c` body.
47
+ *
48
+ * @returns the `sh -c` body.
41
49
  */
42
50
  export function procNetScript() {
43
- return "cat /proc/net/tcp /proc/net/tcp6 2>/dev/null";
51
+ return "cat /proc/net/tcp /proc/net/tcp6 2>/dev/null";
44
52
  }
45
-
46
53
  /**
47
54
  * 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.
55
+ *
56
+ * @param runner - the {@link WslAgent}-like runner.
57
+ * @param timeoutMs - the exec budget.
58
+ * @returns listening ports, ascending; [] on any failure — a snapshot is a
59
+ * convenience, never an error the session should see.
52
60
  */
53
61
  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
- }
62
+ try {
63
+ const result = await runner.exec({ cwd: "/", argv: ["sh", "-c", procNetScript(), "ports"], timeoutMs });
64
+ if (result.exitCode !== 0)
65
+ return [];
66
+ return parseListeningPorts(result.stdout.toString("utf8"));
67
+ }
68
+ catch {
69
+ return [];
70
+ }
61
71
  }
@@ -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>;
@@ -0,0 +1,94 @@
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 } from "./wsl.js";
29
+ import { bwrapFailure, bwrapUsable, missingBwrapRemedy } from "./sandbox-core.js";
30
+ export const name = "wsl-preflight";
31
+ export const Config = z.object({
32
+ /** Distro to preflight; empty means this machine's default distro. */
33
+ distro: z.string().default(""),
34
+ /** The `wsl.exe` path the probe spawns; keep in step with the providers' `wslPath`. */
35
+ wslPath: z.string().default("wsl.exe"),
36
+ /** Whether the session-start sandbox preflight runs at all. */
37
+ sandbox: z.boolean().default(true),
38
+ });
39
+ /**
40
+ * Mount the preflight service.
41
+ *
42
+ * @param ctx - the cordis context.
43
+ * @param config - the schema-validated plugin config.
44
+ */
45
+ export async function apply(ctx, config = {}) {
46
+ if (!(config.sandbox ?? true))
47
+ return;
48
+ const wslPath = config.wslPath ?? "wsl.exe";
49
+ const warn = (message) => ctx.logger?.warn?.(message);
50
+ /**
51
+ * Probe the distro once and surface an unusable `bwrap` as a warning.
52
+ *
53
+ * @param agent - the session that just opened, for the workspace scope.
54
+ */
55
+ const check = async (agent) => {
56
+ // Only a distro workspace has commands that confine: a host session would
57
+ // warn about a sandbox it never uses.
58
+ const cwd = agent?.session?.header?.cwd;
59
+ if (cwd === undefined || !isWslUnc(cwd))
60
+ return;
61
+ let distro = config.distro ?? "";
62
+ if (distro.length === 0) {
63
+ try {
64
+ distro = await defaultDistro({ wslPath });
65
+ }
66
+ catch (error) {
67
+ // No WSL at all is the missing-wsl probe's territory; the first
68
+ // command's own error is the precise one. Stay quiet at warn level.
69
+ ctx.logger?.debug?.(`wsl-preflight: no default distro to preflight (${error?.message ?? error})`);
70
+ return;
71
+ }
72
+ }
73
+ // bwrapUsable answers false on a failed probe — it never rejects — so
74
+ // nothing here can throw except a broken context, which is the caller's
75
+ // bug, not the session's.
76
+ const usable = await bwrapUsable({ wslPath, distro });
77
+ if (usable) {
78
+ ctx.logger?.debug?.(`wsl-preflight: bwrap is usable inside distro "${distro}"`);
79
+ return;
80
+ }
81
+ warn(`wsl-preflight: ${bwrapFailure(wslPath, distro) ?? missingBwrapRemedy(distro)}`);
82
+ };
83
+ ctx.on("agent/created", (payload) => void check(payload?.agent));
84
+ // Mount order is not a guarantee: a session created during startup can finish
85
+ // before this service mounts and miss the event entirely (dsh-headless does
86
+ // exactly that, and auto-preset sweeps for the same reason).
87
+ try {
88
+ for (const agent of ctx.agents?.list() ?? [])
89
+ void check(agent);
90
+ }
91
+ catch (error) {
92
+ ctx.logger?.debug?.(`wsl-preflight: could not sweep already-live agents: ${error?.message ?? error}`);
93
+ }
94
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Whether a newly created session should adopt the WSL preset.
3
+ *
4
+ * The rule is deliberately narrow, and it is the whole of "a session whose workspace
5
+ * is inside a distro gets the distro environment automatically":
6
+ *
7
+ * - the workspace must be a WSL UNC path, so a Windows folder keeps its own
8
+ * environment;
9
+ * - the session must not already carry an explicit choice. `undefined` counts as
10
+ * "no choice", because a caller that mounts nothing at all (the headless runner)
11
+ * is not asking for the host environment either; anything else must equal the
12
+ * registry's default id, which is what "the operator did not pick one" looks like
13
+ * after composition.
14
+ *
15
+ * Pure, so the decision is unit-testable without a boot — the frame timing that
16
+ * applies it still needs the harness.
17
+ *
18
+ * This is a TypeScript source built to `lib/preset-choice.js`; edit THIS file
19
+ * and run `pnpm run build` — the artifact under `lib/` is generated, and
20
+ * `pnpm test` fails when it drifts.
21
+ */
22
+ /**
23
+ * The adoption decision.
24
+ *
25
+ * @param input - the new session's workspace, the preset it currently has, and
26
+ * the registry default.
27
+ * @returns true when the WSL preset should be selected for this session.
28
+ */
29
+ export declare function shouldAdoptWslPreset({ cwd, current, fallback }?: {
30
+ cwd?: string;
31
+ current?: string;
32
+ fallback?: string;
33
+ }): boolean;
@@ -15,14 +15,22 @@
15
15
  * Pure, so the decision is unit-testable without a boot — the frame timing that
16
16
  * applies it still needs the harness.
17
17
  *
18
- * @param {{ cwd?: string, current?: string, fallback?: string }} input - the new
19
- * session's workspace, the preset it currently has, and the registry default.
20
- * @returns true when the WSL preset should be selected for this session.
18
+ * This is a TypeScript source built to `lib/preset-choice.js`; edit THIS file
19
+ * and run `pnpm run build` — the artifact under `lib/` is generated, and
20
+ * `pnpm test` fails when it drifts.
21
21
  */
22
22
  import { isWslUnc } from "./paths.js";
23
-
23
+ /**
24
+ * The adoption decision.
25
+ *
26
+ * @param input - the new session's workspace, the preset it currently has, and
27
+ * the registry default.
28
+ * @returns true when the WSL preset should be selected for this session.
29
+ */
24
30
  export function shouldAdoptWslPreset({ cwd, current, fallback } = {}) {
25
- if (!isWslUnc(cwd)) return false;
26
- if (current === void 0) return true;
27
- return current === fallback;
31
+ if (cwd === undefined || !isWslUnc(cwd))
32
+ return false;
33
+ if (current === void 0)
34
+ return true;
35
+ return current === fallback;
28
36
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The caching policy for a functional probe: which verdicts may be remembered.
3
+ *
4
+ * A SUCCESS is durable — if `bwrap` created a profile a minute ago it will create
5
+ * one now. A FAILURE is not: it describes the machine as it is at that moment, and
6
+ * the common case is a package that is not installed *yet*. Remembering a failure
7
+ * would make the documented remedy (`sudo apt install bubblewrap`) silently
8
+ * ineffective until the app restarts, while the error message tells the user to
9
+ * install exactly that. So a failure is dropped and the next call probes again.
10
+ *
11
+ * Probes are I/O, and concurrent first calls would otherwise each pay for one.
12
+ * Storing the in-flight promise makes them share a single probe.
13
+ *
14
+ * Pure by construction — no Node API, no clock — so the policy is unit-testable
15
+ * without the peers `lib/sandbox.js` needs.
16
+ *
17
+ * This is a TypeScript source built to `lib/probe-cache.js`; edit THIS file and
18
+ * run `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
19
+ * fails when it drifts.
20
+ */
21
+ /** One probe cache's surface. */
22
+ export interface ProbeCache {
23
+ /**
24
+ * Run one probe under the policy.
25
+ *
26
+ * @param key - the probe's cache key.
27
+ * @param probe - the probe to run on a miss. A throw is a failed probe, never
28
+ * a propagated error; its message is remembered for {@link failure}.
29
+ * @returns the verdict; a success is remembered, a failure is not.
30
+ */
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;
43
+ /** Remembered successes, for tests and diagnostics. */
44
+ readonly size: number;
45
+ }
46
+ /**
47
+ * Build one probe cache.
48
+ *
49
+ * @returns the policy object `lib/sandbox-core.js` holds per provider.
50
+ */
51
+ export declare function createProbeCache(): ProbeCache;
@@ -14,35 +14,49 @@
14
14
  * Pure by construction — no Node API, no clock — so the policy is unit-testable
15
15
  * without the peers `lib/sandbox.js` needs.
16
16
  *
17
- * @param {() => Promise<boolean>|boolean} probe - the probe to run on a miss. A
18
- * throw is a failed probe, never a propagated error.
19
- * @returns {{ run: (key: string, probe: () => Promise<boolean>|boolean) => Promise<boolean>, size: number }}
17
+ * This is a TypeScript source built to `lib/probe-cache.js`; edit THIS file and
18
+ * run `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
19
+ * fails when it drifts.
20
+ */
21
+ /**
22
+ * Build one probe cache.
23
+ *
24
+ * @returns the policy object `lib/sandbox-core.js` holds per provider.
20
25
  */
21
26
  export function createProbeCache() {
22
- /** @type {Map<string, true|Promise<boolean>>} */
23
- const verdicts = new Map();
24
- return {
25
- async run(key, probe) {
26
- const cached = verdicts.get(key);
27
- if (cached !== undefined) return cached;
28
-
29
- const pending = (async () => {
30
- try {
31
- return await probe();
32
- } catch {
33
- return false;
34
- }
35
- })();
36
- verdicts.set(key, pending);
37
-
38
- const verdict = await pending;
39
- if (verdict) verdicts.set(key, true);
40
- else verdicts.delete(key);
41
- return verdict;
42
- },
43
- /** Remembered successes, for tests and diagnostics. */
44
- get size() {
45
- return verdicts.size;
46
- },
47
- };
27
+ const verdicts = new Map();
28
+ const failures = new Map();
29
+ return {
30
+ async run(key, probe) {
31
+ const cached = verdicts.get(key);
32
+ if (cached !== undefined)
33
+ return cached;
34
+ const pending = (async () => {
35
+ try {
36
+ return await probe();
37
+ }
38
+ catch (error) {
39
+ failures.set(key, error instanceof Error ? error.message : String(error));
40
+ return false;
41
+ }
42
+ })();
43
+ verdicts.set(key, pending);
44
+ const verdict = await pending;
45
+ if (verdict) {
46
+ verdicts.set(key, true);
47
+ failures.delete(key);
48
+ }
49
+ else {
50
+ verdicts.delete(key);
51
+ }
52
+ return verdict;
53
+ },
54
+ failure(key) {
55
+ return failures.get(key);
56
+ },
57
+ /** Remembered successes, for tests and diagnostics. */
58
+ get size() {
59
+ return verdicts.size;
60
+ },
61
+ };
48
62
  }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The sandbox's probe and confinement logic, free of DSH peers.
3
+ *
4
+ * `lib/sandbox.js` binds this core to the peer's `SandboxUnavailableError` —
5
+ * the class identity `instanceof` consumers match — which is the ONLY thing
6
+ * that kept the whole policy out of unit tests: a checkout without the peers
7
+ * could not even import the module, so CI skipped every check of the probe
8
+ * caching policy and the argv composition. The core takes the error class as
9
+ * the one injected dependency; the tests inject a stand-in with the same
10
+ * contract (`code: "SANDBOX_UNAVAILABLE"`), and what CI runs is the code that
11
+ * ships.
12
+ *
13
+ * This is a TypeScript source built to `lib/sandbox-core.js`; edit THIS file
14
+ * and run `pnpm run build` — the artifact under `lib/` is generated, and
15
+ * `pnpm test` fails when it drifts.
16
+ *
17
+ * @module dsh-plugin-wsl/sandbox-core
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;
89
+ /**
90
+ * The seam's `ConfinedArgv` settlement facts, plus this core's `windowsDrive`
91
+ * mitigation flag.
92
+ */
93
+ export interface WslConfined {
94
+ /** The wrapped argv (bwrap prefix, separator, the caller's argv). */
95
+ argv: string[];
96
+ /**
97
+ * How completely the profile enforces the policy's file effects. Typed as
98
+ * the seam's union (upstream `SandboxEnforcement`) while this core always
99
+ * reports `partial` (see the `ENFORCEMENT` doc above): the value an executor
100
+ * passes through is the seam's, wherever it came from.
101
+ */
102
+ enforcement: "full" | "partial";
103
+ /** The case-insensitive stderr substrings a bwrap denial produces. */
104
+ denialSignatures: string[];
105
+ /** Structured runner-failure evidence rules. */
106
+ runnerFailureRules: Array<{
107
+ fatalSignatures: string[];
108
+ allowedExitCodes?: number[];
109
+ informationalLines?: string[];
110
+ }>;
111
+ /** Whether the profile masks the Windows drive at `/mnt`. */
112
+ windowsDrive: "masked" | "visible";
113
+ }
114
+ /**
115
+ * The sandbox class `createSandboxCore` builds, as its consumers see it.
116
+ */
117
+ export interface WslSandboxCoreInstance {
118
+ /** The `wsl.exe` path this instance spawns. */
119
+ wslPath: string;
120
+ /** Whether every profile masks `/mnt`. */
121
+ maskWindowsDrive: boolean;
122
+ /** Probe the distro's `bwrap`. */
123
+ usable: (distro: string, signal?: AbortSignal | undefined) => Promise<boolean>;
124
+ /** Wrap one argv in the profile. */
125
+ confine: (argv: string[], policy: {
126
+ mode: "read-only" | "workspace-write";
127
+ workspaceRoot: string;
128
+ }, options: {
129
+ distro: string;
130
+ signal?: AbortSignal | undefined;
131
+ }) => Promise<WslConfined>;
132
+ }
133
+ /**
134
+ * Build the sandbox class, bound to one unavailable-error implementation.
135
+ *
136
+ * @param deps - the injected peer surface.
137
+ * @returns the sandbox class (see `lib/sandbox.js` for the shipped binding).
138
+ */
139
+ export declare function createSandboxCore({ SandboxUnavailableError }: {
140
+ /**
141
+ * The failure type `confine` throws; production binds the peer's class so
142
+ * `instanceof` consumers keep matching, tests bind a stand-in with the same
143
+ * `code`.
144
+ */
145
+ SandboxUnavailableError: new (mode: "read-only" | "workspace-write", detail?: string) => Error;
146
+ }): new (config?: {
147
+ wslPath?: string;
148
+ maskWindowsDrive?: boolean;
149
+ } | undefined) => WslSandboxCoreInstance;