@namzu/sandbox 23.0.1 → 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 +290 -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,100 @@
1
+ import { homedir, release } from 'node:os'
2
+ import { join, win32 } from 'node:path'
3
+ import { LocalCommandError, nodeCommandRunner } from '../engine.js'
4
+ import { type CreateWslcSetupHost, RESUME_AT_SIGN_IN_KEY } from './contract.js'
5
+ import { PROBE_LIMIT_MS, ProbeTimeoutError, runLimited } from './limits.js'
6
+ import { queryRegistryKey } from './registry.js'
7
+
8
+ /** `10.0.19045` becomes 19045; undefined when the release string has no build. */
9
+ function windowsBuildOf(platform: NodeJS.Platform): number | undefined {
10
+ if (platform !== 'win32') return undefined
11
+ const build = Number(release().split('.')[2])
12
+ return Number.isInteger(build) ? build : undefined
13
+ }
14
+
15
+ /**
16
+ * The processor type of the PC. On Windows a 32-bit process sees its own
17
+ * type in `PROCESSOR_ARCHITECTURE`; the PC's is in `PROCESSOR_ARCHITEW6432`.
18
+ * Neither is `process.arch`, which names the Node build, not the machine.
19
+ */
20
+ export function windowsArchitectureOf(env: NodeJS.ProcessEnv): string {
21
+ const claimed = (env.PROCESSOR_ARCHITEW6432 ?? env.PROCESSOR_ARCHITECTURE ?? '').toUpperCase()
22
+ if (claimed === 'AMD64') return 'x64'
23
+ if (claimed === 'ARM64') return 'arm64'
24
+ return claimed === '' ? process.arch : claimed.toLowerCase()
25
+ }
26
+
27
+ function defaultStateDirectory(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string {
28
+ if (platform === 'win32') {
29
+ const base = env.LOCALAPPDATA ?? win32.join(homedir(), 'AppData', 'Local')
30
+ return win32.join(base, 'Namzu', 'setup')
31
+ }
32
+ return join(homedir(), '.namzu', 'setup')
33
+ }
34
+
35
+ /** `REG_DWORD 0x1f` becomes 31; anything else is undefined. */
36
+ export function parseRegDword(output: string): number | undefined {
37
+ const match = /REG_DWORD\s+0x([0-9a-f]+)/i.exec(output)
38
+ return match ? Number.parseInt(match[1] as string, 16) : undefined
39
+ }
40
+
41
+ /** The real Windows host, with `overrides` applied. */
42
+ export const createWslcSetupHost: CreateWslcSetupHost = (overrides = {}) => {
43
+ const platform = overrides.platform ?? process.platform
44
+ const env = overrides.env ?? process.env
45
+ const runner = overrides.runner ?? nodeCommandRunner
46
+ const host = {
47
+ platform,
48
+ arch: platform === 'win32' ? windowsArchitectureOf(env) : process.arch,
49
+ windowsBuild: windowsBuildOf(platform),
50
+ env,
51
+ runner,
52
+ stateDirectory: defaultStateDirectory(env, platform),
53
+ resumeRegistryKey: RESUME_AT_SIGN_IN_KEY,
54
+ now: () => new Date(),
55
+ async readDword(key: string, name: string): Promise<number | undefined> {
56
+ try {
57
+ return parseRegDword(
58
+ await runLimited(runner, 'reg.exe', ['query', key, '/v', name], PROBE_LIMIT_MS.command),
59
+ )
60
+ } catch (error) {
61
+ // reg.exe exits 1 for an absent key or value; that is "not set". A
62
+ // check that did not answer in time is not known: it propagates, so a
63
+ // policy read that timed out never reads as "not set" (allowed).
64
+ if (error instanceof LocalCommandError) return undefined
65
+ throw error
66
+ }
67
+ },
68
+ async keyExists(key: string): Promise<boolean | 'unknown'> {
69
+ try {
70
+ const listing = await queryRegistryKey(
71
+ (args) => runLimited(runner, 'reg.exe', args, PROBE_LIMIT_MS.command),
72
+ key,
73
+ )
74
+ return listing !== undefined
75
+ } catch (error) {
76
+ // Denied, a failed parent or no answer in time: not known, never "absent".
77
+ if (error instanceof LocalCommandError || error instanceof ProbeTimeoutError)
78
+ return 'unknown'
79
+ throw error
80
+ }
81
+ },
82
+ async bootTime(): Promise<Date> {
83
+ const output = await runLimited(
84
+ runner,
85
+ 'powershell.exe',
86
+ [
87
+ '-NoProfile',
88
+ '-NonInteractive',
89
+ '-Command',
90
+ "(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString('o')",
91
+ ],
92
+ PROBE_LIMIT_MS.powershell,
93
+ )
94
+ const parsed = new Date(output.trim())
95
+ if (Number.isNaN(parsed.getTime())) throw new Error('Windows did not report its boot time')
96
+ return parsed
97
+ },
98
+ }
99
+ return { ...host, ...overrides }
100
+ }
@@ -0,0 +1,39 @@
1
+ import type { StopIdleWslcSession } from './contract.js'
2
+ import { createWslcSetupHost } from './host.js'
3
+ import { wslcBinaryOf } from './wslc-shared.js'
4
+
5
+ /** A row of `wslc system session list`: id, creator pid, display name. */
6
+ const SESSION_ROW = /^\s*\d+\s+\d+\s+(\S+)/
7
+
8
+ /**
9
+ * Whether the default wslc session is running. `wslc system session list`
10
+ * reads the session manager and does not start a session (unlike `list` and
11
+ * `image`, which do). The default session is the one wslc names `wslc-cli-*`.
12
+ */
13
+ export function defaultSessionRunning(listOutput: string): boolean {
14
+ return listOutput
15
+ .split(/\r?\n/)
16
+ .some((line) => (SESSION_ROW.exec(line)?.[1] ?? '').startsWith('wslc-cli'))
17
+ }
18
+
19
+ /**
20
+ * Stop the wslc session's virtual machine when nothing runs in it. Answers
21
+ * `busy` and does nothing while any container runs, Namzu's or not, and
22
+ * `not-running` when there is no session to stop.
23
+ *
24
+ * A container could start between the check and the terminate; the window is
25
+ * milliseconds and the caller owns the sessions it starts. If the check itself
26
+ * fails, the error is thrown and the session is left alone.
27
+ */
28
+ export const stopIdleWslcSession: StopIdleWslcSession = async (options) => {
29
+ const host = createWslcSetupHost(options.host)
30
+ const binary = wslcBinaryOf(host)
31
+ const { signal } = options
32
+ const sessions = await host.runner.run(binary, ['system', 'session', 'list'], { signal })
33
+ if (!defaultSessionRunning(sessions)) return { outcome: 'not-running' }
34
+ // Running containers only: `list` without `--all`.
35
+ const running = await host.runner.run(binary, ['list', '-q'], { signal })
36
+ if (running.trim() !== '') return { outcome: 'busy' }
37
+ await host.runner.run(binary, ['system', 'session', 'terminate'], { signal })
38
+ return { outcome: 'stopped' }
39
+ }
@@ -0,0 +1,159 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { createReadStream } from 'node:fs'
3
+ import { stat } from 'node:fs/promises'
4
+ import { LocalComputerSetupError } from '../engine.js'
5
+ import { mapWslcFailure } from '../wslc-engine.js'
6
+ import type { LoadWslcImage, WslcImageManifest } from './contract.js'
7
+ import { createWslcSetupHost } from './host.js'
8
+ import {
9
+ ManifestError,
10
+ archivePathOf,
11
+ parseImageInspect,
12
+ readManifest,
13
+ writeImageRecord,
14
+ wslcBinaryOf,
15
+ } from './wslc-shared.js'
16
+
17
+ const HASH_CHUNK_BYTES = 4 * 1024 * 1024
18
+
19
+ async function sha256Of(
20
+ path: string,
21
+ total: number,
22
+ onProgress: ((done: number) => void) | undefined,
23
+ signal: AbortSignal | undefined,
24
+ ): Promise<string> {
25
+ const hash = createHash('sha256')
26
+ let done = 0
27
+ const stream = createReadStream(path, { highWaterMark: HASH_CHUNK_BYTES, signal })
28
+ for await (const chunk of stream) {
29
+ hash.update(chunk as Buffer)
30
+ done += (chunk as Buffer).byteLength
31
+ onProgress?.(Math.min(done, total))
32
+ }
33
+ return hash.digest('hex')
34
+ }
35
+
36
+ /** The image the engine reports under `ref`, or undefined when it has none. */
37
+ async function inspectImage(
38
+ host: ReturnType<typeof createWslcSetupHost>,
39
+ ref: string,
40
+ signal: AbortSignal | undefined,
41
+ ): Promise<
42
+ | { readonly kind: 'found'; readonly id: string; readonly protocol: string | undefined }
43
+ | { readonly kind: 'absent' }
44
+ | { readonly kind: 'engine'; readonly detail: string }
45
+ > {
46
+ try {
47
+ const output = await host.runner.run(
48
+ wslcBinaryOf(host),
49
+ ['image', 'inspect', '-f', 'json', ref],
50
+ { signal },
51
+ )
52
+ const parsed = parseImageInspect(output)
53
+ return parsed ? { kind: 'found', id: parsed.id, protocol: parsed.protocol } : { kind: 'absent' }
54
+ } catch (error) {
55
+ signal?.throwIfAborted()
56
+ const mapped = mapWslcFailure(error)
57
+ if (mapped === undefined) return { kind: 'absent' }
58
+ if (mapped.code === 'image-missing') return { kind: 'absent' }
59
+ return { kind: 'engine', detail: mapped.message }
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Check the bundled archive against its manifest, load it with `wslc load`,
65
+ * and check what loaded. Nothing is loaded that failed a check.
66
+ */
67
+ export const loadWslcImage: LoadWslcImage = async (options) => {
68
+ const host = createWslcSetupHost(options.host)
69
+ const { signal } = options
70
+ let manifest: WslcImageManifest
71
+ try {
72
+ manifest = await readManifest(options.manifestPath)
73
+ } catch (error) {
74
+ if (error instanceof ManifestError)
75
+ return { outcome: 'failed', code: 'image-corrupt', detail: error.message }
76
+ throw error
77
+ }
78
+ const record = async (): Promise<void> => {
79
+ await writeImageRecord(host, {
80
+ imageRef: manifest.imageRef,
81
+ imageId: manifest.imageId,
82
+ loadedAt: host.now().toISOString(),
83
+ })
84
+ }
85
+
86
+ const existing = await inspectImage(host, manifest.imageRef, signal)
87
+ if (existing.kind === 'engine')
88
+ return { outcome: 'failed', code: 'image-load-failed', detail: existing.detail }
89
+ if (existing.kind === 'found' && existing.id === manifest.imageId) {
90
+ await record()
91
+ return { outcome: 'already-loaded' }
92
+ }
93
+
94
+ const archive = archivePathOf(options.manifestPath, manifest)
95
+ let size: number
96
+ try {
97
+ size = (await stat(archive)).size
98
+ } catch {
99
+ return {
100
+ outcome: 'failed',
101
+ code: 'image-corrupt',
102
+ detail: 'The Pal computer image file is missing from this installation.',
103
+ }
104
+ }
105
+ if (size !== manifest.compressedBytes)
106
+ return {
107
+ outcome: 'failed',
108
+ code: 'image-corrupt',
109
+ detail: 'The Pal computer image file is incomplete or damaged.',
110
+ }
111
+ const digest = await sha256Of(
112
+ archive,
113
+ manifest.compressedBytes,
114
+ (done) =>
115
+ options.onProgress?.({
116
+ step: 'load-image',
117
+ phase: 'progress',
118
+ done,
119
+ total: manifest.compressedBytes,
120
+ unit: 'bytes',
121
+ }),
122
+ signal,
123
+ )
124
+ if (digest !== manifest.sha256)
125
+ return {
126
+ outcome: 'failed',
127
+ code: 'image-corrupt',
128
+ detail: 'The Pal computer image file is incomplete or damaged.',
129
+ }
130
+
131
+ try {
132
+ await host.runner.run(wslcBinaryOf(host), ['load', '-q', '-i', archive], { signal })
133
+ } catch (error) {
134
+ signal?.throwIfAborted()
135
+ const mapped = mapWslcFailure(error)
136
+ return {
137
+ outcome: 'failed',
138
+ code: 'image-load-failed',
139
+ detail:
140
+ mapped instanceof LocalComputerSetupError
141
+ ? mapped.message
142
+ : 'Windows could not load the Pal computer image.',
143
+ }
144
+ }
145
+
146
+ const loaded = await inspectImage(host, manifest.imageRef, signal)
147
+ if (
148
+ loaded.kind !== 'found' ||
149
+ loaded.id !== manifest.imageId ||
150
+ loaded.protocol !== manifest.protocol
151
+ )
152
+ return {
153
+ outcome: 'failed',
154
+ code: 'image-mismatch',
155
+ detail: 'The image that loaded is not the one this version of Namzu expects.',
156
+ }
157
+ await record()
158
+ return { outcome: 'loaded' }
159
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Windows Pal computer setup: inspect the machine, plan, and run the plan
3
+ * after the person agrees. Separate from the provider, which never installs,
4
+ * loads or starts anything.
5
+ */
6
+ export * from './contract.js'
7
+ export { createWslcSetupHost } from './host.js'
8
+ export { stopIdleWslcSession } from './idle.js'
9
+ export { loadWslcImage } from './image.js'
10
+ export { planWslcSetup } from './plan.js'
11
+ export { inspectWslcReadiness } from './readiness.js'
12
+ export { clearRestartMarker, readRestartMarker, writeRestartMarker } from './restart.js'
13
+ export { clearResumeAtSignIn, registerResumeAtSignIn } from './resume.js'
14
+ export { runWslcSetup } from './run.js'
15
+ export { runElevatedWslcSteps } from './elevated.js'
16
+ export { downloadWslPackage, PINNED_WSL_PACKAGE } from './wsl-package.js'
@@ -0,0 +1,68 @@
1
+ import type { LocalComputerCommandRunner } from '../engine.js'
2
+
3
+ /**
4
+ * Every probe command has its own time limit, so a stuck Windows tool degrades
5
+ * one fact to `unknown` instead of hanging the whole inspection.
6
+ */
7
+ export const PROBE_LIMIT_MS = {
8
+ /** reg.exe, sc.exe, wsl.exe, wslc version: local and quick. */
9
+ command: 10_000,
10
+ /** PowerShell starts slowly on a cold PC. */
11
+ powershell: 15_000,
12
+ /** The command that starts the wslc session (a virtual machine). */
13
+ session: 60_000,
14
+ } as const
15
+
16
+ /** A probe command did not answer within its limit. */
17
+ export class ProbeTimeoutError extends Error {
18
+ override readonly name = 'ProbeTimeoutError'
19
+ constructor() {
20
+ super('A Windows check did not answer in time.')
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Run one command with its own limit. The limit aborts the runner's signal and
26
+ * also stops waiting, so a runner that ignores the signal cannot hold the
27
+ * caller. The caller's own signal still wins: its abort rejects with its
28
+ * reason, not with a timeout.
29
+ */
30
+ export async function runLimited(
31
+ runner: LocalComputerCommandRunner,
32
+ binary: string,
33
+ args: readonly string[],
34
+ limitMs: number,
35
+ options: {
36
+ readonly signal?: AbortSignal
37
+ readonly env?: NodeJS.ProcessEnv
38
+ readonly cwd?: string
39
+ } = {},
40
+ ): Promise<string> {
41
+ options.signal?.throwIfAborted()
42
+ const controller = new AbortController()
43
+ let timer: ReturnType<typeof setTimeout> | undefined
44
+ let onOuterAbort: (() => void) | undefined
45
+ const stop = new Promise<never>((_, reject) => {
46
+ timer = setTimeout(() => {
47
+ // Reject first: aborting makes a well-behaved runner reject at once, and
48
+ // the caller must see the timeout, not that rejection.
49
+ reject(new ProbeTimeoutError())
50
+ controller.abort()
51
+ }, limitMs)
52
+ onOuterAbort = () => {
53
+ reject(options.signal?.reason)
54
+ controller.abort()
55
+ }
56
+ options.signal?.addEventListener('abort', onOuterAbort, { once: true })
57
+ })
58
+ // Whichever side loses the race must not become an unhandled rejection.
59
+ stop.catch(() => undefined)
60
+ try {
61
+ const running = runner.run(binary, args, { ...options, signal: controller.signal })
62
+ running.catch(() => undefined)
63
+ return await Promise.race([running, stop])
64
+ } finally {
65
+ if (timer !== undefined) clearTimeout(timer)
66
+ if (onOuterAbort) options.signal?.removeEventListener('abort', onOuterAbort)
67
+ }
68
+ }
@@ -0,0 +1,144 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type {
3
+ PlanWslcSetup,
4
+ WslcReadiness,
5
+ WslcSetupBlock,
6
+ WslcSetupPlan,
7
+ WslcSetupStep,
8
+ } from './contract.js'
9
+ import { wslcAtLeastMinimum } from './readiness.js'
10
+ import { PINNED_WSL_PACKAGE } from './wsl-package.js'
11
+
12
+ /** Rough durations for the sentence the person reads; not promises. */
13
+ const ENABLE_FEATURES_SECONDS = 90
14
+ const INSTALL_WSL_SECONDS = 60
15
+ const RESTART_SECONDS = 90
16
+ /** Bytes per second assumed for a download and for loading the image. */
17
+ const DOWNLOAD_BYTES_PER_SECOND = 8_000_000
18
+ /** Measured on a real PC: 3.2 s for the 323 MB archive, so about 5 s plus 150 MB/s. */
19
+ const LOAD_BYTES_PER_SECOND = 150_000_000
20
+ const LOAD_FIXED_SECONDS = 5
21
+
22
+ const BLOCKED: Partial<Record<WslcReadiness['state'], WslcSetupBlock>> = {
23
+ unsupported: 'unsupported',
24
+ 'blocked-by-policy': 'blocked-by-policy',
25
+ 'virtualization-off': 'virtualization-off',
26
+ 'sdk-mismatch': 'sdk-mismatch',
27
+ }
28
+
29
+ /** JSON with sorted keys, so equal plans digest equally. */
30
+ function canonical(value: unknown): string {
31
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
32
+ if (typeof value === 'object' && value !== null) {
33
+ const entries = Object.entries(value as Record<string, unknown>)
34
+ .filter(([, v]) => v !== undefined)
35
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
36
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(',')}}`
37
+ }
38
+ return JSON.stringify(value)
39
+ }
40
+
41
+ /**
42
+ * Turn what a readiness inspection found into the steps that fix it. Pure: the
43
+ * same facts give the same plan and the same id. Steps come from the facts, not
44
+ * only from the headline state.
45
+ */
46
+ export const planWslcSetup: PlanWslcSetup = (readiness, options) => {
47
+ const pinned = options.wslPackage ?? PINNED_WSL_PACKAGE
48
+ const manifest = options.manifest
49
+ const finish = (steps: WslcSetupStep[], blocked?: WslcSetupBlock): WslcSetupPlan => {
50
+ const id = createHash('sha256')
51
+ .update(
52
+ canonical({
53
+ state: readiness.state,
54
+ steps,
55
+ blocked,
56
+ wslVersion: pinned.version,
57
+ manifest: manifest?.sha256 ?? null,
58
+ }),
59
+ )
60
+ .digest('hex')
61
+ return {
62
+ id,
63
+ state: readiness.state,
64
+ steps,
65
+ elevationPrompts: steps.some((step) => step.elevated) ? 1 : 0,
66
+ mayRequireRestart: steps.some((step) => step.mayRequireRestart),
67
+ ...(blocked ? { blocked } : {}),
68
+ }
69
+ }
70
+
71
+ const blocked = BLOCKED[readiness.state]
72
+ if (blocked) return finish([], blocked)
73
+ if (readiness.state === 'ready') return finish([])
74
+
75
+ const hasImage = readiness.image.bundled !== undefined || manifest !== undefined
76
+ const loadImage = (): WslcSetupStep => ({
77
+ kind: 'load-image',
78
+ elevated: false,
79
+ ...(manifest ? { diskBytes: manifest.sizeBytes } : {}),
80
+ estimatedSeconds: manifest
81
+ ? LOAD_FIXED_SECONDS + Math.ceil(manifest.compressedBytes / LOAD_BYTES_PER_SECOND)
82
+ : 60,
83
+ mayRequireRestart: false,
84
+ })
85
+ const needImage = !readiness.image.loaded
86
+
87
+ if (readiness.state === 'restart-pending') {
88
+ const steps: WslcSetupStep[] = [
89
+ {
90
+ kind: 'restart',
91
+ elevated: false,
92
+ estimatedSeconds: RESTART_SECONDS,
93
+ mayRequireRestart: true,
94
+ },
95
+ ]
96
+ if (needImage) {
97
+ if (!hasImage) return finish([], 'image-unavailable')
98
+ steps.push(loadImage())
99
+ }
100
+ return finish(steps)
101
+ }
102
+
103
+ const wslcVersion = readiness.wsl.wslcVersion
104
+ const needWsl = wslcVersion === undefined || !wslcAtLeastMinimum(wslcVersion)
105
+ const needFeatures =
106
+ readiness.components.virtualMachinePlatform === false ||
107
+ readiness.components.wslOptionalComponent === false
108
+
109
+ if (needImage && !hasImage) return finish([], 'image-unavailable')
110
+
111
+ const steps: WslcSetupStep[] = []
112
+ if (needWsl)
113
+ steps.push({
114
+ kind: 'download-wsl',
115
+ elevated: false,
116
+ downloadBytes: pinned.sizeBytes,
117
+ diskBytes: pinned.sizeBytes,
118
+ estimatedSeconds: Math.ceil(pinned.sizeBytes / DOWNLOAD_BYTES_PER_SECOND),
119
+ mayRequireRestart: false,
120
+ })
121
+ if (needFeatures)
122
+ steps.push({
123
+ kind: 'enable-windows-features',
124
+ elevated: true,
125
+ estimatedSeconds: ENABLE_FEATURES_SECONDS,
126
+ mayRequireRestart: true,
127
+ })
128
+ if (needWsl)
129
+ steps.push({
130
+ kind: 'install-wsl',
131
+ elevated: true,
132
+ estimatedSeconds: INSTALL_WSL_SECONDS,
133
+ mayRequireRestart: true,
134
+ })
135
+ if (needFeatures)
136
+ steps.push({
137
+ kind: 'restart',
138
+ elevated: false,
139
+ estimatedSeconds: RESTART_SECONDS,
140
+ mayRequireRestart: true,
141
+ })
142
+ if (needImage) steps.push(loadImage())
143
+ return finish(steps)
144
+ }