@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.
- package/CHANGELOG.md +290 -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,447 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
|
2
|
+
import type { PalEnvironmentLease, PalEnvironmentProvider, PalEnvironmentRequest } from '@namzu/sdk'
|
|
3
|
+
import { OperationDeadline } from '../backends/readiness.js'
|
|
4
|
+
import { localComputerClients } from './client.js'
|
|
5
|
+
import {
|
|
6
|
+
containerIdQuery,
|
|
7
|
+
ensureVolume,
|
|
8
|
+
hardeningFlags,
|
|
9
|
+
parsePublishedPorts,
|
|
10
|
+
portsQuery,
|
|
11
|
+
resourceFlags,
|
|
12
|
+
} from './dialect.js'
|
|
13
|
+
import {
|
|
14
|
+
type EngineDialect,
|
|
15
|
+
LocalCommandError,
|
|
16
|
+
type LocalComputerCommandRunner,
|
|
17
|
+
LocalComputerSetupError,
|
|
18
|
+
type LocalEngine,
|
|
19
|
+
type LocalPortForward,
|
|
20
|
+
resolveLocalEngine,
|
|
21
|
+
} from './engine.js'
|
|
22
|
+
import { resolveLocalPodmanEngine } from './podman-machine.js'
|
|
23
|
+
import { LocalForwardCleanupError } from './ssh-forward.js'
|
|
24
|
+
import { mapWslcFailure, resolveLocalWslcEngine, wslcSetupError } from './wslc-engine.js'
|
|
25
|
+
|
|
26
|
+
export type { LocalComputerCommandRunner } from './engine.js'
|
|
27
|
+
export {
|
|
28
|
+
type EngineDialect,
|
|
29
|
+
LocalComputerSetupError,
|
|
30
|
+
type LocalComputerSetupCode,
|
|
31
|
+
} from './engine.js'
|
|
32
|
+
|
|
33
|
+
export const LOCAL_COMPUTER_IMAGE = 'namzu-local-computer:1'
|
|
34
|
+
export const LOCAL_COMPUTER_PROTOCOL_LABEL = 'org.namzu.local-computer.protocol'
|
|
35
|
+
const OWNER_LABEL = 'org.namzu.pal.owner'
|
|
36
|
+
|
|
37
|
+
/** Uses an already installed local engine and an already built guest image. */
|
|
38
|
+
export interface LocalVirtualComputerOptions {
|
|
39
|
+
readonly image?: string
|
|
40
|
+
/** Foreground applications normally belong to the computer after a successful launcher. */
|
|
41
|
+
readonly normalExitPolicy?: 'strict' | 'computer-lifetime'
|
|
42
|
+
/**
|
|
43
|
+
* Docker remains the default. Podman requires an explicitly selected local
|
|
44
|
+
* Windows WSL machine. wslc is Windows' own container CLI for WSL and must be
|
|
45
|
+
* selected explicitly; it is never chosen automatically.
|
|
46
|
+
*/
|
|
47
|
+
readonly engine?: EngineDialect
|
|
48
|
+
readonly dockerBinary?: string
|
|
49
|
+
/** Path to wslc.exe; defaults to the WSL install directory, then PATH. */
|
|
50
|
+
readonly wslcBinary?: string
|
|
51
|
+
readonly podmanBinary?: string
|
|
52
|
+
readonly podmanMachine?: string
|
|
53
|
+
readonly podmanConnection?: string
|
|
54
|
+
readonly engineEndpoint?: string
|
|
55
|
+
readonly readyTimeoutMs?: number
|
|
56
|
+
readonly width?: number
|
|
57
|
+
readonly height?: number
|
|
58
|
+
/** Host transport injection for embedders/tests; contains authority, never model input. */
|
|
59
|
+
readonly runner?: LocalComputerCommandRunner
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function dimension(value: number | undefined, fallback: number): number {
|
|
63
|
+
const result = value ?? fallback
|
|
64
|
+
if (!Number.isSafeInteger(result) || result < 320 || result > 3840)
|
|
65
|
+
throw new Error('The Pal display dimensions must be integers between 320 and 3840')
|
|
66
|
+
return result
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ownerFor(palId: string): string {
|
|
70
|
+
return createHash('sha256').update(palId).digest('hex')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One readiness probe must not outlive its turn: a published-port relay can
|
|
75
|
+
* accept a connection before the guest listens and then never answer it.
|
|
76
|
+
*/
|
|
77
|
+
const READINESS_ATTEMPT_MS = 3000
|
|
78
|
+
|
|
79
|
+
async function verifyImage(
|
|
80
|
+
engine: LocalEngine,
|
|
81
|
+
image: string,
|
|
82
|
+
signal?: AbortSignal,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
let images: {
|
|
85
|
+
Os?: string
|
|
86
|
+
Config?: { Labels?: Record<string, string>; User?: string }
|
|
87
|
+
}[]
|
|
88
|
+
try {
|
|
89
|
+
images = JSON.parse(await engine.run(['image', 'inspect', image], { signal })) as typeof images
|
|
90
|
+
if (engine.dialect === 'wslc' && images.length === 0) throw wslcSetupError('image-missing')
|
|
91
|
+
} catch (error) {
|
|
92
|
+
signal?.throwIfAborted()
|
|
93
|
+
if (engine.dialect === 'wslc') {
|
|
94
|
+
if (error instanceof LocalComputerSetupError) throw error
|
|
95
|
+
// A missing image makes `image inspect` exit non-zero carrying either
|
|
96
|
+
// the not-found code or no code at all (observed on wslc 3.0.1). Any
|
|
97
|
+
// other code (an HRESULT, a spawn errno) or a non-command failure
|
|
98
|
+
// surfaces as itself, so a crash is not mistaken for missing setup.
|
|
99
|
+
const mapped = mapWslcFailure(error)
|
|
100
|
+
if (mapped) throw mapped
|
|
101
|
+
if (
|
|
102
|
+
error instanceof LocalCommandError &&
|
|
103
|
+
(error.errorCodes.length === 0 || error.errorCodes.includes('WSLC_E_IMAGE_NOT_FOUND'))
|
|
104
|
+
)
|
|
105
|
+
throw wslcSetupError('image-missing', error)
|
|
106
|
+
throw error
|
|
107
|
+
}
|
|
108
|
+
throw new LocalComputerSetupError(
|
|
109
|
+
`The ${image} image is not installed on this local engine. Build packages/sandbox/local-computer/Dockerfile explicitly; Namzu does not pull or build it automatically.`,
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
if (
|
|
113
|
+
images[0]?.Os !== 'linux' ||
|
|
114
|
+
images[0]?.Config?.Labels?.[LOCAL_COMPUTER_PROTOCOL_LABEL] !== '1' ||
|
|
115
|
+
images[0]?.Config?.User !== '1001:1001'
|
|
116
|
+
)
|
|
117
|
+
throw new LocalComputerSetupError(
|
|
118
|
+
'The local Pal computer image must declare the Namzu desktop protocol and non-root guest user; rebuild the shipped Dockerfile',
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function portFor(
|
|
123
|
+
ports: Record<string, { HostIp?: string; HostPort?: string }[]>,
|
|
124
|
+
port: number,
|
|
125
|
+
): number {
|
|
126
|
+
const mappings = ports[`${port}/tcp`]
|
|
127
|
+
if (!mappings || mappings.length !== 1 || mappings[0]?.HostIp !== '127.0.0.1')
|
|
128
|
+
throw new Error('The Pal computer control port must be published on IPv4 loopback only')
|
|
129
|
+
const result = Number(mappings[0].HostPort)
|
|
130
|
+
if (!Number.isSafeInteger(result) || result < 1 || result > 65535)
|
|
131
|
+
throw new Error('The Pal computer control port was not allocated')
|
|
132
|
+
return result
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A separate guest desktop in a local Linux container. It does not provision
|
|
137
|
+
* a dedicated-kernel VM and never falls back to the operator's desktop.
|
|
138
|
+
*/
|
|
139
|
+
export function createLocalVirtualComputerProvider(
|
|
140
|
+
options: LocalVirtualComputerOptions = {},
|
|
141
|
+
): PalEnvironmentProvider {
|
|
142
|
+
if (
|
|
143
|
+
options.engine !== undefined &&
|
|
144
|
+
options.engine !== 'docker' &&
|
|
145
|
+
options.engine !== 'podman' &&
|
|
146
|
+
options.engine !== 'wslc'
|
|
147
|
+
)
|
|
148
|
+
throw new Error('Choose the Docker, Podman or wslc local computer engine')
|
|
149
|
+
const image = options.image ?? LOCAL_COMPUTER_IMAGE
|
|
150
|
+
const normalExitPolicy = options.normalExitPolicy ?? 'computer-lifetime'
|
|
151
|
+
if (normalExitPolicy !== 'strict' && normalExitPolicy !== 'computer-lifetime')
|
|
152
|
+
throw new Error('Choose strict or computer-lifetime foreground execution ownership')
|
|
153
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_./:@-]*$/.test(image))
|
|
154
|
+
throw new Error('Invalid local Pal image reference')
|
|
155
|
+
const width = dimension(options.width, 1280)
|
|
156
|
+
const height = dimension(options.height, 800)
|
|
157
|
+
const timeout = options.readyTimeoutMs ?? 60_000
|
|
158
|
+
if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 2_147_483_647)
|
|
159
|
+
throw new Error('Invalid Pal computer readiness timeout')
|
|
160
|
+
const holders = new Set<string>()
|
|
161
|
+
const pendingCleanup = new Map<string, (signal?: AbortSignal) => Promise<void>>()
|
|
162
|
+
let generation = 0
|
|
163
|
+
const prepare = async (signal?: AbortSignal) => {
|
|
164
|
+
const engine =
|
|
165
|
+
options.engine === 'podman'
|
|
166
|
+
? await resolveLocalPodmanEngine(options, signal)
|
|
167
|
+
: options.engine === 'wslc'
|
|
168
|
+
? await resolveLocalWslcEngine(options, signal)
|
|
169
|
+
: await resolveLocalEngine(options, signal)
|
|
170
|
+
await verifyImage(engine, image, signal)
|
|
171
|
+
return engine
|
|
172
|
+
}
|
|
173
|
+
const dialectOf = (engine: LocalEngine): EngineDialect =>
|
|
174
|
+
engine.dialect ?? options.engine ?? 'docker'
|
|
175
|
+
return {
|
|
176
|
+
async probe() {
|
|
177
|
+
try {
|
|
178
|
+
await new OperationDeadline(timeout, 'local Pal computer probe').run(
|
|
179
|
+
async (signal) => await prepare(signal),
|
|
180
|
+
)
|
|
181
|
+
return { ready: true }
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return {
|
|
184
|
+
ready: false,
|
|
185
|
+
// The code lets an application choose its words by what failed, not
|
|
186
|
+
// by matching the message. A failure that is not a setup error (a
|
|
187
|
+
// timeout, say) carries no code: it is not known to be fixable by setup.
|
|
188
|
+
setupCode: error instanceof LocalComputerSetupError ? error.code : undefined,
|
|
189
|
+
reason:
|
|
190
|
+
error instanceof LocalComputerSetupError
|
|
191
|
+
? error.message
|
|
192
|
+
: options.engine === 'wslc'
|
|
193
|
+
? 'Linux container support and the Pal computer image are required. Namzu sets them up; finish the setup from the Pal computer and try again.'
|
|
194
|
+
: `A local ${options.engine === 'podman' ? 'Podman machine' : 'Docker engine'} running Linux containers and the ${image} image are required. Build packages/sandbox/local-computer/Dockerfile explicitly; Namzu does not install or pull it automatically.`,
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
async acquire(request: PalEnvironmentRequest): Promise<PalEnvironmentLease> {
|
|
199
|
+
request.signal?.throwIfAborted()
|
|
200
|
+
if (request.pal.paused) throw new Error('This Pal is paused')
|
|
201
|
+
if (!request.pal.id || !request.conversationId)
|
|
202
|
+
throw new Error('A Pal and conversation identity are required')
|
|
203
|
+
const owner = ownerFor(request.pal.id)
|
|
204
|
+
if (holders.has(owner)) {
|
|
205
|
+
const cleanup = pendingCleanup.get(owner)
|
|
206
|
+
if (!cleanup) throw new Error('This Pal computer is already in use')
|
|
207
|
+
try {
|
|
208
|
+
await new OperationDeadline(timeout, 'local Pal computer recovery', request.signal).run(
|
|
209
|
+
async (signal) => await cleanup(signal),
|
|
210
|
+
)
|
|
211
|
+
} catch {
|
|
212
|
+
throw new Error(
|
|
213
|
+
'This Pal computer is already in use by an unconfirmed allocation; cleanup recovery is still required',
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
pendingCleanup.delete(owner)
|
|
217
|
+
holders.delete(owner)
|
|
218
|
+
}
|
|
219
|
+
holders.add(owner)
|
|
220
|
+
const name = `namzu-pal-${owner.slice(0, 24)}`
|
|
221
|
+
const volume = `namzu-pal-data-${owner}`
|
|
222
|
+
const environmentId = `local-container:${owner}`
|
|
223
|
+
const token = randomBytes(32).toString('base64url')
|
|
224
|
+
const allocationId = randomUUID()
|
|
225
|
+
generation = Math.max(Date.now(), generation + 1)
|
|
226
|
+
const leaseGeneration = generation
|
|
227
|
+
let engine: LocalEngine | undefined
|
|
228
|
+
let created = false
|
|
229
|
+
let runAttempted = false
|
|
230
|
+
let containerId: string | undefined
|
|
231
|
+
let forwarding: LocalPortForward | undefined
|
|
232
|
+
let clients: ReturnType<typeof localComputerClients> | undefined
|
|
233
|
+
const stop = async (signal?: AbortSignal) => {
|
|
234
|
+
if (!engine) return
|
|
235
|
+
// Only the name we successfully created is removed. No volume is
|
|
236
|
+
// deleted; profile/files survive the next allocation.
|
|
237
|
+
if (created) {
|
|
238
|
+
// Commit the browser profile before removing this allocation.
|
|
239
|
+
// Forced removal remains the recovery path if graceful stop fails.
|
|
240
|
+
try {
|
|
241
|
+
await engine.run(['stop', '--time', '10', containerId ?? name], { signal })
|
|
242
|
+
} catch {
|
|
243
|
+
signal?.throwIfAborted()
|
|
244
|
+
}
|
|
245
|
+
await engine.run(['rm', '-f', containerId ?? name], { signal })
|
|
246
|
+
created = false
|
|
247
|
+
}
|
|
248
|
+
await forwarding?.close()
|
|
249
|
+
holders.delete(owner)
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const deadline = new OperationDeadline(
|
|
253
|
+
timeout,
|
|
254
|
+
'local Pal computer startup',
|
|
255
|
+
request.signal,
|
|
256
|
+
)
|
|
257
|
+
const connected = await deadline.run(async (signal) => {
|
|
258
|
+
engine = await prepare(signal)
|
|
259
|
+
const dialect = dialectOf(engine)
|
|
260
|
+
const occupied = await engine.run(
|
|
261
|
+
containerIdQuery(dialect, `name=^/${name}$`, { full: false }),
|
|
262
|
+
{ signal },
|
|
263
|
+
)
|
|
264
|
+
if (occupied.trim())
|
|
265
|
+
throw new Error(
|
|
266
|
+
'An earlier Pal computer allocation still exists; stop it explicitly before reconnecting',
|
|
267
|
+
)
|
|
268
|
+
await ensureVolume(engine, dialect, volume, `${OWNER_LABEL}=${owner}`, signal)
|
|
269
|
+
const volumes = JSON.parse(
|
|
270
|
+
await engine.run(['volume', 'inspect', volume], { signal }),
|
|
271
|
+
) as { Labels?: Record<string, string> }[]
|
|
272
|
+
if (volumes[0]?.Labels?.[OWNER_LABEL] !== owner)
|
|
273
|
+
throw new Error('The Pal data volume belongs to a different owner')
|
|
274
|
+
runAttempted = true
|
|
275
|
+
const started = await engine.run(
|
|
276
|
+
[
|
|
277
|
+
'run',
|
|
278
|
+
'--detach',
|
|
279
|
+
'--pull=never',
|
|
280
|
+
// WSL machines can lack delegated cgroup controllers.
|
|
281
|
+
// This adapter provides no per-container resource quotas.
|
|
282
|
+
...resourceFlags(dialect),
|
|
283
|
+
'--name',
|
|
284
|
+
name,
|
|
285
|
+
'--label',
|
|
286
|
+
`${OWNER_LABEL}=${owner}`,
|
|
287
|
+
'--label',
|
|
288
|
+
`org.namzu.pal.generation=${leaseGeneration}`,
|
|
289
|
+
'--label',
|
|
290
|
+
`org.namzu.pal.allocation=${allocationId}`,
|
|
291
|
+
'--user',
|
|
292
|
+
'1001:1001',
|
|
293
|
+
...hardeningFlags(dialect),
|
|
294
|
+
'--network=bridge',
|
|
295
|
+
'--shm-size=256m',
|
|
296
|
+
'--tmpfs',
|
|
297
|
+
'/tmp:rw,nosuid,nodev,size=512m,mode=1777',
|
|
298
|
+
'--tmpfs',
|
|
299
|
+
'/run:rw,nosuid,nodev,size=16m,mode=1777',
|
|
300
|
+
'--mount',
|
|
301
|
+
`type=volume,source=${volume},target=/home/namzu`,
|
|
302
|
+
'--publish',
|
|
303
|
+
'127.0.0.1::2024',
|
|
304
|
+
'--publish',
|
|
305
|
+
'127.0.0.1::2025',
|
|
306
|
+
'--env',
|
|
307
|
+
`NAMZU_SANDBOX_SCREEN_WIDTH=${width}`,
|
|
308
|
+
'--env',
|
|
309
|
+
`NAMZU_SANDBOX_SCREEN_HEIGHT=${height}`,
|
|
310
|
+
'--env',
|
|
311
|
+
'NAMZU_SANDBOX_TOKEN',
|
|
312
|
+
image,
|
|
313
|
+
],
|
|
314
|
+
{ signal, env: { NAMZU_SANDBOX_TOKEN: token } },
|
|
315
|
+
)
|
|
316
|
+
created = true
|
|
317
|
+
containerId = started.trim()
|
|
318
|
+
if (!/^[0-9a-f]{64}$/.test(containerId))
|
|
319
|
+
throw new Error('The Pal computer engine did not return a valid allocation identifier')
|
|
320
|
+
const ports = parsePublishedPorts(
|
|
321
|
+
dialect,
|
|
322
|
+
await engine.run(portsQuery(dialect, name), { signal }),
|
|
323
|
+
)
|
|
324
|
+
const guestPorts = [portFor(ports, 2024), portFor(ports, 2025)]
|
|
325
|
+
forwarding = await engine.forwardPorts?.(guestPorts, {
|
|
326
|
+
signal,
|
|
327
|
+
onLost() {
|
|
328
|
+
void clients?.sandbox.destroy().catch(() => {
|
|
329
|
+
// Destroy fences calls first; its retained lease can retry recovery.
|
|
330
|
+
})
|
|
331
|
+
},
|
|
332
|
+
})
|
|
333
|
+
const localPorts = forwarding?.ports ?? guestPorts
|
|
334
|
+
const executionUrl = `http://127.0.0.1:${localPorts[0]}`
|
|
335
|
+
const desktopUrl = `http://127.0.0.1:${localPorts[1]}`
|
|
336
|
+
for (;;) {
|
|
337
|
+
signal.throwIfAborted()
|
|
338
|
+
forwarding?.assertAlive()
|
|
339
|
+
const attempt = new AbortController()
|
|
340
|
+
const attemptTimer = setTimeout(() => attempt.abort(), READINESS_ATTEMPT_MS)
|
|
341
|
+
const attemptSignal = AbortSignal.any([signal, attempt.signal])
|
|
342
|
+
try {
|
|
343
|
+
const response = await fetch(`${desktopUrl}/readyz`, {
|
|
344
|
+
headers: { authorization: `Bearer ${token}` },
|
|
345
|
+
signal: attemptSignal,
|
|
346
|
+
})
|
|
347
|
+
const desktop = response.ok
|
|
348
|
+
? ((await response.json()) as {
|
|
349
|
+
protocol?: number
|
|
350
|
+
width?: number
|
|
351
|
+
height?: number
|
|
352
|
+
browserReady?: boolean
|
|
353
|
+
stream?: { protocol?: unknown }
|
|
354
|
+
heldKeyboard?: { version?: unknown }
|
|
355
|
+
})
|
|
356
|
+
: undefined
|
|
357
|
+
const execution = await fetch(`${executionUrl}/healthz`, { signal: attemptSignal })
|
|
358
|
+
const worker = execution.ok
|
|
359
|
+
? ((await execution.json()) as { protocolVersion?: number })
|
|
360
|
+
: undefined
|
|
361
|
+
if (
|
|
362
|
+
desktop?.protocol === 1 &&
|
|
363
|
+
desktop.width === width &&
|
|
364
|
+
desktop.height === height &&
|
|
365
|
+
desktop.browserReady === true &&
|
|
366
|
+
worker?.protocolVersion === 2
|
|
367
|
+
)
|
|
368
|
+
return {
|
|
369
|
+
executionUrl,
|
|
370
|
+
desktopUrl,
|
|
371
|
+
screenStream: desktop.stream?.protocol === 'rfb',
|
|
372
|
+
heldKeyboard: desktop.heldKeyboard?.version === 1,
|
|
373
|
+
}
|
|
374
|
+
} catch {
|
|
375
|
+
signal.throwIfAborted()
|
|
376
|
+
} finally {
|
|
377
|
+
clearTimeout(attemptTimer)
|
|
378
|
+
}
|
|
379
|
+
await new Promise<void>((resolve, reject) => {
|
|
380
|
+
const timer = setTimeout(() => {
|
|
381
|
+
signal.removeEventListener('abort', abort)
|
|
382
|
+
resolve()
|
|
383
|
+
}, 200)
|
|
384
|
+
const abort = () => {
|
|
385
|
+
clearTimeout(timer)
|
|
386
|
+
reject(signal.reason)
|
|
387
|
+
}
|
|
388
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
request.signal?.throwIfAborted()
|
|
393
|
+
clients = localComputerClients({
|
|
394
|
+
...connected,
|
|
395
|
+
normalExitPolicy,
|
|
396
|
+
token,
|
|
397
|
+
geometry: { width, height, scaleFactor: 1 },
|
|
398
|
+
stop,
|
|
399
|
+
})
|
|
400
|
+
const admitted = clients
|
|
401
|
+
return {
|
|
402
|
+
palId: request.pal.id,
|
|
403
|
+
environmentId,
|
|
404
|
+
generation: leaseGeneration,
|
|
405
|
+
...admitted,
|
|
406
|
+
release: () => admitted.sandbox.destroy(),
|
|
407
|
+
}
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if (error instanceof LocalForwardCleanupError) forwarding = error.forward
|
|
410
|
+
if (runAttempted && engine) {
|
|
411
|
+
const ownedEngine = engine
|
|
412
|
+
const cleanup = async (signal?: AbortSignal) => {
|
|
413
|
+
// Reconcile an unacknowledged start by this attempt's unique
|
|
414
|
+
// label, never by a name another process may have acquired.
|
|
415
|
+
const owned = await ownedEngine.run(
|
|
416
|
+
containerIdQuery(
|
|
417
|
+
dialectOf(ownedEngine),
|
|
418
|
+
`label=org.namzu.pal.allocation=${allocationId}`,
|
|
419
|
+
{ full: true },
|
|
420
|
+
),
|
|
421
|
+
{ signal },
|
|
422
|
+
)
|
|
423
|
+
for (const id of owned.trim().split(/\s+/).filter(Boolean)) {
|
|
424
|
+
if (!/^[0-9a-f]{64}$/.test(id)) throw new Error('Invalid owned allocation identifier')
|
|
425
|
+
await ownedEngine.run(['rm', '-f', id], { signal })
|
|
426
|
+
}
|
|
427
|
+
created = false
|
|
428
|
+
await forwarding?.close()
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
await new OperationDeadline(timeout, 'local Pal computer cleanup').run(
|
|
432
|
+
async (signal) => await cleanup(signal),
|
|
433
|
+
)
|
|
434
|
+
} catch {
|
|
435
|
+
pendingCleanup.set(owner, cleanup)
|
|
436
|
+
throw new Error(
|
|
437
|
+
'Pal computer startup failed and cleanup could not be confirmed; the owned allocation requires operator recovery',
|
|
438
|
+
{ cause: error },
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
holders.delete(owner)
|
|
443
|
+
throw error
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
}
|
|
447
|
+
}
|