@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/doctor.mjs ADDED
@@ -0,0 +1,237 @@
1
+ // Doctor: read-only WSL/distro checks and the few guided fixes we can run
2
+ // without a TTY (Node install, SSH key/config). Everything runs through
3
+ // wsl.exe with base64 scripts so `$` never reaches wsl.exe's argv.
4
+ import { spawn } from 'node:child_process'
5
+ import { readFileSync } from 'node:fs'
6
+ import { homedir } from 'node:os'
7
+ import { join } from 'node:path'
8
+ import { defaultDistro, readSettings, userFor, writeSettings } from './settings.mjs'
9
+
10
+ const WSL_EXE = `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\wsl.exe`
11
+
12
+ function runWsl(args, { timeoutMs = 20_000, utf16 = false } = {}) {
13
+ return new Promise((resolve) => {
14
+ const child = spawn(WSL_EXE, args, { windowsHide: true })
15
+ let out = '', err = ''
16
+ let settled = false
17
+ const finish = (code) => {
18
+ if (settled) return
19
+ settled = true
20
+ clearTimeout(timer)
21
+ resolve({ code, out: out.trim(), err: err.trim() })
22
+ }
23
+ const timer = setTimeout(() => {
24
+ try { child.kill() } catch {}
25
+ finish(-1)
26
+ }, timeoutMs)
27
+ child.stdout.on('data', (chunk) => { out += chunk.toString(utf16 ? 'utf16le' : 'utf8') })
28
+ child.stderr.on('data', (chunk) => { err += chunk.toString(utf16 ? 'utf16le' : 'utf8') })
29
+ child.on('error', (error) => { err += String(error); finish(-1) })
30
+ child.on('close', (code) => finish(code))
31
+ })
32
+ }
33
+
34
+ function base64Script(script) {
35
+ return Buffer.from(script, 'utf8').toString('base64')
36
+ }
37
+
38
+ async function bashScript(distro, user, script, options) {
39
+ const args = ['-d', distro]
40
+ if (user) args.push('-u', user)
41
+ args.push('--', 'bash', '-lc', `printf %s ${base64Script(script)} | base64 -d | bash`)
42
+ return runWsl(args, options)
43
+ }
44
+
45
+ /** Distros installed for this Windows user, in `wsl -l -q` order. */
46
+ export async function listWslDistros() {
47
+ const result = await runWsl(['-l', '-q'], { utf16: true })
48
+ return [...new Set(result.out.replace(/\0/g, '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean))]
49
+ }
50
+
51
+ export async function wslVersion() {
52
+ const result = await runWsl(['--version'], { utf16: true })
53
+ const lines = result.out.replace(/\0/g, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
54
+ return { ok: result.code === 0, lines, raw: lines.join(' | ') }
55
+ }
56
+
57
+ function readWslConfig() {
58
+ const file = join(homedir(), '.wslconfig')
59
+ try {
60
+ const text = readFileSync(file, 'utf8')
61
+ const mode = /^\s*networkingMode\s*=\s*(\S+)/im.exec(text)?.[1] ?? ''
62
+ const localhost = /^\s*localhostForwarding\s*=\s*(\S+)/im.exec(text)?.[1] ?? ''
63
+ const memory = /^\s*memory\s*=\s*(\S+)/im.exec(text)?.[1] ?? ''
64
+ return { present: true, file, networkingMode: mode || 'nat (default)', localhostForwarding: localhost || 'true (default)', memory }
65
+ } catch {
66
+ return { present: false, file, networkingMode: 'nat (default)', localhostForwarding: 'true (default)', memory: '' }
67
+ }
68
+ }
69
+
70
+ function parseKeyValues(output) {
71
+ const values = {}
72
+ for (const line of output.split(/\r?\n/)) {
73
+ const at = line.indexOf('=')
74
+ if (at <= 0) continue
75
+ values[line.slice(0, at)] = line.slice(at + 1)
76
+ }
77
+ return values
78
+ }
79
+
80
+ async function checkDistro(distro, user) {
81
+ const script = [
82
+ 'echo "USER=$(id -un)"',
83
+ 'echo "DEFAULT_USER=$(id -un)"',
84
+ 'echo "UID=$(id -u)"',
85
+ 'echo "HOME=$HOME"',
86
+ 'echo "SHELL=$SHELL"',
87
+ 'echo "NODE=$(command -v node || true)"',
88
+ 'echo "NODE_VERSION=$(node -v 2>/dev/null || true)"',
89
+ 'echo "GIT=$(command -v git || true)"',
90
+ 'echo "GIT_VERSION=$(git --version 2>/dev/null || true)"',
91
+ 'echo "SSH=$(command -v ssh || true)"',
92
+ 'echo "SSH_VERSION=$(ssh -V 2>&1 | head -1)"',
93
+ 'echo "BASH=$(command -v bash || true)"',
94
+ 'echo "KEYS=$(ls ~/.ssh/*.pub 2>/dev/null | wc -l)"',
95
+ 'echo "PRIVATE_KEYS=$(ls ~/.ssh/id_* 2>/dev/null | grep -v "\\.pub$" | wc -l)"',
96
+ 'echo "SSH_CONFIG=$(test -f ~/.ssh/config && echo yes || echo no)"',
97
+ 'echo "LOGIN_PATH_MNT=$(printf "%s" "$PATH" | tr ":" "\\n" | grep -c "^/mnt/")"',
98
+ 'CLEAN_PATH=$(printf "%s" "$PATH" | tr ":" "\\n" | grep -v "^/mnt/" | paste -sd: -)',
99
+ 'echo "CLEAN_PATH=$CLEAN_PATH"',
100
+ 'echo "CLEAN_PATH_MNT=$(printf "%s" "$CLEAN_PATH" | tr ":" "\\n" | grep -c "^/mnt/")"',
101
+ 'echo "PATH=$PATH"',
102
+ "getent passwd | awk -F: '$3>=1000 && $3<65534 {print \"USER_ENTRY=\"$1\":\"$3\":\"$6\":\"$7}'",
103
+ ].join('\n')
104
+ const result = await bashScript(distro, user, script, { timeoutMs: 20_000 })
105
+ const values = parseKeyValues(result.out)
106
+ const users = []
107
+ for (const line of result.out.split(/\r?\n/)) {
108
+ if (!line.startsWith('USER_ENTRY=')) continue
109
+ const [name, uid, home, shell] = line.slice('USER_ENTRY='.length).split(':')
110
+ if (!name || !Number.isFinite(Number(uid))) continue
111
+ users.push({ name, uid: Number(uid), home: home ?? '', shell: shell ?? '' })
112
+ }
113
+ users.sort((left, right) => left.uid - right.uid || left.name.localeCompare(right.name))
114
+ const uid = Number(values.UID ?? -1)
115
+ return {
116
+ distro,
117
+ user: values.USER ?? '',
118
+ defaultUser: values.DEFAULT_USER ?? values.USER ?? '',
119
+ requestedUser: user ?? '',
120
+ uid,
121
+ root: uid === 0,
122
+ home: values.HOME ?? '',
123
+ shell: values.SHELL ?? '',
124
+ users,
125
+ node: { path: values.NODE ?? '', version: values.NODE_VERSION ?? '' },
126
+ git: { path: values.GIT ?? '', version: values.GIT_VERSION ?? '' },
127
+ ssh: { path: values.SSH ?? '', version: values.SSH_VERSION ?? '' },
128
+ bash: values.BASH ?? '',
129
+ keys: Number(values.KEYS ?? 0),
130
+ privateKeys: Number(values.PRIVATE_KEYS ?? 0),
131
+ sshConfig: values.SSH_CONFIG === 'yes',
132
+ path: {
133
+ value: values.CLEAN_PATH ?? values.PATH ?? '',
134
+ loginMntEntries: Number(values.LOGIN_PATH_MNT ?? 0),
135
+ mntEntries: Number(values.CLEAN_PATH_MNT ?? 0),
136
+ },
137
+ error: result.code === 0 ? '' : (result.err || `wsl exited ${result.code}`),
138
+ }
139
+ }
140
+
141
+ /** Lightweight user discovery used at Host boot and by the doctor route. */
142
+ export async function detectUsers(distro) {
143
+ const check = await checkDistro(distro, '')
144
+ return { defaultUser: check.defaultUser, root: check.root, users: check.users }
145
+ }
146
+
147
+ /** Read-only doctor snapshot; the panel renders it. */
148
+ export async function runDoctor(ctx, plugin) {
149
+ const [version, distros] = await Promise.all([wslVersion(), listWslDistros()])
150
+ const settings = readSettings()
151
+ const checks = []
152
+ for (const distro of distros) {
153
+ const user = settings.users[distro] ?? ''
154
+ checks.push(await checkDistro(distro, user))
155
+ }
156
+ // A distro whose default user is root should not silently drive the Agent as
157
+ // root: prefer its first normal user once we know one.
158
+ const users = { ...settings.users }
159
+ let changed = false
160
+ for (const check of checks) {
161
+ if (check.root && check.users.length > 0 && !users[check.distro]) {
162
+ users[check.distro] = check.users[0].name
163
+ changed = true
164
+ }
165
+ }
166
+ if (changed) {
167
+ writeSettings({ users })
168
+ for (const check of checks) {
169
+ if (users[check.distro] && users[check.distro] !== check.requestedUser) {
170
+ Object.assign(check, await checkDistro(check.distro, users[check.distro]))
171
+ }
172
+ }
173
+ }
174
+ return {
175
+ at: new Date().toISOString(),
176
+ wsl: { version, distros },
177
+ network: { ...readWslConfig(), defaultDistro: defaultDistro() },
178
+ settings: changed ? readSettings() : settings,
179
+ checks,
180
+ plugin,
181
+ }
182
+ }
183
+
184
+ /** Guided fixes that need no interactive password. */
185
+ export async function runFix(distro, action, requestedUser, requestedName = '') {
186
+ const user = requestedUser || userFor(distro)
187
+ if (action === 'node') {
188
+ // System packages belong to the distro default/root identity; the Agent's
189
+ // normal user usually has no passwordless sudo.
190
+ const nodeUser = requestedUser || ''
191
+ const script = [
192
+ 'if command -v node >/dev/null 2>&1; then echo "ALREADY"; command -v node; node -v; exit 0; fi',
193
+ 'if ! command -v apt-get >/dev/null 2>&1; then echo "NO_APT"; exit 2; fi',
194
+ 'if [ "$(id -u)" = 0 ]; then SUDO=""; else if ! sudo -n true 2>/dev/null; then echo "NO_SUDO"; exit 3; fi; SUDO="sudo -n"; fi',
195
+ '$SUDO apt-get update -qq && DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq nodejs',
196
+ 'command -v node; node -v',
197
+ ].join('\n')
198
+ const result = await bashScript(distro, nodeUser, script, { timeoutMs: 300_000 })
199
+ return { action, distro, user: nodeUser, code: result.code, out: result.out, err: result.err }
200
+ }
201
+ if (action === 'ssh-key') {
202
+ const script = [
203
+ 'mkdir -p ~/.ssh && chmod 700 ~/.ssh',
204
+ 'if [ ! -f ~/.ssh/id_ed25519 ]; then ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519 -C "dsh-wsl" >/dev/null; fi',
205
+ 'cat ~/.ssh/id_ed25519.pub',
206
+ ].join('\n')
207
+ const result = await bashScript(distro, user, script, { timeoutMs: 30_000 })
208
+ return { action, distro, user, code: result.code, out: result.out, err: result.err }
209
+ }
210
+ if (action === 'ssh-config') {
211
+ const script = [
212
+ 'mkdir -p ~/.ssh && chmod 700 ~/.ssh',
213
+ 'touch ~/.ssh/config && chmod 600 ~/.ssh/config',
214
+ 'if ! grep -qE "ControlMaster|dsh-wsl" ~/.ssh/config; then',
215
+ " printf '%s\\n' '# dsh-wsl: reuse one SSH connection across Agent calls' 'Host *' ' ServerAliveInterval 30' ' ServerAliveCountMax 3' ' ConnectTimeout 10' ' ControlMaster auto' ' ControlPath ~/.ssh/cm-%r@%h:%p' ' ControlPersist 10m' >> ~/.ssh/config",
216
+ 'fi',
217
+ 'cat ~/.ssh/config',
218
+ ].join('\n')
219
+ const result = await bashScript(distro, user, script, { timeoutMs: 30_000 })
220
+ return { action, distro, user, code: result.code, out: result.out, err: result.err }
221
+ }
222
+ if (action === 'user') {
223
+ const name = /^[a-z_][a-z0-9_-]{0,31}$/.test(requestedName) ? requestedName : 'chronos'
224
+ const createAs = requestedUser || ''
225
+ const script = [
226
+ `NAME=${name}`,
227
+ 'if getent passwd "$NAME" >/dev/null 2>&1; then id "$NAME"; exit 0; fi',
228
+ 'if [ "$(id -u)" = 0 ]; then SUDO=""; else if ! sudo -n true 2>/dev/null; then echo "NO_SUDO"; exit 3; fi; SUDO="sudo -n"; fi',
229
+ '$SUDO useradd -m -s /bin/bash "$NAME"',
230
+ '$SUDO passwd -l "$NAME" >/dev/null 2>&1 || true',
231
+ 'id "$NAME"',
232
+ ].join('\n')
233
+ const result = await bashScript(distro, createAs, script, { timeoutMs: 60_000 })
234
+ return { action, distro, user: createAs || 'root', name, code: result.code, out: result.out, err: result.err }
235
+ }
236
+ throw Object.assign(new Error(`unknown doctor action: ${action}`), { code: 'DOCTOR_UNKNOWN_ACTION' })
237
+ }
@@ -0,0 +1,214 @@
1
+ // WSL-backed `@file` discovery for WSL-preset Agents.
2
+ //
3
+ // The shipped `file-reference-local` provider walks the Host filesystem at the
4
+ // session's UNC cwd (Node fs over 9P) and only knows Windows paths. This patch
5
+ // keeps that provider for every other preset and answers WSL Agents from the
6
+ // distribution: a bounded `tree` RPC through the broker, ranked exactly like
7
+ // the host provider, with paths relative to the Linux cwd. A relative `@path`
8
+ // then means the same file to the model and to the Host UI.
9
+ import { registry } from './registry.mjs'
10
+ import { linuxPathFromUnc } from './preset-default.mjs'
11
+ import { userFor } from './settings.mjs'
12
+ import { userFromLinuxPath } from './path-user.mjs'
13
+
14
+ export const WSL_PRESET = 'wsl'
15
+ const MAX_RESULTS = 20
16
+ const MAX_ENTRIES = 50_000
17
+ const CACHE_MS = 5_000
18
+ const PATCH = Symbol.for('dsh-wsl/file-references')
19
+
20
+ const EXCLUDED = new Set([
21
+ '.git', 'node_modules', 'dist', 'build', 'out', 'coverage', 'target',
22
+ '.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache',
23
+ '.mypy_cache', '.gradle',
24
+ ])
25
+
26
+ function visibleForGlobalQuery(path, query) {
27
+ if (query.startsWith('.') || query.includes('/.')) return true
28
+ return !path.split('/').some((segment) => segment.startsWith('.'))
29
+ }
30
+
31
+ function subsequenceScore(target, query) {
32
+ let targetIndex = 0
33
+ let gap = 0
34
+ for (const character of query) {
35
+ const found = target.indexOf(character, targetIndex)
36
+ if (found < 0) return undefined
37
+ gap += found - targetIndex
38
+ targetIndex = found + 1
39
+ }
40
+ return Math.max(0, 100 - gap)
41
+ }
42
+
43
+ function scoreCandidate(candidate, query) {
44
+ if (query === '') return 0
45
+ const path = candidate.path.toLowerCase()
46
+ const name = path.slice(path.lastIndexOf('/') + 1)
47
+ const needle = query.toLowerCase()
48
+ const directoryBonus = candidate.kind === 'directory' ? 25 : 0
49
+ if (name === needle) return 1_000 + directoryBonus
50
+ if (name.startsWith(needle)) return 900 + directoryBonus
51
+ if (name.includes(needle)) return 700 + directoryBonus
52
+ if (path.includes(needle)) return 500 + directoryBonus
53
+ const subsequence = subsequenceScore(path, needle)
54
+ return subsequence === undefined ? undefined : 300 + subsequence + directoryBonus
55
+ }
56
+
57
+ function kindRank(kind) { return kind === 'directory' ? 0 : 1 }
58
+ function compareText(left, right) { return left < right ? -1 : left > right ? 1 : 0 }
59
+
60
+ function rankCandidates(candidates, query, limit) {
61
+ const ranked = []
62
+ for (const candidate of candidates) {
63
+ const score = scoreCandidate(candidate, query)
64
+ if (score !== undefined) ranked.push({ candidate, score })
65
+ }
66
+ ranked.sort((left, right) => right.score - left.score
67
+ || kindRank(left.candidate.kind) - kindRank(right.candidate.kind)
68
+ || (query === '' ? 0 : left.candidate.path.length - right.candidate.path.length)
69
+ || compareText(left.candidate.path, right.candidate.path))
70
+ return ranked.slice(0, limit).map((entry) => entry.candidate)
71
+ }
72
+
73
+ /** Rank bounded tree entries for one `@` token; pure, used by tests. */
74
+ export function candidatesFromEntries(entries, rawQuery, limit = MAX_RESULTS) {
75
+ const query = String(rawQuery ?? '').replaceAll('\\', '/')
76
+ const slash = query.lastIndexOf('/')
77
+ if (query === '' || slash >= 0) {
78
+ const directory = slash < 0 ? '' : query.slice(0, slash + 1)
79
+ const fragment = slash < 0 ? '' : query.slice(slash + 1)
80
+ const direct = entries
81
+ .filter((entry) => entry.path.startsWith(directory)
82
+ && entry.path !== directory.replace(/\/$/, '')
83
+ && !entry.path.slice(directory.length).includes('/'))
84
+ .map((entry) => ({ path: entry.path, kind: entry.kind }))
85
+ return rankCandidates(direct, fragment, limit)
86
+ }
87
+ const visible = entries
88
+ .filter((entry) => visibleForGlobalQuery(entry.path, query))
89
+ .filter((entry) => !entry.path.split('/').some((segment) => EXCLUDED.has(segment)))
90
+ .map((entry) => ({ path: entry.path, kind: entry.kind }))
91
+ return rankCandidates(visible, query, limit)
92
+ }
93
+
94
+ function unwrap(value) {
95
+ if (!value || (typeof value !== 'object' && typeof value !== 'function')) return value
96
+ try { return value[Symbol.for('cordis.original')] ?? value } catch { return value }
97
+ }
98
+
99
+ /** One broker call to the distro embedded in a WSL UNC cwd. */
100
+ function brokerFor(cwd) {
101
+ const match = /^\\\\[?]?(?:wsl\.localhost|wsl\$)\\([^\\]+)\\/i.exec(String(cwd ?? ''))
102
+ const distro = match ? match[1] : ''
103
+ const linux = linuxPathFromUnc(cwd) ?? (typeof cwd === 'string' && cwd.startsWith('/') ? cwd : '')
104
+ return registry.get(distro, userFromLinuxPath(linux) || userFor(distro))
105
+ }
106
+
107
+ /**
108
+ * Install the WSL `@file` provider over the Host `fileReferences` service.
109
+ * Non-WSL Agents keep the shipped provider; a broker failure falls back to it.
110
+ *
111
+ * @returns `{ installed, dispose }`; `installed` is a live getter.
112
+ */
113
+ export function installWslFileReferences(ctx) {
114
+ const disposers = []
115
+ const caches = new Map()
116
+ let installed = false
117
+
118
+ const presetOf = (agent) => {
119
+ try {
120
+ return ctx.get('agentPresets')?.presetForSession?.(agent?.session)
121
+ } catch {
122
+ return undefined
123
+ }
124
+ }
125
+ const cwdOf = (agent) => {
126
+ try {
127
+ return ctx.get('workingDirectory')?.get?.(agent?.session)
128
+ } catch {
129
+ return undefined
130
+ }
131
+ }
132
+
133
+ const listFromWsl = async (agent, query, signal) => {
134
+ if (presetOf(agent) !== WSL_PRESET) return undefined
135
+ const cwd = cwdOf(agent)
136
+ const linux = linuxPathFromUnc(cwd) ?? (typeof cwd === 'string' && cwd.startsWith('/') ? cwd : undefined)
137
+ if (linux === undefined) return undefined
138
+ const key = String(agent?.session?.id ?? agent?.session ?? '')
139
+ let cached = caches.get(key)
140
+ if (cached === undefined || cached.cwd !== linux || Date.now() - cached.at > CACHE_MS) {
141
+ signal?.throwIfAborted?.()
142
+ // `glob` already exists in every helper version and respects the Git
143
+ // worktree on 1.0.8+; directories are derived from the file paths.
144
+ const found = await brokerFor(cwd).call('glob', { path: linux, pattern: '**/*', max: MAX_ENTRIES }, { signal })
145
+ const entries = new Map()
146
+ const base = linux.replace(/\/+$/, '')
147
+ for (const full of found.paths ?? []) {
148
+ const rel = String(full).slice(base.length + 1).replace(/\\/g, '/')
149
+ if (rel === '' || rel.startsWith('/')) continue
150
+ const parts = rel.split('/')
151
+ if (parts.some((part) => EXCLUDED.has(part))) continue
152
+ entries.set(rel, 'file')
153
+ for (let i = 1; i < parts.length; i += 1) entries.set(parts.slice(0, i).join('/'), 'directory')
154
+ }
155
+ cached = { cwd: linux, entries: [...entries].map(([path, kind]) => ({ path, kind })), at: Date.now() }
156
+ caches.set(key, cached)
157
+ }
158
+ return candidatesFromEntries(cached.entries, query, MAX_RESULTS)
159
+ }
160
+
161
+ const install = (service) => {
162
+ if (installed) return true
163
+ const raw = unwrap(service)
164
+ if (!raw || typeof raw.list !== 'function') return false
165
+ if (raw[PATCH]) { installed = true; return true }
166
+ const original = raw.list
167
+ const descriptor = Object.getOwnPropertyDescriptor(raw, 'list')
168
+ const patched = async function list(agent, query, signal) {
169
+ try {
170
+ const result = await listFromWsl(agent, query, signal)
171
+ if (result !== undefined) return result
172
+ } catch (error) {
173
+ if (signal?.aborted) throw error
174
+ }
175
+ return original.call(raw, agent, query, signal)
176
+ }
177
+ try {
178
+ Object.defineProperty(raw, 'list', { configurable: true, enumerable: false, writable: true, value: patched })
179
+ } catch {
180
+ return false
181
+ }
182
+ const dispose = () => {
183
+ try {
184
+ if (descriptor) Object.defineProperty(raw, 'list', descriptor)
185
+ else delete raw.list
186
+ } catch {}
187
+ try { delete raw[PATCH] } catch {}
188
+ }
189
+ Object.defineProperty(raw, PATCH, { configurable: true, enumerable: false, writable: true, value: dispose })
190
+ disposers.push(dispose)
191
+ installed = true
192
+ return true
193
+ }
194
+
195
+ const current = ctx.get('fileReferences')
196
+ if (current) install(current)
197
+ else ctx.on('internal/service', (name, value) => { if (name === 'fileReferences') install(value) })
198
+
199
+ // A tool result can add/remove files; the next @ rebuilds the bounded tree.
200
+ ctx.on('session/event', (session, event) => {
201
+ if (event?.type !== 'tool/result') return
202
+ const key = String(session?.id ?? '')
203
+ if (key) caches.delete(key)
204
+ })
205
+
206
+ return {
207
+ get installed() { return installed },
208
+ dispose() {
209
+ for (const dispose of disposers.splice(0)) dispose()
210
+ caches.clear()
211
+ installed = false
212
+ },
213
+ }
214
+ }