@namzu/sandbox 24.0.0 → 26.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +284 -0
  2. package/dist/backends/http-worker-client.d.ts +3 -1
  3. package/dist/backends/http-worker-client.d.ts.map +1 -1
  4. package/dist/backends/http-worker-client.js +15 -4
  5. package/dist/backends/http-worker-client.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/local-virtual-computer/client.d.ts +21 -0
  11. package/dist/local-virtual-computer/client.d.ts.map +1 -0
  12. package/dist/local-virtual-computer/client.js +503 -0
  13. package/dist/local-virtual-computer/client.js.map +1 -0
  14. package/dist/local-virtual-computer/detached-process.d.ts +15 -0
  15. package/dist/local-virtual-computer/detached-process.d.ts.map +1 -0
  16. package/dist/local-virtual-computer/detached-process.js +111 -0
  17. package/dist/local-virtual-computer/detached-process.js.map +1 -0
  18. package/dist/local-virtual-computer/detached-worker.d.ts +11 -0
  19. package/dist/local-virtual-computer/detached-worker.d.ts.map +1 -0
  20. package/dist/local-virtual-computer/detached-worker.js +97 -0
  21. package/dist/local-virtual-computer/detached-worker.js.map +1 -0
  22. package/dist/local-virtual-computer/dialect.d.ts +26 -0
  23. package/dist/local-virtual-computer/dialect.d.ts.map +1 -0
  24. package/dist/local-virtual-computer/dialect.js +61 -0
  25. package/dist/local-virtual-computer/dialect.js.map +1 -0
  26. package/dist/local-virtual-computer/engine.d.ts +63 -0
  27. package/dist/local-virtual-computer/engine.d.ts.map +1 -0
  28. package/dist/local-virtual-computer/engine.js +125 -0
  29. package/dist/local-virtual-computer/engine.js.map +1 -0
  30. package/dist/local-virtual-computer/guest-stdio.d.ts +15 -0
  31. package/dist/local-virtual-computer/guest-stdio.d.ts.map +1 -0
  32. package/dist/local-virtual-computer/guest-stdio.js +330 -0
  33. package/dist/local-virtual-computer/guest-stdio.js.map +1 -0
  34. package/dist/local-virtual-computer/index.d.ts +36 -0
  35. package/dist/local-virtual-computer/index.d.ts.map +1 -0
  36. package/dist/local-virtual-computer/index.js +341 -0
  37. package/dist/local-virtual-computer/index.js.map +1 -0
  38. package/dist/local-virtual-computer/podman-machine.d.ts +17 -0
  39. package/dist/local-virtual-computer/podman-machine.d.ts.map +1 -0
  40. package/dist/local-virtual-computer/podman-machine.js +181 -0
  41. package/dist/local-virtual-computer/podman-machine.js.map +1 -0
  42. package/dist/local-virtual-computer/setup/contract.d.ts +360 -0
  43. package/dist/local-virtual-computer/setup/contract.d.ts.map +1 -0
  44. package/dist/local-virtual-computer/setup/contract.js +3 -0
  45. package/dist/local-virtual-computer/setup/contract.js.map +1 -0
  46. package/dist/local-virtual-computer/setup/elevated.d.ts +78 -0
  47. package/dist/local-virtual-computer/setup/elevated.d.ts.map +1 -0
  48. package/dist/local-virtual-computer/setup/elevated.js +548 -0
  49. package/dist/local-virtual-computer/setup/elevated.js.map +1 -0
  50. package/dist/local-virtual-computer/setup/host.d.ts +12 -0
  51. package/dist/local-virtual-computer/setup/host.d.ts.map +1 -0
  52. package/dist/local-virtual-computer/setup/host.js +93 -0
  53. package/dist/local-virtual-computer/setup/host.js.map +1 -0
  54. package/dist/local-virtual-computer/setup/idle.d.ts +18 -0
  55. package/dist/local-virtual-computer/setup/idle.d.ts.map +1 -0
  56. package/dist/local-virtual-computer/setup/idle.js +38 -0
  57. package/dist/local-virtual-computer/setup/idle.js.map +1 -0
  58. package/dist/local-virtual-computer/setup/image.d.ts +7 -0
  59. package/dist/local-virtual-computer/setup/image.d.ts.map +1 -0
  60. package/dist/local-virtual-computer/setup/image.js +124 -0
  61. package/dist/local-virtual-computer/setup/image.js.map +1 -0
  62. package/dist/local-virtual-computer/setup/index.d.ts +17 -0
  63. package/dist/local-virtual-computer/setup/index.d.ts.map +1 -0
  64. package/dist/local-virtual-computer/setup/index.js +17 -0
  65. package/dist/local-virtual-computer/setup/index.js.map +1 -0
  66. package/dist/local-virtual-computer/setup/limits.d.ts +30 -0
  67. package/dist/local-virtual-computer/setup/limits.d.ts.map +1 -0
  68. package/dist/local-virtual-computer/setup/limits.js +58 -0
  69. package/dist/local-virtual-computer/setup/limits.js.map +1 -0
  70. package/dist/local-virtual-computer/setup/plan.d.ts +8 -0
  71. package/dist/local-virtual-computer/setup/plan.d.ts.map +1 -0
  72. package/dist/local-virtual-computer/setup/plan.js +131 -0
  73. package/dist/local-virtual-computer/setup/plan.js.map +1 -0
  74. package/dist/local-virtual-computer/setup/readiness.d.ts +11 -0
  75. package/dist/local-virtual-computer/setup/readiness.d.ts.map +1 -0
  76. package/dist/local-virtual-computer/setup/readiness.js +320 -0
  77. package/dist/local-virtual-computer/setup/readiness.js.map +1 -0
  78. package/dist/local-virtual-computer/setup/registry.d.ts +18 -0
  79. package/dist/local-virtual-computer/setup/registry.d.ts.map +1 -0
  80. package/dist/local-virtual-computer/setup/registry.js +50 -0
  81. package/dist/local-virtual-computer/setup/registry.js.map +1 -0
  82. package/dist/local-virtual-computer/setup/restart.d.ts +8 -0
  83. package/dist/local-virtual-computer/setup/restart.d.ts.map +1 -0
  84. package/dist/local-virtual-computer/setup/restart.js +60 -0
  85. package/dist/local-virtual-computer/setup/restart.js.map +1 -0
  86. package/dist/local-virtual-computer/setup/resume.d.ts +14 -0
  87. package/dist/local-virtual-computer/setup/resume.d.ts.map +1 -0
  88. package/dist/local-virtual-computer/setup/resume.js +58 -0
  89. package/dist/local-virtual-computer/setup/resume.js.map +1 -0
  90. package/dist/local-virtual-computer/setup/run.d.ts +8 -0
  91. package/dist/local-virtual-computer/setup/run.d.ts.map +1 -0
  92. package/dist/local-virtual-computer/setup/run.js +200 -0
  93. package/dist/local-virtual-computer/setup/run.js.map +1 -0
  94. package/dist/local-virtual-computer/setup/windows-folder.d.ts +11 -0
  95. package/dist/local-virtual-computer/setup/windows-folder.d.ts.map +1 -0
  96. package/dist/local-virtual-computer/setup/windows-folder.js +14 -0
  97. package/dist/local-virtual-computer/setup/windows-folder.js.map +1 -0
  98. package/dist/local-virtual-computer/setup/wsl-package.d.ts +19 -0
  99. package/dist/local-virtual-computer/setup/wsl-package.d.ts.map +1 -0
  100. package/dist/local-virtual-computer/setup/wsl-package.js +242 -0
  101. package/dist/local-virtual-computer/setup/wsl-package.js.map +1 -0
  102. package/dist/local-virtual-computer/setup/wslc-shared.d.ts +27 -0
  103. package/dist/local-virtual-computer/setup/wslc-shared.d.ts.map +1 -0
  104. package/dist/local-virtual-computer/setup/wslc-shared.js +137 -0
  105. package/dist/local-virtual-computer/setup/wslc-shared.js.map +1 -0
  106. package/dist/local-virtual-computer/ssh-forward.d.ts +19 -0
  107. package/dist/local-virtual-computer/ssh-forward.d.ts.map +1 -0
  108. package/dist/local-virtual-computer/ssh-forward.js +179 -0
  109. package/dist/local-virtual-computer/ssh-forward.js.map +1 -0
  110. package/dist/local-virtual-computer/wslc-engine.d.ts +27 -0
  111. package/dist/local-virtual-computer/wslc-engine.d.ts.map +1 -0
  112. package/dist/local-virtual-computer/wslc-engine.js +98 -0
  113. package/dist/local-virtual-computer/wslc-engine.js.map +1 -0
  114. package/local-computer/Dockerfile +99 -0
  115. package/local-computer/app-catalog.cjs +240 -0
  116. package/local-computer/app-native-host.cjs +144 -0
  117. package/local-computer/browser-home.cjs +249 -0
  118. package/local-computer/build-image.mjs +310 -0
  119. package/local-computer/desktop-worker.cjs +477 -0
  120. package/local-computer/entrypoint.sh +161 -0
  121. package/local-computer/home.html +28 -0
  122. package/local-computer/new-tab/home.css +255 -0
  123. package/local-computer/new-tab/home.js +65 -0
  124. package/local-computer/new-tab/manifest.json +12 -0
  125. package/local-computer/new-tab/wordmark.svg +33 -0
  126. package/local-computer/openbox-rc.xml +41 -0
  127. package/local-computer/prepare-home.cjs +121 -0
  128. package/local-computer/tint2rc +44 -0
  129. package/package.json +13 -4
  130. package/src/backends/http-worker-client.ts +31 -3
  131. package/src/index.ts +13 -0
  132. package/src/local-virtual-computer/client.ts +573 -0
  133. package/src/local-virtual-computer/detached-process.ts +133 -0
  134. package/src/local-virtual-computer/detached-worker.ts +124 -0
  135. package/src/local-virtual-computer/dialect.ts +83 -0
  136. package/src/local-virtual-computer/engine.ts +197 -0
  137. package/src/local-virtual-computer/guest-stdio.ts +332 -0
  138. package/src/local-virtual-computer/index.ts +447 -0
  139. package/src/local-virtual-computer/podman-machine.ts +264 -0
  140. package/src/local-virtual-computer/setup/contract.ts +395 -0
  141. package/src/local-virtual-computer/setup/elevated.ts +655 -0
  142. package/src/local-virtual-computer/setup/host.ts +100 -0
  143. package/src/local-virtual-computer/setup/idle.ts +39 -0
  144. package/src/local-virtual-computer/setup/image.ts +159 -0
  145. package/src/local-virtual-computer/setup/index.ts +16 -0
  146. package/src/local-virtual-computer/setup/limits.ts +68 -0
  147. package/src/local-virtual-computer/setup/plan.ts +144 -0
  148. package/src/local-virtual-computer/setup/readiness.ts +386 -0
  149. package/src/local-virtual-computer/setup/registry.ts +50 -0
  150. package/src/local-virtual-computer/setup/restart.ts +64 -0
  151. package/src/local-virtual-computer/setup/resume.ts +65 -0
  152. package/src/local-virtual-computer/setup/run.ts +225 -0
  153. package/src/local-virtual-computer/setup/windows-folder.ts +13 -0
  154. package/src/local-virtual-computer/setup/wsl-package.ts +242 -0
  155. package/src/local-virtual-computer/setup/wslc-shared.ts +147 -0
  156. package/src/local-virtual-computer/ssh-forward.ts +195 -0
  157. package/src/local-virtual-computer/wslc-engine.ts +124 -0
  158. package/worker/server.js +1499 -0
