feat: support for agent sandboxes#226
Conversation
There was a problem hiding this comment.
🤖 PR Reviewer
The implementation is well-structured with good test coverage, proper error typing, and clean separation between SandboxAPI and Sandbox classes. A few notable concerns include credential exposure via logging, inconsistent property name (timeout vs timeoutMs) between types.d.ts and actual implementation, and repeated https.Agent instantiation in two places that could be refactored into a shared helper.
📝 6 suggestion(s) - Please review inline comments below.
💡 How to re-trigger
Comment /review or /pr-reviewer on this PR
⚠️ Inline comments could not be attached (lines not in diff). See summary above.
This reverts commit 10f5e2b.
There was a problem hiding this comment.
🤖 PR Reviewer
This PR adds a well-structured Sandbox/ComputeAPI feature with WebSocket-based command execution, file management, and network policy support. The code is generally clean with good test coverage, but there are several issues: a missing connect() method implementation in SandboxAPI, an inconsistency between the TypeScript declaration and implementation, a potential resource leak in the https require pattern, and a security concern around API key handling in headers.
📝 7 suggestion(s) - Please review inline comments below.
💡 How to re-trigger
Comment /review or /pr-reviewer on this PR
| `${this._getSandboxPath()}/${sandboxId}` | ||
| ) | ||
| } | ||
|
|
There was a problem hiding this comment.
The SandboxAPI class is missing a connect() method but it is declared in types.d.ts (line 232: connect(options: SandboxConnectOptions): Promise<Sandbox>). Without this method, users cannot reconnect to an existing sandbox. The _buildSandbox helper already does the heavy lifting, so this should be straightforward to add.
| async connect (options = {}) { | |
| const payload = await this._request( | |
| 'get sandbox status', | |
| 'GET', | |
| `${this._getSandboxPath()}/${options.sandboxId}` | |
| ) | |
| payload.token = options.token || payload.token | |
| return await this._buildSandbox(payload) | |
| } |
| if (this.agent) { | ||
| requestOptions.agent = this.agent | ||
| } else if (this.ignoreCerts) { | ||
| const https = require('node:https') |
There was a problem hiding this comment.
Requiring node:https inside a hot path (_request is called for every API operation) is inefficient — Node caches modules but the conditional expression still runs on every call. Move the require to the top of the file.
| const https = require('node:https') | |
| const https = require('node:https') |
| ? content.toString('base64') | ||
| : Buffer.from(content).toString('base64') | ||
|
|
||
| const opPromise = new Promise((resolve, reject) => { |
There was a problem hiding this comment.
Same pattern: require('node:https') is called inside getStatus() which may be called repeatedly. Move it to the top of the file alongside other requires.
| const opPromise = new Promise((resolve, reject) => { | |
| const https = require('node:https') |
| * @returns {string} the Authorization header value | ||
| */ | ||
| function buildAuthorizationHeader (apiKey) { | ||
| return `Basic ${Buffer.from(apiKey).toString('base64')}` |
There was a problem hiding this comment.
The buildAuthorizationHeader function base64-encodes the raw API key string. If the key is already in uuid:password format this is correct for Basic auth, but the function name and JSDoc say 'uuid:key format' without validating or warning. More importantly, the API key is held in memory in plain string form and passed through multiple layers. Consider at minimum documenting that callers must not log the return value, and add a guard for empty/undefined keys to avoid silently sending an empty Authorization header.
| return `Basic ${Buffer.from(apiKey).toString('base64')}` | |
| function buildAuthorizationHeader (apiKey) { | |
| if (!apiKey) { | |
| throw new Error('apiKey is required to build an Authorization header') | |
| } | |
| return `Basic ${Buffer.from(apiKey).toString('base64')}` | |
| } |
| }; | ||
| }; | ||
| }; | ||
|
|
There was a problem hiding this comment.
The SandboxExecOptions type declares timeoutMs but the implementation in Sandbox.js uses timeout (line 163: options.timeout). This mismatch means TypeScript users passing timeoutMs will silently get no timeout behaviour.
| declare type SandboxExecOptions = { | |
| onOutput?: (...params: any[]) => any; | |
| timeout?: number; | |
| stdin?: string | Buffer; | |
| }; |
| } | ||
|
|
||
| const onClose = (code) => { | ||
| cleanup() |
There was a problem hiding this comment.
The connect() method adds a permanent message listener via socket.on('message', onMessage) for the auth handshake, but also registers the general socket.on('message', message => this._handleMessage(message)) listener at line 78. During the auth phase both listeners fire for every message. The cleanup() function removes the auth-phase onMessage listener correctly, but if the WebSocket emits messages before auth.ok, _handleMessage will also be called with them (and safely no-op). This is acceptable, but a comment explaining the dual-listener approach would improve clarity. More critically, if connect() is called again after a close (new socket), the old permanent _handleMessage listener from line 78 is on the old socket which is replaced — this is fine. No code change needed, but add a comment.
| cleanup() | |
| // Two message listeners are registered: the permanent _handleMessage for | |
| // all post-auth frames, and a one-shot onMessage solely to detect auth.ok. | |
| // Both are harmless during the handshake since _handleMessage ignores auth frames. | |
| socket.on('message', message => this._handleMessage(message)) |
| * @param {string} [signal] signal to deliver | ||
| */ | ||
| kill (execId, signal = 'SIGTERM') { | ||
| this._ensureOpen() |
There was a problem hiding this comment.
When exec() successfully sends the exec.run frame but then writeStdin or closeStdin throws (e.g. due to a race where the socket closes between frames), the catch block rejects the promise, but the exec.run frame has already been sent to the server. The server may still send back exec.output/exec.exit frames for this execId after the client has rejected and deleted it from _pendingExecs. Those frames will be silently dropped by _handleMessage, which is acceptable, but worth a comment.
| this._ensureOpen() | |
| // If writeStdin/closeStdin fails after exec.run was sent, we reject | |
| // locally. The server may still emit output/exit frames for this execId | |
| // which will be silently ignored by _handleMessage. | |
| this._rejectPendingExec(execId, new codes.ERROR_SANDBOX_WEBSOCKET({ |
There was a problem hiding this comment.
🤖 PR Reviewer
The diff introduces a well-structured Sandbox/SandboxAPI/ComputeAPI subsystem with good test coverage. Several issues from the previous review round remain unaddressed (require inside hot path, missing apiKey guard, timeoutMs/timeout mismatch in types.d.ts), and a few new issues are present including the connect method on Sandbox registering two permanent message listeners if called after reconnect, and the copyright year '2026' across new files.
📝 9 suggestion(s) (5 new, 4 re-raised)
💡 How to re-trigger
Comment /review or /pr-reviewer on this PR
| declare type SandboxSizes = { | ||
| SMALL: SandboxSize; | ||
| MEDIUM: SandboxSize; | ||
| LARGE: SandboxSize; |
There was a problem hiding this comment.
[Re-raised] SandboxExecOptions declares timeoutMs but the implementation in Sandbox.js reads options.timeout. TypeScript users passing timeoutMs will silently get no timeout behaviour.
| LARGE: SandboxSize; | |
| declare type SandboxExecOptions = { | |
| onOutput?: (...params: any[]) => any; | |
| timeout?: number; | |
| stdin?: string | Buffer; | |
| }; |
| */ | ||
| function buildAuthorizationHeader (apiKey) { | ||
| return `Basic ${Buffer.from(apiKey).toString('base64')}` | ||
| } |
There was a problem hiding this comment.
[Re-raised] buildAuthorizationHeader silently sends an empty/broken Authorization header when apiKey is undefined or empty. Add a guard.
| } | |
| function buildAuthorizationHeader (apiKey) { | |
| if (!apiKey) { | |
| throw new Error('apiKey is required to build an Authorization header') | |
| } | |
| return `Basic ${Buffer.from(apiKey).toString('base64')}` | |
| } |
| return createSandboxHttpError(codes, status, messageValues) | ||
| } | ||
|
|
||
| _buildAuthorizationHeader () { |
There was a problem hiding this comment.
[Re-raised] require('node:https') is called inside _request, which is on the hot path for every API operation. Move it to the top of the file.
| _buildAuthorizationHeader () { | |
| const https = require('node:https') |
| * Writes a file to the sandbox filesystem. Parent directories are created automatically. | ||
| * | ||
| * @param {string} path absolute path inside the sandbox | ||
| * @param {string|Buffer} content file contents |
There was a problem hiding this comment.
[Re-raised] Same pattern: require('node:https') inside getStatus() which may be called repeatedly. Move it to the top of the file.
| * @param {string|Buffer} content file contents | |
| const https = require('node:https') |
| const socket = this._socket | ||
|
|
||
| socket.on('message', message => this._handleMessage(message)) | ||
| socket.on('close', (code, reason) => this._handleClose(code, reason)) |
There was a problem hiding this comment.
Every call to connect() after a socket close creates a new WebSocket and registers a new permanent socket.on('message', message => this._handleMessage(message)) listener on it. This is fine for the new socket, but if connect() is called multiple times (reconnect scenario), each new socket gets its own listener which is correct. However, there is a subtle bug: the onMessage auth-phase listener added at line 107 (socket.on('message', onMessage)) is never removed via socket.off if the socket emits open then auth.ok — cleanup() is called which does socket.off('message', onMessage) correctly. This path is safe. No code change needed here, but the dual-listener comment suggested in the previous review is still missing. Add it for clarity.
| socket.on('close', (code, reason) => this._handleClose(code, reason)) | |
| // Two message listeners are registered: the permanent _handleMessage for | |
| // all post-auth frames, and a one-shot onMessage solely to detect auth.ok. | |
| // Both are harmless during the handshake since _handleMessage ignores auth frames. | |
| socket.on('message', message => this._handleMessage(message)) |
| * @param {string} execId execution id | ||
| * @param {string} [signal] signal to deliver | ||
| */ | ||
| kill (execId, signal = 'SIGTERM') { |
There was a problem hiding this comment.
When exec() successfully sends exec.run but writeStdin or closeStdin throws, the promise is rejected via _rejectPendingExec, but the server may still emit exec.output/exec.exit frames for this execId. These are silently dropped, which is acceptable, but a comment explaining this is still missing (re-raised from previous review).
| kill (execId, signal = 'SIGTERM') { | |
| // If writeStdin/closeStdin fails after exec.run was sent, we reject | |
| // locally. The server may still emit output/exit frames for this execId | |
| // which will be silently ignored by _handleMessage. | |
| this._rejectPendingExec(execId, new codes.ERROR_SANDBOX_WEBSOCKET({ |
| @@ -0,0 +1,29 @@ | |||
| /* | |||
| Copyright 2026 Adobe. All rights reserved. | |||
There was a problem hiding this comment.
Copyright year reads '2026' which is in the future. Same issue exists in Sandbox.js and SandboxAPI.js.
| Copyright 2026 Adobe. All rights reserved. | |
| Copyright 2024 Adobe. All rights reserved. |
| */ | ||
| declare class ComputeSandbox { | ||
| constructor(apiHost: string, namespace: string, apiKey: string, options?: any); | ||
| /** |
There was a problem hiding this comment.
The TypeScript class is named ComputeSandbox but the actual JS class exported from SandboxAPI.js is SandboxAPI. The type name mismatch means generated .d.ts consumers cannot use it correctly. Rename to match the implementation, or update the JS export.
| /** | |
| declare class SandboxAPI { |
| requestOptions.agent = this.agent | ||
| } else if (this.ignoreCerts) { | ||
| const https = require('node:https') | ||
| requestOptions.agent = new https.Agent({ rejectUnauthorized: false }) |
There was a problem hiding this comment.
In destroy(), require('node:https') is also called inside the method body (same hot-path pattern as getStatus). Move to top of file.
| requestOptions.agent = new https.Agent({ rejectUnauthorized: false }) | |
| const https = require('node:https') |
Uh oh!
There was an error while loading. Please reload this page.