@namzu/sandbox 24.0.0 → 25.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.
Files changed (158) hide show
  1. package/CHANGELOG.md +274 -0
  2. package/dist/backends/http-worker-client.d.ts +3 -1
  3. package/dist/backends/http-worker-client.d.ts.map +1 -1
  4. package/dist/backends/http-worker-client.js +15 -4
  5. package/dist/backends/http-worker-client.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/local-virtual-computer/client.d.ts +21 -0
  11. package/dist/local-virtual-computer/client.d.ts.map +1 -0
  12. package/dist/local-virtual-computer/client.js +503 -0
  13. package/dist/local-virtual-computer/client.js.map +1 -0
  14. package/dist/local-virtual-computer/detached-process.d.ts +15 -0
  15. package/dist/local-virtual-computer/detached-process.d.ts.map +1 -0
  16. package/dist/local-virtual-computer/detached-process.js +111 -0
  17. package/dist/local-virtual-computer/detached-process.js.map +1 -0
  18. package/dist/local-virtual-computer/detached-worker.d.ts +11 -0
  19. package/dist/local-virtual-computer/detached-worker.d.ts.map +1 -0
  20. package/dist/local-virtual-computer/detached-worker.js +97 -0
  21. package/dist/local-virtual-computer/detached-worker.js.map +1 -0
  22. package/dist/local-virtual-computer/dialect.d.ts +26 -0
  23. package/dist/local-virtual-computer/dialect.d.ts.map +1 -0
  24. package/dist/local-virtual-computer/dialect.js +61 -0
  25. package/dist/local-virtual-computer/dialect.js.map +1 -0
  26. package/dist/local-virtual-computer/engine.d.ts +63 -0
  27. package/dist/local-virtual-computer/engine.d.ts.map +1 -0
  28. package/dist/local-virtual-computer/engine.js +125 -0
  29. package/dist/local-virtual-computer/engine.js.map +1 -0
  30. package/dist/local-virtual-computer/guest-stdio.d.ts +15 -0
  31. package/dist/local-virtual-computer/guest-stdio.d.ts.map +1 -0
  32. package/dist/local-virtual-computer/guest-stdio.js +330 -0
  33. package/dist/local-virtual-computer/guest-stdio.js.map +1 -0
  34. package/dist/local-virtual-computer/index.d.ts +36 -0
  35. package/dist/local-virtual-computer/index.d.ts.map +1 -0
  36. package/dist/local-virtual-computer/index.js +341 -0
  37. package/dist/local-virtual-computer/index.js.map +1 -0
  38. package/dist/local-virtual-computer/podman-machine.d.ts +17 -0
  39. package/dist/local-virtual-computer/podman-machine.d.ts.map +1 -0
  40. package/dist/local-virtual-computer/podman-machine.js +181 -0
  41. package/dist/local-virtual-computer/podman-machine.js.map +1 -0
  42. package/dist/local-virtual-computer/setup/contract.d.ts +360 -0
  43. package/dist/local-virtual-computer/setup/contract.d.ts.map +1 -0
  44. package/dist/local-virtual-computer/setup/contract.js +3 -0
  45. package/dist/local-virtual-computer/setup/contract.js.map +1 -0
  46. package/dist/local-virtual-computer/setup/elevated.d.ts +78 -0
  47. package/dist/local-virtual-computer/setup/elevated.d.ts.map +1 -0
  48. package/dist/local-virtual-computer/setup/elevated.js +548 -0
  49. package/dist/local-virtual-computer/setup/elevated.js.map +1 -0
  50. package/dist/local-virtual-computer/setup/host.d.ts +12 -0
  51. package/dist/local-virtual-computer/setup/host.d.ts.map +1 -0
  52. package/dist/local-virtual-computer/setup/host.js +93 -0
  53. package/dist/local-virtual-computer/setup/host.js.map +1 -0
  54. package/dist/local-virtual-computer/setup/idle.d.ts +18 -0
  55. package/dist/local-virtual-computer/setup/idle.d.ts.map +1 -0
  56. package/dist/local-virtual-computer/setup/idle.js +38 -0
  57. package/dist/local-virtual-computer/setup/idle.js.map +1 -0
  58. package/dist/local-virtual-computer/setup/image.d.ts +7 -0
  59. package/dist/local-virtual-computer/setup/image.d.ts.map +1 -0
  60. package/dist/local-virtual-computer/setup/image.js +124 -0
  61. package/dist/local-virtual-computer/setup/image.js.map +1 -0
  62. package/dist/local-virtual-computer/setup/index.d.ts +17 -0
  63. package/dist/local-virtual-computer/setup/index.d.ts.map +1 -0
  64. package/dist/local-virtual-computer/setup/index.js +17 -0
  65. package/dist/local-virtual-computer/setup/index.js.map +1 -0
  66. package/dist/local-virtual-computer/setup/limits.d.ts +30 -0
  67. package/dist/local-virtual-computer/setup/limits.d.ts.map +1 -0
  68. package/dist/local-virtual-computer/setup/limits.js +58 -0
  69. package/dist/local-virtual-computer/setup/limits.js.map +1 -0
  70. package/dist/local-virtual-computer/setup/plan.d.ts +8 -0
  71. package/dist/local-virtual-computer/setup/plan.d.ts.map +1 -0
  72. package/dist/local-virtual-computer/setup/plan.js +131 -0
  73. package/dist/local-virtual-computer/setup/plan.js.map +1 -0
  74. package/dist/local-virtual-computer/setup/readiness.d.ts +11 -0
  75. package/dist/local-virtual-computer/setup/readiness.d.ts.map +1 -0
  76. package/dist/local-virtual-computer/setup/readiness.js +320 -0
  77. package/dist/local-virtual-computer/setup/readiness.js.map +1 -0
  78. package/dist/local-virtual-computer/setup/registry.d.ts +18 -0
  79. package/dist/local-virtual-computer/setup/registry.d.ts.map +1 -0
  80. package/dist/local-virtual-computer/setup/registry.js +50 -0
  81. package/dist/local-virtual-computer/setup/registry.js.map +1 -0
  82. package/dist/local-virtual-computer/setup/restart.d.ts +8 -0
  83. package/dist/local-virtual-computer/setup/restart.d.ts.map +1 -0
  84. package/dist/local-virtual-computer/setup/restart.js +60 -0
  85. package/dist/local-virtual-computer/setup/restart.js.map +1 -0
  86. package/dist/local-virtual-computer/setup/resume.d.ts +14 -0
  87. package/dist/local-virtual-computer/setup/resume.d.ts.map +1 -0
  88. package/dist/local-virtual-computer/setup/resume.js +58 -0
  89. package/dist/local-virtual-computer/setup/resume.js.map +1 -0
  90. package/dist/local-virtual-computer/setup/run.d.ts +8 -0
  91. package/dist/local-virtual-computer/setup/run.d.ts.map +1 -0
  92. package/dist/local-virtual-computer/setup/run.js +200 -0
  93. package/dist/local-virtual-computer/setup/run.js.map +1 -0
  94. package/dist/local-virtual-computer/setup/windows-folder.d.ts +11 -0
  95. package/dist/local-virtual-computer/setup/windows-folder.d.ts.map +1 -0
  96. package/dist/local-virtual-computer/setup/windows-folder.js +14 -0
  97. package/dist/local-virtual-computer/setup/windows-folder.js.map +1 -0
  98. package/dist/local-virtual-computer/setup/wsl-package.d.ts +19 -0
  99. package/dist/local-virtual-computer/setup/wsl-package.d.ts.map +1 -0
  100. package/dist/local-virtual-computer/setup/wsl-package.js +242 -0
  101. package/dist/local-virtual-computer/setup/wsl-package.js.map +1 -0
  102. package/dist/local-virtual-computer/setup/wslc-shared.d.ts +27 -0
  103. package/dist/local-virtual-computer/setup/wslc-shared.d.ts.map +1 -0
  104. package/dist/local-virtual-computer/setup/wslc-shared.js +137 -0
  105. package/dist/local-virtual-computer/setup/wslc-shared.js.map +1 -0
  106. package/dist/local-virtual-computer/ssh-forward.d.ts +19 -0
  107. package/dist/local-virtual-computer/ssh-forward.d.ts.map +1 -0
  108. package/dist/local-virtual-computer/ssh-forward.js +179 -0
  109. package/dist/local-virtual-computer/ssh-forward.js.map +1 -0
  110. package/dist/local-virtual-computer/wslc-engine.d.ts +27 -0
  111. package/dist/local-virtual-computer/wslc-engine.d.ts.map +1 -0
  112. package/dist/local-virtual-computer/wslc-engine.js +98 -0
  113. package/dist/local-virtual-computer/wslc-engine.js.map +1 -0
  114. package/local-computer/Dockerfile +99 -0
  115. package/local-computer/app-catalog.cjs +240 -0
  116. package/local-computer/app-native-host.cjs +144 -0
  117. package/local-computer/browser-home.cjs +249 -0
  118. package/local-computer/build-image.mjs +310 -0
  119. package/local-computer/desktop-worker.cjs +477 -0
  120. package/local-computer/entrypoint.sh +161 -0
  121. package/local-computer/home.html +28 -0
  122. package/local-computer/new-tab/home.css +255 -0
  123. package/local-computer/new-tab/home.js +65 -0
  124. package/local-computer/new-tab/manifest.json +12 -0
  125. package/local-computer/new-tab/wordmark.svg +33 -0
  126. package/local-computer/openbox-rc.xml +41 -0
  127. package/local-computer/prepare-home.cjs +121 -0
  128. package/local-computer/tint2rc +44 -0
  129. package/package.json +13 -4
  130. package/src/backends/http-worker-client.ts +31 -3
  131. package/src/index.ts +13 -0
  132. package/src/local-virtual-computer/client.ts +573 -0
  133. package/src/local-virtual-computer/detached-process.ts +133 -0
  134. package/src/local-virtual-computer/detached-worker.ts +124 -0
  135. package/src/local-virtual-computer/dialect.ts +83 -0
  136. package/src/local-virtual-computer/engine.ts +197 -0
  137. package/src/local-virtual-computer/guest-stdio.ts +332 -0
  138. package/src/local-virtual-computer/index.ts +447 -0
  139. package/src/local-virtual-computer/podman-machine.ts +264 -0
  140. package/src/local-virtual-computer/setup/contract.ts +395 -0
  141. package/src/local-virtual-computer/setup/elevated.ts +655 -0
  142. package/src/local-virtual-computer/setup/host.ts +100 -0
  143. package/src/local-virtual-computer/setup/idle.ts +39 -0
  144. package/src/local-virtual-computer/setup/image.ts +159 -0
  145. package/src/local-virtual-computer/setup/index.ts +16 -0
  146. package/src/local-virtual-computer/setup/limits.ts +68 -0
  147. package/src/local-virtual-computer/setup/plan.ts +144 -0
  148. package/src/local-virtual-computer/setup/readiness.ts +386 -0
  149. package/src/local-virtual-computer/setup/registry.ts +50 -0
  150. package/src/local-virtual-computer/setup/restart.ts +64 -0
  151. package/src/local-virtual-computer/setup/resume.ts +65 -0
  152. package/src/local-virtual-computer/setup/run.ts +225 -0
  153. package/src/local-virtual-computer/setup/windows-folder.ts +13 -0
  154. package/src/local-virtual-computer/setup/wsl-package.ts +242 -0
  155. package/src/local-virtual-computer/setup/wslc-shared.ts +147 -0
  156. package/src/local-virtual-computer/ssh-forward.ts +195 -0
  157. package/src/local-virtual-computer/wslc-engine.ts +124 -0
  158. 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
+ }