dsh-plugin-wsl-env 0.11.0 → 0.13.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 (45) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/CONTRIBUTING.md +29 -7
  3. package/README.md +124 -138
  4. package/SECURITY.md +11 -1
  5. package/SUPPORT.md +29 -2
  6. package/cordis.patch.yml +17 -19
  7. package/docs/ARCHITECTURE.md +30 -29
  8. package/docs/CONFIGURATION.md +81 -21
  9. package/docs/LIMITATIONS.md +71 -6
  10. package/docs/MIGRATION.md +98 -0
  11. package/docs/PARITY.md +1 -1
  12. package/docs/PERFORMANCE.md +60 -0
  13. package/docs/QUICKSTART.md +125 -0
  14. package/docs/README.zh.md +117 -137
  15. package/docs/ROADMAP-1.0.md +780 -0
  16. package/docs/ROADMAP.md +4 -0
  17. package/docs/UPSTREAM-FILE-REFERENCE-SEAM.md +80 -0
  18. package/docs/UPSTREAM-LSP-SEAM.md +90 -0
  19. package/docs/UPSTREAM-SESSION-NOTICE.md +95 -0
  20. package/lib/agent-errors.d.ts +19 -0
  21. package/lib/agent-errors.js +22 -0
  22. package/lib/agent-exec.js +2 -2
  23. package/lib/agent.d.ts +68 -5
  24. package/lib/agent.js +129 -22
  25. package/lib/distro-route.d.ts +75 -0
  26. package/lib/distro-route.js +86 -0
  27. package/lib/fs-routing.d.ts +4 -1
  28. package/lib/index.d.ts +166 -37
  29. package/lib/index.js +353 -75
  30. package/lib/paths.d.ts +15 -0
  31. package/lib/paths.js +28 -0
  32. package/lib/preflight.d.ts +46 -3
  33. package/lib/preflight.js +83 -14
  34. package/lib/shell-env.d.ts +9 -3
  35. package/lib/shell-env.js +121 -43
  36. package/lib/subprocess.d.ts +12 -3
  37. package/lib/subprocess.js +21 -3
  38. package/package.json +23 -15
  39. package/scripts/diagnose.sh +9 -88
  40. package/scripts/doctor.mjs +548 -0
  41. package/scripts/profile-preset.mjs +254 -0
  42. package/lib/workspace-files-route.d.ts +0 -136
  43. package/lib/workspace-files-route.js +0 -225
  44. package/lib/workspace-files-wsl.d.ts +0 -257
  45. package/lib/workspace-files-wsl.js +0 -418
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Print or write the complete `- id: preset-wsl` block (P0.3 / WSL-103).
4
+ *
5
+ * `wsl-shell` and `wsl-fs` live inside the `preset-wsl` row's `config.plugins`,
6
+ * so a patch that targets them by id changes nothing — the loader logs
7
+ * `patch: entry "wsl-shell" not found` and boots the shipped values. The only
8
+ * route that works is replacing the whole `preset-wsl` row, which must carry
9
+ * every nested plugin verbatim. This helper extracts that row from the shipped
10
+ * `cordis.patch.yml`, applies the common edits, and can validate or write a
11
+ * profile layer.
12
+ *
13
+ * node scripts/profile-preset.mjs [--distro Debian] [--session-sandbox false|auto]
14
+ * [--sandbox false] [--mask-windows-drive true] [--agent-backend native]
15
+ * [--out <profile/cordis.patch.yml>] [--check <file>] [--force]
16
+ *
17
+ * With no `--out`/`--check`, the block (with the edits) goes to stdout.
18
+ * `--check` fails loudly on a nested id patch and prints the block to paste.
19
+ * `--out` refuses to overwrite a patch that has no `preset-wsl` row unless
20
+ * `--force`, so it cannot silently drop another override.
21
+ *
22
+ * @module dsh-plugin-wsl/scripts/profile-preset
23
+ */
24
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
25
+ import { dirname, join, resolve } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { tmpdir } from "node:os";
28
+
29
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
30
+ const SOURCE = join(ROOT, "cordis.patch.yml");
31
+ /** The shipped row's first line: four spaces deep inside the second `insert:` list. */
32
+ const ROW_START = /^ {4}- id: preset-wsl\s*$/m;
33
+
34
+ /**
35
+ * Extract the shipped `preset-wsl` row, dedented to a top-level patch entry.
36
+ * @param {string} source - the `cordis.patch.yml` text.
37
+ * @returns {string} the block, without a trailing newline.
38
+ */
39
+ export function presetBlock(source) {
40
+ const match = ROW_START.exec(source);
41
+ if (match === null) throw new Error("profile-preset: cordis.patch.yml carries no `- id: preset-wsl` row");
42
+ return source.slice(match.index).replace(/^ {4}/gm, "").replace(/\s+$/u, "");
43
+ }
44
+
45
+ /**
46
+ * The top-level nested-row ids a profile layer wrongly targets.
47
+ * @param {string} text - a profile patch file.
48
+ * @returns {string[]} the offending ids, in file order.
49
+ */
50
+ export function nestedRowIds(text) {
51
+ /** @type {string[]} */
52
+ const found = [];
53
+ for (const line of text.split("\n")) {
54
+ const match = /^ {0,4}-\s*id:\s*(wsl-shell|wsl-fs)\s*$/u.exec(line);
55
+ if (match?.[1] !== undefined && !found.includes(match[1])) found.push(match[1]);
56
+ }
57
+ return found;
58
+ }
59
+
60
+ /**
61
+ * The line range of one nested row inside the block.
62
+ * @param {string[]} lines - the block's lines.
63
+ * @param {string} id - `wsl-shell` or `wsl-fs`.
64
+ * @returns {{ start: number, end: number }} the range, end exclusive.
65
+ */
66
+ function rowRange(lines, id) {
67
+ const start = lines.findIndex((line) => line.trim() === `- id: ${id}`);
68
+ if (start < 0) throw new Error(`profile-preset: the preset block carries no "${id}" row`);
69
+ const indent = /^\s*/u.exec(lines[start] ?? "")?.[0].length ?? 0;
70
+ let end = lines.length;
71
+ for (let i = start + 1; i < lines.length; i += 1) {
72
+ const head = /^(\s*)- id: /u.exec(lines[i] ?? "");
73
+ if (head !== null && head[1]?.length === indent) {
74
+ end = i;
75
+ break;
76
+ }
77
+ }
78
+ return { start, end };
79
+ }
80
+
81
+ /**
82
+ * Set one config key inside one nested row, keeping every other line byte-for-byte.
83
+ * @param {string[]} lines - the block's lines.
84
+ * @param {string} id - the nested row.
85
+ * @param {string} key - the config key.
86
+ * @param {string} value - the rendered YAML value.
87
+ */
88
+ function setKey(lines, id, key, value) {
89
+ const { start, end } = rowRange(lines, id);
90
+ const keyLine = new RegExp(`^(\\s*)${key}:\\s`, "u");
91
+ for (let i = start; i < end; i += 1) {
92
+ const match = keyLine.exec(lines[i] ?? "");
93
+ if (match !== null) {
94
+ lines[i] = `${match[1]}${key}: ${value}`;
95
+ return;
96
+ }
97
+ }
98
+ // Absent keys go under `distro:` (or `config:`), at the same depth as the
99
+ // keys around them.
100
+ const anchor = lines.slice(start, end).findIndex((line) => /^\s*distro:/u.test(line));
101
+ const config = lines.slice(start, end).findIndex((line) => /^\s*config:\s*$/u.test(line));
102
+ const offset = anchor >= 0 ? start + anchor : start + config;
103
+ const base = /^\s*/u.exec(lines[offset] ?? "")?.[0] ?? "";
104
+ // Alongside `distro:` the new key takes the same depth; under a bare
105
+ // `config:` it takes the next level.
106
+ const indent = anchor >= 0 ? base : `${base} `;
107
+ lines.splice(offset + 1, 0, `${indent}${key}: ${value}`);
108
+ }
109
+
110
+ /**
111
+ * Apply the supported edits to a preset block.
112
+ * @param {string} block - the block from {@link presetBlock}.
113
+ * @param {{ distro?: string, sessionSandbox?: boolean | "auto", sandbox?: boolean, maskWindowsDrive?: boolean, agentBackend?: string }} edits - the edits.
114
+ * @returns {string} the edited block.
115
+ */
116
+ export function applyEdits(block, edits) {
117
+ const lines = block.split("\n");
118
+ const both = /** @type {Array<[string, string | undefined]>} */ ([
119
+ ["distro", edits.distro],
120
+ ["sessionSandbox", edits.sessionSandbox === undefined ? undefined : String(edits.sessionSandbox)],
121
+ ["sandbox", edits.sandbox === undefined ? undefined : String(edits.sandbox)],
122
+ ["maskWindowsDrive", edits.maskWindowsDrive === undefined ? undefined : String(edits.maskWindowsDrive)],
123
+ ]);
124
+ for (const [key, value] of both) {
125
+ if (value === undefined) continue;
126
+ setKey(lines, "wsl-shell", key, value);
127
+ setKey(lines, "wsl-fs", key, value);
128
+ }
129
+ if (edits.agentBackend !== undefined) setKey(lines, "wsl-shell", "agentBackend", edits.agentBackend);
130
+ return lines.join("\n");
131
+ }
132
+
133
+ /**
134
+ * Parse the helper's arguments.
135
+ * @param {string[]} argv - `process.argv.slice(2)`.
136
+ * @returns {{ edits: Record<string, string | boolean>, out?: string, check?: string, force: boolean }} the options.
137
+ */
138
+ function parseArgs(argv) {
139
+ /** @type {Record<string, string | boolean>} */
140
+ const edits = {};
141
+ /** @type {{ edits: Record<string, string | boolean>, out?: string, check?: string, force: boolean }} */
142
+ const options = { edits, force: false };
143
+ for (let i = 0; i < argv.length; i += 1) {
144
+ const arg = argv[i] ?? "";
145
+ const [flag, inline] = arg.includes("=") ? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)] : [arg, undefined];
146
+ const value = inline ?? argv[++i] ?? "";
147
+ if (flag === "--distro") {
148
+ if (value.length === 0) throw new Error("profile-preset: --distro needs a name");
149
+ edits.distro = value;
150
+ } else if (flag === "--session-sandbox") edits.sessionSandbox = parseSandboxMode(flag, value);
151
+ else if (flag === "--sandbox") edits.sandbox = parseBoolean(flag, value);
152
+ else if (flag === "--mask-windows-drive") edits.maskWindowsDrive = parseBoolean(flag, value);
153
+ else if (flag === "--agent-backend") {
154
+ if (!["auto", "native", "shell"].includes(value)) throw new Error(`profile-preset: --agent-backend must be auto, native or shell (got "${value}")`);
155
+ edits.agentBackend = value;
156
+ } else if (flag === "--out") options.out = value;
157
+ else if (flag === "--check") options.check = value;
158
+ else if (flag === "--force") {
159
+ options.force = true;
160
+ i -= 1;
161
+ } else if (flag === "--help" || flag === "-h") {
162
+ console.log("node scripts/profile-preset.mjs [--distro NAME] [--session-sandbox true|false|auto] [--sandbox true|false] [--mask-windows-drive true|false] [--agent-backend auto|native|shell] [--out FILE] [--check FILE] [--force]");
163
+ process.exit(0);
164
+ } else throw new Error(`profile-preset: unknown option "${arg}"`);
165
+ }
166
+ return options;
167
+ }
168
+
169
+ /**
170
+ * Parse one boolean flag value.
171
+ * @param {string} flag - the flag, for the error.
172
+ * @param {string} value - the value.
173
+ * @returns {boolean} the boolean.
174
+ */
175
+ function parseBoolean(flag, value) {
176
+ if (value === "true") return true;
177
+ if (value === "false") return false;
178
+ throw new Error(`profile-preset: ${flag} takes true or false (got "${value}")`);
179
+ }
180
+
181
+ /**
182
+ * Parse the `--session-sandbox` value: the boolean modes plus `auto`.
183
+ * @param {string} flag - the flag, for the error.
184
+ * @param {string} value - the value.
185
+ * @returns {boolean | "auto"} the mode.
186
+ */
187
+ function parseSandboxMode(flag, value) {
188
+ if (value === "auto") return "auto";
189
+ return parseBoolean(flag, value);
190
+ }
191
+
192
+ /**
193
+ * Report a nested id patch with the exact block to paste.
194
+ * @param {string} file - the profile patch.
195
+ * @param {string[]} ids - the offending ids.
196
+ * @param {string} block - the corrected block.
197
+ * @returns {number} the exit status.
198
+ */
199
+ function nestedFailure(file, ids, block) {
200
+ console.error(`profile-preset: ${file} patches ${ids.map((id) => `"${id}"`).join(" and ")} by id.`);
201
+ console.error("Those rows live inside preset-wsl's config.plugins; the loader ignores the");
202
+ console.error("entry and boots the shipped values. Replace that entry with this complete");
203
+ console.error("preset-wsl block (it also carries the edits you asked for):");
204
+ console.error("");
205
+ console.error(block);
206
+ return 1;
207
+ }
208
+
209
+ /**
210
+ * Run the helper.
211
+ * @param {string[]} argv - `process.argv.slice(2)`.
212
+ * @returns {number} the exit status.
213
+ */
214
+ function main(argv) {
215
+ const options = parseArgs(argv);
216
+ const block = applyEdits(presetBlock(readFileSync(SOURCE, "utf8")), options.edits);
217
+
218
+ if (options.check !== undefined) {
219
+ if (!existsSync(options.check)) throw new Error(`profile-preset: no such patch: ${options.check}`);
220
+ const ids = nestedRowIds(readFileSync(options.check, "utf8"));
221
+ if (ids.length > 0) return nestedFailure(options.check, ids, block);
222
+ console.log(`ok: ${options.check} has no nested wsl-shell/wsl-fs id patch`);
223
+ }
224
+
225
+ if (options.out !== undefined) {
226
+ if (existsSync(options.out)) {
227
+ const existing = readFileSync(options.out, "utf8");
228
+ const ids = nestedRowIds(existing);
229
+ if (ids.length > 0) return nestedFailure(options.out, ids, block);
230
+ if (!existing.includes("- id: preset-wsl") && !options.force) {
231
+ console.error(`profile-preset: ${options.out} exists and has no preset-wsl row; refusing to replace its other overrides (pass --force to overwrite)`);
232
+ return 1;
233
+ }
234
+ }
235
+ mkdirSync(dirname(options.out), { recursive: true });
236
+ const temporary = join(tmpdir(), `dsh-preset-${process.pid}.yml`);
237
+ writeFileSync(temporary, `${block}\n`);
238
+ renameSync(temporary, options.out);
239
+ console.log(`wrote ${options.out}`);
240
+ return 0;
241
+ }
242
+
243
+ if (options.check === undefined) process.stdout.write(`${block}\n`);
244
+ return 0;
245
+ }
246
+
247
+ if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
248
+ try {
249
+ process.exitCode = main(process.argv.slice(2));
250
+ } catch (error) {
251
+ console.error(/** @type {Error} */ (error).message);
252
+ process.exitCode = 1;
253
+ }
254
+ }
@@ -1,136 +0,0 @@
1
- /**
2
- * Pure builders and parsers for the distro-routed `workspace-files` service —
3
- * the GUI file tree and previews for a `\\wsl.localhost\<distro>` workspace,
4
- * served from inside the distro instead of Windows-side 9P walks.
5
- *
6
- * Peer-free by construction (no DSH imports): the service subclass imports
7
- * this alongside the shipped `dsh-api-workspace-files` base class, and the
8
- * unit tests import it standalone.
9
- *
10
- * This is a TypeScript source built to `lib/workspace-files-route.js`; edit
11
- * THIS file and run `pnpm run build` — the artifact under `lib/` is generated,
12
- * and `pnpm test` fails when it drifts.
13
- *
14
- * @module dsh-plugin-wsl/workspace-files-route
15
- */
16
- /**
17
- * Translate one path-shaped value into distro coordinates: UNC identities map
18
- * onto their Linux paths, drive-letter paths onto their `/mnt/<drive>` mounts,
19
- * everything else passes through verbatim.
20
- *
21
- * @param value - the value to translate.
22
- * @returns the distro-side spelling.
23
- */
24
- export declare function distroPath(value: string): string;
25
- /**
26
- * Whether a workspace root belongs to a distro (and therefore routes
27
- * distro-side).
28
- *
29
- * @param workspaceRoot - the scope's workspace root, any coordinate system.
30
- * @returns true for a `\\wsl.localhost\<distro>` UNC root.
31
- */
32
- export declare function isDistroWorkspace(workspaceRoot: string): boolean;
33
- /**
34
- * The Linux working path for one request: UNC absolutes map onto their Linux
35
- * paths, POSIX absolutes and relative paths join onto the root.
36
- *
37
- * @param linuxRoot - the workspace root in Linux form.
38
- * @param path - the requested path, any coordinate system.
39
- * @returns the absolute Linux path.
40
- */
41
- export declare function linuxJoin(linuxRoot: string, path: string): string;
42
- /**
43
- * The record invocation for one path: line 1 is the no-follow kind (the
44
- * upstream lstat gate), line 2 the follow-stat fields (the upstream final
45
- * stat), tab-joined. The path is resolved and contained first — the GUI's
46
- * workspace is a boundary for every read, not just for listings.
47
- *
48
- * @param file - absolute Linux path.
49
- * @param root - the workspace root in Linux form (containment boundary).
50
- * @returns the agent argv.
51
- */
52
- export declare function statRecordArgv(file: string, root: string): string[];
53
- /**
54
- * Parse one stat record (kind + follow fields, tab-joined).
55
- *
56
- * @param line - the record line.
57
- * @returns the upstream kind spelling, the byte size, and the synthesized
58
- * opaque version.
59
- */
60
- export declare function parseStatRecord(line: string | Buffer): {
61
- kind: string;
62
- type: string;
63
- size: number;
64
- version: string;
65
- };
66
- /**
67
- * Map a `stat` kind onto the wire's kind token.
68
- *
69
- * @param kind - the `stat -c %F` spelling.
70
- * @returns the wire kind token.
71
- */
72
- export declare function wireKind(kind: string): string;
73
- /**
74
- * The read invocation for one line-paged text read: the stat record rides
75
- * stderr (the caller builds the stat half of the response), the page rides
76
- * stdout — one line beyond the page, so EOF is decidable from the yield.
77
- * `$1` is the file, `$2` the 1-based first line, `$3` the line count.
78
- *
79
- * @param file - absolute Linux path.
80
- * @param offset - first line to return (1-based).
81
- * @param limit - maximum lines to return.
82
- * @param root - the workspace root in Linux form (containment boundary).
83
- * @returns the agent argv.
84
- */
85
- export declare function pageArgv(file: string, offset: number, limit: number, root: string): string[];
86
- /**
87
- * The byte-window invocation: the stat record rides stderr, the window rides
88
- * stdout. `$1` is the file, `$2` the byte offset, `$3` the byte count.
89
- *
90
- * @param file - absolute Linux path.
91
- * @param offset - byte offset (0-based).
92
- * @param length - byte count.
93
- * @param root - the workspace root in Linux form (containment boundary).
94
- * @returns the agent argv.
95
- */
96
- export declare function bytesArgv(file: string, offset: number, length: number, root: string): string[];
97
- /**
98
- * Parse the stat record a read or byte-window relayed on stderr.
99
- *
100
- * @param stderr - the relay's raw stderr.
101
- * @returns the parsed stat record, or undefined when stderr carries none.
102
- */
103
- export declare function parseStatRecordFromStderr(stderr: string | Buffer): {
104
- kind: string;
105
- type: string;
106
- size: number;
107
- version: string;
108
- } | undefined;
109
- /**
110
- * The listing invocation for one directory: resolve the final component
111
- * through symlinks, refuse paths outside the root and non-directories, then
112
- * list one more entry than the cap so truncation is decidable from the yield.
113
- * `-L` matches the upstream child semantics (a symlink to a directory lists
114
- * as a directory, a broken link drops out); `-p` marks directories with the
115
- * trailing `/` the parser keys on.
116
- *
117
- * @param linuxRoot - the workspace root in Linux form (containment boundary).
118
- * @param linuxDir - the directory to list.
119
- * @param maxEntries - the caller's entry cap.
120
- * @returns the agent argv.
121
- */
122
- export declare function listDistroArgv(linuxRoot: string, linuxDir: string, maxEntries: number): string[];
123
- /**
124
- * Parse one capped `ls -1ALp` listing into wire entries.
125
- *
126
- * @param stdout - the listing's raw stdout.
127
- * @param maxEntries - the entry cap the caller configured.
128
- * @returns at most `maxEntries` entries and whether the level had more.
129
- */
130
- export declare function parseDirListing(stdout: string | Buffer, maxEntries: number): {
131
- entries: Array<{
132
- name: string;
133
- type: "file" | "directory";
134
- }>;
135
- truncated: boolean;
136
- };
@@ -1,225 +0,0 @@
1
- /**
2
- * Pure builders and parsers for the distro-routed `workspace-files` service —
3
- * the GUI file tree and previews for a `\\wsl.localhost\<distro>` workspace,
4
- * served from inside the distro instead of Windows-side 9P walks.
5
- *
6
- * Peer-free by construction (no DSH imports): the service subclass imports
7
- * this alongside the shipped `dsh-api-workspace-files` base class, and the
8
- * unit tests import it standalone.
9
- *
10
- * This is a TypeScript source built to `lib/workspace-files-route.js`; edit
11
- * THIS file and run `pnpm run build` — the artifact under `lib/` is generated,
12
- * and `pnpm test` fails when it drifts.
13
- *
14
- * @module dsh-plugin-wsl/workspace-files-route
15
- */
16
- import { isWslUnc, uncToPosix, windowsToLinuxMount } from "./paths.js";
17
- /**
18
- * Translate one path-shaped value into distro coordinates: UNC identities map
19
- * onto their Linux paths, drive-letter paths onto their `/mnt/<drive>` mounts,
20
- * everything else passes through verbatim.
21
- *
22
- * @param value - the value to translate.
23
- * @returns the distro-side spelling.
24
- */
25
- export function distroPath(value) {
26
- if (typeof value !== "string")
27
- return value;
28
- if (isWslUnc(value)) {
29
- const parsed = uncToPosix(value);
30
- return parsed === undefined ? value : parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
31
- }
32
- const mounted = windowsToLinuxMount(value);
33
- return mounted === undefined ? value : mounted;
34
- }
35
- /**
36
- * Whether a workspace root belongs to a distro (and therefore routes
37
- * distro-side).
38
- *
39
- * @param workspaceRoot - the scope's workspace root, any coordinate system.
40
- * @returns true for a `\\wsl.localhost\<distro>` UNC root.
41
- */
42
- export function isDistroWorkspace(workspaceRoot) {
43
- return isWslUnc(workspaceRoot);
44
- }
45
- /**
46
- * The Linux working path for one request: UNC absolutes map onto their Linux
47
- * paths, POSIX absolutes and relative paths join onto the root.
48
- *
49
- * @param linuxRoot - the workspace root in Linux form.
50
- * @param path - the requested path, any coordinate system.
51
- * @returns the absolute Linux path.
52
- */
53
- export function linuxJoin(linuxRoot, path) {
54
- // The UNC check runs on the RAW spelling: the backslash-to-slash rewrite
55
- // below would leave `//wsl.localhost/...`, which the identity regex cannot
56
- // see.
57
- const parsed = uncToPosix(path);
58
- if (parsed !== undefined)
59
- return parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
60
- const text = String(path).replaceAll("\\", "/");
61
- if (text.startsWith("/"))
62
- return text.length > 1 ? text.replace(/\/+$/, "") : "/";
63
- const base = linuxRoot === "/" ? "" : linuxRoot;
64
- const joined = text.length === 0 ? base : `${base}/${text}`.replace(/\/\.(?=\/|$)/gu, "");
65
- return joined.length === 0 ? "/" : joined;
66
- }
67
- /**
68
- * The containment guard every distro-side workspace read shares. `$1` is the
69
- * requested file, `$2` the workspace root; both are canonicalized with
70
- * `realpath -L`, and a target that is neither the root itself nor below it is
71
- * refused with exit 3 (the `workspace-file/outside-workspace` refusal). The
72
- * resolved file is left in `real` for the rest of the script.
73
- *
74
- * A root of `/` admits every absolute path: the workspace IS the distro. The
75
- * `-L` spelling matches the listing guard's, so symlinked workspace roots and
76
- * `..` segments resolve the same way on both sides.
77
- */
78
- const CONTAIN_FN = [
79
- 'contain() {',
80
- ' real=$(realpath -L -- "$1") || exit 2',
81
- ' root=$(realpath -L -- "$2") || exit 2',
82
- ' case "$root" in /) ;; *) case "$real" in "$root"|"$root"/*) ;; *) echo "OUTSIDE:$real" >&2; exit 3;; esac;; esac',
83
- '}',
84
- ].join("\n");
85
- /**
86
- * The record invocation for one path: line 1 is the no-follow kind (the
87
- * upstream lstat gate), line 2 the follow-stat fields (the upstream final
88
- * stat), tab-joined. The path is resolved and contained first — the GUI's
89
- * workspace is a boundary for every read, not just for listings.
90
- *
91
- * @param file - absolute Linux path.
92
- * @param root - the workspace root in Linux form (containment boundary).
93
- * @returns the agent argv.
94
- */
95
- export function statRecordArgv(file, root) {
96
- const script = [
97
- CONTAIN_FN,
98
- 'contain "$1" "$2"',
99
- 'k=$(stat -c %F -- "$real") || exit 2',
100
- 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$real") || exit 2',
101
- "printf '%s\\t%s\\n' \"$k\" \"$s\"",
102
- ].join("\n");
103
- return ["sh", "-c", script, "sh", file, root];
104
- }
105
- /**
106
- * Parse one stat record (kind + follow fields, tab-joined).
107
- *
108
- * @param line - the record line.
109
- * @returns the upstream kind spelling, the byte size, and the synthesized
110
- * opaque version.
111
- */
112
- export function parseStatRecord(line) {
113
- const [kind, size, dev, ino, mtime, ctime] = String(line).trim().split("\t");
114
- return {
115
- kind,
116
- type: wireKind(kind),
117
- size: Number(size),
118
- version: `${dev}:${ino}:${size}:${mtime}:${ctime}`,
119
- };
120
- }
121
- /**
122
- * Map a `stat` kind onto the wire's kind token.
123
- *
124
- * @param kind - the `stat -c %F` spelling.
125
- * @returns the wire kind token.
126
- */
127
- export function wireKind(kind) {
128
- const mapped = { "regular file": "file", directory: "directory", "symbolic link": "symlink" };
129
- return mapped[kind] ?? kind;
130
- }
131
- /**
132
- * The read invocation for one line-paged text read: the stat record rides
133
- * stderr (the caller builds the stat half of the response), the page rides
134
- * stdout — one line beyond the page, so EOF is decidable from the yield.
135
- * `$1` is the file, `$2` the 1-based first line, `$3` the line count.
136
- *
137
- * @param file - absolute Linux path.
138
- * @param offset - first line to return (1-based).
139
- * @param limit - maximum lines to return.
140
- * @param root - the workspace root in Linux form (containment boundary).
141
- * @returns the agent argv.
142
- */
143
- export function pageArgv(file, offset, limit, root) {
144
- const script = [
145
- CONTAIN_FN,
146
- 'contain "$1" "$4"',
147
- 'k=$(stat -c %F -- "$real") || exit 2',
148
- 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$real") || exit 2',
149
- "printf '%s\\t%s\\n' \"$k\" \"$s\" >&2",
150
- 'sed -n "${2},$(( $2 + $3 ))p" -- "$real"',
151
- ].join("\n");
152
- return ["sh", "-c", script, "sh", file, String(offset), String(limit), root];
153
- }
154
- /**
155
- * The byte-window invocation: the stat record rides stderr, the window rides
156
- * stdout. `$1` is the file, `$2` the byte offset, `$3` the byte count.
157
- *
158
- * @param file - absolute Linux path.
159
- * @param offset - byte offset (0-based).
160
- * @param length - byte count.
161
- * @param root - the workspace root in Linux form (containment boundary).
162
- * @returns the agent argv.
163
- */
164
- export function bytesArgv(file, offset, length, root) {
165
- const script = [
166
- CONTAIN_FN,
167
- 'contain "$1" "$4"',
168
- 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$real") || exit 2',
169
- "printf '%s\\n' \"$s\" >&2",
170
- 'tail -c "+$(( $2 + 1 ))" -- "$real" | head -c "$3"',
171
- ].join("\n");
172
- return ["sh", "-c", script, "sh", file, String(offset), String(length), root];
173
- }
174
- /**
175
- * Parse the stat record a read or byte-window relayed on stderr.
176
- *
177
- * @param stderr - the relay's raw stderr.
178
- * @returns the parsed stat record, or undefined when stderr carries none.
179
- */
180
- export function parseStatRecordFromStderr(stderr) {
181
- const line = String(stderr)
182
- .split("\n")
183
- .find((candidate) => candidate.includes("\t"));
184
- return line === undefined ? undefined : parseStatRecord(line);
185
- }
186
- /**
187
- * The listing invocation for one directory: resolve the final component
188
- * through symlinks, refuse paths outside the root and non-directories, then
189
- * list one more entry than the cap so truncation is decidable from the yield.
190
- * `-L` matches the upstream child semantics (a symlink to a directory lists
191
- * as a directory, a broken link drops out); `-p` marks directories with the
192
- * trailing `/` the parser keys on.
193
- *
194
- * @param linuxRoot - the workspace root in Linux form (containment boundary).
195
- * @param linuxDir - the directory to list.
196
- * @param maxEntries - the caller's entry cap.
197
- * @returns the agent argv.
198
- */
199
- export function listDistroArgv(linuxRoot, linuxDir, maxEntries) {
200
- const script = [
201
- "root=$1 dir=$2 max=$3",
202
- 'real=$(realpath -L -- "$dir") || exit 2',
203
- 'case "$real" in "$root") ;; "$root"/*) ;; *) echo "OUTSIDE:$real" >&2; exit 3;; esac',
204
- '[ -d "$real" ] || { echo "NOTDIR:$real" >&2; exit 4; }',
205
- 'ls -1ALp -- "$real" | head -n "$max"',
206
- ].join("\n");
207
- return ["sh", "-c", script, "sh", linuxRoot, linuxDir, String(maxEntries + 1)];
208
- }
209
- /**
210
- * Parse one capped `ls -1ALp` listing into wire entries.
211
- *
212
- * @param stdout - the listing's raw stdout.
213
- * @param maxEntries - the entry cap the caller configured.
214
- * @returns at most `maxEntries` entries and whether the level had more.
215
- */
216
- export function parseDirListing(stdout, maxEntries) {
217
- const lines = String(stdout).split("\n").filter((line) => line.length > 0);
218
- const truncated = lines.length > maxEntries;
219
- const entries = lines.slice(0, maxEntries).map((line) => {
220
- const directory = line.endsWith("/");
221
- const name = directory ? line.slice(0, -1) : line;
222
- return { name, type: directory ? "directory" : "file" };
223
- });
224
- return { entries, truncated };
225
- }