@namzu/sandbox 24.0.0 → 26.0.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/CHANGELOG.md +284 -0
- package/dist/backends/http-worker-client.d.ts +3 -1
- package/dist/backends/http-worker-client.d.ts.map +1 -1
- package/dist/backends/http-worker-client.js +15 -4
- package/dist/backends/http-worker-client.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/local-virtual-computer/client.d.ts +21 -0
- package/dist/local-virtual-computer/client.d.ts.map +1 -0
- package/dist/local-virtual-computer/client.js +503 -0
- package/dist/local-virtual-computer/client.js.map +1 -0
- package/dist/local-virtual-computer/detached-process.d.ts +15 -0
- package/dist/local-virtual-computer/detached-process.d.ts.map +1 -0
- package/dist/local-virtual-computer/detached-process.js +111 -0
- package/dist/local-virtual-computer/detached-process.js.map +1 -0
- package/dist/local-virtual-computer/detached-worker.d.ts +11 -0
- package/dist/local-virtual-computer/detached-worker.d.ts.map +1 -0
- package/dist/local-virtual-computer/detached-worker.js +97 -0
- package/dist/local-virtual-computer/detached-worker.js.map +1 -0
- package/dist/local-virtual-computer/dialect.d.ts +26 -0
- package/dist/local-virtual-computer/dialect.d.ts.map +1 -0
- package/dist/local-virtual-computer/dialect.js +61 -0
- package/dist/local-virtual-computer/dialect.js.map +1 -0
- package/dist/local-virtual-computer/engine.d.ts +63 -0
- package/dist/local-virtual-computer/engine.d.ts.map +1 -0
- package/dist/local-virtual-computer/engine.js +125 -0
- package/dist/local-virtual-computer/engine.js.map +1 -0
- package/dist/local-virtual-computer/guest-stdio.d.ts +15 -0
- package/dist/local-virtual-computer/guest-stdio.d.ts.map +1 -0
- package/dist/local-virtual-computer/guest-stdio.js +330 -0
- package/dist/local-virtual-computer/guest-stdio.js.map +1 -0
- package/dist/local-virtual-computer/index.d.ts +36 -0
- package/dist/local-virtual-computer/index.d.ts.map +1 -0
- package/dist/local-virtual-computer/index.js +341 -0
- package/dist/local-virtual-computer/index.js.map +1 -0
- package/dist/local-virtual-computer/podman-machine.d.ts +17 -0
- package/dist/local-virtual-computer/podman-machine.d.ts.map +1 -0
- package/dist/local-virtual-computer/podman-machine.js +181 -0
- package/dist/local-virtual-computer/podman-machine.js.map +1 -0
- package/dist/local-virtual-computer/setup/contract.d.ts +360 -0
- package/dist/local-virtual-computer/setup/contract.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/contract.js +3 -0
- package/dist/local-virtual-computer/setup/contract.js.map +1 -0
- package/dist/local-virtual-computer/setup/elevated.d.ts +78 -0
- package/dist/local-virtual-computer/setup/elevated.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/elevated.js +548 -0
- package/dist/local-virtual-computer/setup/elevated.js.map +1 -0
- package/dist/local-virtual-computer/setup/host.d.ts +12 -0
- package/dist/local-virtual-computer/setup/host.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/host.js +93 -0
- package/dist/local-virtual-computer/setup/host.js.map +1 -0
- package/dist/local-virtual-computer/setup/idle.d.ts +18 -0
- package/dist/local-virtual-computer/setup/idle.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/idle.js +38 -0
- package/dist/local-virtual-computer/setup/idle.js.map +1 -0
- package/dist/local-virtual-computer/setup/image.d.ts +7 -0
- package/dist/local-virtual-computer/setup/image.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/image.js +124 -0
- package/dist/local-virtual-computer/setup/image.js.map +1 -0
- package/dist/local-virtual-computer/setup/index.d.ts +17 -0
- package/dist/local-virtual-computer/setup/index.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/index.js +17 -0
- package/dist/local-virtual-computer/setup/index.js.map +1 -0
- package/dist/local-virtual-computer/setup/limits.d.ts +30 -0
- package/dist/local-virtual-computer/setup/limits.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/limits.js +58 -0
- package/dist/local-virtual-computer/setup/limits.js.map +1 -0
- package/dist/local-virtual-computer/setup/plan.d.ts +8 -0
- package/dist/local-virtual-computer/setup/plan.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/plan.js +131 -0
- package/dist/local-virtual-computer/setup/plan.js.map +1 -0
- package/dist/local-virtual-computer/setup/readiness.d.ts +11 -0
- package/dist/local-virtual-computer/setup/readiness.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/readiness.js +320 -0
- package/dist/local-virtual-computer/setup/readiness.js.map +1 -0
- package/dist/local-virtual-computer/setup/registry.d.ts +18 -0
- package/dist/local-virtual-computer/setup/registry.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/registry.js +50 -0
- package/dist/local-virtual-computer/setup/registry.js.map +1 -0
- package/dist/local-virtual-computer/setup/restart.d.ts +8 -0
- package/dist/local-virtual-computer/setup/restart.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/restart.js +60 -0
- package/dist/local-virtual-computer/setup/restart.js.map +1 -0
- package/dist/local-virtual-computer/setup/resume.d.ts +14 -0
- package/dist/local-virtual-computer/setup/resume.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/resume.js +58 -0
- package/dist/local-virtual-computer/setup/resume.js.map +1 -0
- package/dist/local-virtual-computer/setup/run.d.ts +8 -0
- package/dist/local-virtual-computer/setup/run.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/run.js +200 -0
- package/dist/local-virtual-computer/setup/run.js.map +1 -0
- package/dist/local-virtual-computer/setup/windows-folder.d.ts +11 -0
- package/dist/local-virtual-computer/setup/windows-folder.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/windows-folder.js +14 -0
- package/dist/local-virtual-computer/setup/windows-folder.js.map +1 -0
- package/dist/local-virtual-computer/setup/wsl-package.d.ts +19 -0
- package/dist/local-virtual-computer/setup/wsl-package.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/wsl-package.js +242 -0
- package/dist/local-virtual-computer/setup/wsl-package.js.map +1 -0
- package/dist/local-virtual-computer/setup/wslc-shared.d.ts +27 -0
- package/dist/local-virtual-computer/setup/wslc-shared.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/wslc-shared.js +137 -0
- package/dist/local-virtual-computer/setup/wslc-shared.js.map +1 -0
- package/dist/local-virtual-computer/ssh-forward.d.ts +19 -0
- package/dist/local-virtual-computer/ssh-forward.d.ts.map +1 -0
- package/dist/local-virtual-computer/ssh-forward.js +179 -0
- package/dist/local-virtual-computer/ssh-forward.js.map +1 -0
- package/dist/local-virtual-computer/wslc-engine.d.ts +27 -0
- package/dist/local-virtual-computer/wslc-engine.d.ts.map +1 -0
- package/dist/local-virtual-computer/wslc-engine.js +98 -0
- package/dist/local-virtual-computer/wslc-engine.js.map +1 -0
- package/local-computer/Dockerfile +99 -0
- package/local-computer/app-catalog.cjs +240 -0
- package/local-computer/app-native-host.cjs +144 -0
- package/local-computer/browser-home.cjs +249 -0
- package/local-computer/build-image.mjs +310 -0
- package/local-computer/desktop-worker.cjs +477 -0
- package/local-computer/entrypoint.sh +161 -0
- package/local-computer/home.html +28 -0
- package/local-computer/new-tab/home.css +255 -0
- package/local-computer/new-tab/home.js +65 -0
- package/local-computer/new-tab/manifest.json +12 -0
- package/local-computer/new-tab/wordmark.svg +33 -0
- package/local-computer/openbox-rc.xml +41 -0
- package/local-computer/prepare-home.cjs +121 -0
- package/local-computer/tint2rc +44 -0
- package/package.json +13 -4
- package/src/backends/http-worker-client.ts +31 -3
- package/src/index.ts +13 -0
- package/src/local-virtual-computer/client.ts +573 -0
- package/src/local-virtual-computer/detached-process.ts +133 -0
- package/src/local-virtual-computer/detached-worker.ts +124 -0
- package/src/local-virtual-computer/dialect.ts +83 -0
- package/src/local-virtual-computer/engine.ts +197 -0
- package/src/local-virtual-computer/guest-stdio.ts +332 -0
- package/src/local-virtual-computer/index.ts +447 -0
- package/src/local-virtual-computer/podman-machine.ts +264 -0
- package/src/local-virtual-computer/setup/contract.ts +395 -0
- package/src/local-virtual-computer/setup/elevated.ts +655 -0
- package/src/local-virtual-computer/setup/host.ts +100 -0
- package/src/local-virtual-computer/setup/idle.ts +39 -0
- package/src/local-virtual-computer/setup/image.ts +159 -0
- package/src/local-virtual-computer/setup/index.ts +16 -0
- package/src/local-virtual-computer/setup/limits.ts +68 -0
- package/src/local-virtual-computer/setup/plan.ts +144 -0
- package/src/local-virtual-computer/setup/readiness.ts +386 -0
- package/src/local-virtual-computer/setup/registry.ts +50 -0
- package/src/local-virtual-computer/setup/restart.ts +64 -0
- package/src/local-virtual-computer/setup/resume.ts +65 -0
- package/src/local-virtual-computer/setup/run.ts +225 -0
- package/src/local-virtual-computer/setup/windows-folder.ts +13 -0
- package/src/local-virtual-computer/setup/wsl-package.ts +242 -0
- package/src/local-virtual-computer/setup/wslc-shared.ts +147 -0
- package/src/local-virtual-computer/ssh-forward.ts +195 -0
- package/src/local-virtual-computer/wslc-engine.ts +124 -0
- package/worker/server.js +1499 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { mkdir, readFile } from 'node:fs/promises'
|
|
3
|
+
import { posix, win32 } from 'node:path'
|
|
4
|
+
import type {
|
|
5
|
+
RunElevatedWslcSteps,
|
|
6
|
+
WslcElevatedOutcome,
|
|
7
|
+
WslcSetupHost,
|
|
8
|
+
WslcSetupProgress,
|
|
9
|
+
} from './contract.js'
|
|
10
|
+
import { trustedWindowsDirectory } from './windows-folder.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The one elevated process of a setup plan.
|
|
14
|
+
*
|
|
15
|
+
* Windows PowerShell (a Microsoft-signed program, so the Windows prompt names
|
|
16
|
+
* Microsoft rather than an unknown publisher) is started with
|
|
17
|
+
* `Start-Process -Verb RunAs` and runs a FIXED script handed over with
|
|
18
|
+
* `-EncodedCommand`. There is no script file anyone could edit between the
|
|
19
|
+
* prompt and the run. Values reach the script only as single-quoted PowerShell
|
|
20
|
+
* literals, after being validated here. Inside, only Microsoft tools run:
|
|
21
|
+
* dism.exe, msiexec.exe and Get-FileHash.
|
|
22
|
+
*
|
|
23
|
+
* Exit codes of the elevated script: 0 ok, 3010 restart required,
|
|
24
|
+
* 20 enabling Windows features failed, 21 installing the WSL package failed,
|
|
25
|
+
* 22 the package did not match the pinned digest. The launcher (a second,
|
|
26
|
+
* non-elevated PowerShell) prints `NAMZU_EXIT=<code>` with the child's exit
|
|
27
|
+
* code, 1223 when the person declined the prompt, or `NAMZU_LAUNCH_FAILED`,
|
|
28
|
+
* and exits 0 itself: the command runner keeps no output from a process that
|
|
29
|
+
* exits nonzero, so the code travels on stdout.
|
|
30
|
+
*
|
|
31
|
+
* The elevated child cannot be stopped from here. Aborting the signal stops
|
|
32
|
+
* waiting and stops reading progress; if the person already approved, the
|
|
33
|
+
* elevated steps run to the end.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export const ELEVATED_EXIT = {
|
|
37
|
+
ok: 0,
|
|
38
|
+
restartRequired: 3010,
|
|
39
|
+
enableFeaturesFailed: 20,
|
|
40
|
+
installWslFailed: 21,
|
|
41
|
+
packageMismatch: 22,
|
|
42
|
+
folderFailed: 23,
|
|
43
|
+
cancelled: 1223,
|
|
44
|
+
} as const
|
|
45
|
+
|
|
46
|
+
type ElevatedStep = 'enable-windows-features' | 'install-wsl'
|
|
47
|
+
|
|
48
|
+
const ELEVATED_STEPS: readonly ElevatedStep[] = ['enable-windows-features', 'install-wsl']
|
|
49
|
+
const STANDARD_ADMIN_GROUP_SID = 'S-1-5-32-544'
|
|
50
|
+
const WINDOWS_11_FIRST_BUILD = 22000
|
|
51
|
+
const POLL_MS = 250
|
|
52
|
+
/** Windows refuses a command line longer than 32,767 characters. */
|
|
53
|
+
const MAX_COMMAND_LINE = 30000
|
|
54
|
+
/** Write, append, delete, delete-children, attribute and extended-attribute writes, change permissions, take ownership. */
|
|
55
|
+
const WRITE_RIGHTS_MASK = 0xd0156
|
|
56
|
+
const PROGRESS_FILE = 'elevated-progress.jsonl'
|
|
57
|
+
|
|
58
|
+
type Request = Parameters<RunElevatedWslcSteps>[0]
|
|
59
|
+
type PathApi = typeof win32
|
|
60
|
+
|
|
61
|
+
/** Quotes, line breaks and control characters, including PowerShell's typographic quotes. */
|
|
62
|
+
const FORBIDDEN_QUOTES = /['"\u2018\u2019\u201A\u201B\u201C\u201D\u201E]/
|
|
63
|
+
|
|
64
|
+
function hasControlCharacter(value: string): boolean {
|
|
65
|
+
for (let i = 0; i < value.length; i++) {
|
|
66
|
+
const code = value.charCodeAt(i)
|
|
67
|
+
if (code < 0x20 || code === 0x7f) return true
|
|
68
|
+
}
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function usesWindowsPaths(directory: string): boolean {
|
|
73
|
+
return /^[A-Za-z]:[\\/]/.test(directory)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function psLiteral(value: string): string {
|
|
77
|
+
return `'${value.replace(/'/g, "''")}'`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function checkLiteralPath(label: string, value: string): void {
|
|
81
|
+
if (FORBIDDEN_QUOTES.test(value) || hasControlCharacter(value)) {
|
|
82
|
+
throw new Error(`${label} contains a quote, a line break or a control character.`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function inside(api: PathApi, directory: string, candidate: string): boolean {
|
|
87
|
+
const fold = (value: string) => (api === win32 ? value.toLowerCase() : value)
|
|
88
|
+
const relative = api.relative(fold(api.resolve(directory)), fold(api.resolve(candidate)))
|
|
89
|
+
return relative !== '' && !relative.startsWith('..') && !api.isAbsolute(relative)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ElevatedScripts {
|
|
93
|
+
/** The script that runs elevated (the user's SID is filled in by the launcher). */
|
|
94
|
+
readonly inner: string
|
|
95
|
+
/** The script the non-elevated launcher runs; it starts `inner`. */
|
|
96
|
+
readonly launcher: string
|
|
97
|
+
/** `powershell.exe` arguments that run the launcher. */
|
|
98
|
+
readonly launcherArgs: readonly string[]
|
|
99
|
+
/** Where the elevated process writes progress, under the protected folder. */
|
|
100
|
+
readonly progressFile: string
|
|
101
|
+
readonly isWindows11: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ElevatedBuildOptions {
|
|
105
|
+
/** 32 hex characters naming the protected folder; random per run. */
|
|
106
|
+
readonly runId?: string
|
|
107
|
+
/** The Windows folder as the host reports it, used only to locate the progress file. */
|
|
108
|
+
readonly windowsDirectory?: string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const RUN_ID = /^[0-9a-f]{32}$/
|
|
112
|
+
const USER_SID_PLACEHOLDER = '@@USER_SID@@'
|
|
113
|
+
/** The encoded elevated script must stay well under what ShellExecute accepts for a RunAs launch. */
|
|
114
|
+
const MAX_INNER_ENCODED = 16000
|
|
115
|
+
|
|
116
|
+
function joinFor(base: string, ...parts: string[]): string {
|
|
117
|
+
return (usesWindowsPaths(base) ? win32 : posix).join(base, ...parts)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Validate a request and build both scripts. Exported for tests; the public
|
|
122
|
+
* entry point is {@link runElevatedWslcSteps}. Throws on any request that
|
|
123
|
+
* could put a foreign value into a script.
|
|
124
|
+
*/
|
|
125
|
+
export function buildElevatedScripts(
|
|
126
|
+
request: Request,
|
|
127
|
+
options: ElevatedBuildOptions = {},
|
|
128
|
+
): ElevatedScripts {
|
|
129
|
+
const { steps, wslPackage, host } = request
|
|
130
|
+
if (host.platform !== 'win32') throw new Error('Elevated setup runs only on Windows.')
|
|
131
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
132
|
+
throw new Error('No elevated step was requested.')
|
|
133
|
+
}
|
|
134
|
+
const wanted = new Set<string>(steps)
|
|
135
|
+
if (wanted.size !== steps.length) throw new Error('An elevated step was listed twice.')
|
|
136
|
+
for (const step of steps) {
|
|
137
|
+
if (!ELEVATED_STEPS.includes(step))
|
|
138
|
+
throw new Error(`"${String(step)}" is not an elevated step.`)
|
|
139
|
+
}
|
|
140
|
+
if (
|
|
141
|
+
host.windowsBuild === undefined ||
|
|
142
|
+
!Number.isInteger(host.windowsBuild) ||
|
|
143
|
+
host.windowsBuild < 1
|
|
144
|
+
) {
|
|
145
|
+
throw new Error('The Windows build is unknown.')
|
|
146
|
+
}
|
|
147
|
+
const isWindows11 = host.windowsBuild >= WINDOWS_11_FIRST_BUILD
|
|
148
|
+
|
|
149
|
+
const stateDirectory = host.stateDirectory
|
|
150
|
+
const api: PathApi = usesWindowsPaths(stateDirectory) ? win32 : posix
|
|
151
|
+
if (!api.isAbsolute(stateDirectory))
|
|
152
|
+
throw new Error('The setup directory is not an absolute path.')
|
|
153
|
+
checkLiteralPath('The setup directory', stateDirectory)
|
|
154
|
+
|
|
155
|
+
const runId = options.runId ?? randomBytes(16).toString('hex')
|
|
156
|
+
if (!RUN_ID.test(runId)) throw new Error('The run id is not 32 hex characters.')
|
|
157
|
+
const windowsDirectory = options.windowsDirectory ?? trustedWindowsDirectory(host.env)
|
|
158
|
+
const progressFile = joinFor(windowsDirectory, 'Temp', `namzu-wsl-${runId}`, PROGRESS_FILE)
|
|
159
|
+
|
|
160
|
+
let installBlock = ''
|
|
161
|
+
if (wanted.has('install-wsl')) {
|
|
162
|
+
if (!wslPackage) throw new Error('Installing WSL needs the downloaded package.')
|
|
163
|
+
const { path, sha256 } = wslPackage
|
|
164
|
+
if (!api.isAbsolute(path)) throw new Error('The WSL package path is not absolute.')
|
|
165
|
+
checkLiteralPath('The WSL package path', path)
|
|
166
|
+
if (!path.toLowerCase().endsWith('.msi'))
|
|
167
|
+
throw new Error('The WSL package is not an .msi file.')
|
|
168
|
+
if (path.split(/[\\/]/).some((segment) => segment === '..' || segment === '.')) {
|
|
169
|
+
throw new Error('The WSL package path is not normalised.')
|
|
170
|
+
}
|
|
171
|
+
if (!inside(api, stateDirectory, path)) {
|
|
172
|
+
throw new Error('The WSL package is not inside the setup directory.')
|
|
173
|
+
}
|
|
174
|
+
if (!/^[0-9a-fA-F]{64}$/.test(sha256))
|
|
175
|
+
throw new Error('The WSL package digest is not a SHA-256.')
|
|
176
|
+
installBlock = installBlockFor(path, sha256.toLowerCase())
|
|
177
|
+
} else if (wslPackage !== undefined) {
|
|
178
|
+
throw new Error('A WSL package was given but install-wsl was not requested.')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const inner = innerScript({
|
|
182
|
+
runId,
|
|
183
|
+
enable: wanted.has('enable-windows-features'),
|
|
184
|
+
install: wanted.has('install-wsl'),
|
|
185
|
+
isWindows11,
|
|
186
|
+
installBlock,
|
|
187
|
+
})
|
|
188
|
+
// A real SID is at most 184 characters; budget for the longest.
|
|
189
|
+
const sample = inner.replace(USER_SID_PLACEHOLDER, 'S-1-5-21'.padEnd(184, '1'))
|
|
190
|
+
if (encodeCommand(sample).length > MAX_INNER_ENCODED) {
|
|
191
|
+
throw new Error('The elevated script is too long.')
|
|
192
|
+
}
|
|
193
|
+
const launcher = launcherScript(inner)
|
|
194
|
+
const launcherArgs = [
|
|
195
|
+
'-NoProfile',
|
|
196
|
+
'-NonInteractive',
|
|
197
|
+
'-ExecutionPolicy',
|
|
198
|
+
'Bypass',
|
|
199
|
+
'-EncodedCommand',
|
|
200
|
+
encodeCommand(launcher),
|
|
201
|
+
]
|
|
202
|
+
if (launcherArgs.join(' ').length > MAX_COMMAND_LINE) {
|
|
203
|
+
throw new Error('The elevated script is too long for one command line.')
|
|
204
|
+
}
|
|
205
|
+
return { inner, launcher, launcherArgs, progressFile, isWindows11 }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function encodeCommand(script: string): string {
|
|
209
|
+
return Buffer.from(script, 'utf16le').toString('base64')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function installBlockFor(msi: string, sha256: string): string {
|
|
213
|
+
return [
|
|
214
|
+
"$cur = 'install-wsl'",
|
|
215
|
+
"Note $cur 'started'",
|
|
216
|
+
`$src = ${psLiteral(msi)}`,
|
|
217
|
+
`$want = ${psLiteral(sha256)}`,
|
|
218
|
+
// The package is copied into a folder only administrators can enter, then
|
|
219
|
+
// hashed and installed from there, so nothing can swap it after the check.
|
|
220
|
+
"$pkg = Join-Path $dir 'pkg'",
|
|
221
|
+
'if (Test-Path -LiteralPath $pkg) { throw "pkg exists" }',
|
|
222
|
+
'try {',
|
|
223
|
+
' $item = Get-Item -LiteralPath $src -Force',
|
|
224
|
+
' if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "link" }',
|
|
225
|
+
' New-Item -ItemType Directory -Path $pkg | Out-Null',
|
|
226
|
+
' $pacl = New-Object Security.AccessControl.DirectorySecurity',
|
|
227
|
+
' $pacl.SetAccessRuleProtection($true, $false)',
|
|
228
|
+
' foreach ($sid in $adminSids) { $pacl.AddAccessRule((Rule $sid "FullControl" "ContainerInherit,ObjectInherit")) }',
|
|
229
|
+
' Set-Acl -LiteralPath $pkg -AclObject $pacl',
|
|
230
|
+
' Test-Folder $pkg $false',
|
|
231
|
+
" $msi = Join-Path $pkg 'wsl.msi'",
|
|
232
|
+
' Copy-Item -LiteralPath $src -Destination $msi',
|
|
233
|
+
' $got = (Get-FileHash -LiteralPath $msi -Algorithm SHA256).Hash',
|
|
234
|
+
' if ($got -ine $want) {',
|
|
235
|
+
" Note $cur 'failed' @{ exitCode = 22 }",
|
|
236
|
+
` $code = ${ELEVATED_EXIT.packageMismatch}`,
|
|
237
|
+
' } else {',
|
|
238
|
+
" $msilog = Join-Path $dir 'wsl-install.log'",
|
|
239
|
+
' if (Test-Path -LiteralPath $msilog) { throw "log exists" }',
|
|
240
|
+
" $args2 = @('/i', ('\"' + $msi + '\"'), '/qn', '/norestart', '/l*v', ('\"' + $msilog + '\"'))",
|
|
241
|
+
" $p = Start-Process -FilePath (Join-Path $sys 'msiexec.exe') -ArgumentList $args2 -Wait -PassThru",
|
|
242
|
+
' $c = $p.ExitCode',
|
|
243
|
+
' if ($c -eq 0) {',
|
|
244
|
+
" Note $cur 'finished' @{ result = 'ok' }",
|
|
245
|
+
' } elseif ($c -eq 3010 -or $c -eq 1641) {',
|
|
246
|
+
' $restart = $true',
|
|
247
|
+
" Note $cur 'finished' @{ result = 'restart-required' }",
|
|
248
|
+
' } else {',
|
|
249
|
+
" Note $cur 'failed' @{ exitCode = $c }",
|
|
250
|
+
` $code = ${ELEVATED_EXIT.installWslFailed}`,
|
|
251
|
+
' }',
|
|
252
|
+
' }',
|
|
253
|
+
'} finally {',
|
|
254
|
+
' Remove-Item -LiteralPath $pkg -Recurse -Force -ErrorAction SilentlyContinue',
|
|
255
|
+
'}',
|
|
256
|
+
].join('\n')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function innerScript(parts: {
|
|
260
|
+
runId: string
|
|
261
|
+
enable: boolean
|
|
262
|
+
install: boolean
|
|
263
|
+
isWindows11: boolean
|
|
264
|
+
installBlock: string
|
|
265
|
+
}): string {
|
|
266
|
+
const features = parts.isWindows11
|
|
267
|
+
? ["'VirtualMachinePlatform'"]
|
|
268
|
+
: ["'VirtualMachinePlatform'", "'Microsoft-Windows-Subsystem-Linux'"]
|
|
269
|
+
const lines: string[] = [
|
|
270
|
+
"$ErrorActionPreference = 'Stop'",
|
|
271
|
+
// Tools and modules come from the operating system, never from the environment.
|
|
272
|
+
'$sys = [Environment]::SystemDirectory',
|
|
273
|
+
'$win = [Environment]::GetFolderPath([Environment+SpecialFolder]::Windows)',
|
|
274
|
+
'$env:PATH = $sys',
|
|
275
|
+
"$env:PSModulePath = Join-Path $sys 'WindowsPowerShell\\v1.0\\Modules'",
|
|
276
|
+
`$userSid = '${USER_SID_PLACEHOLDER}'`,
|
|
277
|
+
"$adminSids = @('S-1-5-32-544', 'S-1-5-18')",
|
|
278
|
+
'function Rule($sid, $rights, $inherit) {',
|
|
279
|
+
' $id = New-Object Security.Principal.SecurityIdentifier $sid',
|
|
280
|
+
" New-Object Security.AccessControl.FileSystemAccessRule($id, $rights, $inherit, 'None', 'Allow')",
|
|
281
|
+
'}',
|
|
282
|
+
'function Test-Folder($path, $allowUser) {',
|
|
283
|
+
' $item = Get-Item -LiteralPath $path -Force',
|
|
284
|
+
' if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "link" }',
|
|
285
|
+
' $a = Get-Acl -LiteralPath $path',
|
|
286
|
+
' $owner = $a.GetOwner([Security.Principal.SecurityIdentifier]).Value',
|
|
287
|
+
' if ($adminSids -notcontains $owner) { throw "owner" }',
|
|
288
|
+
' if (-not $a.AreAccessRulesProtected) { throw "inherited" }',
|
|
289
|
+
' foreach ($r in $a.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) {',
|
|
290
|
+
' $who = $r.IdentityReference.Value',
|
|
291
|
+
' if ($adminSids -contains $who) { continue }',
|
|
292
|
+
` if ($allowUser -and $who -eq $userSid -and $r.AccessControlType -eq 'Allow' -and (([int]$r.FileSystemRights -band ${WRITE_RIGHTS_MASK}) -eq 0)) { continue }`,
|
|
293
|
+
' throw "access"',
|
|
294
|
+
' }',
|
|
295
|
+
'}',
|
|
296
|
+
'$code = 0',
|
|
297
|
+
'$restart = $false',
|
|
298
|
+
"$cur = ''",
|
|
299
|
+
'$log = $null',
|
|
300
|
+
'function Note($step, $phase, $extra) {',
|
|
301
|
+
' try {',
|
|
302
|
+
' $o = [ordered]@{ step = $step; phase = $phase }',
|
|
303
|
+
' if ($extra) { foreach ($k in $extra.Keys) { $o[$k] = $extra[$k] } }',
|
|
304
|
+
' $line = (ConvertTo-Json -InputObject $o -Compress) + "`n"',
|
|
305
|
+
' [IO.File]::AppendAllText($log, $line, (New-Object Text.UTF8Encoding $false))',
|
|
306
|
+
' } catch {}',
|
|
307
|
+
'}',
|
|
308
|
+
'try {',
|
|
309
|
+
// The folder for progress and logs: new, owned by administrators, readable
|
|
310
|
+
// (not writable) by the person who started setup. Elevated code writes
|
|
311
|
+
// nowhere a standard user can write.
|
|
312
|
+
` $dir = Join-Path $win ('Temp\\namzu-wsl-${parts.runId}')`,
|
|
313
|
+
// Exclusive creation: New-Item without -Force fails when the name exists,
|
|
314
|
+
// so a folder someone else prepared is never adopted. The ACL is applied
|
|
315
|
+
// and then READ BACK: the owner must be an administrator account, the
|
|
316
|
+
// access list must be protected, and nobody but administrators may hold
|
|
317
|
+
// more than read.
|
|
318
|
+
' New-Item -ItemType Directory -Path $dir | Out-Null',
|
|
319
|
+
' $acl = New-Object Security.AccessControl.DirectorySecurity',
|
|
320
|
+
' $acl.SetAccessRuleProtection($true, $false)',
|
|
321
|
+
' foreach ($sid in $adminSids) { $acl.AddAccessRule((Rule $sid "FullControl" "ContainerInherit,ObjectInherit")) }',
|
|
322
|
+
' $acl.AddAccessRule((Rule $userSid "Read" "ContainerInherit,ObjectInherit"))',
|
|
323
|
+
' Set-Acl -LiteralPath $dir -AclObject $acl',
|
|
324
|
+
' Test-Folder $dir $true',
|
|
325
|
+
" $log = Join-Path $dir 'elevated-progress.jsonl'",
|
|
326
|
+
' if (Test-Path -LiteralPath $log) { throw "log exists" }',
|
|
327
|
+
]
|
|
328
|
+
if (parts.enable) {
|
|
329
|
+
lines.push(
|
|
330
|
+
" $cur = 'enable-windows-features'",
|
|
331
|
+
" Note $cur 'started'",
|
|
332
|
+
' $dismRestart = $false',
|
|
333
|
+
` foreach ($name in @(${features.join(', ')})) {`,
|
|
334
|
+
' & (Join-Path $sys \'dism.exe\') /online /enable-feature "/featurename:$name" /all /norestart | Out-Null',
|
|
335
|
+
' $c = $LASTEXITCODE',
|
|
336
|
+
' if ($c -eq 3010) { $dismRestart = $true }',
|
|
337
|
+
" elseif ($c -ne 0) { Note $cur 'failed' @{ exitCode = $c }; $code = " +
|
|
338
|
+
`${ELEVATED_EXIT.enableFeaturesFailed}; break }`,
|
|
339
|
+
' }',
|
|
340
|
+
' if ($code -eq 0) {',
|
|
341
|
+
" if ($dismRestart) { $restart = $true; Note $cur 'finished' @{ result = 'restart-required' } }",
|
|
342
|
+
" else { Note $cur 'finished' @{ result = 'ok' } }",
|
|
343
|
+
' }',
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
if (parts.install) {
|
|
347
|
+
lines.push(
|
|
348
|
+
` if ($code -eq 0) {\n${parts.installBlock
|
|
349
|
+
.split('\n')
|
|
350
|
+
.map((line) => ` ${line}`)
|
|
351
|
+
.join('\n')}\n }`,
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
lines.push(
|
|
355
|
+
'} catch {',
|
|
356
|
+
` if ($cur -eq 'install-wsl') { $code = ${ELEVATED_EXIT.installWslFailed} } elseif ($cur -eq '') { $code = ${ELEVATED_EXIT.folderFailed} } else { $code = ${ELEVATED_EXIT.enableFeaturesFailed} }`,
|
|
357
|
+
" Note $cur 'failed' @{ exitCode = 1 }",
|
|
358
|
+
'}',
|
|
359
|
+
`if ($code -eq 0 -and $restart) { $code = ${ELEVATED_EXIT.restartRequired} }`,
|
|
360
|
+
'exit $code',
|
|
361
|
+
)
|
|
362
|
+
return lines.join('\n')
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function launcherScript(inner: string): string {
|
|
366
|
+
return [
|
|
367
|
+
"$ErrorActionPreference = 'Stop'",
|
|
368
|
+
'$sys = [Environment]::SystemDirectory',
|
|
369
|
+
'$env:PATH = $sys',
|
|
370
|
+
"$env:PSModulePath = Join-Path $sys 'WindowsPowerShell\\v1.0\\Modules'",
|
|
371
|
+
'try {',
|
|
372
|
+
" $ps = Join-Path $sys 'WindowsPowerShell\\v1.0\\powershell.exe'",
|
|
373
|
+
' $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value',
|
|
374
|
+
` $inner = ${psLiteral(inner)}.Replace('${USER_SID_PLACEHOLDER}', $sid)`,
|
|
375
|
+
' $enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($inner))',
|
|
376
|
+
' $psi = New-Object Diagnostics.ProcessStartInfo',
|
|
377
|
+
' $psi.FileName = $ps',
|
|
378
|
+
" $psi.Arguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ' + $enc",
|
|
379
|
+
// Windows looks for DLLs in the working directory before the system ones;
|
|
380
|
+
// never let that be a folder the person can write to.
|
|
381
|
+
' $psi.WorkingDirectory = $sys',
|
|
382
|
+
" $psi.Verb = 'runas'",
|
|
383
|
+
' $psi.UseShellExecute = $true',
|
|
384
|
+
" $psi.WindowStyle = 'Hidden'",
|
|
385
|
+
// ShellExecute raises a Win32Exception straight from the declined prompt.
|
|
386
|
+
' $p = [Diagnostics.Process]::Start($psi)',
|
|
387
|
+
' $p.WaitForExit()',
|
|
388
|
+
" Write-Output ('NAMZU_EXIT=' + $p.ExitCode)",
|
|
389
|
+
'} catch {',
|
|
390
|
+
' $e = $_.Exception',
|
|
391
|
+
' $declined = $false',
|
|
392
|
+
' while ($e -ne $null) {',
|
|
393
|
+
` if (($e -is [ComponentModel.Win32Exception] -and $e.NativeErrorCode -eq ${ELEVATED_EXIT.cancelled}) -or $e.Message -match 'cancel+ed by the user') { $declined = $true }`,
|
|
394
|
+
' $e = $e.InnerException',
|
|
395
|
+
' }',
|
|
396
|
+
` if ($declined) { Write-Output 'NAMZU_EXIT=${ELEVATED_EXIT.cancelled}'; exit 0 }`,
|
|
397
|
+
" Write-Output 'NAMZU_LAUNCH_FAILED'",
|
|
398
|
+
'}',
|
|
399
|
+
].join('\n')
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** What `whoami /groups` lists for an account: an administrator, even with a filtered token, has the group. */
|
|
403
|
+
export function isAdministratorAccount(whoamiGroupsOutput: string): boolean {
|
|
404
|
+
for (const row of whoamiGroupsOutput.split(/\r?\n/)) {
|
|
405
|
+
const cells = [...row.matchAll(/"((?:[^"]|"")*)"/g)].map((m) => m[1] ?? '')
|
|
406
|
+
if (cells[2] === STANDARD_ADMIN_GROUP_SID) return true
|
|
407
|
+
}
|
|
408
|
+
return false
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function isGroupListing(output: string): boolean {
|
|
412
|
+
return output.split(/\r?\n/).some((row) => /^"[^"]*","[^"]*","S-1-[0-9-]+",/.test(row))
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
interface ProgressLine {
|
|
416
|
+
readonly step: ElevatedStep
|
|
417
|
+
readonly phase: 'started' | 'finished' | 'failed'
|
|
418
|
+
readonly result?: 'ok' | 'restart-required'
|
|
419
|
+
readonly exitCode?: number
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function parseProgressLine(line: string): ProgressLine | undefined {
|
|
423
|
+
let value: unknown
|
|
424
|
+
try {
|
|
425
|
+
value = JSON.parse(line.replace(/^\uFEFF/, ''))
|
|
426
|
+
} catch {
|
|
427
|
+
return undefined
|
|
428
|
+
}
|
|
429
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
430
|
+
const record = value as Record<string, unknown>
|
|
431
|
+
const step = record.step
|
|
432
|
+
const phase = record.phase
|
|
433
|
+
if (step !== 'enable-windows-features' && step !== 'install-wsl') return undefined
|
|
434
|
+
if (phase !== 'started' && phase !== 'finished' && phase !== 'failed') return undefined
|
|
435
|
+
const result =
|
|
436
|
+
record.result === 'ok' || record.result === 'restart-required' ? record.result : undefined
|
|
437
|
+
const exitCode = typeof record.exitCode === 'number' ? record.exitCode : undefined
|
|
438
|
+
return { step, phase, result, exitCode }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Map the launcher's answer to an outcome. `standardUser` is only consulted when needed. */
|
|
442
|
+
export function mapElevatedExit(
|
|
443
|
+
code: number,
|
|
444
|
+
steps: readonly ElevatedStep[],
|
|
445
|
+
failure: { readonly step?: ElevatedStep; readonly exitCode?: number },
|
|
446
|
+
): WslcElevatedOutcome | 'cancelled' {
|
|
447
|
+
switch (code) {
|
|
448
|
+
case ELEVATED_EXIT.ok:
|
|
449
|
+
return { outcome: 'ok' }
|
|
450
|
+
case ELEVATED_EXIT.restartRequired:
|
|
451
|
+
return { outcome: 'restart-required' }
|
|
452
|
+
case ELEVATED_EXIT.cancelled:
|
|
453
|
+
return 'cancelled'
|
|
454
|
+
case ELEVATED_EXIT.enableFeaturesFailed:
|
|
455
|
+
return {
|
|
456
|
+
outcome: 'failed',
|
|
457
|
+
step: 'enable-windows-features',
|
|
458
|
+
code: 'feature-enable-failed',
|
|
459
|
+
exitCode: failure.exitCode ?? code,
|
|
460
|
+
}
|
|
461
|
+
case ELEVATED_EXIT.installWslFailed:
|
|
462
|
+
case ELEVATED_EXIT.packageMismatch:
|
|
463
|
+
return {
|
|
464
|
+
outcome: 'failed',
|
|
465
|
+
step: 'install-wsl',
|
|
466
|
+
code: 'wsl-install-failed',
|
|
467
|
+
exitCode: failure.exitCode ?? code,
|
|
468
|
+
}
|
|
469
|
+
default:
|
|
470
|
+
return {
|
|
471
|
+
outcome: 'failed',
|
|
472
|
+
step: failure.step ?? steps[0] ?? 'enable-windows-features',
|
|
473
|
+
code: 'elevation-failed',
|
|
474
|
+
exitCode: code,
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function progressEvent(line: ProgressLine): WslcSetupProgress | undefined {
|
|
480
|
+
if (line.phase === 'started') return { step: line.step, phase: 'started' }
|
|
481
|
+
if (line.phase === 'finished' && line.result) {
|
|
482
|
+
return { step: line.step, phase: 'finished', result: line.result }
|
|
483
|
+
}
|
|
484
|
+
return undefined
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function sleepUnlessSettled(
|
|
488
|
+
ms: number,
|
|
489
|
+
settled: Promise<unknown>,
|
|
490
|
+
signal?: AbortSignal,
|
|
491
|
+
): Promise<void> {
|
|
492
|
+
return new Promise((resolve) => {
|
|
493
|
+
const timer = setTimeout(done, ms)
|
|
494
|
+
function done() {
|
|
495
|
+
clearTimeout(timer)
|
|
496
|
+
signal?.removeEventListener('abort', done)
|
|
497
|
+
resolve()
|
|
498
|
+
}
|
|
499
|
+
signal?.addEventListener('abort', done, { once: true })
|
|
500
|
+
settled.then(done, done)
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
class ProgressTail {
|
|
505
|
+
private seen = 0
|
|
506
|
+
readonly failures: ProgressLine[] = []
|
|
507
|
+
constructor(
|
|
508
|
+
private readonly file: string,
|
|
509
|
+
private readonly onProgress?: (progress: WslcSetupProgress) => void,
|
|
510
|
+
) {}
|
|
511
|
+
|
|
512
|
+
async poll(): Promise<void> {
|
|
513
|
+
let text: string
|
|
514
|
+
try {
|
|
515
|
+
text = await readFile(this.file, 'utf8')
|
|
516
|
+
} catch {
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
const lines = text.split('\n')
|
|
520
|
+
lines.pop() // the last piece is empty or a line still being written
|
|
521
|
+
for (; this.seen < lines.length; this.seen++) {
|
|
522
|
+
const parsed = parseProgressLine(lines[this.seen]?.trim() ?? '')
|
|
523
|
+
if (!parsed) continue
|
|
524
|
+
if (parsed.phase === 'failed') this.failures.push(parsed)
|
|
525
|
+
const event = progressEvent(parsed)
|
|
526
|
+
if (event) this.onProgress?.(event)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Names the launcher may inherit. Everything else (runtime hooks such as the
|
|
533
|
+
* profiler, startup-hook and compatibility-layer variables, PATHEXT, module
|
|
534
|
+
* paths, anything a caller or a registry value added) is dropped, because the
|
|
535
|
+
* elevated copy of Windows PowerShell inherits what the launcher has.
|
|
536
|
+
* Values set for the person in the registry (`HKCU\Environment`) reach every
|
|
537
|
+
* process they start and cannot be defended against from here.
|
|
538
|
+
*/
|
|
539
|
+
const LAUNCHER_ENV_ALLOW =
|
|
540
|
+
/^(ComSpec|USERNAME|USERDOMAIN|USERPROFILE|APPDATA|LOCALAPPDATA|ProgramData|ProgramFiles(\(x86\)|W6432)?|CommonProgramFiles(\(x86\)|W6432)?|PROCESSOR_[A-Z]+|NUMBER_OF_PROCESSORS|OS)$/i
|
|
541
|
+
|
|
542
|
+
function underProfile(profile: string | undefined, value: string): boolean {
|
|
543
|
+
if (!profile || !/^[A-Za-z]:\\/.test(profile)) return false
|
|
544
|
+
const fold = (text: string) => text.replace(/[\\/]+$/, '').toLowerCase()
|
|
545
|
+
return fold(value).startsWith(`${fold(profile)}\\`)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function launcherEnvironment(host: WslcSetupHost, windowsDirectory: string): NodeJS.ProcessEnv {
|
|
549
|
+
const env: NodeJS.ProcessEnv = {}
|
|
550
|
+
let profile: string | undefined
|
|
551
|
+
for (const [name, value] of Object.entries(host.env)) {
|
|
552
|
+
if (/^USERPROFILE$/i.test(name)) profile = value
|
|
553
|
+
}
|
|
554
|
+
for (const [name, value] of Object.entries(host.env)) {
|
|
555
|
+
if (value === undefined) continue
|
|
556
|
+
if (LAUNCHER_ENV_ALLOW.test(name)) env[name] = value
|
|
557
|
+
else if (/^(TEMP|TMP)$/i.test(name) && underProfile(profile, value)) env[name] = value
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
...env,
|
|
561
|
+
SystemRoot: windowsDirectory,
|
|
562
|
+
windir: windowsDirectory,
|
|
563
|
+
PATH: `${windowsDirectory}\\System32`,
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export const runElevatedWslcSteps: RunElevatedWslcSteps = (request) => runElevatedWith(request, {})
|
|
568
|
+
|
|
569
|
+
/** {@link runElevatedWslcSteps} with the run id and Windows folder a test pins. */
|
|
570
|
+
export async function runElevatedWith(
|
|
571
|
+
request: Request,
|
|
572
|
+
options: ElevatedBuildOptions,
|
|
573
|
+
): Promise<WslcElevatedOutcome> {
|
|
574
|
+
const { host, signal, onProgress } = request
|
|
575
|
+
const scripts = buildElevatedScripts(request, options)
|
|
576
|
+
const steps = request.steps as readonly ElevatedStep[]
|
|
577
|
+
signal?.throwIfAborted()
|
|
578
|
+
|
|
579
|
+
await mkdir(host.stateDirectory, { recursive: true })
|
|
580
|
+
|
|
581
|
+
const windowsDirectory = options.windowsDirectory ?? trustedWindowsDirectory(host.env)
|
|
582
|
+
const powershell = `${windowsDirectory}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
|
|
583
|
+
const tail = new ProgressTail(scripts.progressFile, onProgress)
|
|
584
|
+
|
|
585
|
+
type Settled = { ok: true; output: string } | { ok: false }
|
|
586
|
+
let finished = false
|
|
587
|
+
const launched: Promise<Settled> = host.runner
|
|
588
|
+
.run(powershell, scripts.launcherArgs, {
|
|
589
|
+
signal,
|
|
590
|
+
env: launcherEnvironment(host, windowsDirectory),
|
|
591
|
+
cwd: `${windowsDirectory}\\System32`,
|
|
592
|
+
})
|
|
593
|
+
.then(
|
|
594
|
+
(output): Settled => ({ ok: true, output }),
|
|
595
|
+
(): Settled => ({ ok: false }),
|
|
596
|
+
)
|
|
597
|
+
.finally(() => {
|
|
598
|
+
finished = true
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
while (!finished) {
|
|
602
|
+
await sleepUnlessSettled(POLL_MS, launched, signal)
|
|
603
|
+
if (signal?.aborted) throw signal.reason
|
|
604
|
+
await tail.poll()
|
|
605
|
+
}
|
|
606
|
+
const settled = await launched
|
|
607
|
+
if (signal?.aborted) throw signal.reason
|
|
608
|
+
await tail.poll()
|
|
609
|
+
|
|
610
|
+
const lastFailure = tail.failures[tail.failures.length - 1] ?? {}
|
|
611
|
+
const fallbackStep = steps[0] ?? 'enable-windows-features'
|
|
612
|
+
const failed = (exitCode?: number): WslcElevatedOutcome => ({
|
|
613
|
+
outcome: 'failed',
|
|
614
|
+
step: fallbackStep,
|
|
615
|
+
code: 'elevation-failed',
|
|
616
|
+
exitCode,
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
let mapped: WslcElevatedOutcome | 'cancelled' | 'launch-failed'
|
|
620
|
+
const match = settled.ok ? /NAMZU_EXIT=(-?\d+)/.exec(settled.output) : null
|
|
621
|
+
if (match?.[1] !== undefined) {
|
|
622
|
+
mapped = mapElevatedExit(Number(match[1]), steps, lastFailure)
|
|
623
|
+
} else {
|
|
624
|
+
mapped = 'launch-failed'
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (mapped === 'cancelled' || mapped === 'launch-failed') {
|
|
628
|
+
// A standard user who cannot supply an administrator's approval reads
|
|
629
|
+
// differently from an administrator who said no.
|
|
630
|
+
const standard = await isStandardUser(host, windowsDirectory, signal)
|
|
631
|
+
if (standard) return { outcome: 'needs-administrator' }
|
|
632
|
+
return mapped === 'cancelled' ? { outcome: 'cancelled' } : failed()
|
|
633
|
+
}
|
|
634
|
+
return mapped
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function isStandardUser(
|
|
638
|
+
host: WslcSetupHost,
|
|
639
|
+
root: string,
|
|
640
|
+
signal?: AbortSignal,
|
|
641
|
+
): Promise<boolean> {
|
|
642
|
+
try {
|
|
643
|
+
const output = await host.runner.run(
|
|
644
|
+
`${root}\\System32\\whoami.exe`,
|
|
645
|
+
['/groups', '/fo', 'csv', '/nh'],
|
|
646
|
+
{ signal, env: launcherEnvironment(host, root), cwd: `${root}\\System32` },
|
|
647
|
+
)
|
|
648
|
+
// Only a listing that parses and lacks the group shows a standard user;
|
|
649
|
+
// empty or garbled output shows nothing.
|
|
650
|
+
return isGroupListing(output) && !isAdministratorAccount(output)
|
|
651
|
+
} catch {
|
|
652
|
+
// Not knowing is not a reason to tell an administrator they are not one.
|
|
653
|
+
return false
|
|
654
|
+
}
|
|
655
|
+
}
|