@chronosun/dsh-wsl 0.0.0-stage → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/helper.mjs ADDED
@@ -0,0 +1,455 @@
1
+ // Runs inside the distro. NDJSON over stdio; no network, no ports.
2
+ // Sanitises PATH (drops Windows interop entries) at startup — see PLAN §12.4.
3
+ const cleanPath = (process.env.PATH ?? '')
4
+ .split(':')
5
+ .filter((p) => p && !/^\/mnt\/[a-z](\/|$)/i.test(p))
6
+ .join(':')
7
+ process.env.PATH = cleanPath
8
+
9
+ import { createInterface } from 'node:readline'
10
+ import { promises as fsp } from 'node:fs'
11
+ import { execFile, spawn } from 'node:child_process'
12
+ import { promisify } from 'node:util'
13
+ import { tmpdir } from 'node:os'
14
+ import path from 'node:path'
15
+
16
+ const execFileAsync = promisify(execFile)
17
+
18
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity })
19
+ let jobs = new Map()
20
+
21
+ function hello() {
22
+ return {
23
+ v: 1,
24
+ pid: process.pid,
25
+ user: process.env.USER ?? process.env.LOGNAME ?? '',
26
+ home: process.env.HOME ?? '',
27
+ shell: process.env.SHELL || '/bin/bash',
28
+ cwd: process.cwd(),
29
+ path: process.env.PATH,
30
+ node: process.execPath,
31
+ }
32
+ }
33
+
34
+ function escapeRe(text) {
35
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
36
+ }
37
+
38
+ /** Minimal glob → RegExp: `**`, `*`, `?`, `[...]`, `{a,b}`. */
39
+ function globToRegExp(glob) {
40
+ let out = '^'
41
+ for (let i = 0; i < glob.length; i++) {
42
+ const c = glob[i]
43
+ if (c === '*') {
44
+ if (glob[i + 1] === '*') {
45
+ i += 1
46
+ if (glob[i + 1] === '/') { i += 1; out += '(?:.*/)?' } else out += '.*'
47
+ } else out += '[^/]*'
48
+ } else if (c === '?') {
49
+ out += '[^/]'
50
+ } else if (c === '[') {
51
+ const end = glob.indexOf(']', i + 1)
52
+ if (end === -1) out += '\\['
53
+ else { out += '[' + glob.slice(i + 1, end).replace(/^!/, '^') + ']'; i = end }
54
+ } else if (c === '{') {
55
+ const end = glob.indexOf('}', i + 1)
56
+ if (end === -1) out += '\\{'
57
+ else { out += '(?:' + glob.slice(i + 1, end).split(',').map(escapeRe).join('|') + ')'; i = end }
58
+ } else {
59
+ out += escapeRe(c)
60
+ }
61
+ }
62
+ return new RegExp(out + '$')
63
+ }
64
+
65
+ /** Depth-first file walk; VCS metadata and dependency trees are pruned. */
66
+ async function walk(root, visit) {
67
+ const stack = [root]
68
+ while (stack.length > 0) {
69
+ const dir = stack.pop()
70
+ let entries
71
+ try { entries = await fsp.readdir(dir, { withFileTypes: true }) } catch { continue }
72
+ for (const entry of entries) {
73
+ const full = path.join(dir, entry.name)
74
+ if (entry.isDirectory()) {
75
+ if (entry.name === '.git' || entry.name === 'node_modules') continue
76
+ stack.push(full)
77
+ } else if (entry.isFile()) {
78
+ if (visit(full) === false) return
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Files of a Git worktree, respecting `.gitignore` (the `--exclude-standard`
86
+ * set) and nested ignore files. Returns null outside a worktree, where the
87
+ * caller falls back to `walk` (which prunes `.git`/`node_modules`).
88
+ */
89
+ async function listGitFiles(root) {
90
+ try {
91
+ const { stdout } = await execFileAsync('git', [
92
+ '-C', root,
93
+ '-c', 'core.quotepath=false',
94
+ 'ls-files', '-z',
95
+ '--cached', '--others', '--exclude-standard',
96
+ ], { maxBuffer: 64 * 1024 * 1024 })
97
+ return stdout.split('\0').filter(Boolean).map((rel) => path.join(root, rel))
98
+ } catch {
99
+ return null
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Prefer ripgrep's file walk when the distribution has it: it also honours
105
+ * `.ignore`, `.rgignore` and nested ignore files, which `git ls-files` does
106
+ * not. Falls back to the Git list.
107
+ */
108
+ async function listFiles(root) {
109
+ try {
110
+ const { stdout } = await execFileAsync('rg', [
111
+ '--files', '--hidden', '-0',
112
+ '--glob', '!.git', '--glob', '!.git/**',
113
+ ], { cwd: root, maxBuffer: 64 * 1024 * 1024 })
114
+ return stdout.split('\0').filter(Boolean).map((rel) => path.join(root, rel))
115
+ } catch {
116
+ return await listGitFiles(root)
117
+ }
118
+ }
119
+
120
+ /** Run one git command in a worktree with an optional private index. */
121
+ async function git(cwd, args, env) {
122
+ const { stdout } = await execFileAsync('git', ['-C', cwd, '-c', 'core.quotepath=false', ...args], {
123
+ maxBuffer: 16 * 1024 * 1024,
124
+ env: env ? { ...process.env, ...env } : undefined,
125
+ })
126
+ return stdout.trim()
127
+ }
128
+
129
+ /** `git rev-parse --show-prefix` for a cwd below the repository root. */
130
+ async function gitPrefix(cwd) {
131
+ try {
132
+ return (await git(cwd, ['rev-parse', '--show-prefix'])).replace(/\r/g, '')
133
+ } catch {
134
+ return ''
135
+ }
136
+ }
137
+
138
+ /** Snapshot the work tree's non-ignored state into a tree object. */
139
+ async function gitSnapshot(cwd) {
140
+ const dir = await fsp.mkdtemp(path.join(tmpdir(), 'dsh-wsl-git-'))
141
+ const index = path.join(dir, 'index')
142
+ const env = { GIT_INDEX_FILE: index }
143
+ try {
144
+ await git(cwd, ['read-tree', '--empty'], env)
145
+ await git(cwd, ['add', '-A', '--', '.', ':(exclude).agents/worktrees'], env)
146
+ const tree = await git(cwd, ['write-tree'], env)
147
+ return { tree }
148
+ } finally {
149
+ await fsp.rm(dir, { recursive: true, force: true }).catch(() => {})
150
+ }
151
+ }
152
+
153
+ /** numstat between two trees, with paths relative to `cwd`. */
154
+ async function gitSummary(cwd, before, after) {
155
+ const prefix = await gitPrefix(cwd)
156
+ const raw = await git(cwd, ['diff-tree', '-r', '--no-commit-id', '--numstat', '--no-renames', before, after])
157
+ const files = []
158
+ for (const line of raw.split(/\r?\n/)) {
159
+ const match = /^(\S+)\t(\S+)\t(.*)$/.exec(line)
160
+ if (!match) continue
161
+ let file = match[3]
162
+ if (prefix && file.startsWith(prefix)) file = file.slice(prefix.length)
163
+ const binary = match[1] === '-' && match[2] === '-'
164
+ files.push({ path: file, added: binary ? 0 : Number(match[1]) || 0, deleted: binary ? 0 : Number(match[2]) || 0, ...(binary ? { binary: true } : {}) })
165
+ }
166
+ return { files }
167
+ }
168
+
169
+ function parseUnifiedDiff(raw, pathValue, display, before, after, coarse) {
170
+ const hunks = []
171
+ let current = null
172
+ for (const line of raw.split(/\r?\n/)) {
173
+ const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line)
174
+ if (header) {
175
+ if (current) hunks.push(current)
176
+ current = {
177
+ oldStart: Number(header[1]),
178
+ oldLines: Number(header[2] ?? 1),
179
+ newStart: Number(header[3]),
180
+ newLines: Number(header[4] ?? 1),
181
+ lines: [],
182
+ }
183
+ continue
184
+ }
185
+ if (!current) continue
186
+ if (line.startsWith('diff --git ') || line.startsWith('index ')) continue
187
+ if (line.startsWith('\\ No newline')) { current.lines.push(line); continue }
188
+ if (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) current.lines.push(line)
189
+ }
190
+ if (current) hunks.push(current)
191
+ return { kind: 'text', path: pathValue, display, before, after, hunks, coarse }
192
+ }
193
+
194
+ /** One file's before/after comparison from two tree objects. */
195
+ async function gitDiffFile(cwd, before, after, file) {
196
+ const prefix = await gitPrefix(cwd)
197
+ const full = `${prefix}${file}`
198
+ const exists = async (tree) => {
199
+ try {
200
+ await git(cwd, ['cat-file', '-e', `${tree}:${full}`])
201
+ return true
202
+ } catch {
203
+ return false
204
+ }
205
+ }
206
+ const beforeExists = await exists(before)
207
+ const afterExists = await exists(after)
208
+ let size = 0
209
+ if (afterExists) {
210
+ try { size = Number(await git(cwd, ['cat-file', '-s', `${after}:${full}`])) || 0 } catch {}
211
+ }
212
+ if (size > 2 * 1024 * 1024) return { kind: 'oversized', path: file, display: file }
213
+ const raw = await git(cwd, ['diff', '--no-color', '--unified=3', before, after, '--', full])
214
+ if (/^Binary files /m.test(raw)) return { kind: 'binary', path: file, display: file }
215
+ return parseUnifiedDiff(raw, file, file, beforeExists, afterExists, false)
216
+ }
217
+
218
+ /** Directory basenames the `@` index never descends into. */
219
+ const INDEX_EXCLUDED = new Set([
220
+ '.git', 'node_modules', 'dist', 'build', 'out', 'coverage', 'target',
221
+ '.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache',
222
+ '.mypy_cache', '.gradle',
223
+ ])
224
+
225
+ function relativeParts(root, full) {
226
+ return full.slice(root.length + 1).replace(/\\/g, '/').split('/').filter(Boolean)
227
+ }
228
+
229
+ /**
230
+ * The bounded file+directory index used by `@` completion. In a Git worktree
231
+ * the file set is `git ls-files --exclude-standard` (`.gitignore` respected)
232
+ * and directories are derived from those paths; outside one it walks. Returns
233
+ * at most `max` entries, both with paths relative to `root`.
234
+ */
235
+ async function listTree(root, max) {
236
+ const entries = new Map()
237
+ const files = await listFiles(root)
238
+ if (files !== null) {
239
+ for (const full of files.sort()) {
240
+ const parts = relativeParts(root, full)
241
+ if (parts.length === 0 || parts.some((part) => INDEX_EXCLUDED.has(part))) continue
242
+ entries.set(parts.join('/'), 'file')
243
+ for (let i = 1; i < parts.length; i += 1) entries.set(parts.slice(0, i).join('/'), 'directory')
244
+ if (entries.size >= max) break
245
+ }
246
+ } else {
247
+ const stack = [{ abs: root, rel: '' }]
248
+ while (stack.length > 0 && entries.size < max) {
249
+ const current = stack.pop()
250
+ let children
251
+ try { children = await fsp.readdir(current.abs, { withFileTypes: true }) } catch { continue }
252
+ for (const child of children) {
253
+ const rel = current.rel === '' ? child.name : `${current.rel}/${child.name}`
254
+ if (child.isDirectory()) {
255
+ if (INDEX_EXCLUDED.has(child.name)) continue
256
+ entries.set(rel, 'directory')
257
+ stack.push({ abs: path.join(current.abs, child.name), rel })
258
+ } else if (child.isFile()) {
259
+ entries.set(rel, 'file')
260
+ }
261
+ }
262
+ }
263
+ }
264
+ return { entries: [...entries].map(([entryPath, kind]) => ({ path: entryPath, kind })) }
265
+ }
266
+
267
+ async function handle(req) {
268
+ switch (req.op) {
269
+ case 'ping':
270
+ return { pong: true, pid: process.pid, cwd: process.cwd() }
271
+ case 'stat': {
272
+ const s = await fsp.stat(req.path)
273
+ return { type: s.isDirectory() ? 'directory' : s.isFile() ? 'file' : 'other', size: s.size, mtime: s.mtimeMs, mode: (s.mode & 0o777).toString(8) }
274
+ }
275
+ case 'lstat': {
276
+ try {
277
+ const s = await fsp.lstat(req.path)
278
+ return {
279
+ type: s.isSymbolicLink() ? 'symlink' : s.isDirectory() ? 'directory' : s.isFile() ? 'file' : 'other',
280
+ size: s.size,
281
+ mtime: s.mtimeMs,
282
+ }
283
+ } catch (error) {
284
+ if (error.code === 'ENOENT') return { absent: true }
285
+ throw error
286
+ }
287
+ }
288
+ case 'realpath': {
289
+ try { return { path: await fsp.realpath(req.path) } } catch (error) {
290
+ if (error.code === 'ENOENT') return { path: null }
291
+ throw error
292
+ }
293
+ }
294
+ case 'gitSnapshot': {
295
+ return await gitSnapshot(req.cwd)
296
+ }
297
+ case 'gitSummary': {
298
+ return await gitSummary(req.cwd, req.before, req.after)
299
+ }
300
+ case 'gitDiffFile': {
301
+ return await gitDiffFile(req.cwd, req.before, req.after, req.path)
302
+ }
303
+ case 'tree': {
304
+ const { path: root, max = 50_000 } = req
305
+ return await listTree(root, max)
306
+ }
307
+ case 'glob': {
308
+ const { path: root, pattern = '**/*', max = 1000 } = req
309
+ const re = globToRegExp(pattern)
310
+ const base = root.replace(/\/+$/, '')
311
+ const found = []
312
+ let truncated = false
313
+ const visit = (file) => {
314
+ const rel = file.slice(base.length + 1)
315
+ const name = path.basename(file)
316
+ if (re.test(rel) || re.test(name)) {
317
+ found.push(file)
318
+ if (found.length >= max) { truncated = true; return false }
319
+ }
320
+ return true
321
+ }
322
+ const gitFiles = await listFiles(root)
323
+ if (gitFiles === null) await walk(root, visit)
324
+ else for (const file of gitFiles.sort()) { if (visit(file) === false) break }
325
+ found.sort()
326
+ return { paths: found, truncated, total: found.length }
327
+ }
328
+ case 'grep': {
329
+ const { path: root, pattern, include, max = 250 } = req
330
+ let re
331
+ try { re = new RegExp(pattern) } catch (error) {
332
+ throw Object.assign(new Error(`invalid regular expression: ${error.message}`), { code: 'FS_BAD_PATTERN' })
333
+ }
334
+ const includeRe = include ? globToRegExp(include) : null
335
+ const files = []
336
+ const addFile = (file) => {
337
+ if (includeRe && !includeRe.test(path.basename(file))) return true
338
+ files.push(file)
339
+ return true
340
+ }
341
+ const gitFiles = await listFiles(root)
342
+ if (gitFiles === null) await walk(root, addFile)
343
+ else for (const file of gitFiles.sort()) addFile(file)
344
+ const matches = []
345
+ let seen = 0
346
+ let truncated = false
347
+ for (const file of files) {
348
+ let text
349
+ try {
350
+ const info = await fsp.stat(file)
351
+ if (info.size > 2 * 1024 * 1024) continue
352
+ text = await fsp.readFile(file, 'utf8')
353
+ } catch { continue }
354
+ if (text.includes('\u0000')) continue // binary
355
+ const lines = text.split('\n')
356
+ const hits = []
357
+ for (let i = 0; i < lines.length; i++) {
358
+ if (!re.test(lines[i])) continue
359
+ hits.push({ lineNumber: i + 1, line: lines[i].slice(0, 2000) })
360
+ seen += 1
361
+ if (seen >= max) { truncated = true; break }
362
+ }
363
+ if (hits.length > 0) matches.push({ path: file, matches: hits })
364
+ if (truncated) break
365
+ }
366
+ return { files: matches, truncated, total: seen }
367
+ }
368
+ case 'rm': {
369
+ await fsp.unlink(req.path)
370
+ return { removed: true }
371
+ }
372
+ case 'readBytes': {
373
+ const buf = await fsp.readFile(req.path)
374
+ const cap = req.maxBytes ?? 0
375
+ if (cap > 0 && buf.length > cap) {
376
+ throw Object.assign(new Error(`file is larger than ${cap} bytes`), { code: 'FS_TOO_LARGE' })
377
+ }
378
+ return { base64: buf.toString('base64'), bytes: buf.length }
379
+ }
380
+ case 'readRange': {
381
+ const handle = await fsp.open(req.path, 'r')
382
+ try {
383
+ const buf = Buffer.alloc(req.length)
384
+ const { bytesRead } = await handle.read(buf, 0, req.length, req.offset)
385
+ return { base64: buf.subarray(0, bytesRead).toString('base64'), bytes: bytesRead }
386
+ } finally {
387
+ await handle.close()
388
+ }
389
+ }
390
+ case 'read':
391
+ return { text: await fsp.readFile(req.path, 'utf8') }
392
+ case 'list':
393
+ return { entries: (await fsp.readdir(req.path, { withFileTypes: true })).map((e) => ({ name: e.name, dir: e.isDirectory(), link: e.isSymbolicLink() })) }
394
+ case 'write': {
395
+ const dir = path.dirname(req.path)
396
+ await fsp.mkdir(dir, { recursive: true })
397
+ const tmp = path.join(dir, `.dsh-tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`)
398
+ const mode = req.mode ? parseInt(req.mode, 8) : undefined
399
+ try {
400
+ if (mode) await fsp.writeFile(tmp, req.text, { encoding: 'utf8', mode })
401
+ else await fsp.writeFile(tmp, req.text, 'utf8')
402
+ await fsp.rename(tmp, req.path)
403
+ } catch (error) {
404
+ await fsp.rm(tmp, { force: true }).catch(() => {})
405
+ throw error
406
+ }
407
+ return { bytes: Buffer.byteLength(req.text) }
408
+ }
409
+ case 'exec': {
410
+ // Bounded one-shot; the plugin's subprocess provider drives longer work.
411
+ return await new Promise((resolve) => {
412
+ const child = spawn(req.argv[0], req.argv.slice(1), { cwd: req.cwd, env: { ...process.env, ...(req.env ?? {}) } })
413
+ let out = '', err = '', truncated = false
414
+ const cap = req.maxBytes ?? 1 << 20
415
+ child.stdout.on('data', (d) => { if (out.length < cap) out += d.toString('utf8'); else truncated = true })
416
+ child.stderr.on('data', (d) => { if (err.length < cap) err += d.toString('utf8'); else truncated = true })
417
+ child.on('error', (e) => resolve({ code: -1, out, err: err + String(e), truncated }))
418
+ child.on('close', (code, signal) => resolve({ code, signal, out, err, truncated }))
419
+ })
420
+ }
421
+ case 'which': {
422
+ const { command } = req
423
+ const searchPath = (req.env?.PATH ?? process.env.PATH ?? '').split(':').filter(Boolean)
424
+ const candidates = command.startsWith('/')
425
+ ? [command]
426
+ : searchPath.map((dir) => path.join(dir, command))
427
+ for (const candidate of candidates) {
428
+ if (/^\/mnt\/[a-z]\//i.test(candidate)) continue // interop binary: never hand it out
429
+ try {
430
+ await fsp.access(candidate, (await import('node:fs')).constants.X_OK)
431
+ const real = await fsp.realpath(candidate).catch(() => candidate)
432
+ return { path: candidate, real }
433
+ } catch {}
434
+ }
435
+ return { path: null }
436
+ }
437
+ case 'shutdown':
438
+ process.stdout.write(JSON.stringify({ id: req.id, result: { bye: true } }) + '\n', () => process.exit(0))
439
+ return undefined
440
+ default:
441
+ throw Object.assign(new Error(`unknown op: ${req.op}`), { code: 'EUNKNOWNOP' })
442
+ }
443
+ }
444
+
445
+ rl.on('line', (line) => {
446
+ if (!line.trim()) return
447
+ let req
448
+ try { req = JSON.parse(line) } catch { return }
449
+ handle(req).then(
450
+ (result) => { if (result !== undefined) process.stdout.write(JSON.stringify({ id: req.id, result }) + '\n') },
451
+ (error) => process.stdout.write(JSON.stringify({ id: req.id, error: { code: error.code ?? 'EFAIL', message: String(error.message ?? error) } }) + '\n'),
452
+ )
453
+ })
454
+
455
+ process.stdout.write(JSON.stringify({ event: 'hello', result: hello() }) + '\n')
package/src/launch.mjs ADDED
@@ -0,0 +1,54 @@
1
+ // Builds the wsl.exe argv. Pure functions, no DSH dependency, unit-testable.
2
+ //
3
+ // Two hard-won constraints:
4
+ //
5
+ // 1. `wsl.exe` silently drops the arguments that follow a command
6
+ // (`sh -c 'echo $#' sh a b c` answers 0), so nothing may travel as a
7
+ // trailing argument — everything goes inside the single `-c` script.
8
+ // 2. `wsl.exe` expands `$VAR` in its command line against the *target* Linux
9
+ // environment before the script runs. A bare `$PATH` therefore arrives as
10
+ // the dirty default PATH, whose `Program Files (x86)` breaks the second
11
+ // parse. The script is base64-encoded so no `$` ever appears in argv.
12
+
13
+ export const CLEAN_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
14
+
15
+ // Used before the broker has resolved the distribution's login PATH: keep the
16
+ // usual Linux system dirs plus the user-space toolchain locations, but never
17
+ // `/mnt/*` (Windows interop binaries must stay unreachable).
18
+ const FALLBACK_PATH_EXPR = `"$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.hermes/node/bin:${CLEAN_PATH}"`
19
+
20
+ function shellQuote(value) {
21
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
22
+ }
23
+
24
+ /** A `bash -c <script>` request keeps its command; anything else is exec'd. */
25
+ export function commandBody(argv) {
26
+ if (argv.length >= 3 && /(^|\/)bash$/.test(argv[0]) && argv[1] === '-c') return String(argv[2])
27
+ return 'exec ' + argv.map(shellQuote).join(' ')
28
+ }
29
+
30
+ export function buildScript(spec, { nonce = Date.now().toString(36), path } = {}) {
31
+ const env = Object.entries(spec.env ?? {})
32
+ .filter(([, v]) => v !== undefined)
33
+ .map(([k, v]) => `export ${k}=${shellQuote(v)}; `)
34
+ .join('')
35
+ const body = commandBody(spec.argv)
36
+ const encoded = Buffer.from(body, 'utf8').toString('base64')
37
+ const file = `/tmp/.dsh-wsl-${nonce}.sh`
38
+ // `path` is the distribution's login PATH with `/mnt/*` removed, resolved by
39
+ // the broker. Until it exists, the fallback keeps user toolchains
40
+ // (`~/.local/bin`, cargo, …) reachable while still excluding Windows.
41
+ const pathExpr = path ? shellQuote(path) : FALLBACK_PATH_EXPR
42
+ // No `$` anywhere in argv: the script is base64-decoded inside WSL, so `$`
43
+ // in FALLBACK_PATH_EXPR expands there, never in wsl.exe's own command line.
44
+ return `PATH=${pathExpr}; export PATH; ${env}trap 'rm -f ${file}' EXIT; printf %s ${encoded} | base64 -d > ${file}; /bin/bash ${file}`
45
+ }
46
+
47
+ export function buildArgv(spec, { distro, user = '', cwd, path, nonce } = {}) {
48
+ const args = []
49
+ if (distro) args.push('-d', distro)
50
+ if (user) args.push('-u', user)
51
+ if (cwd) args.push('--cd', cwd)
52
+ args.push('--', '/bin/bash', '-c', buildScript(spec, { nonce, path }))
53
+ return args
54
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A workspace under `/home/<user>` (or `/root`) names its Linux user; that is
3
+ * the identity the broker/terminal/session should use, independent of the
4
+ * distro's default user. Kept in its own module so live upgrades cannot hit an
5
+ * older cached `settings.mjs` without the named export.
6
+ */
7
+ export function userFromLinuxPath(linuxPath) {
8
+ const value = String(linuxPath ?? '')
9
+ const home = /^\/home\/([^/]+)(?:\/|$)/.exec(value)
10
+ if (home) return home[1]
11
+ if (/^\/root(?:\/|$)/.test(value)) return 'root'
12
+ return ''
13
+ }