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