dsh-plugin-wsl-env 0.1.10 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,194 @@
1
+ /**
2
+ * An agent-backed implementation of the shell execution handle.
3
+ *
4
+ * `LocalBashExecutor.executeArgv` builds its handle around a subprocess spawn:
5
+ * live output readers with byte offsets, spill files beyond the collect
6
+ * budget, a deadline that kills the Windows process tree. This module builds
7
+ * the SAME handle shape around one agent `exec` — the shape `dsh-tool-bash`
8
+ * consumes, so the tool layer cannot tell which path ran. The deliberate
9
+ * deviations, both memory-for-simplicity trades documented here rather than
10
+ * hidden:
11
+ *
12
+ * - **No spill files.** The agent returns each stream as one buffer; the
13
+ * handle serves reads from memory. The upstream budget machinery exists to
14
+ * bound host memory for unbounded streams; the agent path accepts holding
15
+ * the stream (the protocol line already carries it whole).
16
+ * - **Killed commands report `SIGTERM`**, not a raw exit status: the
17
+ * in-distro timeout and `kill()` both mean "we stopped it", and upstream
18
+ * reports its own kills the same way.
19
+ *
20
+ * Pure shaping over an injected runner, so the handle semantics are
21
+ * unit-testable without a distro.
22
+ *
23
+ * @module dsh-plugin-wsl/agent-exec
24
+ */
25
+
26
+ import { CwdError } from "./agent-errors.js";
27
+
28
+ /**
29
+ * A memory-backed output reader in the seam's shape.
30
+ * @param {Buffer} buffer - the complete stream.
31
+ * @returns {{readFrom: (fromByte: number) => {text: string, lossy: boolean, nextOffset: number}}}
32
+ */
33
+ function memoryReader(buffer) {
34
+ return {
35
+ readFrom(fromByte) {
36
+ const start = Math.min(fromByte, buffer.length);
37
+ return {
38
+ text: buffer.subarray(start).toString("utf8"),
39
+ lossy: false,
40
+ nextOffset: buffer.length,
41
+ };
42
+ },
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Run one command through the agent and present it as an execution handle.
48
+ *
49
+ * @param {object} request - the execution request.
50
+ * @param {object} request.agent - the {@link WslAgent}-like runner: `exec`
51
+ * accepting `{cwd, argv, timeoutMs, signal}` and resolving
52
+ * `{exitCode, stdout, stderr}`; throws `AgentUnavailableError` when out.
53
+ * @param {string} request.cwd - Linux workdir.
54
+ * @param {string[]} request.argv - the distro-side argv, bwrap-wrapped when confined.
55
+ * @param {number} [request.timeoutMs] - whole-command budget; the agent TERMs
56
+ * at the ceiling second and KILLs after its grace.
57
+ * @param {AbortSignal} [request.signal] - the caller's cancellation.
58
+ * @param {(proc: object) => void} [request.onStarted] - provider-facts hook,
59
+ * called synchronously before the handle can settle.
60
+ * @returns {object} the execution handle (`status`, `exitCode`, `signal`,
61
+ * `observed`, `done`, `readOutput`, `kill`, `result`).
62
+ */
63
+ export function agentExecutionHandle({ agent, cwd, argv, timeoutMs = 0, signal, onStarted }) {
64
+ // Our own controller is the kill channel; the caller's signal races it.
65
+ const controller = new AbortController();
66
+ let timedOut = false;
67
+ let killed = false;
68
+ let callerAborted = false;
69
+ if (signal) {
70
+ if (signal.aborted) callerAborted = true;
71
+ else signal.addEventListener("abort", () => { callerAborted = true; controller.abort(signal.reason); }, { once: true });
72
+ }
73
+ if (timeoutMs > 0) {
74
+ setTimeout(() => {
75
+ timedOut = true;
76
+ controller.abort(new Error("BASH_TIMEOUT"));
77
+ }, timeoutMs).unref?.();
78
+ }
79
+
80
+ const settle = agent.exec({ cwd, argv, timeoutMs, signal: controller.signal }).then(
81
+ (result) => ({ ok: true, result }),
82
+ (error) => {
83
+ // A workdir the distro cannot enter is the command's own failure, not
84
+ // the agent's: synthesize the relay-shaped stderr the one-shot path
85
+ // produces, so the executor's `workdirFailure` check reports it the
86
+ // same way. An agent that is actually OUT propagates for fallback.
87
+ if (error instanceof CwdError) {
88
+ return {
89
+ ok: true,
90
+ result: {
91
+ exitCode: 1,
92
+ stdout: Buffer.alloc(0),
93
+ stderr: Buffer.from(`<3>WSL: CreateProcessCommon: chdir(${error.linuxPath}) failed: No such file or directory`, "utf8"),
94
+ },
95
+ };
96
+ }
97
+ return { ok: false, error };
98
+ },
99
+ );
100
+
101
+ /** @type {Buffer} */
102
+ let stdout = Buffer.alloc(0);
103
+ /** @type {Buffer} */
104
+ let stderr = Buffer.alloc(0);
105
+ let exitOutcome = { exitCode: null, signal: "SIGTERM" };
106
+
107
+ let settleError;
108
+ const done = settle.then(({ ok, result, error }) => {
109
+ if (ok) {
110
+ stdout = result.stdout;
111
+ stderr = result.stderr;
112
+ // The distro timeout and kill() both mean "we stopped it"; a genuine
113
+ // exit keeps its code.
114
+ if (timedOut || killed || callerAborted) exitOutcome = { exitCode: null, signal: "SIGTERM" };
115
+ else exitOutcome = { exitCode: result.exitCode, signal: null };
116
+ } else if (timedOut || killed) {
117
+ // OUR kill (the in-distro timeout or kill()): the agent's rejection is
118
+ // the abort plumbing, not a failure — settle as the killed outcome the
119
+ // one-shot path reports for the same situation.
120
+ stdout = Buffer.alloc(0);
121
+ stderr = Buffer.alloc(0);
122
+ } else {
123
+ // The caller's cancellation rejections propagate (upstream keeps
124
+ // cancellation as a rejection), as does a genuinely out-of-service
125
+ // agent — that one is the executor's fallback cue.
126
+ settleError = error;
127
+ }
128
+ return exitOutcome;
129
+ });
130
+
131
+ const stdoutReader = () => memoryReader(stdout);
132
+ const stderrReader = () => memoryReader(stderr);
133
+
134
+ let stdoutOffset = 0;
135
+ let stderrOffset = 0;
136
+
137
+ /** @type {object} */
138
+ const proc = {
139
+ status: "running",
140
+ exitCode: null,
141
+ signal: null,
142
+ observed: {
143
+ get stdout() { return stdoutReader(); },
144
+ get stderr() { return stderrReader(); },
145
+ },
146
+ done: done.then(() => {
147
+ proc.status = exitOutcome.signal !== null || exitOutcome.exitCode === null ? "killed" : "completed";
148
+ proc.exitCode = exitOutcome.exitCode;
149
+ proc.signal = exitOutcome.signal;
150
+ }),
151
+ readOutput() {
152
+ const out = stdoutReader().readFrom(stdoutOffset);
153
+ const err = stderrReader().readFrom(stderrOffset);
154
+ stdoutOffset = out.nextOffset;
155
+ stderrOffset = err.nextOffset;
156
+ const separator = out.text.length > 0 && !out.text.endsWith("\n") ? "\n" : "";
157
+ return {
158
+ delta: out.text + (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : ""),
159
+ lossy: false,
160
+ };
161
+ },
162
+ kill() {
163
+ if (proc.status !== "running") return false;
164
+ // Upstream marks the handle killed SYNCHRONOUSLY, before the process is
165
+ // observed to die — the second kill() must already be a no-op.
166
+ proc.status = "killed";
167
+ killed = true;
168
+ controller.abort(new Error("killed"));
169
+ return true;
170
+ },
171
+ result() {
172
+ return done.then(() => {
173
+ // The spawn-equivalent failure (the agent being out) rejects here,
174
+ // exactly like upstream's `running.done` rejection carried by result().
175
+ if (settleError !== undefined) throw settleError;
176
+ // `truncated` is REQUIRED by the bash tool's canonical result — it
177
+ // copies the field unconditionally, and an `undefined` fails the wire's
178
+ // lossless-JSON snapshot ("value is not lossless JSON").
179
+ const output = (text) => ({ text, lossy: false, truncated: false });
180
+ return {
181
+ exitCode: proc.exitCode,
182
+ signal: proc.signal,
183
+ timedOut,
184
+ aborted: callerAborted && !timedOut,
185
+ timeoutMs,
186
+ stdout: output(stdout.toString("utf8")),
187
+ stderr: output(stderr.toString("utf8")),
188
+ };
189
+ });
190
+ },
191
+ };
192
+ onStarted?.(proc);
193
+ return proc;
194
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Wire protocol for the resident in-distro agent (`agent/wsl-agent.sh`).
3
+ *
4
+ * Pure by construction — no Node API, no I/O — so the frame codecs and the
5
+ * line parser are unit-testable without a distro. The protocol itself is line
6
+ * oriented with `|` field separators and single-line base64 payloads; see the
7
+ * agent script's header for the grammar. Base64 was chosen over JSON because
8
+ * the agent must run under a bare POSIX sh with nothing but coreutils.
9
+ *
10
+ * @module dsh-plugin-wsl/agent-protocol
11
+ */
12
+
13
+ /** The protocol version this host side speaks; the agent must match. */
14
+ export const PROTOCOL_VERSION = 1;
15
+
16
+ /** The agent identifies itself with this name in its HELLO line. */
17
+ export const AGENT_NAME = "wsl-agent";
18
+
19
+ /**
20
+ * Rough ceiling on one protocol line, chosen well above the executor's own
21
+ * 64 KB output budget spill: stdout and stderr ride ONE line each, so a frame
22
+ * is as large as the largest capture the caller asked for. Documented so a
23
+ * future streaming design does not inherit it silently.
24
+ */
25
+ export const MAX_FRAME_BYTES = 256 * 1024 * 1024;
26
+
27
+ /**
28
+ * Encode a buffer as the protocol's single-line base64 (no line wraps, no
29
+ * padding surprises — standard base64 including `=` padding).
30
+ * @param {string|Uint8Array} value - the payload to encode.
31
+ * @returns {string} the wire form.
32
+ */
33
+ export function encodeB64(value) {
34
+ return Buffer.from(value).toString("base64");
35
+ }
36
+
37
+ /**
38
+ * Decode one protocol base64 field.
39
+ * @param {string} value - the wire form.
40
+ * @returns {Buffer} the decoded bytes; a malformed field decodes to empty
41
+ * rather than throwing, mirroring the agent's own lenient decode.
42
+ */
43
+ export function decodeB64(value) {
44
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value)) return Buffer.alloc(0);
45
+ return Buffer.from(value, "base64");
46
+ }
47
+
48
+ /**
49
+ * Build the request lines for one EXEC frame: a header line naming the cwd,
50
+ * the whole-request timeout in whole seconds (0 = none) and the argv word
51
+ * count, followed by one base64 line per argv word.
52
+ * @param {object} request - the execution request.
53
+ * @param {string} request.id - caller-chosen request id (unique per agent).
54
+ * @param {string} request.cwd - Linux path to cd into before exec.
55
+ * @param {string[]} request.argv - the argv to execute, already bwrap-wrapped.
56
+ * @param {number} [request.timeoutMs] - in-distro timeout; sub-second
57
+ * remainders round up, and `<= 0` disables the timeout.
58
+ * @returns {string[]} the lines to write to the agent's stdin, in order.
59
+ */
60
+ export function encodeExecFrame({ id, cwd, argv, timeoutMs = 0 }) {
61
+ const timeoutSeconds = timeoutMs > 0 ? Math.ceil(timeoutMs / 1000) : 0;
62
+ const lines = [
63
+ `EXEC|${id}|${encodeB64(cwd)}|${timeoutSeconds}|${argv.length}`,
64
+ ...argv.map((word) => encodeB64(word)),
65
+ ];
66
+ return lines;
67
+ }
68
+
69
+ /**
70
+ * Build a SETENV request line. Note the agent's decode strips trailing
71
+ * newlines from the value — the same leniency the shell itself applies.
72
+ * @param {string} key - the variable name.
73
+ * @param {string} value - the variable value.
74
+ * @returns {string} the line to write.
75
+ */
76
+ export function encodeSetEnv(key, value) {
77
+ return `SETENV|${key}|${encodeB64(value)}`;
78
+ }
79
+
80
+ /** @param {string} id - the request id to interrupt. @returns {string} */
81
+ export function encodeKill(id) {
82
+ return `KILL|${id}`;
83
+ }
84
+
85
+ /** The line parser's verdict for one line of agent stdout. */
86
+
87
+ /**
88
+ * Parse one line of the agent's stdout.
89
+ * @param {string} line - a line without its terminator.
90
+ * @returns {object} one of:
91
+ * - `{ type: "hello", name, version }`
92
+ * - `{ type: "pong" }`
93
+ * - `{ type: "result", id, exitCode, stdout, stderr }` — outputs as Buffers
94
+ * - `{ type: "agentError", id, reason, message }` — `id` is `""` when the
95
+ * agent reports a request-less problem (a protocol violation)
96
+ * - `{ type: "unknown", line }` — never thrown; the host decides whether an
97
+ * unrecognized line is fatal. (Anything during the handshake is; after it,
98
+ * garbage on stdout is logged and dropped, because a `RES` for a request
99
+ * the host already gave up on is expected noise.)
100
+ */
101
+ export function parseAgentLine(line) {
102
+ if (line.startsWith("HELLO|")) {
103
+ const [, name, version] = line.split("|");
104
+ return { type: "hello", name, version: Number(version) };
105
+ }
106
+ if (line === "PONG") return { type: "pong" };
107
+ if (line.startsWith("RES|")) {
108
+ const [, id, exitCode, stdout, stderr] = line.split("|");
109
+ return { type: "result", id, exitCode: Number(exitCode), stdout: decodeB64(stdout ?? ""), stderr: decodeB64(stderr ?? "") };
110
+ }
111
+ if (line.startsWith("ERR|")) {
112
+ const [, id, reason, message] = line.split("|");
113
+ return { type: "agentError", id: id ?? "", reason: reason ?? "protocol", message: decodeB64(message ?? "").toString("utf8") };
114
+ }
115
+ return { type: "unknown", line };
116
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The per-distro resident agent shared by every provider in this process.
3
+ *
4
+ * Extracted from `lib/index.js` so modules the index does not import back
5
+ * (`shell-env`, future consumers) can use the same peer without a cycle: the
6
+ * shell executor's commands, the filesystem's in-distro side work and the
7
+ * port snapshot all queue on ONE agent per distro, and the per-distro startup
8
+ * cost is paid once per process.
9
+ *
10
+ * @module dsh-plugin-wsl/agent-shared
11
+ */
12
+
13
+ import { fileURLToPath } from "node:url";
14
+ import { toLinuxPath } from "./paths.js";
15
+ import { WslAgent } from "./agent.js";
16
+
17
+ const AGENT_SCRIPT_PATH = fileURLToPath(new URL("../agent/wsl-agent.sh", import.meta.url));
18
+ const sharedAgents = new Map();
19
+
20
+ /**
21
+ * The shared agent for one distro, created on first use.
22
+ * @param distro - the distro name.
23
+ * @returns {WslAgent} the shared agent.
24
+ */
25
+ export function sharedAgent(distro) {
26
+ let agent = sharedAgents.get(distro);
27
+ if (agent === undefined) {
28
+ agent = new WslAgent({ distro, scriptPath: toLinuxPath(AGENT_SCRIPT_PATH) });
29
+ sharedAgents.set(distro, agent);
30
+ }
31
+ return agent;
32
+ }