@@ -0,0 +1,477 @@
1
+ const http = require('node:http')
2
+ const { execFile } = require('node:child_process')
3
+ const { createHash, timingSafeEqual } = require('node:crypto')
4
+ const { createConnection } = require('node:net')
5
+
6
+ const STREAM_MAX_PAYLOAD = 64 * 1024
7
+ const STREAM_MAX_VIEWERS = 4
8
+
9
+ /** Probe the actual guest RFB server, not merely a listening TCP socket. */
10
+ function probeVnc(connect) {
11
+ return new Promise((resolve) => {
12
+ const socket = connect()
13
+ let banner = Buffer.alloc(0)
14
+ let settled = false
15
+ const finish = (ready) => {
16
+ if (settled) return
17
+ settled = true
18
+ socket.destroy()
19
+ resolve(ready)
20
+ }
21
+ socket.setTimeout(2_000, () => finish(false))
22
+ socket.on('error', () => finish(false))
23
+ socket.on('close', () => finish(false))
24
+ socket.on('data', (data) => {
25
+ banner = Buffer.concat([banner, data.subarray(0, 12 - banner.length)])
26
+ if (banner.length === 12) finish(/^RFB 003\.00[378]\n$/.test(banner.toString('ascii')))
27
+ })
28
+ })
29
+ }
30
+
31
+ function attachScreenStream(server, authorized, connect) {
32
+ const { WebSocketServer, createWebSocketStream } = require('ws')
33
+ const websocketServer = new WebSocketServer({
34
+ noServer: true,
35
+ perMessageDeflate: false,
36
+ maxPayload: STREAM_MAX_PAYLOAD,
37
+ maxBufferedChunks: 32,
38
+ maxFragments: 32,
39
+ })
40
+ const viewers = new Set()
41
+ server.on('upgrade', (req, socket, head) => {
42
+ const refused = !authorized(req)
43
+ ? 401
44
+ : req.method !== 'GET' || req.url !== '/stream'
45
+ ? 404
46
+ : viewers.size >= STREAM_MAX_VIEWERS
47
+ ? 503
48
+ : undefined
49
+ if (refused) {
50
+ socket.end(`HTTP/1.1 ${refused} Refused\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
51
+ return
52
+ }
53
+ websocketServer.handleUpgrade(req, socket, head, (websocket) => {
54
+ let guest
55
+ try {
56
+ guest = connect()
57
+ } catch {
58
+ websocket.terminate()
59
+ return
60
+ }
61
+ let closed = false
62
+ const close = () => {
63
+ if (closed) return
64
+ closed = true
65
+ viewers.delete(close)
66
+ guest.destroy()
67
+ stream.destroy()
68
+ websocket.terminate()
69
+ }
70
+ viewers.add(close)
71
+ websocket.on('error', close)
72
+ websocket.on('close', close)
73
+ websocket.on('message', (_data, binary) => {
74
+ if (!binary) close()
75
+ })
76
+ const stream = createWebSocketStream(websocket, {
77
+ highWaterMark: STREAM_MAX_PAYLOAD,
78
+ })
79
+ stream.on('error', close)
80
+ stream.on('close', close)
81
+ guest.on('error', close)
82
+ guest.on('close', close)
83
+ guest.setTimeout(2_000, close)
84
+ guest.once('connect', () => guest.setTimeout(0))
85
+ // Duplex pipes propagate backpressure instead of retaining whole frames.
86
+ // The target is an independently enforced view-only x11vnc server.
87
+ stream.pipe(guest)
88
+ guest.pipe(stream)
89
+ })
90
+ })
91
+ const closeViewers = () => {
92
+ for (const close of viewers) close()
93
+ }
94
+ const closeServer = server.close.bind(server)
95
+ server.close = (callback) => {
96
+ // HTTP close waits for upgraded sockets, so retire them before waiting
97
+ // for its close event. Waiting until that event would deadlock shutdown.
98
+ closeViewers()
99
+ return closeServer(callback)
100
+ }
101
+ server.on('close', () => {
102
+ closeViewers()
103
+ websocketServer.close()
104
+ })
105
+ }
106
+
107
+ const BUTTONS = { left: '1', middle: '2', right: '3' }
108
+ const SCROLL = { up: '4', down: '5', left: '6', right: '7' }
109
+ const KEY_NAMES = {
110
+ CTRL: 'ctrl',
111
+ CONTROL: 'ctrl',
112
+ ALT: 'alt',
113
+ SHIFT: 'shift',
114
+ META: 'Super_L',
115
+ SUPER: 'Super_L',
116
+ ENTER: 'Return',
117
+ RETURN: 'Return',
118
+ ESC: 'Escape',
119
+ ESCAPE: 'Escape',
120
+ BACKSPACE: 'BackSpace',
121
+ DELETE: 'Delete',
122
+ SPACE: 'space',
123
+ TAB: 'Tab',
124
+ ARROWUP: 'Up',
125
+ ARROWDOWN: 'Down',
126
+ ARROWLEFT: 'Left',
127
+ ARROWRIGHT: 'Right',
128
+ PAGEUP: 'Prior',
129
+ PAGEDOWN: 'Next',
130
+ }
131
+ const HELD_KEYS = new Set([
132
+ 'Control_L',
133
+ 'Alt_L',
134
+ 'Shift_L',
135
+ 'Super_L',
136
+ 'Return',
137
+ 'Escape',
138
+ 'BackSpace',
139
+ 'Delete',
140
+ 'Up',
141
+ 'Down',
142
+ 'Left',
143
+ 'Right',
144
+ 'Home',
145
+ 'End',
146
+ 'Prior',
147
+ 'Next',
148
+ 'Insert',
149
+ 'Tab',
150
+ 'space',
151
+ 'minus',
152
+ 'equal',
153
+ 'period',
154
+ 'comma',
155
+ 'slash',
156
+ 'backslash',
157
+ 'semicolon',
158
+ 'apostrophe',
159
+ 'bracketleft',
160
+ 'bracketright',
161
+ 'grave',
162
+ ])
163
+ function heldKey(value) {
164
+ const alias = KEY_NAMES[value.toUpperCase()] ?? value
165
+ const key =
166
+ alias === 'ctrl'
167
+ ? 'Control_L'
168
+ : alias === 'alt'
169
+ ? 'Alt_L'
170
+ : alias === 'shift'
171
+ ? 'Shift_L'
172
+ : /^[A-Z]$/.test(alias)
173
+ ? alias.toLowerCase()
174
+ : alias
175
+ // xdotool supports command chaining; a syntactically safe word is not necessarily a keysym.
176
+ if (!HELD_KEYS.has(key) && !/^(?:[a-z0-9]|F(?:[1-9]|1[0-9]|2[0-4]))$/.test(key))
177
+ throw new Error('Unsupported held keysym')
178
+ return key
179
+ }
180
+
181
+ function run(binary, args) {
182
+ return new Promise((resolve, reject) => {
183
+ const env = { ...process.env }
184
+ for (const key of Object.keys(env)) if (key.startsWith('NAMZU_SANDBOX_')) delete env[key]
185
+ execFile(
186
+ binary,
187
+ args,
188
+ { env, encoding: 'buffer', maxBuffer: 24 * 1024 * 1024, timeout: 10_000 },
189
+ (error, stdout) =>
190
+ error ? reject(new Error('Guest desktop command failed')) : resolve(stdout),
191
+ )
192
+ })
193
+ }
194
+
195
+ function integer(value, low, high) {
196
+ if (!Number.isSafeInteger(value) || value < low || value > high)
197
+ throw new Error('Invalid desktop coordinate or amount')
198
+ return String(value)
199
+ }
200
+
201
+ function point(value, geometry) {
202
+ if (!value || typeof value !== 'object') throw new Error('Missing desktop coordinate')
203
+ return [integer(value.x, 0, geometry.width - 1), integer(value.y, 0, geometry.height - 1)]
204
+ }
205
+
206
+ function pngGeometry(data) {
207
+ if (data.length < 24 || data.toString('hex', 0, 8) !== '89504e470d0a1a0a')
208
+ throw new Error('Guest capture did not return PNG')
209
+ return { width: data.readUInt32BE(16), height: data.readUInt32BE(20) }
210
+ }
211
+
212
+ /** argv only; no shell parsing and no host desktop adapter. */
213
+ function actionArguments(action, geometry) {
214
+ switch (action.type) {
215
+ case 'mouse_move':
216
+ return ['mousemove', ...point(action.to, geometry)]
217
+ case 'mouse_click': {
218
+ if (!Object.hasOwn(BUTTONS, action.button)) throw new Error('Invalid desktop button')
219
+ return ['mousemove', ...point(action.at, geometry), 'click', BUTTONS[action.button]]
220
+ }
221
+ case 'mouse_drag': {
222
+ if (!Object.hasOwn(BUTTONS, action.button)) throw new Error('Invalid desktop button')
223
+ return [
224
+ 'mousemove',
225
+ ...point(action.from, geometry),
226
+ 'mousedown',
227
+ BUTTONS[action.button],
228
+ 'mousemove',
229
+ ...point(action.to, geometry),
230
+ 'mouseup',
231
+ BUTTONS[action.button],
232
+ ]
233
+ }
234
+ case 'scroll': {
235
+ if (!SCROLL[action.direction]) throw new Error('Invalid scroll direction')
236
+ return [
237
+ 'mousemove',
238
+ ...point(action.at, geometry),
239
+ 'click',
240
+ '--repeat',
241
+ integer(action.amount, 1, 100),
242
+ '--delay',
243
+ '20',
244
+ SCROLL[action.direction],
245
+ ]
246
+ }
247
+ case 'type_text': {
248
+ if (
249
+ typeof action.text !== 'string' ||
250
+ action.text.length > 100_000 ||
251
+ action.text.includes('\0')
252
+ )
253
+ throw new Error('Invalid desktop text')
254
+ return ['type', '--clearmodifiers', '--delay', '0', '--', action.text]
255
+ }
256
+ case 'key': {
257
+ if (
258
+ typeof action.keys !== 'string' ||
259
+ action.keys.length > 100 ||
260
+ !/^[a-zA-Z0-9_+ -]+$/.test(action.keys)
261
+ )
262
+ throw new Error('Invalid desktop key')
263
+ return [
264
+ 'key',
265
+ '--clearmodifiers',
266
+ '--',
267
+ action.keys
268
+ .split('+')
269
+ .map((key) => KEY_NAMES[key.toUpperCase()] ?? key)
270
+ .join('+'),
271
+ ]
272
+ }
273
+ default:
274
+ throw new Error('Unsupported desktop action')
275
+ }
276
+ }
277
+
278
+ function createDesktopServer(options) {
279
+ if (!/^[a-zA-Z0-9_-]{16,}$/.test(options.token ?? ''))
280
+ throw new Error('A per-allocation desktop token is required')
281
+ const digest = createHash('sha256').update(options.token).digest()
282
+ const authorized = (req) => {
283
+ const authorization =
284
+ typeof req.headers.authorization === 'string' ? req.headers.authorization : ''
285
+ const presented = authorization.startsWith('Bearer ') ? authorization.slice(7) : ''
286
+ return (
287
+ req.headers.origin === undefined &&
288
+ timingSafeEqual(digest, createHash('sha256').update(presented).digest())
289
+ )
290
+ }
291
+ const connectVnc =
292
+ options.connectVnc ?? (() => createConnection({ host: '127.0.0.1', port: 5900 }))
293
+ const execute = options.run ?? run
294
+ const fetchBrowser =
295
+ options.fetchBrowser ??
296
+ (async () => {
297
+ const response = await fetch('http://127.0.0.1:9222/json/version', {
298
+ signal: AbortSignal.timeout(2_000),
299
+ })
300
+ if (!response.ok) return false
301
+ const result = await response.json()
302
+ return (
303
+ typeof result.Browser === 'string' &&
304
+ result.Browser.includes('Chrome') &&
305
+ typeof result.webSocketDebuggerUrl === 'string'
306
+ )
307
+ })
308
+ const capture = async () => await execute('maim', ['-f', 'png'])
309
+ const geometry = async () => {
310
+ const value = (await execute('xdotool', ['getdisplaygeometry']))
311
+ .toString('utf8')
312
+ .trim()
313
+ .split(/\s+/)
314
+ .map(Number)
315
+ if (
316
+ !Number.isSafeInteger(value[0]) ||
317
+ !Number.isSafeInteger(value[1]) ||
318
+ value[0] <= 0 ||
319
+ value[1] <= 0
320
+ )
321
+ throw new Error('Guest display is not ready')
322
+ return { width: value[0], height: value[1] }
323
+ }
324
+ let queue = Promise.resolve()
325
+ // Per-allocation and per-focus ownership. Never send a global keyup.
326
+ const keyboards = new Map()
327
+ const heldAction = async (action) => {
328
+ const fields =
329
+ action.type === 'release_keys' ? ['type', 'keyboardId'] : ['type', 'key', 'keyboardId']
330
+ if (
331
+ Object.keys(action).length !== fields.length ||
332
+ !fields.every((field) => Object.hasOwn(action, field)) ||
333
+ typeof action.keyboardId !== 'string' ||
334
+ !/^[a-zA-Z0-9_-]{16,80}$/.test(action.keyboardId) ||
335
+ (action.type !== 'release_keys' &&
336
+ (typeof action.key !== 'string' || !/^[a-zA-Z0-9_]{1,40}$/.test(action.key)))
337
+ )
338
+ throw new Error('Invalid held keyboard input')
339
+ const key = action.type === 'release_keys' ? undefined : heldKey(action.key)
340
+ const owned = keyboards.get(action.keyboardId)
341
+ const heldElsewhere = (candidate) =>
342
+ [...keyboards].some(([id, keys]) => id !== action.keyboardId && keys.has(candidate))
343
+ let args
344
+ if (action.type === 'key_down') {
345
+ if (owned?.has(key)) return
346
+ if ((!owned && keyboards.size >= 16) || (owned?.size ?? 0) >= 32)
347
+ throw new Error('Held keyboard limit exceeded')
348
+ if (!heldElsewhere(key)) args = ['keydown', '--', key]
349
+ } else if (action.type === 'key_up') {
350
+ if (!owned?.has(key)) return
351
+ if (!heldElsewhere(key)) args = ['keyup', '--', key]
352
+ } else {
353
+ if (!owned) return
354
+ const releasing = [...owned].filter((candidate) => !heldElsewhere(candidate))
355
+ if (releasing.length) args = ['keyup', '--', ...releasing]
356
+ }
357
+ if (args) {
358
+ try {
359
+ await execute('xdotool', args)
360
+ } catch {
361
+ return { outcome: 'unknown' }
362
+ }
363
+ }
364
+ if (action.type === 'key_down') {
365
+ const keys = owned ?? new Set()
366
+ keys.add(key)
367
+ keyboards.set(action.keyboardId, keys)
368
+ } else if (action.type === 'key_up') {
369
+ owned.delete(key)
370
+ if (!owned.size) keyboards.delete(action.keyboardId)
371
+ } else keyboards.delete(action.keyboardId)
372
+ }
373
+ const json = (res, status, body) => {
374
+ res.writeHead(status, {
375
+ 'content-type': 'application/json',
376
+ 'cache-control': 'no-store',
377
+ 'content-security-policy': "default-src 'none'; frame-ancestors 'none'",
378
+ 'x-content-type-options': 'nosniff',
379
+ })
380
+ res.end(JSON.stringify(body))
381
+ }
382
+ const server = http.createServer(async (req, res) => {
383
+ if (!authorized(req)) {
384
+ json(res, 401, { error: 'unauthorized' })
385
+ return
386
+ }
387
+ if (req.method === 'GET' && req.url === '/readyz') {
388
+ try {
389
+ const display = await geometry()
390
+ const screenshot = pngGeometry(await capture())
391
+ const browserReady = await fetchBrowser()
392
+ const streamReady = options.stream === true ? await probeVnc(connectVnc) : false
393
+ if (
394
+ !browserReady ||
395
+ (options.stream === true && !streamReady) ||
396
+ screenshot.width !== display.width ||
397
+ screenshot.height !== display.height
398
+ )
399
+ throw new Error('Guest is not ready')
400
+ json(res, 200, {
401
+ protocol: 1,
402
+ ...display,
403
+ browserReady: true,
404
+ heldKeyboard: { version: 1 },
405
+ ...(streamReady ? { stream: { protocol: 'rfb' } } : {}),
406
+ })
407
+ } catch {
408
+ json(res, 503, { error: 'desktop_not_ready' })
409
+ }
410
+ return
411
+ }
412
+ if (req.method !== 'POST' || req.url !== '/action') {
413
+ json(res, 404, { error: 'not_found' })
414
+ return
415
+ }
416
+ let body = ''
417
+ try {
418
+ for await (const chunk of req) {
419
+ body += chunk.toString('utf8')
420
+ if (body.length > 1024 * 1024) throw new Error('Request too large')
421
+ }
422
+ const action = JSON.parse(body)
423
+ const perform = async () => {
424
+ try {
425
+ if (['key_down', 'key_up', 'release_keys'].includes(action.type)) {
426
+ const result = await heldAction(action)
427
+ json(res, 200, result ?? { type: 'ok' })
428
+ return
429
+ }
430
+ if (action.type === 'screenshot') {
431
+ json(res, 200, {
432
+ type: 'screenshot',
433
+ data: (await capture()).toString('base64'),
434
+ })
435
+ return
436
+ }
437
+ if (action.type === 'cursor_position') {
438
+ const text = (await execute('xdotool', ['getmouselocation', '--shell'])).toString(
439
+ 'utf8',
440
+ )
441
+ const x = Number(text.match(/^X=(-?\d+)$/m)?.[1])
442
+ const y = Number(text.match(/^Y=(-?\d+)$/m)?.[1])
443
+ if (!Number.isFinite(x) || !Number.isFinite(y))
444
+ throw new Error('Invalid cursor response')
445
+ json(res, 200, { type: 'cursor_position', point: { x, y } })
446
+ return
447
+ }
448
+ const args = actionArguments(action, await geometry())
449
+ try {
450
+ await execute('xdotool', args)
451
+ json(res, 200, { type: 'ok' })
452
+ } catch {
453
+ json(res, 200, { outcome: 'unknown' })
454
+ }
455
+ } catch {
456
+ json(res, 400, { error: 'invalid_or_unavailable_action' })
457
+ }
458
+ }
459
+ queue = queue.then(perform, perform)
460
+ await queue
461
+ } catch {
462
+ json(res, 400, { error: 'invalid_request' })
463
+ }
464
+ })
465
+ if (options.stream === true) attachScreenStream(server, authorized, connectVnc)
466
+ return server
467
+ }
468
+
469
+ module.exports = { createDesktopServer, actionArguments, pngGeometry }
470
+
471
+ if (require.main === module) {
472
+ const server = createDesktopServer({
473
+ token: process.env.NAMZU_SANDBOX_TOKEN,
474
+ stream: true,
475
+ })
476
+ server.listen(2025, '0.0.0.0')
477
+ }
@@ -0,0 +1,161 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ width=${NAMZU_SANDBOX_SCREEN_WIDTH:-1280}
5
+ height=${NAMZU_SANDBOX_SCREEN_HEIGHT:-800}
6
+ browser_width=$((width - 64))
7
+ browser_height=$((height - 140))
8
+ if [ "$browser_width" -lt 256 ]; then browser_width=256; fi
9
+ if [ "$browser_height" -lt 200 ]; then browser_height=200; fi
10
+
11
+ launch_browser() {
12
+ # Container confinement remains the boundary. Its no-new-privileges and
13
+ # capability policy cannot provide Chromium's namespace/suid setup.
14
+ # The controlled guest uses Chromium's test mode to avoid startup infobars;
15
+ # it does not alter the host browser, profile or sandbox configuration.
16
+ exec node /opt/namzu-computer/browser-home.cjs \
17
+ --no-sandbox --test-type --disable-dev-shm-usage --no-first-run \
18
+ --no-default-browser-check --password-store=basic --force-dark-mode \
19
+ --disable-features=NtpFooter \
20
+ --user-data-dir=/home/namzu/.config/chromium \
21
+ --load-extension=/opt/namzu-computer/new-tab \
22
+ --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222 \
23
+ --window-position=32,24 --window-size="${browser_width},${browser_height}" "$@"
24
+ }
25
+ # A dock launch joins the existing browser profile rather than starting another
26
+ # X server or any worker. The dock can also reopen a closed browser window.
27
+ if [ "${1:-}" = '--browser' ]; then
28
+ shift
29
+ if [ "$#" -eq 0 ]; then set -- chrome://newtab/; fi
30
+ launch_browser "$@"
31
+ fi
32
+
33
+ mkdir -p /home/namzu/workspace /home/namzu/.config/chromium \
34
+ /home/namzu/.local/share/applications
35
+ cat > /home/namzu/.local/share/applications/namzu-browser.desktop <<'APP'
36
+ [Desktop Entry]
37
+ Type=Application
38
+ Name=Browser
39
+ Icon=chromium
40
+ Exec=/bin/sh /opt/namzu-computer/entrypoint.sh --browser
41
+ Path=/home/namzu/workspace
42
+ Terminal=false
43
+ APP
44
+ cat > /home/namzu/.local/share/applications/namzu-terminal.desktop <<'APP'
45
+ [Desktop Entry]
46
+ Type=Application
47
+ Name=Terminal
48
+ Icon=utilities-terminal
49
+ Exec=xterm -fa "DejaVu Sans Mono" -fs 11 -bg "#171c18" -fg "#e7eee8" -cr "#76d77d" -title Terminal
50
+ Path=/home/namzu/workspace
51
+ Terminal=false
52
+ APP
53
+ cat > /home/namzu/.local/share/applications/namzu-files.desktop <<'APP'
54
+ [Desktop Entry]
55
+ Type=Application
56
+ Name=Files
57
+ Icon=system-file-manager
58
+ Exec=pcmanfm /home/namzu/workspace
59
+ Path=/home/namzu/workspace
60
+ Terminal=false
61
+ APP
62
+ # Only stale links from the previous exclusive allocation are removed.
63
+ for lock in SingletonLock SingletonSocket SingletonCookie; do
64
+ path="/home/namzu/.config/chromium/$lock"
65
+ if [ -L "$path" ]; then rm -f -- "$path"; fi
66
+ done
67
+
68
+ # A fresh X server belongs only to this guest, never the host DISPLAY/socket.
69
+ Xvfb :99 -screen 0 "${width}x${height}x24" -nolisten tcp &
70
+ xvfb_pid=$!
71
+ node /opt/namzu-computer/execution-worker.cjs &
72
+ execution_pid=$!
73
+ browser_pid=
74
+ openbox_pid=
75
+ dock_pid=
76
+ vnc_pid=
77
+ desktop_pid=
78
+
79
+ shutdown() {
80
+ # The profile lock tracks the current main process, including a dock reopen.
81
+ # Pin its guest-owned PID before asking it to flush while X remains alive.
82
+ python3 - <<'FLUSH' || true
83
+ import os, select, signal
84
+
85
+ descriptor = None
86
+ try:
87
+ lock = os.readlink('/home/namzu/.config/chromium/SingletonLock')
88
+ pid = int(lock.rsplit('-', 1)[1])
89
+ descriptor = os.pidfd_open(pid)
90
+ process = f'/proc/{pid}'
91
+ with open(f'{process}/comm') as command:
92
+ owned_browser = command.read().strip() == 'chromium'
93
+ if owned_browser and os.stat(process).st_uid == os.geteuid():
94
+ signal.pidfd_send_signal(descriptor, signal.SIGTERM)
95
+ select.select([descriptor], [], [], 5)
96
+ except (OSError, ValueError, IndexError):
97
+ pass
98
+ finally:
99
+ if descriptor is not None:
100
+ os.close(descriptor)
101
+ FLUSH
102
+ if [ -n "$browser_pid" ]; then
103
+ kill "$browser_pid" 2>/dev/null || true
104
+ wait "$browser_pid" 2>/dev/null || true
105
+ fi
106
+ for pid in "$desktop_pid" "$vnc_pid" "$dock_pid" "$openbox_pid" "$execution_pid"; do
107
+ if [ -n "$pid" ]; then kill "$pid" 2>/dev/null || true; fi
108
+ done
109
+ kill "$xvfb_pid" 2>/dev/null || true
110
+ wait 2>/dev/null || true
111
+ }
112
+ trap shutdown EXIT
113
+ trap 'exit 0' TERM INT
114
+
115
+ # The window manager and dock belong to this allocation, just like its workers.
116
+ until xdotool getdisplaygeometry >/dev/null 2>&1; do
117
+ kill -0 "$xvfb_pid" 2>/dev/null || exit 1
118
+ sleep 0.1
119
+ done
120
+ openbox --config-file /opt/namzu-computer/openbox-rc.xml >/tmp/openbox.log 2>&1 &
121
+ openbox_pid=$!
122
+ until xdotool get_desktop >/dev/null 2>&1; do
123
+ kill -0 "$openbox_pid" "$xvfb_pid" 2>/dev/null || exit 1
124
+ sleep 0.1
125
+ done
126
+ hsetroot -add '#234732' -add '#17251d' -add '#121614' -gradient 45 >/tmp/wallpaper.log 2>&1
127
+ tint2 -c /opt/namzu-computer/tint2rc >/tmp/tint2.log 2>&1 &
128
+ dock_pid=$!
129
+ rm -f -- /tmp/namzu-browser-home-ready
130
+ NAMZU_BROWSER_HOME_READY=1 launch_browser chrome://newtab/ >/tmp/chromium.log 2>&1 &
131
+ browser_pid=$!
132
+ # Warm the trusted extension page, then finish this launcher's own marker tab
133
+ # through Chromium's native New Tab route. No observer or input is admitted
134
+ # before its genuine native catalogue is available.
135
+ home_wait=0
136
+ until [ -f /tmp/namzu-browser-home-ready ]; do
137
+ kill -0 "$browser_pid" 2>/dev/null || exit 1
138
+ [ "$home_wait" -lt 200 ] || exit 1
139
+ home_wait=$((home_wait + 1))
140
+ sleep 0.1
141
+ done
142
+
143
+ # VNC is a guest-loopback, view-only framebuffer source. It never receives
144
+ # operator input authority and cannot synchronize either selection direction.
145
+ x11vnc -display :99 -rfbport 5900 -listen 127.0.0.1 -no6 \
146
+ -forever -shared -viewonly -nopw -norc -noremote -novncconnect \
147
+ -noclipboard -noprimary -nosetclipboard -nosetprimary \
148
+ -nolookup -quiet >/tmp/x11vnc.log 2>&1 &
149
+ vnc_pid=$!
150
+
151
+ node /opt/namzu-computer/desktop-worker.cjs &
152
+ desktop_pid=$!
153
+
154
+ # A dead critical service invalidates this whole allocation. dumb-init owns
155
+ # the shell; the shell shuts services down in order before PID 1 exits.
156
+ # A browser window is an application, so closing it leaves the desktop alive.
157
+ while kill -0 "$desktop_pid" "$vnc_pid" "$execution_pid" \
158
+ "$openbox_pid" "$dock_pid" "$xvfb_pid" 2>/dev/null; do
159
+ sleep 1
160
+ done
161
+ exit 1
@@ -0,0 +1,28 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="dark" />
7
+ <title>New Tab · Namzu</title>
8
+ <link rel="stylesheet" href="home.css" />
9
+ </head>
10
+ <body>
11
+ <header><img class="brand" src="wordmark.svg" width="126" height="24" alt="Namzu" /><span class="workspace-label">Pal workspace</span></header>
12
+ <main>
13
+ <time id="clock" aria-label="Current time"></time>
14
+ <p id="date"></p>
15
+ <h1>Your workspace, ready to go.</h1>
16
+ <form id="search" action="https://duckduckgo.com/" method="get">
17
+ <label for="query">Search the web or enter a website address</label>
18
+ <input id="query" name="q" type="text" placeholder="Search or enter a website" autocomplete="off" />
19
+ <button type="submit" aria-label="Search or open website">
20
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M5 12h14m-6-6 6 6-6 6" stroke-linecap="round" stroke-linejoin="round" /></svg>
21
+ </button>
22
+ </form>
23
+ <section class="apps" aria-label="Installed applications" id="apps"></section>
24
+ <output class="hint" id="app-status" aria-live="polite"></output>
25
+ </main>
26
+ <script src="home.js"></script>
27
+ </body>
28
+ </html>