@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.
Files changed (158) hide show
  1. package/CHANGELOG.md +284 -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,133 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { fileURLToPath } from 'node:url'
3
+ import type { SandboxDetachedProcess, SandboxSpawnOptions } from '@namzu/sdk'
4
+ import type { DetachedWorkerConfiguration } from './detached-worker.js'
5
+
6
+ export interface DetachedProcessOptions {
7
+ readonly executionUrl: string
8
+ readonly token: string
9
+ readonly retire: () => Promise<void>
10
+ /** Tests using source modules select the already built worker entry. */
11
+ readonly workerPath?: string
12
+ }
13
+
14
+ export interface OwnedDetachedProcess extends SandboxDetachedProcess {
15
+ readonly closed: Promise<void>
16
+ terminate(signal?: NodeJS.Signals): Promise<void>
17
+ }
18
+
19
+ /** Credentials and command payload travel over private IPC, never argv. */
20
+ export function startDetachedGuestProcess(
21
+ options: DetachedProcessOptions,
22
+ command: string,
23
+ args: readonly string[] = [],
24
+ spawnOptions?: SandboxSpawnOptions,
25
+ ): OwnedDetachedProcess {
26
+ const child = spawn(
27
+ process.execPath,
28
+ [options.workerPath ?? fileURLToPath(new URL('./detached-worker.js', import.meta.url))],
29
+ {
30
+ windowsHide: true,
31
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
32
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
33
+ },
34
+ )
35
+ let physicallyClosed = false
36
+ let confirmed = false
37
+ let recoveryRequired = false
38
+ let retirement: Promise<void> | undefined
39
+ const waiters = new Set<{
40
+ resolve: () => void
41
+ reject: (error: Error) => void
42
+ }>()
43
+ const closed = new Promise<void>((resolve) =>
44
+ child.once('close', () => {
45
+ physicallyClosed = true
46
+ resolve()
47
+ if (!confirmed) void retire().catch(() => {})
48
+ }),
49
+ )
50
+ const acceptConfirmation = () => {
51
+ confirmed = true
52
+ recoveryRequired = false
53
+ void closed.then(() => {
54
+ for (const waiter of waiters) waiter.resolve()
55
+ waiters.clear()
56
+ })
57
+ }
58
+ const retire = (): Promise<void> => {
59
+ if (retirement) return retirement
60
+ const attempt = Promise.resolve()
61
+ .then(() => options.retire())
62
+ .then(
63
+ () => {
64
+ acceptConfirmation()
65
+ if (child.connected) child.send({ type: 'retired', confirmed: true }, () => {})
66
+ },
67
+ () => {
68
+ recoveryRequired = true
69
+ const error = new Error(
70
+ 'Guest termination and computer retirement could not be confirmed; operator recovery or a termination retry is required',
71
+ )
72
+ for (const waiter of waiters) waiter.reject(error)
73
+ waiters.clear()
74
+ if (child.connected) child.send({ type: 'retired', confirmed: false }, () => {})
75
+ throw error
76
+ },
77
+ )
78
+ .finally(() => {
79
+ retirement = undefined
80
+ })
81
+ retirement = attempt
82
+ return attempt
83
+ }
84
+ const requestCancellation = (signal: NodeJS.Signals) => {
85
+ if (confirmed) return
86
+ if (recoveryRequired || physicallyClosed) {
87
+ void retire().catch(() => {})
88
+ return
89
+ }
90
+ if (child.connected)
91
+ child.send({ type: 'cancel', signal }, (error) => {
92
+ if (error) void retire().catch(() => {})
93
+ })
94
+ }
95
+ child.once('spawn', () => {
96
+ const config: DetachedWorkerConfiguration = {
97
+ type: 'start',
98
+ executionUrl: options.executionUrl,
99
+ token: options.token,
100
+ command,
101
+ args: [...args],
102
+ ...(spawnOptions?.cwd ? { cwd: spawnOptions.cwd } : {}),
103
+ ...(spawnOptions?.env ? { env: spawnOptions.env } : {}),
104
+ }
105
+ if (child.connected)
106
+ child.send(config, (error) => {
107
+ if (error)
108
+ child.emit('error', new Error('The guest background bridge could not be initialized'))
109
+ })
110
+ })
111
+ child.on('message', (raw: unknown) => {
112
+ if (!raw || typeof raw !== 'object' || !('type' in raw)) return
113
+ if (raw.type === 'terminal-confirmed') acceptConfirmation()
114
+ if (raw.type === 'retire-computer') void retire().catch(() => {})
115
+ })
116
+ return {
117
+ child,
118
+ closed,
119
+ kill(signal) {
120
+ // Both TERM and escalation request confirmed guest cancellation.
121
+ // SIGKILL must never merely kill this host bridge and leave its
122
+ // remote shell running after the registry believes the job stopped.
123
+ requestCancellation(signal)
124
+ },
125
+ terminate(signal = 'SIGTERM') {
126
+ if (confirmed) return closed
127
+ return new Promise<void>((resolve, reject) => {
128
+ waiters.add({ resolve, reject })
129
+ requestCancellation(signal)
130
+ })
131
+ },
132
+ }
133
+ }
@@ -0,0 +1,124 @@
1
+ /** Host-side stream bridge. No command is executed on this host process. */
2
+ import { HttpWorkerClient } from '../backends/http-worker-client.js'
3
+ import { RemoteCancellationUnknownError } from '../backends/remote-execution-controller.js'
4
+
5
+ export const LOCAL_BACKGROUND_TIMEOUT_MS = 2_147_000_000
6
+
7
+ export interface DetachedWorkerConfiguration {
8
+ readonly type: 'start'
9
+ readonly executionUrl: string
10
+ readonly token: string
11
+ readonly command: string
12
+ readonly args: string[]
13
+ readonly cwd?: string
14
+ readonly env?: Record<string, string>
15
+ }
16
+
17
+ const controller = new AbortController()
18
+ let started = false
19
+ let disconnected = false
20
+ let cancellation: NodeJS.Signals | undefined
21
+ let retirement: ((confirmed: boolean) => void) | undefined
22
+
23
+ function cancel(signal: NodeJS.Signals = 'SIGTERM'): void {
24
+ cancellation ??= signal
25
+ controller.abort(new Error('Guest background command cancelled'))
26
+ }
27
+
28
+ process.on('SIGTERM', () => cancel())
29
+ process.on('SIGINT', () => cancel('SIGINT'))
30
+ process.on('disconnect', () => {
31
+ disconnected = true
32
+ cancel()
33
+ retirement?.(false)
34
+ })
35
+
36
+ async function finish(code: number, confirmed = true): Promise<never> {
37
+ // Drain Node's own pipe buffers before reporting terminal state.
38
+ await Promise.all([
39
+ new Promise<void>((resolve) => process.stdout.write('', () => resolve())),
40
+ new Promise<void>((resolve) => process.stderr.write('', () => resolve())),
41
+ ])
42
+ if (confirmed && process.connected)
43
+ await new Promise<void>((resolve) =>
44
+ process.send?.({ type: 'terminal-confirmed' }, () => resolve()),
45
+ )
46
+ process.exit(code)
47
+ }
48
+
49
+ async function requireRetirement(): Promise<never> {
50
+ let confirmed = disconnected
51
+ ? false
52
+ : await new Promise<boolean>((resolve) => {
53
+ retirement = resolve
54
+ process.send?.({ type: 'retire-computer' }, (error) => {
55
+ if (error) resolve(false)
56
+ })
57
+ })
58
+ while (!confirmed && !disconnected) {
59
+ process.stderr.write(
60
+ '\n[Guest cancellation could not be confirmed; the computer requires operator recovery.]\n',
61
+ )
62
+ // Keep the bridge and ownership alive. The provider's next termination
63
+ // attempt retries retirement, never the command or its guest signals.
64
+ confirmed = await new Promise<boolean>((resolve) => {
65
+ retirement = resolve
66
+ })
67
+ }
68
+ return await finish(1, confirmed)
69
+ }
70
+
71
+ async function execute(config: DetachedWorkerConfiguration): Promise<void> {
72
+ try {
73
+ const result = await new HttpWorkerClient(config.executionUrl, config.token).exec(
74
+ config.command,
75
+ config.args,
76
+ {
77
+ timeout: LOCAL_BACKGROUND_TIMEOUT_MS,
78
+ cwd: config.cwd,
79
+ env: config.env,
80
+ signal: controller.signal,
81
+ onOutput: (chunk) =>
82
+ (chunk.stream === 'stdout' ? process.stdout : process.stderr).write(chunk.data),
83
+ },
84
+ )
85
+ if (result.stdoutTruncated || result.stderrTruncated)
86
+ process.stderr.write(
87
+ '\n[Guest worker output exceeded its capture cap; the omitted bytes are unavailable.]\n',
88
+ )
89
+ if (result.timedOut)
90
+ process.stderr.write('\n[Guest background command reached its maximum lifetime.]\n')
91
+ await finish(result.exitCode)
92
+ } catch (error) {
93
+ if (error instanceof RemoteCancellationUnknownError) {
94
+ // Closing a host pipe cannot prove a guest tree stopped. Ask the
95
+ // owning provider to retire the actual computer before exiting.
96
+ await requireRetirement()
97
+ }
98
+ if (controller.signal.aborted) await finish(128 + (cancellation === 'SIGINT' ? 2 : 15))
99
+ process.stderr.write('\n[Guest background command could not be completed.]\n')
100
+ // A transport failure may happen after admission. Retire the computer
101
+ // rather than report a host bridge failure as a guest process exit.
102
+ await requireRetirement()
103
+ }
104
+ }
105
+
106
+ process.on('message', (raw: unknown) => {
107
+ if (!raw || typeof raw !== 'object' || !('type' in raw)) return
108
+ const message = raw as {
109
+ type: string
110
+ signal?: NodeJS.Signals
111
+ confirmed?: boolean
112
+ }
113
+ if (message.type === 'cancel') {
114
+ cancel(message.signal)
115
+ return
116
+ }
117
+ if (message.type === 'retired') {
118
+ retirement?.(message.confirmed === true)
119
+ return
120
+ }
121
+ if (message.type !== 'start' || started) return
122
+ started = true
123
+ void execute(raw as DetachedWorkerConfiguration)
124
+ })
@@ -0,0 +1,83 @@
1
+ import type { EngineDialect, LocalEngine } from './engine.js'
2
+
3
+ /**
4
+ * The engines speak nearly the same command line. Every place they differ is
5
+ * here, so the lifecycle in index.ts reads the same for all of them.
6
+ */
7
+
8
+ export type PublishedPorts = Record<string, { HostIp?: string; HostPort?: string }[]>
9
+
10
+ /** Podman machines on WSL can lack delegated cgroup controllers. */
11
+ export function resourceFlags(dialect: EngineDialect): string[] {
12
+ return dialect === 'podman' ? ['--cgroups=disabled'] : []
13
+ }
14
+
15
+ /**
16
+ * The container boundary. wslc has no flag for any of these; its boundary is
17
+ * the per-user virtual machine, the unprivileged guest user and the image.
18
+ */
19
+ export function hardeningFlags(dialect: EngineDialect): string[] {
20
+ if (dialect === 'wslc') return []
21
+ return ['--cap-drop=ALL', '--security-opt=no-new-privileges', '--read-only', '--ipc=private']
22
+ }
23
+
24
+ /** Command line that prints full container ids, one per line, for a filter. */
25
+ export function containerIdQuery(
26
+ dialect: EngineDialect,
27
+ filter: string,
28
+ options: { readonly full: boolean },
29
+ ): string[] {
30
+ // wslc has no Go templates and truncates -q ids to 12 characters.
31
+ if (dialect === 'wslc') return ['ps', '-a', '--filter', filter, '-q', '--no-trunc']
32
+ return [
33
+ 'ps',
34
+ '--all',
35
+ '--filter',
36
+ filter,
37
+ ...(options.full ? ['--no-trunc'] : []),
38
+ '--format',
39
+ '{{.ID}}',
40
+ ]
41
+ }
42
+
43
+ export function portsQuery(dialect: EngineDialect, name: string): string[] {
44
+ if (dialect === 'wslc') return ['inspect', '-f', 'json', name]
45
+ return ['inspect', '--format', '{{json .NetworkSettings.Ports}}', name]
46
+ }
47
+
48
+ /** Read published ports from what `portsQuery` printed. */
49
+ export function parsePublishedPorts(dialect: EngineDialect, output: string): PublishedPorts {
50
+ const parsed: unknown = JSON.parse(output)
51
+ if (dialect !== 'wslc') return parsed as PublishedPorts
52
+ const first = (Array.isArray(parsed) ? parsed[0] : undefined) as
53
+ | { Ports?: PublishedPorts; NetworkSettings?: { Ports?: PublishedPorts } }
54
+ | undefined
55
+ return first?.Ports ?? first?.NetworkSettings?.Ports ?? {}
56
+ }
57
+
58
+ /** Make sure the labelled volume exists, creating it only when absent. */
59
+ export async function ensureVolume(
60
+ engine: LocalEngine,
61
+ dialect: EngineDialect,
62
+ volume: string,
63
+ label: string,
64
+ signal?: AbortSignal,
65
+ ): Promise<void> {
66
+ if (dialect !== 'wslc') {
67
+ await engine.run(
68
+ ['volume', 'create', ...(dialect === 'podman' ? ['--ignore'] : []), '--label', label, volume],
69
+ { signal },
70
+ )
71
+ return
72
+ }
73
+ // wslc has no --ignore. A missing volume is either a failed command or an
74
+ // empty list, depending on how the output was captured.
75
+ let present = false
76
+ try {
77
+ const found: unknown = JSON.parse(await engine.run(['volume', 'inspect', volume], { signal }))
78
+ present = Array.isArray(found) && found.length > 0
79
+ } catch {
80
+ signal?.throwIfAborted()
81
+ }
82
+ if (!present) await engine.run(['volume', 'create', '--label', label, volume], { signal })
83
+ }
@@ -0,0 +1,197 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ /** Stable reasons a caller can branch on without parsing prose. */
4
+ export type LocalComputerSetupCode =
5
+ | 'engine-missing'
6
+ | 'engine-too-old'
7
+ | 'virtualization-off'
8
+ | 'blocked-by-policy'
9
+ | 'image-missing'
10
+ | 'sdk-mismatch'
11
+
12
+ /** Safe operator-facing diagnosis, without daemon output or credentials. */
13
+ export class LocalComputerSetupError extends Error {
14
+ override readonly name = 'LocalComputerSetupError'
15
+ readonly code?: LocalComputerSetupCode
16
+ constructor(message: string, code?: LocalComputerSetupCode, options?: { cause?: unknown }) {
17
+ super(message, options)
18
+ this.code = code
19
+ }
20
+ }
21
+
22
+ /**
23
+ * A failed engine command. The message never carries engine output; only
24
+ * recognised machine-readable codes (HRESULTs, WSLC_E_* names, spawn errno
25
+ * names) survive, so a typed diagnosis can be made without leaking text.
26
+ */
27
+ export class LocalCommandError extends Error {
28
+ override readonly name = 'LocalCommandError'
29
+ readonly errorCodes: readonly string[]
30
+ /** The process exit status, when the command ran and failed. */
31
+ readonly exitCode?: number
32
+ constructor(message: string, errorCodes: readonly string[] = [], exitCode?: number) {
33
+ super(message)
34
+ this.errorCodes = errorCodes
35
+ if (exitCode !== undefined) this.exitCode = exitCode
36
+ }
37
+ }
38
+
39
+ const ERROR_CODE_PATTERN = /WSLC_E_[A-Z0-9_]+|HCS_E_[A-Z0-9_]+|0x[0-9A-Fa-f]{8}/g
40
+
41
+ /** Extract the codes worth keeping from a command's stderr. */
42
+ export function extractErrorCodes(text: string): string[] {
43
+ const found = new Set<string>()
44
+ for (const match of text.match(ERROR_CODE_PATTERN) ?? []) {
45
+ found.add(match.startsWith('0x') ? `0x${match.slice(2).toUpperCase()}` : match)
46
+ }
47
+ return [...found]
48
+ }
49
+
50
+ /** Which command-line dialect an engine speaks. */
51
+ export type EngineDialect = 'docker' | 'podman' | 'wslc'
52
+
53
+ /** A host-controlled transport. Never pass this or its environment to an agent. */
54
+ export interface LocalComputerCommandRunner {
55
+ run(
56
+ binary: string,
57
+ args: readonly string[],
58
+ options?: {
59
+ readonly signal?: AbortSignal
60
+ readonly env?: NodeJS.ProcessEnv
61
+ readonly cwd?: string
62
+ },
63
+ ): Promise<string>
64
+ }
65
+
66
+ export const nodeCommandRunner: LocalComputerCommandRunner = {
67
+ run(binary, args, options) {
68
+ options?.signal?.throwIfAborted()
69
+ return new Promise((resolve, reject) => {
70
+ const child = spawn(binary, [...args], {
71
+ env: options?.env ?? process.env,
72
+ cwd: options?.cwd,
73
+ signal: options?.signal,
74
+ windowsHide: true,
75
+ stdio: ['ignore', 'pipe', 'pipe'],
76
+ })
77
+ const output: Buffer[] = []
78
+ const diagnostics: Buffer[] = []
79
+ let diagnosticBytes = 0
80
+ let outputBytes = 0
81
+ let failed = false
82
+ child.stdout.on('data', (chunk: Buffer) => {
83
+ output.push(chunk)
84
+ outputBytes += chunk.byteLength
85
+ if (outputBytes > 1024 * 1024) {
86
+ failed = true
87
+ child.kill()
88
+ }
89
+ })
90
+ // Docker's error text can echo worker configuration. Preserve the exit
91
+ // code, never stdout/stderr or the environment in a failure message.
92
+ child.stderr.on('data', (chunk: Buffer) => {
93
+ if (diagnosticBytes >= 64 * 1024) return
94
+ diagnostics.push(chunk)
95
+ diagnosticBytes += chunk.byteLength
96
+ })
97
+ child.once('error', (error: NodeJS.ErrnoException) =>
98
+ reject(
99
+ new LocalCommandError(
100
+ 'The local container engine could not be started',
101
+ error.code ? [error.code] : [],
102
+ ),
103
+ ),
104
+ )
105
+ child.once('close', (code) => {
106
+ if (failed) reject(new Error('The local container engine response exceeded its size limit'))
107
+ else if (code !== 0)
108
+ reject(
109
+ new LocalCommandError(
110
+ `The local container engine command failed (${code})`,
111
+ extractErrorCodes(Buffer.concat(diagnostics).toString('utf8')),
112
+ code ?? undefined,
113
+ ),
114
+ )
115
+ else {
116
+ const bytes = Buffer.concat(output)
117
+ // Native wsl.exe emits UTF-16LE through a redirected pipe.
118
+ const utf16 = bytes[1] === 0 || (bytes[0] === 0xff && bytes[1] === 0xfe)
119
+ resolve(bytes.toString(utf16 ? 'utf16le' : 'utf8').replace(/^\uFEFF/, ''))
120
+ }
121
+ })
122
+ })
123
+ },
124
+ }
125
+
126
+ /** TCP, SSH and remote named pipes cannot establish a local-device boundary. */
127
+ export function assertLocalEngineEndpoint(endpoint: string): void {
128
+ if (/^unix:\/\/\/[\S]+$/.test(endpoint)) return
129
+ if (/^npipe:\/\/\/\/\.\/pipe\/[a-zA-Z0-9_.-]+$/.test(endpoint)) return
130
+ throw new LocalComputerSetupError(
131
+ 'Pals require a local Docker Unix socket or Windows named pipe; remote engines are refused',
132
+ )
133
+ }
134
+
135
+ export interface LocalEngineOptions {
136
+ readonly dockerBinary?: string
137
+ readonly engineEndpoint?: string
138
+ readonly runner?: LocalComputerCommandRunner
139
+ }
140
+
141
+ export interface LocalEngine {
142
+ readonly endpoint: string
143
+ /** Command-line dialect. Absent means the engine was selected as Docker. */
144
+ readonly dialect?: EngineDialect
145
+ forwardPorts?(
146
+ ports: readonly number[],
147
+ options: { readonly signal?: AbortSignal; readonly onLost: () => void },
148
+ ): Promise<LocalPortForward>
149
+ run(
150
+ args: readonly string[],
151
+ options?: { readonly signal?: AbortSignal; readonly env?: NodeJS.ProcessEnv },
152
+ ): Promise<string>
153
+ }
154
+
155
+ export interface LocalPortForward {
156
+ readonly ports: readonly number[]
157
+ assertAlive(): void
158
+ close(): Promise<void>
159
+ }
160
+
161
+ /** Resolve once, then pin every operation to the verified endpoint. */
162
+ export async function resolveLocalEngine(
163
+ options: LocalEngineOptions,
164
+ signal?: AbortSignal,
165
+ ): Promise<LocalEngine> {
166
+ const binary = options.dockerBinary ?? 'docker'
167
+ const runner = options.runner ?? nodeCommandRunner
168
+ let endpoint = options.engineEndpoint
169
+ if (endpoint === undefined) {
170
+ const current = (await runner.run(binary, ['context', 'show'], { signal })).trim()
171
+ if (!current)
172
+ throw new LocalComputerSetupError('The current Docker context could not be determined')
173
+ const contexts = JSON.parse(
174
+ await runner.run(binary, ['context', 'inspect', current], { signal }),
175
+ ) as { Endpoints?: { docker?: { Host?: string } } }[]
176
+ endpoint = contexts[0]?.Endpoints?.docker?.Host
177
+ // DOCKER_HOST overrides a context in normal Docker invocations. Honor
178
+ // its actual destination and validate it rather than checking one
179
+ // endpoint while accidentally provisioning on another.
180
+ if (!process.env.DOCKER_CONTEXT && process.env.DOCKER_HOST) endpoint = process.env.DOCKER_HOST
181
+ }
182
+ if (!endpoint) throw new LocalComputerSetupError('Docker did not provide a local engine endpoint')
183
+ assertLocalEngineEndpoint(endpoint)
184
+ const host = endpoint
185
+ const run: LocalEngine['run'] = (args, call) => {
186
+ const env = { ...process.env, ...call?.env }
187
+ env.DOCKER_CONTEXT = undefined
188
+ env.DOCKER_HOST = undefined
189
+ return runner.run(binary, ['--host', host, ...args], { ...call, env })
190
+ }
191
+ const info = JSON.parse(await run(['info', '--format', '{{json .}}'], { signal })) as {
192
+ OSType?: string
193
+ }
194
+ if (info.OSType !== 'linux')
195
+ throw new LocalComputerSetupError('The local Docker engine must run Linux containers')
196
+ return { endpoint: host, run }
197
+ }