@namzu/sandbox 24.0.0 → 26.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +284 -0
- package/dist/backends/http-worker-client.d.ts +3 -1
- package/dist/backends/http-worker-client.d.ts.map +1 -1
- package/dist/backends/http-worker-client.js +15 -4
- package/dist/backends/http-worker-client.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/local-virtual-computer/client.d.ts +21 -0
- package/dist/local-virtual-computer/client.d.ts.map +1 -0
- package/dist/local-virtual-computer/client.js +503 -0
- package/dist/local-virtual-computer/client.js.map +1 -0
- package/dist/local-virtual-computer/detached-process.d.ts +15 -0
- package/dist/local-virtual-computer/detached-process.d.ts.map +1 -0
- package/dist/local-virtual-computer/detached-process.js +111 -0
- package/dist/local-virtual-computer/detached-process.js.map +1 -0
- package/dist/local-virtual-computer/detached-worker.d.ts +11 -0
- package/dist/local-virtual-computer/detached-worker.d.ts.map +1 -0
- package/dist/local-virtual-computer/detached-worker.js +97 -0
- package/dist/local-virtual-computer/detached-worker.js.map +1 -0
- package/dist/local-virtual-computer/dialect.d.ts +26 -0
- package/dist/local-virtual-computer/dialect.d.ts.map +1 -0
- package/dist/local-virtual-computer/dialect.js +61 -0
- package/dist/local-virtual-computer/dialect.js.map +1 -0
- package/dist/local-virtual-computer/engine.d.ts +63 -0
- package/dist/local-virtual-computer/engine.d.ts.map +1 -0
- package/dist/local-virtual-computer/engine.js +125 -0
- package/dist/local-virtual-computer/engine.js.map +1 -0
- package/dist/local-virtual-computer/guest-stdio.d.ts +15 -0
- package/dist/local-virtual-computer/guest-stdio.d.ts.map +1 -0
- package/dist/local-virtual-computer/guest-stdio.js +330 -0
- package/dist/local-virtual-computer/guest-stdio.js.map +1 -0
- package/dist/local-virtual-computer/index.d.ts +36 -0
- package/dist/local-virtual-computer/index.d.ts.map +1 -0
- package/dist/local-virtual-computer/index.js +341 -0
- package/dist/local-virtual-computer/index.js.map +1 -0
- package/dist/local-virtual-computer/podman-machine.d.ts +17 -0
- package/dist/local-virtual-computer/podman-machine.d.ts.map +1 -0
- package/dist/local-virtual-computer/podman-machine.js +181 -0
- package/dist/local-virtual-computer/podman-machine.js.map +1 -0
- package/dist/local-virtual-computer/setup/contract.d.ts +360 -0
- package/dist/local-virtual-computer/setup/contract.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/contract.js +3 -0
- package/dist/local-virtual-computer/setup/contract.js.map +1 -0
- package/dist/local-virtual-computer/setup/elevated.d.ts +78 -0
- package/dist/local-virtual-computer/setup/elevated.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/elevated.js +548 -0
- package/dist/local-virtual-computer/setup/elevated.js.map +1 -0
- package/dist/local-virtual-computer/setup/host.d.ts +12 -0
- package/dist/local-virtual-computer/setup/host.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/host.js +93 -0
- package/dist/local-virtual-computer/setup/host.js.map +1 -0
- package/dist/local-virtual-computer/setup/idle.d.ts +18 -0
- package/dist/local-virtual-computer/setup/idle.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/idle.js +38 -0
- package/dist/local-virtual-computer/setup/idle.js.map +1 -0
- package/dist/local-virtual-computer/setup/image.d.ts +7 -0
- package/dist/local-virtual-computer/setup/image.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/image.js +124 -0
- package/dist/local-virtual-computer/setup/image.js.map +1 -0
- package/dist/local-virtual-computer/setup/index.d.ts +17 -0
- package/dist/local-virtual-computer/setup/index.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/index.js +17 -0
- package/dist/local-virtual-computer/setup/index.js.map +1 -0
- package/dist/local-virtual-computer/setup/limits.d.ts +30 -0
- package/dist/local-virtual-computer/setup/limits.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/limits.js +58 -0
- package/dist/local-virtual-computer/setup/limits.js.map +1 -0
- package/dist/local-virtual-computer/setup/plan.d.ts +8 -0
- package/dist/local-virtual-computer/setup/plan.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/plan.js +131 -0
- package/dist/local-virtual-computer/setup/plan.js.map +1 -0
- package/dist/local-virtual-computer/setup/readiness.d.ts +11 -0
- package/dist/local-virtual-computer/setup/readiness.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/readiness.js +320 -0
- package/dist/local-virtual-computer/setup/readiness.js.map +1 -0
- package/dist/local-virtual-computer/setup/registry.d.ts +18 -0
- package/dist/local-virtual-computer/setup/registry.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/registry.js +50 -0
- package/dist/local-virtual-computer/setup/registry.js.map +1 -0
- package/dist/local-virtual-computer/setup/restart.d.ts +8 -0
- package/dist/local-virtual-computer/setup/restart.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/restart.js +60 -0
- package/dist/local-virtual-computer/setup/restart.js.map +1 -0
- package/dist/local-virtual-computer/setup/resume.d.ts +14 -0
- package/dist/local-virtual-computer/setup/resume.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/resume.js +58 -0
- package/dist/local-virtual-computer/setup/resume.js.map +1 -0
- package/dist/local-virtual-computer/setup/run.d.ts +8 -0
- package/dist/local-virtual-computer/setup/run.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/run.js +200 -0
- package/dist/local-virtual-computer/setup/run.js.map +1 -0
- package/dist/local-virtual-computer/setup/windows-folder.d.ts +11 -0
- package/dist/local-virtual-computer/setup/windows-folder.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/windows-folder.js +14 -0
- package/dist/local-virtual-computer/setup/windows-folder.js.map +1 -0
- package/dist/local-virtual-computer/setup/wsl-package.d.ts +19 -0
- package/dist/local-virtual-computer/setup/wsl-package.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/wsl-package.js +242 -0
- package/dist/local-virtual-computer/setup/wsl-package.js.map +1 -0
- package/dist/local-virtual-computer/setup/wslc-shared.d.ts +27 -0
- package/dist/local-virtual-computer/setup/wslc-shared.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/wslc-shared.js +137 -0
- package/dist/local-virtual-computer/setup/wslc-shared.js.map +1 -0
- package/dist/local-virtual-computer/ssh-forward.d.ts +19 -0
- package/dist/local-virtual-computer/ssh-forward.d.ts.map +1 -0
- package/dist/local-virtual-computer/ssh-forward.js +179 -0
- package/dist/local-virtual-computer/ssh-forward.js.map +1 -0
- package/dist/local-virtual-computer/wslc-engine.d.ts +27 -0
- package/dist/local-virtual-computer/wslc-engine.d.ts.map +1 -0
- package/dist/local-virtual-computer/wslc-engine.js +98 -0
- package/dist/local-virtual-computer/wslc-engine.js.map +1 -0
- package/local-computer/Dockerfile +99 -0
- package/local-computer/app-catalog.cjs +240 -0
- package/local-computer/app-native-host.cjs +144 -0
- package/local-computer/browser-home.cjs +249 -0
- package/local-computer/build-image.mjs +310 -0
- package/local-computer/desktop-worker.cjs +477 -0
- package/local-computer/entrypoint.sh +161 -0
- package/local-computer/home.html +28 -0
- package/local-computer/new-tab/home.css +255 -0
- package/local-computer/new-tab/home.js +65 -0
- package/local-computer/new-tab/manifest.json +12 -0
- package/local-computer/new-tab/wordmark.svg +33 -0
- package/local-computer/openbox-rc.xml +41 -0
- package/local-computer/prepare-home.cjs +121 -0
- package/local-computer/tint2rc +44 -0
- package/package.json +13 -4
- package/src/backends/http-worker-client.ts +31 -3
- package/src/index.ts +13 -0
- package/src/local-virtual-computer/client.ts +573 -0
- package/src/local-virtual-computer/detached-process.ts +133 -0
- package/src/local-virtual-computer/detached-worker.ts +124 -0
- package/src/local-virtual-computer/dialect.ts +83 -0
- package/src/local-virtual-computer/engine.ts +197 -0
- package/src/local-virtual-computer/guest-stdio.ts +332 -0
- package/src/local-virtual-computer/index.ts +447 -0
- package/src/local-virtual-computer/podman-machine.ts +264 -0
- package/src/local-virtual-computer/setup/contract.ts +395 -0
- package/src/local-virtual-computer/setup/elevated.ts +655 -0
- package/src/local-virtual-computer/setup/host.ts +100 -0
- package/src/local-virtual-computer/setup/idle.ts +39 -0
- package/src/local-virtual-computer/setup/image.ts +159 -0
- package/src/local-virtual-computer/setup/index.ts +16 -0
- package/src/local-virtual-computer/setup/limits.ts +68 -0
- package/src/local-virtual-computer/setup/plan.ts +144 -0
- package/src/local-virtual-computer/setup/readiness.ts +386 -0
- package/src/local-virtual-computer/setup/registry.ts +50 -0
- package/src/local-virtual-computer/setup/restart.ts +64 -0
- package/src/local-virtual-computer/setup/resume.ts +65 -0
- package/src/local-virtual-computer/setup/run.ts +225 -0
- package/src/local-virtual-computer/setup/windows-folder.ts +13 -0
- package/src/local-virtual-computer/setup/wsl-package.ts +242 -0
- package/src/local-virtual-computer/setup/wslc-shared.ts +147 -0
- package/src/local-virtual-computer/ssh-forward.ts +195 -0
- package/src/local-virtual-computer/wslc-engine.ts +124 -0
- package/worker/server.js +1499 -0
|
@@ -0,0 +1,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
|
+
}
|