@adobe/aio-lib-sandbox 0.1.0-alpha.4 → 0.1.0-alpha.5
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/README.md +42 -4
- package/RELEASING.md +36 -0
- package/package.json +1 -1
- package/src/Sandbox.js +178 -70
- package/src/errors.js +7 -1
- package/src/index.js +8 -2
- package/src/ws.js +310 -10
- package/test/Sandbox.test.js +552 -17
- package/.github/workflows/check-npm-token.yml +0 -16
package/src/ws.js
CHANGED
|
@@ -12,6 +12,7 @@ governing permissions and limitations under the License.
|
|
|
12
12
|
const WebSocket = require('ws')
|
|
13
13
|
const {
|
|
14
14
|
SandboxClientError,
|
|
15
|
+
SandboxCommandNotFoundError,
|
|
15
16
|
SandboxUnauthorizedError,
|
|
16
17
|
SandboxWebSocketError
|
|
17
18
|
} = require('./errors')
|
|
@@ -36,11 +37,27 @@ class SandboxSocket {
|
|
|
36
37
|
|
|
37
38
|
this.socket = null
|
|
38
39
|
this.connectPromise = null
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
this.intentionalClose = false
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pending exec entries
|
|
44
|
+
*
|
|
45
|
+
* @type {Map<string, {
|
|
46
|
+
* resolve: Function, reject: Function,
|
|
47
|
+
* waitResolve: Function|null, waitReject: Function|null,
|
|
48
|
+
* stdout: string, stderr: string,
|
|
49
|
+
* onOutput: Function|undefined, timeout: any,
|
|
50
|
+
* detached: boolean, resolved: boolean
|
|
51
|
+
* }>}
|
|
52
|
+
*/
|
|
41
53
|
this.pendingExecs = new Map()
|
|
42
54
|
/** @type {Map<string, {resolve: Function, reject: Function}>} */
|
|
43
55
|
this.pendingFileOps = new Map()
|
|
56
|
+
/**
|
|
57
|
+
* Pending exec.get operations keyed by execId.
|
|
58
|
+
* @type {Map<string, {resolve: Function, reject: Function, onOutput: Function|undefined, sandbox: object}>}
|
|
59
|
+
*/
|
|
60
|
+
this.pendingGetOps = new Map()
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
/**
|
|
@@ -84,6 +101,10 @@ class SandboxSocket {
|
|
|
84
101
|
const onClose = (code) => {
|
|
85
102
|
cleanup()
|
|
86
103
|
this.connectPromise = null
|
|
104
|
+
if (this.intentionalClose) {
|
|
105
|
+
resolve()
|
|
106
|
+
return
|
|
107
|
+
}
|
|
87
108
|
reject(this.createCloseError(code))
|
|
88
109
|
}
|
|
89
110
|
|
|
@@ -129,10 +150,28 @@ class SandboxSocket {
|
|
|
129
150
|
this.socket.send(JSON.stringify(frame))
|
|
130
151
|
}
|
|
131
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Marks the next socket close as expected by the caller.
|
|
155
|
+
*/
|
|
156
|
+
beginIntentionalClose () {
|
|
157
|
+
this.intentionalClose = true
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Clears a previously requested intentional close.
|
|
162
|
+
*/
|
|
163
|
+
cancelIntentionalClose () {
|
|
164
|
+
this.intentionalClose = false
|
|
165
|
+
}
|
|
166
|
+
|
|
132
167
|
/**
|
|
133
168
|
* Closes the underlying socket.
|
|
169
|
+
*
|
|
170
|
+
* @param {object} [options]
|
|
171
|
+
* @param {boolean} [options.intentional] whether pending work should be drained without error
|
|
134
172
|
*/
|
|
135
|
-
close () {
|
|
173
|
+
close ({ intentional = false } = {}) {
|
|
174
|
+
if (intentional) this.beginIntentionalClose()
|
|
136
175
|
this.socket?.close()
|
|
137
176
|
}
|
|
138
177
|
|
|
@@ -140,6 +179,60 @@ class SandboxSocket {
|
|
|
140
179
|
// Pending operation helpers
|
|
141
180
|
// ------------------------------------------------------------------
|
|
142
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Registers a pending exec, sends the frame, and returns the promises
|
|
184
|
+
* that will be settled by subsequent result or ack frames (for detached)
|
|
185
|
+
*
|
|
186
|
+
* @param {string} execId
|
|
187
|
+
* @param {object} frame the frame to send (must include `type` and `execId`)
|
|
188
|
+
* @param {{ detached: boolean, onOutput?: Function }} options
|
|
189
|
+
* @returns {{ ackPromise: Promise, waitPromise: Promise|null }}
|
|
190
|
+
*/
|
|
191
|
+
sendExec (execId, frame, { detached, onOutput }) {
|
|
192
|
+
let waitResolve, waitReject, waitPromise
|
|
193
|
+
if (detached) {
|
|
194
|
+
waitPromise = new Promise((res, rej) => { waitResolve = res; waitReject = rej })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let resolve, reject
|
|
198
|
+
const ackPromise = new Promise((res, rej) => { resolve = res; reject = rej })
|
|
199
|
+
|
|
200
|
+
this.pendingExecs.set(execId, {
|
|
201
|
+
resolve,
|
|
202
|
+
reject,
|
|
203
|
+
waitResolve: waitResolve || null,
|
|
204
|
+
waitReject: waitReject || null,
|
|
205
|
+
_waitPromise: waitPromise || null,
|
|
206
|
+
stdout: '',
|
|
207
|
+
stderr: '',
|
|
208
|
+
onOutput: onOutput || null,
|
|
209
|
+
timeout: undefined,
|
|
210
|
+
detached,
|
|
211
|
+
resolved: false
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
this.send(frame)
|
|
216
|
+
} catch (error) {
|
|
217
|
+
this.rejectExec(execId, new SandboxWebSocketError(
|
|
218
|
+
`Could not send exec frame: ${error.message}`
|
|
219
|
+
))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { ackPromise, waitPromise: waitPromise || null }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Stores a timer handle on a pending exec entry so it can be cleared on completion.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} execId
|
|
229
|
+
* @param {ReturnType<setTimeout>} handle
|
|
230
|
+
*/
|
|
231
|
+
setExecTimeout (execId, handle) {
|
|
232
|
+
const entry = this.pendingExecs.get(execId)
|
|
233
|
+
if (entry) entry.timeout = handle
|
|
234
|
+
}
|
|
235
|
+
|
|
143
236
|
/**
|
|
144
237
|
* Rejects and removes a pending exec, clearing its timeout.
|
|
145
238
|
*
|
|
@@ -151,7 +244,13 @@ class SandboxSocket {
|
|
|
151
244
|
if (!pending) return
|
|
152
245
|
this.pendingExecs.delete(execId)
|
|
153
246
|
clearTimeout(pending.timeout)
|
|
154
|
-
|
|
247
|
+
|
|
248
|
+
// For detached execs, the first promise is always resolved, so reject the wait promise instead
|
|
249
|
+
if (pending.detached && pending.resolved) {
|
|
250
|
+
if (pending.waitReject) pending.waitReject(error)
|
|
251
|
+
} else {
|
|
252
|
+
pending.reject(error)
|
|
253
|
+
}
|
|
155
254
|
}
|
|
156
255
|
|
|
157
256
|
/**
|
|
@@ -167,10 +266,45 @@ class SandboxSocket {
|
|
|
167
266
|
pending.reject(error)
|
|
168
267
|
}
|
|
169
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Resolves and removes a pending exec during an intentional sandbox shutdown.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} execId
|
|
273
|
+
*/
|
|
274
|
+
resolveExecOnIntentionalClose (execId) {
|
|
275
|
+
const pending = this.pendingExecs.get(execId)
|
|
276
|
+
if (!pending) return
|
|
277
|
+
this.pendingExecs.delete(execId)
|
|
278
|
+
clearTimeout(pending.timeout)
|
|
279
|
+
|
|
280
|
+
const result = { exitCode: null, destroyed: true }
|
|
281
|
+
if (pending.detached) {
|
|
282
|
+
if (!pending.resolved) {
|
|
283
|
+
pending.resolved = true
|
|
284
|
+
pending.resolve({ pid: undefined, startedAt: undefined, destroyed: true })
|
|
285
|
+
}
|
|
286
|
+
if (pending.waitResolve) pending.waitResolve(result)
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
pending.resolve({
|
|
291
|
+
execId,
|
|
292
|
+
stdout: pending.stdout,
|
|
293
|
+
stderr: pending.stderr,
|
|
294
|
+
...result
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
170
298
|
handleMessage (message) {
|
|
171
299
|
const frame = this.parseFrame(message)
|
|
172
300
|
if (!frame || this.isAuthAckFrame(frame)) return
|
|
173
301
|
|
|
302
|
+
if (frame.type === 'exec.info' ||
|
|
303
|
+
(frame.type === 'error' && this.pendingGetOps.has(frame.execId))) {
|
|
304
|
+
this.handleGetFrame(frame)
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
|
|
174
308
|
if (this.pendingFileOps.has(frame.execId)) {
|
|
175
309
|
this.handleFileFrame(frame)
|
|
176
310
|
return
|
|
@@ -182,6 +316,24 @@ class SandboxSocket {
|
|
|
182
316
|
}
|
|
183
317
|
|
|
184
318
|
handleClose (code) {
|
|
319
|
+
if (this.intentionalClose) {
|
|
320
|
+
for (const execId of [...this.pendingExecs.keys()]) {
|
|
321
|
+
this.resolveExecOnIntentionalClose(execId)
|
|
322
|
+
}
|
|
323
|
+
for (const [, pending] of [...this.pendingFileOps.entries()]) {
|
|
324
|
+
pending.resolve(undefined)
|
|
325
|
+
}
|
|
326
|
+
for (const [, pending] of [...this.pendingGetOps.entries()]) {
|
|
327
|
+
pending.resolve(null)
|
|
328
|
+
}
|
|
329
|
+
this.pendingFileOps.clear()
|
|
330
|
+
this.pendingGetOps.clear()
|
|
331
|
+
this.connectPromise = null
|
|
332
|
+
this.socket = null
|
|
333
|
+
this.intentionalClose = false
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
185
337
|
const error = this.createCloseError(code)
|
|
186
338
|
for (const execId of [...this.pendingExecs.keys()]) {
|
|
187
339
|
this.rejectExec(execId, error)
|
|
@@ -189,6 +341,10 @@ class SandboxSocket {
|
|
|
189
341
|
for (const execId of [...this.pendingFileOps.keys()]) {
|
|
190
342
|
this.rejectFileOp(execId, error)
|
|
191
343
|
}
|
|
344
|
+
for (const [, pending] of [...this.pendingGetOps.entries()]) {
|
|
345
|
+
pending.reject(error)
|
|
346
|
+
}
|
|
347
|
+
this.pendingGetOps.clear()
|
|
192
348
|
this.connectPromise = null
|
|
193
349
|
this.socket = null
|
|
194
350
|
}
|
|
@@ -236,15 +392,32 @@ class SandboxSocket {
|
|
|
236
392
|
return
|
|
237
393
|
}
|
|
238
394
|
|
|
395
|
+
// For detached cmds, we will resolve with the server response, which is all the command info needed for a
|
|
396
|
+
// handle. The entry stays in pendingExecs to receive subsequent output and exec.exit.
|
|
397
|
+
if (frame.type === 'exec.detached') {
|
|
398
|
+
clearTimeout(pending.timeout)
|
|
399
|
+
pending.timeout = undefined
|
|
400
|
+
pending.resolved = true
|
|
401
|
+
pending.resolve({ pid: frame.pid, startedAt: frame.startedAt })
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
|
|
239
405
|
if (frame.type === 'exec.exit') {
|
|
240
406
|
this.pendingExecs.delete(frame.execId)
|
|
241
407
|
clearTimeout(pending.timeout)
|
|
242
|
-
pending.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
408
|
+
if (pending.detached && pending.resolved) {
|
|
409
|
+
// For detached, the initial ack promise already resolved, so we resolve the wait promise
|
|
410
|
+
if (pending.waitResolve) {
|
|
411
|
+
pending.waitResolve({ exitCode: frame.exitCode })
|
|
412
|
+
}
|
|
413
|
+
} else {
|
|
414
|
+
pending.resolve({
|
|
415
|
+
execId: frame.execId,
|
|
416
|
+
stdout: pending.stdout,
|
|
417
|
+
stderr: pending.stderr,
|
|
418
|
+
exitCode: frame.exitCode
|
|
419
|
+
})
|
|
420
|
+
}
|
|
248
421
|
return
|
|
249
422
|
}
|
|
250
423
|
|
|
@@ -292,6 +465,133 @@ class SandboxSocket {
|
|
|
292
465
|
))
|
|
293
466
|
}
|
|
294
467
|
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Handles exec.info (response to exec.get) and error frames routed from handleMessage.
|
|
471
|
+
*
|
|
472
|
+
* @param {object} frame
|
|
473
|
+
*/
|
|
474
|
+
handleGetFrame (frame) {
|
|
475
|
+
const pending = this.pendingGetOps.get(frame.execId)
|
|
476
|
+
if (!pending) return
|
|
477
|
+
|
|
478
|
+
if (frame.type === 'exec.info') {
|
|
479
|
+
this.resolveGetOp(frame, pending)
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (frame.type === 'error') {
|
|
484
|
+
this.rejectGetOp(frame, pending)
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Resolves a pending exec.get by building a command handle and resolving the caller's promise.
|
|
490
|
+
*
|
|
491
|
+
* @param {object} frame exec.info frame
|
|
492
|
+
* @param {object} pending entry from pendingGetOps
|
|
493
|
+
*/
|
|
494
|
+
resolveGetOp (frame, pending) {
|
|
495
|
+
this.pendingGetOps.delete(frame.execId)
|
|
496
|
+
const waitPromise = this.resolveExecEntry(frame, pending)
|
|
497
|
+
const commandObj = this.buildCommandObject(frame, waitPromise, pending.sandbox)
|
|
498
|
+
pending.resolve(commandObj)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Rejects a pending exec.get with a not-found error.
|
|
503
|
+
*
|
|
504
|
+
* @param {object} frame error frame
|
|
505
|
+
* @param {object} pending entry from pendingGetOps
|
|
506
|
+
*/
|
|
507
|
+
rejectGetOp (frame, pending) {
|
|
508
|
+
this.pendingGetOps.delete(frame.execId)
|
|
509
|
+
pending.reject(new SandboxCommandNotFoundError(
|
|
510
|
+
frame.message || `No running process for execId '${frame.execId}'`
|
|
511
|
+
))
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Returns the wait promise for the exec, either by reusing an existing pendingExecs entry
|
|
516
|
+
* (same session) or by registering a fresh reattached entry (new session / previous connection).
|
|
517
|
+
*
|
|
518
|
+
* @param {object} frame exec.info frame
|
|
519
|
+
* @param {object} pending entry from pendingGetOps
|
|
520
|
+
* @returns {Promise}
|
|
521
|
+
*/
|
|
522
|
+
resolveExecEntry (frame, pending) {
|
|
523
|
+
const existingExec = this.pendingExecs.get(frame.execId)
|
|
524
|
+
if (existingExec) {
|
|
525
|
+
this.mergeOnOutputCallback(existingExec, pending.onOutput)
|
|
526
|
+
return existingExec._waitPromise
|
|
527
|
+
}
|
|
528
|
+
return this.registerReattachedExec(frame, pending.onOutput)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Appends `onOutput` to an existing exec entry's callback chain, preserving the previous handler.
|
|
533
|
+
*
|
|
534
|
+
* @param {object} existingExec entry from pendingExecs
|
|
535
|
+
* @param {Function|undefined} onOutput new callback to add
|
|
536
|
+
*/
|
|
537
|
+
mergeOnOutputCallback (existingExec, onOutput) {
|
|
538
|
+
if (!onOutput) return
|
|
539
|
+
const prev = existingExec.onOutput
|
|
540
|
+
existingExec.onOutput = (data, stream) => {
|
|
541
|
+
if (prev) prev(data, stream)
|
|
542
|
+
onOutput(data, stream)
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Creates a fresh pendingExecs entry for a process reattached from a previous connection,
|
|
548
|
+
* registers it, and returns its wait promise.
|
|
549
|
+
*
|
|
550
|
+
* @param {object} frame exec.info frame
|
|
551
|
+
* @param {Function|undefined} onOutput output callback
|
|
552
|
+
* @returns {Promise}
|
|
553
|
+
*/
|
|
554
|
+
registerReattachedExec (frame, onOutput) {
|
|
555
|
+
let waitResolve, waitReject
|
|
556
|
+
const waitPromise = new Promise((res, rej) => { waitResolve = res; waitReject = rej })
|
|
557
|
+
this.pendingExecs.set(frame.execId, {
|
|
558
|
+
resolve: () => {},
|
|
559
|
+
reject: () => {},
|
|
560
|
+
waitResolve,
|
|
561
|
+
waitReject,
|
|
562
|
+
_waitPromise: waitPromise,
|
|
563
|
+
stdout: '',
|
|
564
|
+
stderr: '',
|
|
565
|
+
onOutput: onOutput || null,
|
|
566
|
+
timeout: undefined,
|
|
567
|
+
detached: frame.detached,
|
|
568
|
+
resolved: true
|
|
569
|
+
})
|
|
570
|
+
return waitPromise
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Builds the command handle object returned to the caller of exec.get.
|
|
575
|
+
*
|
|
576
|
+
* @param {object} frame exec.info frame
|
|
577
|
+
* @param {Promise} waitPromise resolves when the process exits
|
|
578
|
+
* @param {object} sandbox Sandbox instance for delegating control operations
|
|
579
|
+
* @returns {object}
|
|
580
|
+
*/
|
|
581
|
+
buildCommandObject (frame, waitPromise, sandbox) {
|
|
582
|
+
const { execId } = frame
|
|
583
|
+
return {
|
|
584
|
+
execId,
|
|
585
|
+
command: frame.command,
|
|
586
|
+
pid: frame.pid,
|
|
587
|
+
startedAt: frame.startedAt,
|
|
588
|
+
detached: frame.detached,
|
|
589
|
+
wait: () => waitPromise,
|
|
590
|
+
writeStdin: (data) => sandbox.writeStdin(execId, data),
|
|
591
|
+
closeStdin: () => sandbox.closeStdin(execId),
|
|
592
|
+
kill: (signal) => sandbox.kill(execId, signal)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
295
595
|
}
|
|
296
596
|
|
|
297
597
|
module.exports = { SandboxSocket }
|