@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
|
@@ -125,6 +125,7 @@ async function readExecution(
|
|
|
125
125
|
argv: string[] | undefined,
|
|
126
126
|
opts: SandboxExecOptions | undefined,
|
|
127
127
|
transportSignal: AbortSignal,
|
|
128
|
+
normalExitPolicy: 'strict' | 'computer-lifetime',
|
|
128
129
|
): Promise<SandboxExecResult> {
|
|
129
130
|
let response: Response
|
|
130
131
|
try {
|
|
@@ -134,6 +135,7 @@ async function readExecution(
|
|
|
134
135
|
signal: transportSignal,
|
|
135
136
|
body: JSON.stringify({
|
|
136
137
|
...(executionId ? { executionId } : {}),
|
|
138
|
+
...(normalExitPolicy === 'computer-lifetime' ? { normalExitPolicy } : {}),
|
|
137
139
|
command,
|
|
138
140
|
args: argv ?? [],
|
|
139
141
|
cwd: opts?.cwd,
|
|
@@ -243,7 +245,12 @@ async function readExecution(
|
|
|
243
245
|
export class HttpWorkerClient {
|
|
244
246
|
private readonly controller: RemoteExecutionController
|
|
245
247
|
|
|
246
|
-
constructor(
|
|
248
|
+
constructor(
|
|
249
|
+
baseUrl: string,
|
|
250
|
+
token?: string,
|
|
251
|
+
/** Host-owned foreground policy; registered background jobs keep strict ownership. */
|
|
252
|
+
normalExitPolicy: 'strict' | 'computer-lifetime' = 'strict',
|
|
253
|
+
) {
|
|
247
254
|
const adapter: RemoteExecutionAdapter = {
|
|
248
255
|
label: 'HTTP worker',
|
|
249
256
|
reserve: async (signal) => {
|
|
@@ -268,7 +275,19 @@ export class HttpWorkerClient {
|
|
|
268
275
|
`execution reservation failed: HTTP ${response.status} ${await response.text()}`,
|
|
269
276
|
)
|
|
270
277
|
}
|
|
271
|
-
|
|
278
|
+
const reservation: unknown = await response.json()
|
|
279
|
+
if (
|
|
280
|
+
normalExitPolicy === 'computer-lifetime' &&
|
|
281
|
+
(!reservation ||
|
|
282
|
+
typeof reservation !== 'object' ||
|
|
283
|
+
!('normalExitPolicy' in reservation) ||
|
|
284
|
+
reservation.normalExitPolicy !== 'computer-lifetime')
|
|
285
|
+
) {
|
|
286
|
+
throw new RemoteProtocolError(
|
|
287
|
+
'The local Pal computer worker does not support computer-owned applications. Rebuild the local Pal computer image from the same Namzu release before running commands.',
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
return reservation
|
|
272
291
|
},
|
|
273
292
|
cancel: async (executionId, signal) => {
|
|
274
293
|
const response = await fetch(`${baseUrl}/cancel`, {
|
|
@@ -283,7 +302,16 @@ export class HttpWorkerClient {
|
|
|
283
302
|
return await response.json()
|
|
284
303
|
},
|
|
285
304
|
execute: async (executionId, command, argv, opts, signal) =>
|
|
286
|
-
await readExecution(
|
|
305
|
+
await readExecution(
|
|
306
|
+
baseUrl,
|
|
307
|
+
token,
|
|
308
|
+
executionId,
|
|
309
|
+
command,
|
|
310
|
+
argv,
|
|
311
|
+
opts,
|
|
312
|
+
signal,
|
|
313
|
+
normalExitPolicy,
|
|
314
|
+
),
|
|
287
315
|
}
|
|
288
316
|
this.controller = new RemoteExecutionController(adapter)
|
|
289
317
|
}
|
package/src/index.ts
CHANGED
|
@@ -59,6 +59,19 @@ import type {
|
|
|
59
59
|
SandboxProvider,
|
|
60
60
|
} from '@namzu/sdk'
|
|
61
61
|
|
|
62
|
+
export {
|
|
63
|
+
LOCAL_COMPUTER_IMAGE,
|
|
64
|
+
LOCAL_COMPUTER_PROTOCOL_LABEL,
|
|
65
|
+
LocalComputerSetupError,
|
|
66
|
+
createLocalVirtualComputerProvider,
|
|
67
|
+
} from './local-virtual-computer/index.js'
|
|
68
|
+
export type {
|
|
69
|
+
EngineDialect,
|
|
70
|
+
LocalComputerCommandRunner,
|
|
71
|
+
LocalComputerSetupCode,
|
|
72
|
+
LocalVirtualComputerOptions,
|
|
73
|
+
} from './local-virtual-computer/index.js'
|
|
74
|
+
|
|
62
75
|
import { buildAciStandbyPoolBackend } from './backends/aci-standby-pool/index.js'
|
|
63
76
|
import { buildDockerBackend, resolveLayout } from './backends/docker/index.js'
|
|
64
77
|
import { buildFirecrackerBackend } from './backends/firecracker/index.js'
|
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComputerUseAction,
|
|
3
|
+
ComputerUseHost,
|
|
4
|
+
ComputerUseResult,
|
|
5
|
+
DisplayGeometry,
|
|
6
|
+
PalComputerControl,
|
|
7
|
+
PalComputerInput,
|
|
8
|
+
PalComputerScreenStream,
|
|
9
|
+
Sandbox,
|
|
10
|
+
SandboxDestroyOptions,
|
|
11
|
+
SandboxReadFileOptions,
|
|
12
|
+
SandboxStdioOptions,
|
|
13
|
+
} from '@namzu/sdk'
|
|
14
|
+
import { generateSandboxId, walkFilesViaExec } from '@namzu/sdk'
|
|
15
|
+
import { HttpWorkerClient, workerAuthorization } from '../backends/http-worker-client.js'
|
|
16
|
+
import { RemoteCancellationUnknownError } from '../backends/remote-execution-controller.js'
|
|
17
|
+
import { type OwnedDetachedProcess, startDetachedGuestProcess } from './detached-process.js'
|
|
18
|
+
import { type OwnedGuestStdioChannel, openGuestStdio } from './guest-stdio.js'
|
|
19
|
+
|
|
20
|
+
export interface LocalComputerClientOptions {
|
|
21
|
+
readonly executionUrl: string
|
|
22
|
+
readonly desktopUrl: string
|
|
23
|
+
readonly token: string
|
|
24
|
+
readonly normalExitPolicy?: 'strict' | 'computer-lifetime'
|
|
25
|
+
readonly geometry: DisplayGeometry
|
|
26
|
+
readonly stop: (signal?: AbortSignal) => Promise<void>
|
|
27
|
+
readonly detachedWorkerPath?: string
|
|
28
|
+
/** Set only after the actual guest readiness response advertises RFB. */
|
|
29
|
+
readonly screenStream?: boolean
|
|
30
|
+
/** Set only after the owned guest advertises session-scoped held keys. */
|
|
31
|
+
readonly heldKeyboard?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function localComputerClients(options: LocalComputerClientOptions): {
|
|
35
|
+
sandbox: Sandbox
|
|
36
|
+
computerUseHost: ComputerUseHost
|
|
37
|
+
operatorControl: PalComputerControl
|
|
38
|
+
screenStream?: PalComputerScreenStream
|
|
39
|
+
} {
|
|
40
|
+
let active = true
|
|
41
|
+
let busy = 0
|
|
42
|
+
let mode: PalComputerControl['mode'] = 'pal'
|
|
43
|
+
let uncertain = false
|
|
44
|
+
let screenEpoch = 0
|
|
45
|
+
let needsFreshScreen = false
|
|
46
|
+
let stopping: Promise<void> | undefined
|
|
47
|
+
const detached = new Set<OwnedDetachedProcess>()
|
|
48
|
+
const stdio = new Set<OwnedGuestStdioChannel>()
|
|
49
|
+
const heldKeyboards = new Map<string, Set<string>>()
|
|
50
|
+
const assertActive = () => {
|
|
51
|
+
if (!active) throw new Error('This Pal computer lease has ended')
|
|
52
|
+
}
|
|
53
|
+
const assertCertain = () => {
|
|
54
|
+
if (uncertain)
|
|
55
|
+
throw new Error(
|
|
56
|
+
'The Pal computer operation outcome is unknown. Stop this computer before retrying.',
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
const assertPal = () => {
|
|
60
|
+
assertActive()
|
|
61
|
+
assertCertain()
|
|
62
|
+
if (mode !== 'pal') throw new Error('The operator has control of this Pal computer')
|
|
63
|
+
}
|
|
64
|
+
const stop = (destroyOptions?: SandboxDestroyOptions): Promise<void> => {
|
|
65
|
+
active = false
|
|
66
|
+
if (stopping) return stopping
|
|
67
|
+
stopping = options
|
|
68
|
+
.stop(destroyOptions?.signal)
|
|
69
|
+
.then(async () => {
|
|
70
|
+
for (const channel of stdio) channel.allocationEnded()
|
|
71
|
+
stdio.clear()
|
|
72
|
+
// Guest removal is confirmed first. These pipes may now end without
|
|
73
|
+
// being mistaken for proof that a guest process was stopped.
|
|
74
|
+
for (const process of detached) process.child.kill('SIGKILL')
|
|
75
|
+
await Promise.all([...detached].map((process) => process.closed))
|
|
76
|
+
})
|
|
77
|
+
.catch((error: unknown) => {
|
|
78
|
+
stopping = undefined
|
|
79
|
+
throw error
|
|
80
|
+
})
|
|
81
|
+
return stopping
|
|
82
|
+
}
|
|
83
|
+
const headers = {
|
|
84
|
+
'content-type': 'application/json',
|
|
85
|
+
...workerAuthorization(options.token),
|
|
86
|
+
}
|
|
87
|
+
// A successfully completed foreground launcher may leave applications owned
|
|
88
|
+
// by this exclusive computer. Detached jobs use their own strict client.
|
|
89
|
+
const worker = new HttpWorkerClient(
|
|
90
|
+
options.executionUrl,
|
|
91
|
+
options.token,
|
|
92
|
+
options.normalExitPolicy ?? 'computer-lifetime',
|
|
93
|
+
)
|
|
94
|
+
const request = async (route: string, body: unknown, signal?: AbortSignal) => {
|
|
95
|
+
assertPal()
|
|
96
|
+
busy += 1
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetch(`${options.executionUrl}${route}`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers,
|
|
101
|
+
body: JSON.stringify(body),
|
|
102
|
+
signal,
|
|
103
|
+
})
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
if (route === '/write-file' && response.status >= 500) uncertain = true
|
|
106
|
+
throw Object.assign(
|
|
107
|
+
new Error(`The Pal file worker refused the operation (${response.status})`),
|
|
108
|
+
{ refused: true },
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
const result = (await response.json()) as Record<string, unknown>
|
|
112
|
+
if (route === '/write-file' && result.ok !== true) uncertain = true
|
|
113
|
+
return result
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (route === '/write-file' && !(error && typeof error === 'object' && 'refused' in error))
|
|
116
|
+
uncertain = true
|
|
117
|
+
throw error
|
|
118
|
+
} finally {
|
|
119
|
+
busy -= 1
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
let rangeLimit: number | undefined
|
|
123
|
+
const requireRangeSupport = async (signal?: AbortSignal) => {
|
|
124
|
+
if (rangeLimit !== undefined) return rangeLimit
|
|
125
|
+
let result: Record<string, unknown>
|
|
126
|
+
try {
|
|
127
|
+
result = await request('/read-file', { capabilitiesOnly: true }, signal)
|
|
128
|
+
} catch (error) {
|
|
129
|
+
signal?.throwIfAborted()
|
|
130
|
+
throw new Error(
|
|
131
|
+
'This Pal computer worker did not confirm bounded file reads. Rebuild the local Pal image from the same Namzu release before inspecting artifact images.',
|
|
132
|
+
{ cause: error },
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
const capability = result.readFileRanges
|
|
136
|
+
if (
|
|
137
|
+
result.ok !== true ||
|
|
138
|
+
!capability ||
|
|
139
|
+
typeof capability !== 'object' ||
|
|
140
|
+
!('version' in capability) ||
|
|
141
|
+
capability.version !== 1 ||
|
|
142
|
+
!('maxBytes' in capability) ||
|
|
143
|
+
typeof capability.maxBytes !== 'number' ||
|
|
144
|
+
!Number.isSafeInteger(capability.maxBytes) ||
|
|
145
|
+
capability.maxBytes < 1 ||
|
|
146
|
+
capability.maxBytes > 32 * 1024 * 1024
|
|
147
|
+
)
|
|
148
|
+
throw new Error('This Pal computer worker does not acknowledge bounded file reads.')
|
|
149
|
+
rangeLimit = capability.maxBytes
|
|
150
|
+
return rangeLimit
|
|
151
|
+
}
|
|
152
|
+
const sandbox: Sandbox = {
|
|
153
|
+
id: generateSandboxId(),
|
|
154
|
+
environment: 'linux-namespace',
|
|
155
|
+
rootDir: '/home/namzu/workspace',
|
|
156
|
+
get status() {
|
|
157
|
+
return !active ? 'destroyed' : busy > 0 || uncertain ? 'busy' : 'ready'
|
|
158
|
+
},
|
|
159
|
+
async openStdio(command, args = [], spawnOptions?: SandboxStdioOptions) {
|
|
160
|
+
const acquire = async (signal?: AbortSignal) => {
|
|
161
|
+
signal?.throwIfAborted()
|
|
162
|
+
assertPal()
|
|
163
|
+
busy += 1
|
|
164
|
+
let released = false
|
|
165
|
+
const release = () => {
|
|
166
|
+
if (!released) {
|
|
167
|
+
released = true
|
|
168
|
+
busy -= 1
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
await spawnOptions?.assertExecutionAllowed?.()
|
|
173
|
+
signal?.throwIfAborted()
|
|
174
|
+
assertPal()
|
|
175
|
+
return release
|
|
176
|
+
} catch (error) {
|
|
177
|
+
release()
|
|
178
|
+
throw error
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const release = await acquire(spawnOptions?.signal)
|
|
182
|
+
try {
|
|
183
|
+
const channel = await openGuestStdio(
|
|
184
|
+
{
|
|
185
|
+
executionUrl: options.executionUrl,
|
|
186
|
+
token: options.token,
|
|
187
|
+
acquire,
|
|
188
|
+
uncertain: () => {
|
|
189
|
+
uncertain = true
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
command,
|
|
193
|
+
args,
|
|
194
|
+
{
|
|
195
|
+
...spawnOptions,
|
|
196
|
+
assertExecutionAllowed: async () => {
|
|
197
|
+
await spawnOptions?.assertExecutionAllowed?.()
|
|
198
|
+
assertPal()
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
stdio.add(channel)
|
|
203
|
+
void channel.closed.then(
|
|
204
|
+
() => stdio.delete(channel),
|
|
205
|
+
() => stdio.delete(channel),
|
|
206
|
+
)
|
|
207
|
+
return channel
|
|
208
|
+
} finally {
|
|
209
|
+
release()
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
spawnDetached(command, args, spawnOptions) {
|
|
213
|
+
assertPal()
|
|
214
|
+
const process = startDetachedGuestProcess(
|
|
215
|
+
{
|
|
216
|
+
executionUrl: options.executionUrl,
|
|
217
|
+
token: options.token,
|
|
218
|
+
retire: stop,
|
|
219
|
+
workerPath: options.detachedWorkerPath,
|
|
220
|
+
},
|
|
221
|
+
command,
|
|
222
|
+
args,
|
|
223
|
+
spawnOptions,
|
|
224
|
+
)
|
|
225
|
+
detached.add(process)
|
|
226
|
+
void process.closed.then(() => detached.delete(process))
|
|
227
|
+
return process
|
|
228
|
+
},
|
|
229
|
+
async exec(command, args, opts) {
|
|
230
|
+
assertPal()
|
|
231
|
+
busy += 1
|
|
232
|
+
try {
|
|
233
|
+
return await worker.exec(command, args, opts)
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error instanceof RemoteCancellationUnknownError) {
|
|
236
|
+
try {
|
|
237
|
+
await stop()
|
|
238
|
+
error.retirement = { accepted: true }
|
|
239
|
+
} catch (retirementError) {
|
|
240
|
+
error.retirement = {
|
|
241
|
+
accepted: false,
|
|
242
|
+
error:
|
|
243
|
+
retirementError instanceof Error
|
|
244
|
+
? retirementError
|
|
245
|
+
: new Error('Computer retirement failed'),
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
throw error
|
|
250
|
+
} finally {
|
|
251
|
+
busy -= 1
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
async writeFile(path, content) {
|
|
255
|
+
const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content)
|
|
256
|
+
const result = await request('/write-file', {
|
|
257
|
+
path,
|
|
258
|
+
content: bytes.toString('base64'),
|
|
259
|
+
encoding: 'base64',
|
|
260
|
+
})
|
|
261
|
+
if (result.ok !== true) throw new Error('The Pal file worker did not confirm the write')
|
|
262
|
+
},
|
|
263
|
+
async readFile(path, readOptions?: SandboxReadFileOptions) {
|
|
264
|
+
readOptions?.signal?.throwIfAborted()
|
|
265
|
+
assertPal()
|
|
266
|
+
const ranged = readOptions?.offset !== undefined || readOptions?.length !== undefined
|
|
267
|
+
const offset = readOptions?.offset ?? 0
|
|
268
|
+
const length = readOptions?.length
|
|
269
|
+
if (
|
|
270
|
+
ranged &&
|
|
271
|
+
(!Number.isSafeInteger(offset) ||
|
|
272
|
+
offset < 0 ||
|
|
273
|
+
(length !== undefined &&
|
|
274
|
+
(!Number.isSafeInteger(length) || length < 0 || length > 32 * 1024 * 1024)))
|
|
275
|
+
)
|
|
276
|
+
throw new Error('Invalid or oversized Pal file read range')
|
|
277
|
+
const limit = ranged ? await requireRangeSupport(readOptions?.signal) : undefined
|
|
278
|
+
if (length !== undefined && limit !== undefined && length > limit)
|
|
279
|
+
throw new Error('The requested file range exceeds this Pal worker’s acknowledged limit')
|
|
280
|
+
const range = {
|
|
281
|
+
version: 1,
|
|
282
|
+
offset,
|
|
283
|
+
...(length === undefined ? {} : { length }),
|
|
284
|
+
}
|
|
285
|
+
const result = await request(
|
|
286
|
+
'/read-file',
|
|
287
|
+
{ path, encoding: 'base64', ...(ranged ? { range } : {}) },
|
|
288
|
+
readOptions?.signal,
|
|
289
|
+
)
|
|
290
|
+
if (result.ok !== true || typeof result.content !== 'string')
|
|
291
|
+
throw new Error('The Pal file worker did not return a file')
|
|
292
|
+
if (ranged) {
|
|
293
|
+
const received = result.range
|
|
294
|
+
if (
|
|
295
|
+
!received ||
|
|
296
|
+
typeof received !== 'object' ||
|
|
297
|
+
!('version' in received) ||
|
|
298
|
+
received.version !== 1 ||
|
|
299
|
+
!('offset' in received) ||
|
|
300
|
+
received.offset !== offset ||
|
|
301
|
+
!('length' in received) ||
|
|
302
|
+
received.length !== (length ?? null)
|
|
303
|
+
)
|
|
304
|
+
throw new Error('The Pal file worker did not confirm the requested byte range')
|
|
305
|
+
const maximum = length ?? limit ?? 0
|
|
306
|
+
if (result.content.length > Math.ceil(maximum / 3) * 4)
|
|
307
|
+
throw new Error('The Pal file worker exceeded the requested byte range')
|
|
308
|
+
}
|
|
309
|
+
const bytes = Buffer.from(result.content, 'base64')
|
|
310
|
+
if (
|
|
311
|
+
ranged &&
|
|
312
|
+
(bytes.length > (length ?? limit ?? 0) ||
|
|
313
|
+
result.sizeBytes !== bytes.length ||
|
|
314
|
+
bytes.toString('base64') !== result.content)
|
|
315
|
+
)
|
|
316
|
+
throw new Error('The Pal file worker returned invalid bounded file bytes')
|
|
317
|
+
return bytes
|
|
318
|
+
},
|
|
319
|
+
async listFiles(rootPath) {
|
|
320
|
+
assertPal()
|
|
321
|
+
const entries = []
|
|
322
|
+
for await (const entry of walkFilesViaExec((...args) => sandbox.exec(...args), rootPath, {
|
|
323
|
+
maxEntries: 100_000,
|
|
324
|
+
}))
|
|
325
|
+
entries.push(entry)
|
|
326
|
+
return entries
|
|
327
|
+
},
|
|
328
|
+
walkFiles(rootPath, walkOptions) {
|
|
329
|
+
assertPal()
|
|
330
|
+
return walkFilesViaExec((...args) => sandbox.exec(...args), rootPath, walkOptions)
|
|
331
|
+
},
|
|
332
|
+
destroy: stop,
|
|
333
|
+
}
|
|
334
|
+
const executeDesktop = async (
|
|
335
|
+
action: ComputerUseAction | PalComputerInput,
|
|
336
|
+
operator: boolean,
|
|
337
|
+
transitionRelease = false,
|
|
338
|
+
): Promise<ComputerUseResult> => {
|
|
339
|
+
assertActive()
|
|
340
|
+
const held =
|
|
341
|
+
action.type === 'key_down' || action.type === 'key_up' || action.type === 'release_keys'
|
|
342
|
+
if (held && (!operator || !options.heldKeyboard))
|
|
343
|
+
throw new Error('This Pal computer does not support operator held keyboard input')
|
|
344
|
+
const mutation = action.type !== 'screenshot' && action.type !== 'cursor_position'
|
|
345
|
+
const epoch = screenEpoch
|
|
346
|
+
if (mutation) {
|
|
347
|
+
assertCertain()
|
|
348
|
+
if (operator) {
|
|
349
|
+
if (
|
|
350
|
+
mode !== 'operator' &&
|
|
351
|
+
!(transitionRelease && mode === 'transitioning' && action.type === 'release_keys')
|
|
352
|
+
)
|
|
353
|
+
throw new Error('The operator does not have control of this Pal computer')
|
|
354
|
+
} else {
|
|
355
|
+
assertPal()
|
|
356
|
+
if (needsFreshScreen)
|
|
357
|
+
throw new Error('Capture a fresh Pal desktop screenshot before sending input')
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
busy += 1
|
|
361
|
+
try {
|
|
362
|
+
let response: Response
|
|
363
|
+
let result: {
|
|
364
|
+
type?: string
|
|
365
|
+
data?: string
|
|
366
|
+
point?: { x: number; y: number }
|
|
367
|
+
outcome?: string
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
response = await fetch(`${options.desktopUrl}/action`, {
|
|
371
|
+
method: 'POST',
|
|
372
|
+
headers,
|
|
373
|
+
body: JSON.stringify(action),
|
|
374
|
+
signal: AbortSignal.timeout(30_000),
|
|
375
|
+
})
|
|
376
|
+
if (response.status >= 500) throw new Error('Desktop outcome was not confirmed')
|
|
377
|
+
if (!response.ok)
|
|
378
|
+
throw Object.assign(
|
|
379
|
+
new Error(`The Pal desktop refused the action (${response.status})`),
|
|
380
|
+
{ refused: true },
|
|
381
|
+
)
|
|
382
|
+
const raw: unknown = await response.json()
|
|
383
|
+
if (!raw || typeof raw !== 'object') throw new Error('Invalid desktop confirmation')
|
|
384
|
+
result = raw as typeof result
|
|
385
|
+
if (
|
|
386
|
+
action.type !== 'screenshot' &&
|
|
387
|
+
action.type !== 'cursor_position' &&
|
|
388
|
+
result.type !== 'ok' &&
|
|
389
|
+
result.outcome !== 'unknown'
|
|
390
|
+
)
|
|
391
|
+
throw new Error('Desktop mutation was not confirmed')
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (error && typeof error === 'object' && 'refused' in error) throw error
|
|
394
|
+
if (mutation) {
|
|
395
|
+
uncertain = true
|
|
396
|
+
throw Object.assign(
|
|
397
|
+
new Error('The Pal desktop action may have run; do not automatically replay it'),
|
|
398
|
+
{
|
|
399
|
+
code: 'computer_use_outcome_unknown',
|
|
400
|
+
action: action.type,
|
|
401
|
+
outcome: 'unknown',
|
|
402
|
+
retrySafety: 'unsafe',
|
|
403
|
+
},
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
throw new Error('The Pal desktop is not responding')
|
|
407
|
+
}
|
|
408
|
+
if (result.outcome === 'unknown') {
|
|
409
|
+
uncertain = true
|
|
410
|
+
throw Object.assign(new Error('The Pal desktop action has an unknown outcome'), {
|
|
411
|
+
code: 'computer_use_outcome_unknown',
|
|
412
|
+
action: action.type,
|
|
413
|
+
outcome: 'unknown',
|
|
414
|
+
retrySafety: 'unsafe',
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
if (held && result.type === 'ok') {
|
|
418
|
+
if (action.type === 'key_down') {
|
|
419
|
+
const keys = heldKeyboards.get(action.keyboardId) ?? new Set<string>()
|
|
420
|
+
keys.add(action.key)
|
|
421
|
+
heldKeyboards.set(action.keyboardId, keys)
|
|
422
|
+
} else if (action.type === 'key_up') {
|
|
423
|
+
const keys = heldKeyboards.get(action.keyboardId)
|
|
424
|
+
keys?.delete(action.key)
|
|
425
|
+
if (!keys?.size) heldKeyboards.delete(action.keyboardId)
|
|
426
|
+
} else if (action.type === 'release_keys') heldKeyboards.delete(action.keyboardId)
|
|
427
|
+
}
|
|
428
|
+
if (
|
|
429
|
+
action.type === 'screenshot' &&
|
|
430
|
+
result.type === 'screenshot' &&
|
|
431
|
+
typeof result.data === 'string'
|
|
432
|
+
) {
|
|
433
|
+
const data = Buffer.from(result.data, 'base64')
|
|
434
|
+
if (data.length < 24 || data.toString('hex', 0, 8) !== '89504e470d0a1a0a')
|
|
435
|
+
throw new Error('The Pal desktop returned an invalid PNG')
|
|
436
|
+
const width = data.readUInt32BE(16)
|
|
437
|
+
const height = data.readUInt32BE(20)
|
|
438
|
+
if (mode === 'pal' && epoch === screenEpoch) needsFreshScreen = false
|
|
439
|
+
return {
|
|
440
|
+
type: 'screenshot',
|
|
441
|
+
result: {
|
|
442
|
+
data,
|
|
443
|
+
mimeType: 'image/png',
|
|
444
|
+
width,
|
|
445
|
+
height,
|
|
446
|
+
display: {
|
|
447
|
+
id: 'pal-display',
|
|
448
|
+
x: 0,
|
|
449
|
+
y: 0,
|
|
450
|
+
width,
|
|
451
|
+
height,
|
|
452
|
+
scaleFactor: 1,
|
|
453
|
+
primary: true,
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (
|
|
459
|
+
action.type === 'cursor_position' &&
|
|
460
|
+
result.type === 'cursor_position' &&
|
|
461
|
+
result.point &&
|
|
462
|
+
Number.isFinite(result.point.x) &&
|
|
463
|
+
Number.isFinite(result.point.y)
|
|
464
|
+
)
|
|
465
|
+
return { type: 'cursor_position', point: result.point }
|
|
466
|
+
if (action.type !== 'screenshot' && action.type !== 'cursor_position' && result.type === 'ok')
|
|
467
|
+
return { type: 'ok' }
|
|
468
|
+
throw new Error('The Pal desktop returned an invalid action result')
|
|
469
|
+
} finally {
|
|
470
|
+
busy -= 1
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const computerUseHost: ComputerUseHost = {
|
|
474
|
+
id: 'pal-local-container-desktop',
|
|
475
|
+
capabilities: {
|
|
476
|
+
displayServer: 'x11',
|
|
477
|
+
screenshot: true,
|
|
478
|
+
mouse: true,
|
|
479
|
+
keyboard: true,
|
|
480
|
+
cursorPosition: true,
|
|
481
|
+
clipboard: false,
|
|
482
|
+
supportedActions: [
|
|
483
|
+
'screenshot',
|
|
484
|
+
'cursor_position',
|
|
485
|
+
'mouse_move',
|
|
486
|
+
'mouse_click',
|
|
487
|
+
'mouse_drag',
|
|
488
|
+
'scroll',
|
|
489
|
+
'type_text',
|
|
490
|
+
'key',
|
|
491
|
+
],
|
|
492
|
+
mouseClickButtons: ['left', 'middle', 'right'],
|
|
493
|
+
mouseDragButtons: ['left', 'middle', 'right'],
|
|
494
|
+
},
|
|
495
|
+
async getDisplayGeometry() {
|
|
496
|
+
assertActive()
|
|
497
|
+
return options.geometry
|
|
498
|
+
},
|
|
499
|
+
execute: (action) => executeDesktop(action, false),
|
|
500
|
+
}
|
|
501
|
+
const transfer = async (from: 'pal' | 'operator', to: 'pal' | 'operator') => {
|
|
502
|
+
assertActive()
|
|
503
|
+
assertCertain()
|
|
504
|
+
if (mode !== from) throw new Error(`This Pal computer is not controlled by ${from}`)
|
|
505
|
+
// This synchronous reservation excludes new guest work before the idle check.
|
|
506
|
+
mode = 'transitioning'
|
|
507
|
+
if (busy > 0 || detached.size > 0) {
|
|
508
|
+
mode = from
|
|
509
|
+
throw new Error(
|
|
510
|
+
'Wait for all Pal computer work and background jobs to stop before changing control',
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
if (to === 'pal')
|
|
515
|
+
for (const keyboardId of heldKeyboards.keys())
|
|
516
|
+
await executeDesktop({ type: 'release_keys', keyboardId }, true, true)
|
|
517
|
+
screenEpoch += 1
|
|
518
|
+
needsFreshScreen = to === 'pal'
|
|
519
|
+
mode = to
|
|
520
|
+
} catch (error) {
|
|
521
|
+
mode = from
|
|
522
|
+
throw error
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const operatorControl: PalComputerControl = {
|
|
526
|
+
...(options.heldKeyboard ? { heldKeyboard: true as const } : {}),
|
|
527
|
+
get mode() {
|
|
528
|
+
return mode
|
|
529
|
+
},
|
|
530
|
+
async takeOver() {
|
|
531
|
+
await transfer('pal', 'operator')
|
|
532
|
+
},
|
|
533
|
+
async returnControl() {
|
|
534
|
+
await transfer('operator', 'pal')
|
|
535
|
+
},
|
|
536
|
+
executeInput(input: PalComputerInput) {
|
|
537
|
+
if (
|
|
538
|
+
![
|
|
539
|
+
'mouse_move',
|
|
540
|
+
'mouse_click',
|
|
541
|
+
'mouse_drag',
|
|
542
|
+
'scroll',
|
|
543
|
+
'type_text',
|
|
544
|
+
'key',
|
|
545
|
+
'key_down',
|
|
546
|
+
'key_up',
|
|
547
|
+
'release_keys',
|
|
548
|
+
].includes(input.type)
|
|
549
|
+
)
|
|
550
|
+
throw new Error('Only desktop input is supported for operator control')
|
|
551
|
+
return executeDesktop(input, true)
|
|
552
|
+
},
|
|
553
|
+
}
|
|
554
|
+
const screenStream = options.screenStream
|
|
555
|
+
? (() => {
|
|
556
|
+
const url = new URL('/stream', options.desktopUrl)
|
|
557
|
+
if (
|
|
558
|
+
url.protocol !== 'http:' ||
|
|
559
|
+
url.hostname !== '127.0.0.1' ||
|
|
560
|
+
url.username ||
|
|
561
|
+
url.password
|
|
562
|
+
)
|
|
563
|
+
throw new Error('The Pal screen stream must use the owned guest loopback transport')
|
|
564
|
+
url.protocol = 'ws:'
|
|
565
|
+
return Object.freeze({
|
|
566
|
+
protocol: 'rfb' as const,
|
|
567
|
+
url: url.href,
|
|
568
|
+
authorization: `Bearer ${options.token}`,
|
|
569
|
+
})
|
|
570
|
+
})()
|
|
571
|
+
: undefined
|
|
572
|
+
return { sandbox, computerUseHost, operatorControl, ...(screenStream ? { screenStream } : {}) }
|
|
573
|
+
}
|