@namzu/sandbox 24.0.0 → 26.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +284 -0
  2. package/dist/backends/http-worker-client.d.ts +3 -1
  3. package/dist/backends/http-worker-client.d.ts.map +1 -1
  4. package/dist/backends/http-worker-client.js +15 -4
  5. package/dist/backends/http-worker-client.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/local-virtual-computer/client.d.ts +21 -0
  11. package/dist/local-virtual-computer/client.d.ts.map +1 -0
  12. package/dist/local-virtual-computer/client.js +503 -0
  13. package/dist/local-virtual-computer/client.js.map +1 -0
  14. package/dist/local-virtual-computer/detached-process.d.ts +15 -0
  15. package/dist/local-virtual-computer/detached-process.d.ts.map +1 -0
  16. package/dist/local-virtual-computer/detached-process.js +111 -0
  17. package/dist/local-virtual-computer/detached-process.js.map +1 -0
  18. package/dist/local-virtual-computer/detached-worker.d.ts +11 -0
  19. package/dist/local-virtual-computer/detached-worker.d.ts.map +1 -0
  20. package/dist/local-virtual-computer/detached-worker.js +97 -0
  21. package/dist/local-virtual-computer/detached-worker.js.map +1 -0
  22. package/dist/local-virtual-computer/dialect.d.ts +26 -0
  23. package/dist/local-virtual-computer/dialect.d.ts.map +1 -0
  24. package/dist/local-virtual-computer/dialect.js +61 -0
  25. package/dist/local-virtual-computer/dialect.js.map +1 -0
  26. package/dist/local-virtual-computer/engine.d.ts +63 -0
  27. package/dist/local-virtual-computer/engine.d.ts.map +1 -0
  28. package/dist/local-virtual-computer/engine.js +125 -0
  29. package/dist/local-virtual-computer/engine.js.map +1 -0
  30. package/dist/local-virtual-computer/guest-stdio.d.ts +15 -0
  31. package/dist/local-virtual-computer/guest-stdio.d.ts.map +1 -0
  32. package/dist/local-virtual-computer/guest-stdio.js +330 -0
  33. package/dist/local-virtual-computer/guest-stdio.js.map +1 -0
  34. package/dist/local-virtual-computer/index.d.ts +36 -0
  35. package/dist/local-virtual-computer/index.d.ts.map +1 -0
  36. package/dist/local-virtual-computer/index.js +341 -0
  37. package/dist/local-virtual-computer/index.js.map +1 -0
  38. package/dist/local-virtual-computer/podman-machine.d.ts +17 -0
  39. package/dist/local-virtual-computer/podman-machine.d.ts.map +1 -0
  40. package/dist/local-virtual-computer/podman-machine.js +181 -0
  41. package/dist/local-virtual-computer/podman-machine.js.map +1 -0
  42. package/dist/local-virtual-computer/setup/contract.d.ts +360 -0
  43. package/dist/local-virtual-computer/setup/contract.d.ts.map +1 -0
  44. package/dist/local-virtual-computer/setup/contract.js +3 -0
  45. package/dist/local-virtual-computer/setup/contract.js.map +1 -0
  46. package/dist/local-virtual-computer/setup/elevated.d.ts +78 -0
  47. package/dist/local-virtual-computer/setup/elevated.d.ts.map +1 -0
  48. package/dist/local-virtual-computer/setup/elevated.js +548 -0
  49. package/dist/local-virtual-computer/setup/elevated.js.map +1 -0
  50. package/dist/local-virtual-computer/setup/host.d.ts +12 -0
  51. package/dist/local-virtual-computer/setup/host.d.ts.map +1 -0
  52. package/dist/local-virtual-computer/setup/host.js +93 -0
  53. package/dist/local-virtual-computer/setup/host.js.map +1 -0
  54. package/dist/local-virtual-computer/setup/idle.d.ts +18 -0
  55. package/dist/local-virtual-computer/setup/idle.d.ts.map +1 -0
  56. package/dist/local-virtual-computer/setup/idle.js +38 -0
  57. package/dist/local-virtual-computer/setup/idle.js.map +1 -0
  58. package/dist/local-virtual-computer/setup/image.d.ts +7 -0
  59. package/dist/local-virtual-computer/setup/image.d.ts.map +1 -0
  60. package/dist/local-virtual-computer/setup/image.js +124 -0
  61. package/dist/local-virtual-computer/setup/image.js.map +1 -0
  62. package/dist/local-virtual-computer/setup/index.d.ts +17 -0
  63. package/dist/local-virtual-computer/setup/index.d.ts.map +1 -0
  64. package/dist/local-virtual-computer/setup/index.js +17 -0
  65. package/dist/local-virtual-computer/setup/index.js.map +1 -0
  66. package/dist/local-virtual-computer/setup/limits.d.ts +30 -0
  67. package/dist/local-virtual-computer/setup/limits.d.ts.map +1 -0
  68. package/dist/local-virtual-computer/setup/limits.js +58 -0
  69. package/dist/local-virtual-computer/setup/limits.js.map +1 -0
  70. package/dist/local-virtual-computer/setup/plan.d.ts +8 -0
  71. package/dist/local-virtual-computer/setup/plan.d.ts.map +1 -0
  72. package/dist/local-virtual-computer/setup/plan.js +131 -0
  73. package/dist/local-virtual-computer/setup/plan.js.map +1 -0
  74. package/dist/local-virtual-computer/setup/readiness.d.ts +11 -0
  75. package/dist/local-virtual-computer/setup/readiness.d.ts.map +1 -0
  76. package/dist/local-virtual-computer/setup/readiness.js +320 -0
  77. package/dist/local-virtual-computer/setup/readiness.js.map +1 -0
  78. package/dist/local-virtual-computer/setup/registry.d.ts +18 -0
  79. package/dist/local-virtual-computer/setup/registry.d.ts.map +1 -0
  80. package/dist/local-virtual-computer/setup/registry.js +50 -0
  81. package/dist/local-virtual-computer/setup/registry.js.map +1 -0
  82. package/dist/local-virtual-computer/setup/restart.d.ts +8 -0
  83. package/dist/local-virtual-computer/setup/restart.d.ts.map +1 -0
  84. package/dist/local-virtual-computer/setup/restart.js +60 -0
  85. package/dist/local-virtual-computer/setup/restart.js.map +1 -0
  86. package/dist/local-virtual-computer/setup/resume.d.ts +14 -0
  87. package/dist/local-virtual-computer/setup/resume.d.ts.map +1 -0
  88. package/dist/local-virtual-computer/setup/resume.js +58 -0
  89. package/dist/local-virtual-computer/setup/resume.js.map +1 -0
  90. package/dist/local-virtual-computer/setup/run.d.ts +8 -0
  91. package/dist/local-virtual-computer/setup/run.d.ts.map +1 -0
  92. package/dist/local-virtual-computer/setup/run.js +200 -0
  93. package/dist/local-virtual-computer/setup/run.js.map +1 -0
  94. package/dist/local-virtual-computer/setup/windows-folder.d.ts +11 -0
  95. package/dist/local-virtual-computer/setup/windows-folder.d.ts.map +1 -0
  96. package/dist/local-virtual-computer/setup/windows-folder.js +14 -0
  97. package/dist/local-virtual-computer/setup/windows-folder.js.map +1 -0
  98. package/dist/local-virtual-computer/setup/wsl-package.d.ts +19 -0
  99. package/dist/local-virtual-computer/setup/wsl-package.d.ts.map +1 -0
  100. package/dist/local-virtual-computer/setup/wsl-package.js +242 -0
  101. package/dist/local-virtual-computer/setup/wsl-package.js.map +1 -0
  102. package/dist/local-virtual-computer/setup/wslc-shared.d.ts +27 -0
  103. package/dist/local-virtual-computer/setup/wslc-shared.d.ts.map +1 -0
  104. package/dist/local-virtual-computer/setup/wslc-shared.js +137 -0
  105. package/dist/local-virtual-computer/setup/wslc-shared.js.map +1 -0
  106. package/dist/local-virtual-computer/ssh-forward.d.ts +19 -0
  107. package/dist/local-virtual-computer/ssh-forward.d.ts.map +1 -0
  108. package/dist/local-virtual-computer/ssh-forward.js +179 -0
  109. package/dist/local-virtual-computer/ssh-forward.js.map +1 -0
  110. package/dist/local-virtual-computer/wslc-engine.d.ts +27 -0
  111. package/dist/local-virtual-computer/wslc-engine.d.ts.map +1 -0
  112. package/dist/local-virtual-computer/wslc-engine.js +98 -0
  113. package/dist/local-virtual-computer/wslc-engine.js.map +1 -0
  114. package/local-computer/Dockerfile +99 -0
  115. package/local-computer/app-catalog.cjs +240 -0
  116. package/local-computer/app-native-host.cjs +144 -0
  117. package/local-computer/browser-home.cjs +249 -0
  118. package/local-computer/build-image.mjs +310 -0
  119. package/local-computer/desktop-worker.cjs +477 -0
  120. package/local-computer/entrypoint.sh +161 -0
  121. package/local-computer/home.html +28 -0
  122. package/local-computer/new-tab/home.css +255 -0
  123. package/local-computer/new-tab/home.js +65 -0
  124. package/local-computer/new-tab/manifest.json +12 -0
  125. package/local-computer/new-tab/wordmark.svg +33 -0
  126. package/local-computer/openbox-rc.xml +41 -0
  127. package/local-computer/prepare-home.cjs +121 -0
  128. package/local-computer/tint2rc +44 -0
  129. package/package.json +13 -4
  130. package/src/backends/http-worker-client.ts +31 -3
  131. package/src/index.ts +13 -0
  132. package/src/local-virtual-computer/client.ts +573 -0
  133. package/src/local-virtual-computer/detached-process.ts +133 -0
  134. package/src/local-virtual-computer/detached-worker.ts +124 -0
  135. package/src/local-virtual-computer/dialect.ts +83 -0
  136. package/src/local-virtual-computer/engine.ts +197 -0
  137. package/src/local-virtual-computer/guest-stdio.ts +332 -0
  138. package/src/local-virtual-computer/index.ts +447 -0
  139. package/src/local-virtual-computer/podman-machine.ts +264 -0
  140. package/src/local-virtual-computer/setup/contract.ts +395 -0
  141. package/src/local-virtual-computer/setup/elevated.ts +655 -0
  142. package/src/local-virtual-computer/setup/host.ts +100 -0
  143. package/src/local-virtual-computer/setup/idle.ts +39 -0
  144. package/src/local-virtual-computer/setup/image.ts +159 -0
  145. package/src/local-virtual-computer/setup/index.ts +16 -0
  146. package/src/local-virtual-computer/setup/limits.ts +68 -0
  147. package/src/local-virtual-computer/setup/plan.ts +144 -0
  148. package/src/local-virtual-computer/setup/readiness.ts +386 -0
  149. package/src/local-virtual-computer/setup/registry.ts +50 -0
  150. package/src/local-virtual-computer/setup/restart.ts +64 -0
  151. package/src/local-virtual-computer/setup/resume.ts +65 -0
  152. package/src/local-virtual-computer/setup/run.ts +225 -0
  153. package/src/local-virtual-computer/setup/windows-folder.ts +13 -0
  154. package/src/local-virtual-computer/setup/wsl-package.ts +242 -0
  155. package/src/local-virtual-computer/setup/wslc-shared.ts +147 -0
  156. package/src/local-virtual-computer/ssh-forward.ts +195 -0
  157. package/src/local-virtual-computer/wslc-engine.ts +124 -0
  158. package/worker/server.js +1499 -0
@@ -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(baseUrl: string, token?: string) {
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
- return await response.json()
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(baseUrl, token, executionId, command, argv, opts, signal),
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
+ }