@adobe/aio-lib-sandbox 0.1.0-alpha.10
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/.eslintignore +2 -0
- package/.eslintrc.json +21 -0
- package/.github/CONTRIBUTING.md +44 -0
- package/.github/workflows/daily.yml +11 -0
- package/.github/workflows/node.js.yml +15 -0
- package/.github/workflows/on-push-publish-to-npm.yml +38 -0
- package/CODE_OF_CONDUCT.md +79 -0
- package/COPYRIGHT +5 -0
- package/LICENSE +201 -0
- package/README.md +244 -0
- package/RELEASING.md +36 -0
- package/jest.config.js +19 -0
- package/package.json +40 -0
- package/src/Sandbox.js +599 -0
- package/src/constants.js +22 -0
- package/src/errors.js +47 -0
- package/src/index.js +44 -0
- package/src/utils.js +170 -0
- package/src/ws.js +609 -0
- package/test/Sandbox.test.js +1464 -0
package/src/ws.js
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2026 Adobe. All rights reserved.
|
|
3
|
+
This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License. You may obtain a copy
|
|
5
|
+
of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
Unless required by applicable law or agreed to in writing, software distributed under
|
|
7
|
+
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
|
8
|
+
OF ANY KIND, either express or implied. See the License for the specific language
|
|
9
|
+
governing permissions and limitations under the License.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const WebSocket = require('ws')
|
|
13
|
+
const {
|
|
14
|
+
SandboxClientError,
|
|
15
|
+
SandboxCommandNotFoundError,
|
|
16
|
+
ProtocolVersionMismatchError,
|
|
17
|
+
SandboxMalformedFrameError,
|
|
18
|
+
SandboxUnauthorizedError,
|
|
19
|
+
SandboxWebSocketError
|
|
20
|
+
} = require('./errors')
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Manages the WebSocket connection, authentication, and frame routing for a sandbox.
|
|
24
|
+
*
|
|
25
|
+
* Holds the raw socket, all pending exec and file-op promises, and handles every
|
|
26
|
+
* incoming message. `Sandbox` creates one instance and delegates all WS work here.
|
|
27
|
+
*/
|
|
28
|
+
class SandboxSocket {
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} options socket options
|
|
31
|
+
* @param {string} options.id sandbox id
|
|
32
|
+
* @param {string} options.endpoint WebSocket endpoint URL
|
|
33
|
+
* @param {string} options.token authentication token
|
|
34
|
+
*/
|
|
35
|
+
constructor ({ id, endpoint, token }) {
|
|
36
|
+
this.id = id
|
|
37
|
+
this.endpoint = endpoint
|
|
38
|
+
this.token = token
|
|
39
|
+
|
|
40
|
+
this.socket = null
|
|
41
|
+
this.connectPromise = null
|
|
42
|
+
this.intentionalClose = false
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pending exec entries
|
|
46
|
+
*
|
|
47
|
+
* @type {Map<string, {
|
|
48
|
+
* resolve: Function, reject: Function,
|
|
49
|
+
* waitResolve: Function|null, waitReject: Function|null,
|
|
50
|
+
* stdout: string, stderr: string,
|
|
51
|
+
* onOutput: Function|undefined, timeout: any,
|
|
52
|
+
* detached: boolean, resolved: boolean
|
|
53
|
+
* }>}
|
|
54
|
+
*/
|
|
55
|
+
this.pendingExecs = new Map()
|
|
56
|
+
/** @type {Map<string, {resolve: Function, reject: Function}>} */
|
|
57
|
+
this.pendingFileOps = new Map()
|
|
58
|
+
/**
|
|
59
|
+
* Pending exec.get operations keyed by execId.
|
|
60
|
+
* @type {Map<string, {resolve: Function, reject: Function, onOutput: Function|undefined, sandbox: object}>}
|
|
61
|
+
*/
|
|
62
|
+
this.pendingGetOps = new Map()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Opens the WebSocket, authenticates, and starts routing messages
|
|
67
|
+
*
|
|
68
|
+
* @returns {Promise<void>}
|
|
69
|
+
*/
|
|
70
|
+
connect () {
|
|
71
|
+
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
|
72
|
+
return Promise.resolve()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (this.connectPromise) {
|
|
76
|
+
return this.connectPromise
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this.socket = new WebSocket(this.endpoint)
|
|
80
|
+
const socket = this.socket
|
|
81
|
+
|
|
82
|
+
socket.on('message', message => this.handleMessage(message))
|
|
83
|
+
socket.on('close', code => this.handleClose(code))
|
|
84
|
+
socket.on('error', () => {})
|
|
85
|
+
|
|
86
|
+
this.connectPromise = new Promise((resolve, reject) => {
|
|
87
|
+
const onOpen = () => {
|
|
88
|
+
try {
|
|
89
|
+
this.send({ type: 'auth', token: this.token })
|
|
90
|
+
} catch (error) {
|
|
91
|
+
onError(error)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const onMessage = (message) => {
|
|
96
|
+
const frame = this.parseFrame(message)
|
|
97
|
+
if (!frame || !this.isAuthAckFrame(frame)) return
|
|
98
|
+
cleanup()
|
|
99
|
+
this.connectPromise = null
|
|
100
|
+
resolve()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const onClose = (code) => {
|
|
104
|
+
cleanup()
|
|
105
|
+
this.connectPromise = null
|
|
106
|
+
if (this.intentionalClose) {
|
|
107
|
+
resolve()
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
reject(this.createCloseError(code))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const onError = (error) => {
|
|
114
|
+
cleanup()
|
|
115
|
+
this.connectPromise = null
|
|
116
|
+
reject(new SandboxWebSocketError(
|
|
117
|
+
`Could not connect sandbox '${this.id}': ${error.message}`
|
|
118
|
+
))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const cleanup = () => {
|
|
122
|
+
socket.off('open', onOpen)
|
|
123
|
+
socket.off('message', onMessage)
|
|
124
|
+
socket.off('close', onClose)
|
|
125
|
+
socket.off('error', onError)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
socket.once('open', onOpen)
|
|
129
|
+
socket.on('message', onMessage)
|
|
130
|
+
socket.once('close', onClose)
|
|
131
|
+
socket.once('error', onError)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
return this.connectPromise
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Throws `SandboxWebSocketError` if the socket is not currently open.
|
|
139
|
+
*/
|
|
140
|
+
ensureOpen () {
|
|
141
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
142
|
+
throw new SandboxWebSocketError(`Sandbox '${this.id}' is not connected`)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Serialises `frame` and sends it over the socket.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} frame WebSocket frame to send
|
|
150
|
+
*/
|
|
151
|
+
send (frame) {
|
|
152
|
+
this.socket.send(JSON.stringify(frame))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Marks the next socket close as expected by the caller.
|
|
157
|
+
*/
|
|
158
|
+
beginIntentionalClose () {
|
|
159
|
+
this.intentionalClose = true
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Clears a previously requested intentional close.
|
|
164
|
+
*/
|
|
165
|
+
cancelIntentionalClose () {
|
|
166
|
+
this.intentionalClose = false
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Closes the underlying socket.
|
|
171
|
+
*
|
|
172
|
+
* @param {object} [options] close options
|
|
173
|
+
* @param {boolean} [options.intentional] whether pending work should be drained without error
|
|
174
|
+
*/
|
|
175
|
+
close ({ intentional = false } = {}) {
|
|
176
|
+
if (intentional) this.beginIntentionalClose()
|
|
177
|
+
this.socket?.close()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ------------------------------------------------------------------
|
|
181
|
+
// Pending operation helpers
|
|
182
|
+
// ------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Registers a pending exec, sends the frame, and returns the promises
|
|
186
|
+
* that will be settled by subsequent result or ack frames (for detached)
|
|
187
|
+
*
|
|
188
|
+
* @param {string} execId execution id for the pending command
|
|
189
|
+
* @param {object} frame the frame to send (must include `type` and `execId`)
|
|
190
|
+
* @param {{ detached: boolean, onOutput?: Function }} options pending exec options
|
|
191
|
+
* @returns {{ ackPromise: Promise, waitPromise: Promise|null }} promises for ack and optional completion
|
|
192
|
+
*/
|
|
193
|
+
sendExec (execId, frame, { detached, onOutput }) {
|
|
194
|
+
let waitResolve, waitReject, waitPromise
|
|
195
|
+
if (detached) {
|
|
196
|
+
waitPromise = new Promise((resolve, reject) => { waitResolve = resolve; waitReject = reject })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let resolve, reject
|
|
200
|
+
const ackPromise = new Promise((_resolve, _reject) => { resolve = _resolve; reject = _reject })
|
|
201
|
+
|
|
202
|
+
this.pendingExecs.set(execId, {
|
|
203
|
+
resolve,
|
|
204
|
+
reject,
|
|
205
|
+
waitResolve: waitResolve || null,
|
|
206
|
+
waitReject: waitReject || null,
|
|
207
|
+
_waitPromise: waitPromise || null,
|
|
208
|
+
stdout: '',
|
|
209
|
+
stderr: '',
|
|
210
|
+
onOutput: onOutput || null,
|
|
211
|
+
timeout: undefined,
|
|
212
|
+
detached,
|
|
213
|
+
resolved: false
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
this.send(frame)
|
|
218
|
+
} catch (error) {
|
|
219
|
+
this.rejectExec(execId, new SandboxWebSocketError(
|
|
220
|
+
`Could not send exec frame: ${error.message}`
|
|
221
|
+
))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { ackPromise, waitPromise: waitPromise || null }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Stores a timer handle on a pending exec entry so it can be cleared on completion.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} execId execution id for the pending command
|
|
231
|
+
* @param {ReturnType<setTimeout>} handle timeout handle to store
|
|
232
|
+
*/
|
|
233
|
+
setExecTimeout (execId, handle) {
|
|
234
|
+
const entry = this.pendingExecs.get(execId)
|
|
235
|
+
if (entry) entry.timeout = handle
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Rejects and removes a pending exec, clearing its timeout.
|
|
240
|
+
*
|
|
241
|
+
* @param {string} execId execution id for the pending command
|
|
242
|
+
* @param {Error} error error used to reject the command
|
|
243
|
+
*/
|
|
244
|
+
rejectExec (execId, error) {
|
|
245
|
+
const pending = this.pendingExecs.get(execId)
|
|
246
|
+
if (!pending) return
|
|
247
|
+
this.pendingExecs.delete(execId)
|
|
248
|
+
clearTimeout(pending.timeout)
|
|
249
|
+
|
|
250
|
+
// For detached execs, the first promise is always resolved, so reject the wait promise instead
|
|
251
|
+
if (pending.detached && pending.resolved) {
|
|
252
|
+
if (pending.waitReject) pending.waitReject(error)
|
|
253
|
+
} else {
|
|
254
|
+
pending.reject(error)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Rejects and removes a pending file operation.
|
|
260
|
+
*
|
|
261
|
+
* @param {string} execId file operation id
|
|
262
|
+
* @param {Error} error error used to reject the file operation
|
|
263
|
+
*/
|
|
264
|
+
rejectFileOp (execId, error) {
|
|
265
|
+
const pending = this.pendingFileOps.get(execId)
|
|
266
|
+
if (!pending) return
|
|
267
|
+
this.pendingFileOps.delete(execId)
|
|
268
|
+
pending.reject(error)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Resolves and removes a pending exec during an intentional sandbox shutdown.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} execId execution id for the pending command
|
|
275
|
+
*/
|
|
276
|
+
resolveExecOnIntentionalClose (execId) {
|
|
277
|
+
const pending = this.pendingExecs.get(execId)
|
|
278
|
+
if (!pending) return
|
|
279
|
+
this.pendingExecs.delete(execId)
|
|
280
|
+
clearTimeout(pending.timeout)
|
|
281
|
+
|
|
282
|
+
const result = { exitCode: null, destroyed: true }
|
|
283
|
+
if (pending.detached) {
|
|
284
|
+
if (!pending.resolved) {
|
|
285
|
+
pending.resolved = true
|
|
286
|
+
pending.resolve({ pid: undefined, startedAt: undefined, destroyed: true })
|
|
287
|
+
}
|
|
288
|
+
if (pending.waitResolve) pending.waitResolve(result)
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
pending.resolve({
|
|
293
|
+
execId,
|
|
294
|
+
stdout: pending.stdout,
|
|
295
|
+
stderr: pending.stderr,
|
|
296
|
+
...result
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
handleMessage (message) {
|
|
301
|
+
const frame = this.parseFrame(message)
|
|
302
|
+
if (!frame || this.isAuthAckFrame(frame)) return
|
|
303
|
+
|
|
304
|
+
if (frame.type === 'exec.info' ||
|
|
305
|
+
(frame.type === 'error' && this.pendingGetOps.has(frame.execId))) {
|
|
306
|
+
this.handleGetFrame(frame)
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (this.pendingFileOps.has(frame.execId)) {
|
|
311
|
+
this.handleFileFrame(frame)
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (this.pendingExecs.has(frame.execId)) {
|
|
316
|
+
this.handleExecFrame(frame)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
handleClose (code) {
|
|
321
|
+
if (this.intentionalClose) {
|
|
322
|
+
for (const execId of [...this.pendingExecs.keys()]) {
|
|
323
|
+
this.resolveExecOnIntentionalClose(execId)
|
|
324
|
+
}
|
|
325
|
+
for (const [, pending] of [...this.pendingFileOps.entries()]) {
|
|
326
|
+
pending.resolve(undefined)
|
|
327
|
+
}
|
|
328
|
+
for (const [, pending] of [...this.pendingGetOps.entries()]) {
|
|
329
|
+
pending.resolve(null)
|
|
330
|
+
}
|
|
331
|
+
this.pendingFileOps.clear()
|
|
332
|
+
this.pendingGetOps.clear()
|
|
333
|
+
this.connectPromise = null
|
|
334
|
+
this.socket = null
|
|
335
|
+
this.intentionalClose = false
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const error = this.createCloseError(code)
|
|
340
|
+
for (const execId of [...this.pendingExecs.keys()]) {
|
|
341
|
+
this.rejectExec(execId, error)
|
|
342
|
+
}
|
|
343
|
+
for (const execId of [...this.pendingFileOps.keys()]) {
|
|
344
|
+
this.rejectFileOp(execId, error)
|
|
345
|
+
}
|
|
346
|
+
for (const [, pending] of [...this.pendingGetOps.entries()]) {
|
|
347
|
+
pending.reject(error)
|
|
348
|
+
}
|
|
349
|
+
this.pendingGetOps.clear()
|
|
350
|
+
this.connectPromise = null
|
|
351
|
+
this.socket = null
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
createCloseError (code) {
|
|
355
|
+
if (code === 4001) {
|
|
356
|
+
return new SandboxUnauthorizedError(
|
|
357
|
+
`Sandbox '${this.id}' rejected the WebSocket authentication token`
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
if (code === 4003) {
|
|
361
|
+
return new ProtocolVersionMismatchError(
|
|
362
|
+
`Sandbox '${this.id}' WebSocket protocol version does not match this SDK`
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
if (code === 4004) {
|
|
366
|
+
return new SandboxMalformedFrameError(
|
|
367
|
+
`Sandbox '${this.id}' rejected a malformed WebSocket frame`
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
return new SandboxWebSocketError(
|
|
371
|
+
`Sandbox '${this.id}' WebSocket closed with code ${code}`
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
parseFrame (message) {
|
|
376
|
+
try {
|
|
377
|
+
return JSON.parse(message.toString())
|
|
378
|
+
} catch (_) {
|
|
379
|
+
return null
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
isAuthAckFrame (frame) {
|
|
384
|
+
return frame?.type === 'auth.ok' && (!frame.sandboxId || frame.sandboxId === this.id)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ------------------------------------------------------------------
|
|
388
|
+
// Frame routing
|
|
389
|
+
// ------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
handleExecFrame (frame) {
|
|
392
|
+
const pending = this.pendingExecs.get(frame.execId)
|
|
393
|
+
if (!pending) return
|
|
394
|
+
|
|
395
|
+
if (frame.type === 'exec.output') {
|
|
396
|
+
if (frame.stream === 'stderr') {
|
|
397
|
+
pending.stderr += frame.data || ''
|
|
398
|
+
} else {
|
|
399
|
+
pending.stdout += frame.data || ''
|
|
400
|
+
}
|
|
401
|
+
if (pending.onOutput) {
|
|
402
|
+
pending.onOutput(frame.data || '', frame.stream || 'stdout')
|
|
403
|
+
}
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// For detached cmds, we will resolve with the server response, which is all the command info needed for a
|
|
408
|
+
// handle. The entry stays in pendingExecs to receive subsequent output and exec.exit.
|
|
409
|
+
if (frame.type === 'exec.detached') {
|
|
410
|
+
clearTimeout(pending.timeout)
|
|
411
|
+
pending.timeout = undefined
|
|
412
|
+
pending.resolved = true
|
|
413
|
+
pending.resolve({ pid: frame.pid, startedAt: frame.startedAt })
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (frame.type === 'exec.exit') {
|
|
418
|
+
this.pendingExecs.delete(frame.execId)
|
|
419
|
+
clearTimeout(pending.timeout)
|
|
420
|
+
if (pending.detached && pending.resolved) {
|
|
421
|
+
// For detached, the initial ack promise already resolved, so we resolve the wait promise
|
|
422
|
+
if (pending.waitResolve) {
|
|
423
|
+
pending.waitResolve({ exitCode: frame.exitCode })
|
|
424
|
+
}
|
|
425
|
+
} else {
|
|
426
|
+
pending.resolve({
|
|
427
|
+
execId: frame.execId,
|
|
428
|
+
stdout: pending.stdout,
|
|
429
|
+
stderr: pending.stderr,
|
|
430
|
+
exitCode: frame.exitCode
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (frame.type === 'error') {
|
|
437
|
+
this.rejectExec(frame.execId, new SandboxClientError(
|
|
438
|
+
frame.message || `Command '${frame.execId}' failed`
|
|
439
|
+
))
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
handleFileFrame (frame) {
|
|
444
|
+
const pending = this.pendingFileOps.get(frame.execId)
|
|
445
|
+
if (!pending) return
|
|
446
|
+
|
|
447
|
+
if (frame.type === 'file.content') {
|
|
448
|
+
this.pendingFileOps.delete(frame.execId)
|
|
449
|
+
const content = frame.encoding === 'base64'
|
|
450
|
+
? Buffer.from(frame.content, 'base64').toString('utf8')
|
|
451
|
+
: (frame.content || '')
|
|
452
|
+
pending.resolve(content)
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (frame.type === 'file.writeResult') {
|
|
457
|
+
this.pendingFileOps.delete(frame.execId)
|
|
458
|
+
if (!frame.ok) {
|
|
459
|
+
pending.reject(new SandboxClientError(
|
|
460
|
+
`file.write failed for path '${frame.path}'`
|
|
461
|
+
))
|
|
462
|
+
} else {
|
|
463
|
+
pending.resolve({ path: frame.path, size: frame.size, ok: frame.ok })
|
|
464
|
+
}
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (frame.type === 'file.entries') {
|
|
469
|
+
this.pendingFileOps.delete(frame.execId)
|
|
470
|
+
pending.resolve(frame.entries || [])
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (frame.type === 'error') {
|
|
475
|
+
this.rejectFileOp(frame.execId, new SandboxClientError(
|
|
476
|
+
frame.message || `File operation '${frame.execId}' failed`
|
|
477
|
+
))
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Handles exec.info (response to exec.get) and error frames routed from handleMessage.
|
|
483
|
+
*
|
|
484
|
+
* @param {object} frame exec.get response or error frame
|
|
485
|
+
*/
|
|
486
|
+
handleGetFrame (frame) {
|
|
487
|
+
const pending = this.pendingGetOps.get(frame.execId)
|
|
488
|
+
if (!pending) return
|
|
489
|
+
|
|
490
|
+
if (frame.type === 'exec.info') {
|
|
491
|
+
this.resolveGetOp(frame, pending)
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (frame.type === 'error') {
|
|
496
|
+
this.rejectGetOp(frame, pending)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Resolves a pending exec.get by building a command handle and resolving the caller's promise.
|
|
502
|
+
*
|
|
503
|
+
* @param {object} frame exec.info frame
|
|
504
|
+
* @param {object} pending entry from pendingGetOps
|
|
505
|
+
*/
|
|
506
|
+
resolveGetOp (frame, pending) {
|
|
507
|
+
this.pendingGetOps.delete(frame.execId)
|
|
508
|
+
const waitPromise = this.resolveExecEntry(frame, pending)
|
|
509
|
+
const commandObj = this.buildCommandObject(frame, waitPromise, pending.sandbox)
|
|
510
|
+
pending.resolve(commandObj)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Rejects a pending exec.get with a not-found error.
|
|
515
|
+
*
|
|
516
|
+
* @param {object} frame error frame
|
|
517
|
+
* @param {object} pending entry from pendingGetOps
|
|
518
|
+
*/
|
|
519
|
+
rejectGetOp (frame, pending) {
|
|
520
|
+
this.pendingGetOps.delete(frame.execId)
|
|
521
|
+
pending.reject(new SandboxCommandNotFoundError(
|
|
522
|
+
frame.message || `No running process for execId '${frame.execId}'`
|
|
523
|
+
))
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Returns the wait promise for the exec, either by reusing an existing pendingExecs entry
|
|
528
|
+
* (same session) or by registering a fresh reattached entry (new session / previous connection).
|
|
529
|
+
*
|
|
530
|
+
* @param {object} frame exec.info frame
|
|
531
|
+
* @param {object} pending entry from pendingGetOps
|
|
532
|
+
* @returns {Promise} wait promise for the running command
|
|
533
|
+
*/
|
|
534
|
+
resolveExecEntry (frame, pending) {
|
|
535
|
+
const existingExec = this.pendingExecs.get(frame.execId)
|
|
536
|
+
if (existingExec) {
|
|
537
|
+
existingExec.onOutput = this.mergeOnOutputCallback(existingExec.onOutput, pending.onOutput)
|
|
538
|
+
return existingExec._waitPromise
|
|
539
|
+
}
|
|
540
|
+
return this.registerReattachedExec(frame, pending.onOutput)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Appends `onOutput` to an existing exec entry's callback chain, preserving the previous handler.
|
|
545
|
+
*
|
|
546
|
+
* @param {Function|null|undefined} prev existing callback
|
|
547
|
+
* @param {Function|undefined} onOutput new callback to add
|
|
548
|
+
* @returns {Function|null|undefined} merged callback
|
|
549
|
+
*/
|
|
550
|
+
mergeOnOutputCallback (prev, onOutput) {
|
|
551
|
+
if (!onOutput) return prev
|
|
552
|
+
return (data, stream) => {
|
|
553
|
+
if (prev) prev(data, stream)
|
|
554
|
+
onOutput(data, stream)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Creates a fresh pendingExecs entry for a process reattached from a previous connection,
|
|
560
|
+
* registers it, and returns its wait promise.
|
|
561
|
+
*
|
|
562
|
+
* @param {object} frame exec.info frame
|
|
563
|
+
* @param {Function|undefined} onOutput output callback
|
|
564
|
+
* @returns {Promise} wait promise for the reattached command
|
|
565
|
+
*/
|
|
566
|
+
registerReattachedExec (frame, onOutput) {
|
|
567
|
+
let waitResolve, waitReject
|
|
568
|
+
const waitPromise = new Promise((resolve, reject) => { waitResolve = resolve; waitReject = reject })
|
|
569
|
+
this.pendingExecs.set(frame.execId, {
|
|
570
|
+
resolve: () => {},
|
|
571
|
+
reject: () => {},
|
|
572
|
+
waitResolve,
|
|
573
|
+
waitReject,
|
|
574
|
+
_waitPromise: waitPromise,
|
|
575
|
+
stdout: '',
|
|
576
|
+
stderr: '',
|
|
577
|
+
onOutput: onOutput || null,
|
|
578
|
+
timeout: undefined,
|
|
579
|
+
detached: frame.detached,
|
|
580
|
+
resolved: true
|
|
581
|
+
})
|
|
582
|
+
return waitPromise
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Builds the command handle object returned to the caller of exec.get.
|
|
587
|
+
*
|
|
588
|
+
* @param {object} frame exec.info frame
|
|
589
|
+
* @param {Promise} waitPromise resolves when the process exits
|
|
590
|
+
* @param {object} sandbox Sandbox instance for delegating control operations
|
|
591
|
+
* @returns {object} command handle with wait and control helpers
|
|
592
|
+
*/
|
|
593
|
+
buildCommandObject (frame, waitPromise, sandbox) {
|
|
594
|
+
const { execId } = frame
|
|
595
|
+
return {
|
|
596
|
+
execId,
|
|
597
|
+
command: frame.command,
|
|
598
|
+
pid: frame.pid,
|
|
599
|
+
startedAt: frame.startedAt,
|
|
600
|
+
detached: frame.detached,
|
|
601
|
+
wait: () => waitPromise,
|
|
602
|
+
writeStdin: (data) => sandbox.writeStdin(execId, data),
|
|
603
|
+
closeStdin: () => sandbox.closeStdin(execId),
|
|
604
|
+
kill: (signal) => sandbox.kill(execId, signal)
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
module.exports = { SandboxSocket }
|