@skanl/brambo-sandbox-remote 0.1.1
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/LICENSE +21 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +354 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SKANL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { SandboxCapabilityFacts, SandboxPolicy, SandboxProvider, SandboxSnapshot } from '@skanl/brambo-contracts';
|
|
2
|
+
export interface RemoteSessionIdentity {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly providerId: string;
|
|
5
|
+
readonly policy: SandboxPolicy;
|
|
6
|
+
}
|
|
7
|
+
export interface RemoteSessionCreateRequest {
|
|
8
|
+
readonly session: RemoteSessionIdentity & {
|
|
9
|
+
readonly snapshots: readonly SandboxSnapshot[];
|
|
10
|
+
};
|
|
11
|
+
readonly signal: undefined;
|
|
12
|
+
}
|
|
13
|
+
export interface RemoteSessionExecuteRequest {
|
|
14
|
+
readonly session: RemoteSessionIdentity;
|
|
15
|
+
readonly argv: readonly [string, ...string[]];
|
|
16
|
+
readonly cwd: string;
|
|
17
|
+
readonly environment: Readonly<Record<string, string>>;
|
|
18
|
+
readonly snapshots: readonly SandboxSnapshot[] | undefined;
|
|
19
|
+
readonly signal: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
export interface RemoteSessionDestroyRequest {
|
|
22
|
+
readonly session: RemoteSessionIdentity;
|
|
23
|
+
}
|
|
24
|
+
export type RemoteSessionOpenStdioRequest = RemoteSessionExecuteRequest;
|
|
25
|
+
export interface RemoteSandboxTransport {
|
|
26
|
+
createSession(request: RemoteSessionCreateRequest): Promise<unknown>;
|
|
27
|
+
execute(request: RemoteSessionExecuteRequest): Promise<unknown>;
|
|
28
|
+
openStdio?(request: RemoteSessionOpenStdioRequest): Promise<unknown>;
|
|
29
|
+
destroy(request: RemoteSessionDestroyRequest): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export interface RemoteSandboxProviderOptions {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly capabilities: SandboxCapabilityFacts;
|
|
34
|
+
readonly transport: RemoteSandboxTransport;
|
|
35
|
+
readonly timeoutMs?: number;
|
|
36
|
+
/** Test seam; production defaults to a UUID-backed opaque session identity. */
|
|
37
|
+
readonly createSessionId?: () => string;
|
|
38
|
+
}
|
|
39
|
+
export declare function createRemoteSandboxProvider(options: RemoteSandboxProviderOptions): SandboxProvider;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { BRAMBO_ERROR_CODES, BramboError, SANDBOX_ERROR_CODES, validateSandboxCapabilities, validateSandboxExecutionRequest, validateSandboxExecutionResult, validateSandboxPolicy, validateSandboxSnapshot, } from '@skanl/brambo-contracts';
|
|
2
|
+
function unavailable(message, cause) {
|
|
3
|
+
return new BramboError(BRAMBO_ERROR_CODES.sandboxUnavailable, message, cause === undefined ? {} : { cause });
|
|
4
|
+
}
|
|
5
|
+
function stdioError(code, message) {
|
|
6
|
+
return new BramboError(code, message);
|
|
7
|
+
}
|
|
8
|
+
function responseInvalid(message) {
|
|
9
|
+
return new BramboError(BRAMBO_ERROR_CODES.sandboxResponseInvalid, message);
|
|
10
|
+
}
|
|
11
|
+
function samePolicy(left, right) {
|
|
12
|
+
if (left.version !== right.version || left.mode !== right.mode || left.workspaceRoot !== right.workspaceRoot || (left.networkMode ?? 'deny') !== (right.networkMode ?? 'deny') || left.allowDangerous !== right.allowDangerous)
|
|
13
|
+
return false;
|
|
14
|
+
const leftAllowlist = [...(left.networkAllowlist ?? [])].sort();
|
|
15
|
+
const rightAllowlist = [...(right.networkAllowlist ?? [])].sort();
|
|
16
|
+
if (leftAllowlist.length !== rightAllowlist.length || leftAllowlist.some((value, index) => value !== rightAllowlist[index]))
|
|
17
|
+
return false;
|
|
18
|
+
const leftLimits = left.resourceLimits;
|
|
19
|
+
const rightLimits = right.resourceLimits;
|
|
20
|
+
if ((leftLimits === undefined) !== (rightLimits === undefined))
|
|
21
|
+
return false;
|
|
22
|
+
if (leftLimits !== undefined && rightLimits !== undefined) {
|
|
23
|
+
if (leftLimits.wallTimeMs !== rightLimits.wallTimeMs ||
|
|
24
|
+
leftLimits.memoryBytes !== rightLimits.memoryBytes ||
|
|
25
|
+
leftLimits.outputBytes !== rightLimits.outputBytes ||
|
|
26
|
+
leftLimits.fileSizeBytes !== rightLimits.fileSizeBytes ||
|
|
27
|
+
leftLimits.processCount !== rightLimits.processCount ||
|
|
28
|
+
leftLimits.cpuQuotaMicros !== rightLimits.cpuQuotaMicros ||
|
|
29
|
+
leftLimits.cpuPeriodMicros !== rightLimits.cpuPeriodMicros)
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const leftEntries = Object.entries(left.requiredCapabilities).sort(([a], [b]) => a.localeCompare(b));
|
|
33
|
+
const rightEntries = Object.entries(right.requiredCapabilities).sort(([a], [b]) => a.localeCompare(b));
|
|
34
|
+
return leftEntries.length === rightEntries.length && leftEntries.every(([key, value], index) => rightEntries[index]?.[0] === key && rightEntries[index]?.[1] === value);
|
|
35
|
+
}
|
|
36
|
+
function ownRecord(value, label) {
|
|
37
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
38
|
+
throw responseInvalid(`${label} must be an object`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function onlyKeys(value, keys, label) {
|
|
42
|
+
if (Object.keys(value).some((key) => !keys.includes(key)))
|
|
43
|
+
throw responseInvalid(`${label} has an unsupported field`);
|
|
44
|
+
}
|
|
45
|
+
function remoteIdentity(value, expected, label) {
|
|
46
|
+
const candidate = ownRecord(value, label);
|
|
47
|
+
onlyKeys(candidate, ['id', 'providerId', 'policy'], label);
|
|
48
|
+
if (candidate['id'] !== expected.id || candidate['providerId'] !== expected.providerId) {
|
|
49
|
+
throw responseInvalid(`${label} does not match the requested session identity`);
|
|
50
|
+
}
|
|
51
|
+
let policy;
|
|
52
|
+
try {
|
|
53
|
+
policy = validateSandboxPolicy(candidate['policy']);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
throw responseInvalid(`${label} has an invalid policy: ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
57
|
+
}
|
|
58
|
+
if (!samePolicy(policy, expected.policy))
|
|
59
|
+
throw responseInvalid(`${label} does not match the requested policy`);
|
|
60
|
+
return Object.freeze({ id: expected.id, providerId: expected.providerId, policy: expected.policy });
|
|
61
|
+
}
|
|
62
|
+
function remoteCreateResponse(value, expected, policy) {
|
|
63
|
+
const candidate = ownRecord(value, 'remote create-session response');
|
|
64
|
+
onlyKeys(candidate, ['session', 'capabilities'], 'remote create-session response');
|
|
65
|
+
remoteIdentity(candidate['session'], expected, 'remote create-session response session');
|
|
66
|
+
let capabilities;
|
|
67
|
+
try {
|
|
68
|
+
capabilities = validateSandboxCapabilities(policy, candidate['capabilities']);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error instanceof BramboError)
|
|
72
|
+
throw error;
|
|
73
|
+
throw responseInvalid('remote create-session response has invalid capability evidence');
|
|
74
|
+
}
|
|
75
|
+
if (capabilities.providerId !== expected.providerId || capabilities.enforcement !== 'remote') {
|
|
76
|
+
throw responseInvalid('remote create-session response capability evidence does not identify the selected remote provider');
|
|
77
|
+
}
|
|
78
|
+
return capabilities;
|
|
79
|
+
}
|
|
80
|
+
function remoteExecuteResponse(value, expected, policy) {
|
|
81
|
+
const candidate = ownRecord(value, 'remote execute response');
|
|
82
|
+
onlyKeys(candidate, ['session', 'result'], 'remote execute response');
|
|
83
|
+
remoteIdentity(candidate['session'], expected, 'remote execute response session');
|
|
84
|
+
let result;
|
|
85
|
+
try {
|
|
86
|
+
result = validateSandboxExecutionResult(candidate['result']);
|
|
87
|
+
validateSandboxCapabilities(policy, result.enforcement);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error instanceof BramboError)
|
|
91
|
+
throw error;
|
|
92
|
+
throw responseInvalid('remote execute response has invalid enforcement evidence');
|
|
93
|
+
}
|
|
94
|
+
if (result.enforcement.providerId !== expected.providerId || result.enforcement.enforcement !== 'remote') {
|
|
95
|
+
throw responseInvalid('remote execute response enforcement evidence does not identify the selected remote provider');
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
function remoteStdioResponse(value, expected) {
|
|
100
|
+
const candidate = ownRecord(value, 'remote stdio response');
|
|
101
|
+
onlyKeys(candidate, ['session', 'stdio'], 'remote stdio response');
|
|
102
|
+
remoteIdentity(candidate['session'], expected, 'remote stdio response session');
|
|
103
|
+
const stdio = ownRecord(candidate['stdio'], 'remote stdio response stdio');
|
|
104
|
+
onlyKeys(stdio, ['sendFrame', 'receiveFrame', 'close'], 'remote stdio response stdio');
|
|
105
|
+
const sendFrame = stdio['sendFrame'];
|
|
106
|
+
const receiveFrame = stdio['receiveFrame'];
|
|
107
|
+
const close = stdio['close'];
|
|
108
|
+
if (typeof sendFrame !== 'function' || typeof receiveFrame !== 'function' || typeof close !== 'function') {
|
|
109
|
+
throw responseInvalid('remote stdio response must provide sendFrame, receiveFrame, and close methods');
|
|
110
|
+
}
|
|
111
|
+
return Object.freeze({
|
|
112
|
+
sendFrame: async (frame, signal) => {
|
|
113
|
+
if (signal?.aborted)
|
|
114
|
+
throw stdioError(SANDBOX_ERROR_CODES.aborted, 'remote stdio send was aborted');
|
|
115
|
+
if (frame.includes('\n') || frame.includes('\r'))
|
|
116
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxRequestInvalid, 'stdio frames cannot contain line breaks');
|
|
117
|
+
try {
|
|
118
|
+
await sendFrame.call(stdio, frame, signal);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error instanceof BramboError)
|
|
122
|
+
throw error;
|
|
123
|
+
throw unavailable(`remote sandbox session '${expected.id}' stdio send is unavailable`, error);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
receiveFrame: async (signal) => {
|
|
127
|
+
try {
|
|
128
|
+
const frame = await receiveFrame.call(stdio, signal);
|
|
129
|
+
if (typeof frame !== 'string')
|
|
130
|
+
throw responseInvalid('remote stdio receive returned a non-string frame');
|
|
131
|
+
return frame;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error instanceof BramboError)
|
|
135
|
+
throw error;
|
|
136
|
+
throw unavailable(`remote sandbox session '${expected.id}' stdio receive is unavailable`, error);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
close: async () => {
|
|
140
|
+
try {
|
|
141
|
+
await close.call(stdio);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (error instanceof BramboError)
|
|
145
|
+
throw error;
|
|
146
|
+
throw unavailable(`remote sandbox session '${expected.id}' stdio close is unavailable`, error);
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
function terminalResult(enforcement, status) {
|
|
152
|
+
const timedOut = status === 'timed-out';
|
|
153
|
+
return Object.freeze({
|
|
154
|
+
status,
|
|
155
|
+
stdout: '',
|
|
156
|
+
stderr: '',
|
|
157
|
+
enforcement,
|
|
158
|
+
error: {
|
|
159
|
+
code: timedOut ? SANDBOX_ERROR_CODES.timedOut : SANDBOX_ERROR_CODES.aborted,
|
|
160
|
+
message: timedOut ? 'remote sandbox request timed out' : 'remote sandbox request was aborted',
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
function executionWatchdog(signal, timeoutMs, enforcement) {
|
|
165
|
+
const controller = new AbortController();
|
|
166
|
+
let resolveResult;
|
|
167
|
+
const result = new Promise((resolve) => {
|
|
168
|
+
resolveResult = resolve;
|
|
169
|
+
});
|
|
170
|
+
let settled = false;
|
|
171
|
+
const complete = (status, reason) => {
|
|
172
|
+
if (settled)
|
|
173
|
+
return;
|
|
174
|
+
settled = true;
|
|
175
|
+
controller.abort(reason);
|
|
176
|
+
resolveResult(terminalResult(enforcement, status));
|
|
177
|
+
};
|
|
178
|
+
const abort = () => complete('aborted', signal?.reason);
|
|
179
|
+
if (signal?.aborted)
|
|
180
|
+
abort();
|
|
181
|
+
else
|
|
182
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
183
|
+
const timeout = timeoutMs === undefined ? undefined : setTimeout(() => complete('timed-out', new Error('remote sandbox request timed out')), timeoutMs);
|
|
184
|
+
return Object.freeze({
|
|
185
|
+
signal: controller.signal,
|
|
186
|
+
result,
|
|
187
|
+
dispose: () => {
|
|
188
|
+
settled = true;
|
|
189
|
+
signal?.removeEventListener('abort', abort);
|
|
190
|
+
if (timeout !== undefined)
|
|
191
|
+
clearTimeout(timeout);
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
function unavailableResult(enforcement, message) {
|
|
196
|
+
return Object.freeze({ status: 'unavailable', stdout: '', stderr: '', enforcement, error: { code: SANDBOX_ERROR_CODES.unavailable, message } });
|
|
197
|
+
}
|
|
198
|
+
class RemoteSandboxSession {
|
|
199
|
+
#disposePromise;
|
|
200
|
+
#disposed = false;
|
|
201
|
+
id;
|
|
202
|
+
identity;
|
|
203
|
+
policy;
|
|
204
|
+
capabilities;
|
|
205
|
+
transport;
|
|
206
|
+
timeoutMs;
|
|
207
|
+
constructor(id, identity, policy, capabilities, transport, timeoutMs) {
|
|
208
|
+
this.id = id;
|
|
209
|
+
this.identity = identity;
|
|
210
|
+
this.policy = policy;
|
|
211
|
+
this.capabilities = capabilities;
|
|
212
|
+
this.transport = transport;
|
|
213
|
+
this.timeoutMs = timeoutMs;
|
|
214
|
+
}
|
|
215
|
+
async execute(value) {
|
|
216
|
+
if (this.#disposed)
|
|
217
|
+
return unavailableResult(this.capabilities, 'remote sandbox session is disposed');
|
|
218
|
+
const request = validateSandboxExecutionRequest(value);
|
|
219
|
+
if (!samePolicy(this.policy, request.policy)) {
|
|
220
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxRequestInvalid, `sandbox execution policy does not match session '${this.id}' policy`);
|
|
221
|
+
}
|
|
222
|
+
validateSandboxCapabilities(this.policy, this.capabilities);
|
|
223
|
+
const deadline = executionWatchdog(request.signal, this.timeoutMs, this.capabilities);
|
|
224
|
+
const remoteRequest = Object.freeze({
|
|
225
|
+
session: this.identity,
|
|
226
|
+
argv: Object.freeze([...request.argv]),
|
|
227
|
+
cwd: request.cwd,
|
|
228
|
+
environment: Object.freeze({ ...request.environment }),
|
|
229
|
+
snapshots: request.snapshots === undefined ? undefined : Object.freeze(request.snapshots.map((snapshot) => Object.freeze({ ...snapshot }))),
|
|
230
|
+
signal: deadline.signal,
|
|
231
|
+
});
|
|
232
|
+
try {
|
|
233
|
+
const response = Promise.resolve()
|
|
234
|
+
.then(() => this.transport.execute(remoteRequest))
|
|
235
|
+
.then((value) => ({ kind: 'response', value }));
|
|
236
|
+
const outcome = await Promise.race([
|
|
237
|
+
response,
|
|
238
|
+
deadline.result.then((result) => ({ kind: 'terminal', result })),
|
|
239
|
+
]);
|
|
240
|
+
return outcome.kind === 'terminal' ? outcome.result : remoteExecuteResponse(outcome.value, this.identity, this.policy);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (error instanceof BramboError)
|
|
244
|
+
throw error;
|
|
245
|
+
throw unavailable(`remote sandbox session '${this.id}' execution is unavailable`, error);
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
deadline.dispose();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async openStdio(value) {
|
|
252
|
+
if (this.#disposed)
|
|
253
|
+
throw unavailable(`remote sandbox session '${this.id}' is disposed`);
|
|
254
|
+
const request = validateSandboxExecutionRequest(value);
|
|
255
|
+
if (!samePolicy(this.policy, request.policy)) {
|
|
256
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxRequestInvalid, `sandbox execution policy does not match session '${this.id}' policy`);
|
|
257
|
+
}
|
|
258
|
+
validateSandboxCapabilities(this.policy, this.capabilities);
|
|
259
|
+
const openStdio = this.transport.openStdio;
|
|
260
|
+
if (typeof openStdio !== 'function')
|
|
261
|
+
throw unavailable(`remote sandbox session '${this.id}' does not expose stdio`);
|
|
262
|
+
if (request.signal?.aborted)
|
|
263
|
+
throw stdioError(SANDBOX_ERROR_CODES.aborted, `remote sandbox session '${this.id}' stdio open was aborted before transport invocation`);
|
|
264
|
+
const deadline = executionWatchdog(request.signal, this.timeoutMs, this.capabilities);
|
|
265
|
+
const remoteRequest = Object.freeze({
|
|
266
|
+
session: this.identity,
|
|
267
|
+
argv: Object.freeze([...request.argv]),
|
|
268
|
+
cwd: request.cwd,
|
|
269
|
+
environment: Object.freeze({ ...request.environment }),
|
|
270
|
+
snapshots: request.snapshots === undefined ? undefined : Object.freeze(request.snapshots.map((snapshot) => Object.freeze({ ...snapshot }))),
|
|
271
|
+
signal: deadline.signal,
|
|
272
|
+
});
|
|
273
|
+
try {
|
|
274
|
+
const response = Promise.resolve()
|
|
275
|
+
.then(() => openStdio.call(this.transport, remoteRequest));
|
|
276
|
+
const racedResponse = response.then((value) => ({ kind: 'response', value }));
|
|
277
|
+
const outcome = await Promise.race([
|
|
278
|
+
racedResponse,
|
|
279
|
+
deadline.result.then((result) => ({ kind: 'terminal', result })),
|
|
280
|
+
]);
|
|
281
|
+
if (outcome.kind === 'terminal') {
|
|
282
|
+
void response.then((value) => remoteStdioResponse(value, this.identity).close(), () => undefined).catch(() => undefined);
|
|
283
|
+
const error = outcome.result.error;
|
|
284
|
+
if (error === undefined)
|
|
285
|
+
throw unavailable(`remote sandbox session '${this.id}' stdio open ${outcome.result.status} without a structured error`);
|
|
286
|
+
throw stdioError(error.code, error.message);
|
|
287
|
+
}
|
|
288
|
+
return remoteStdioResponse(outcome.value, this.identity);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
if (error instanceof BramboError)
|
|
292
|
+
throw error;
|
|
293
|
+
throw unavailable(`remote sandbox session '${this.id}' stdio could not be opened`, error);
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
deadline.dispose();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
dispose() {
|
|
300
|
+
if (this.#disposePromise !== undefined)
|
|
301
|
+
return this.#disposePromise;
|
|
302
|
+
this.#disposed = true;
|
|
303
|
+
const request = Object.freeze({ session: this.identity });
|
|
304
|
+
this.#disposePromise = Promise.resolve()
|
|
305
|
+
.then(() => this.transport.destroy(request))
|
|
306
|
+
.catch((error) => {
|
|
307
|
+
throw unavailable(`remote sandbox session '${this.id}' teardown is unavailable`, error);
|
|
308
|
+
});
|
|
309
|
+
return this.#disposePromise;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
export function createRemoteSandboxProvider(options) {
|
|
313
|
+
if (typeof options.id !== 'string' || options.id.length === 0)
|
|
314
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxResponseInvalid, 'remote sandbox provider id must be a non-empty string');
|
|
315
|
+
if (options.capabilities.providerId !== options.id || options.capabilities.enforcement !== 'remote') {
|
|
316
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxResponseInvalid, 'remote sandbox provider capabilities must identify the configured remote provider');
|
|
317
|
+
}
|
|
318
|
+
if (typeof options.transport.createSession !== 'function' || typeof options.transport.execute !== 'function' || typeof options.transport.destroy !== 'function') {
|
|
319
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxUnavailable, 'remote sandbox transport is unavailable');
|
|
320
|
+
}
|
|
321
|
+
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
|
|
322
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxRequestInvalid, 'remote sandbox timeout must be a positive finite number');
|
|
323
|
+
}
|
|
324
|
+
const providerId = options.id;
|
|
325
|
+
const capabilities = Object.freeze({ ...options.capabilities, controls: Object.freeze({ ...options.capabilities.controls }) });
|
|
326
|
+
const transport = options.transport;
|
|
327
|
+
let sessions = 0;
|
|
328
|
+
return Object.freeze({
|
|
329
|
+
id: providerId,
|
|
330
|
+
capabilities,
|
|
331
|
+
async createSession(value) {
|
|
332
|
+
const policy = validateSandboxPolicy(value.policy);
|
|
333
|
+
const snapshots = Object.freeze(value.snapshots.map((snapshot) => validateSandboxSnapshot(snapshot)));
|
|
334
|
+
validateSandboxCapabilities(policy, capabilities);
|
|
335
|
+
const generatedId = options.createSessionId?.() ?? `${providerId}-${crypto.randomUUID()}`;
|
|
336
|
+
if (typeof generatedId !== 'string' || generatedId.length === 0)
|
|
337
|
+
throw unavailable('remote sandbox session identity is unavailable');
|
|
338
|
+
sessions += 1;
|
|
339
|
+
const id = sessions === 1 ? generatedId : `${generatedId}-${sessions}`;
|
|
340
|
+
const identity = Object.freeze({ id, providerId, policy });
|
|
341
|
+
const remoteRequest = Object.freeze({ session: Object.freeze({ ...identity, snapshots }), signal: undefined });
|
|
342
|
+
let remoteCapabilities;
|
|
343
|
+
try {
|
|
344
|
+
remoteCapabilities = remoteCreateResponse(await transport.createSession(remoteRequest), identity, policy);
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
if (error instanceof BramboError)
|
|
348
|
+
throw error;
|
|
349
|
+
throw unavailable('remote sandbox session creation is unavailable', error);
|
|
350
|
+
}
|
|
351
|
+
return new RemoteSandboxSession(id, identity, policy, remoteCapabilities, transport, options.timeoutMs);
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@skanl/brambo-sandbox-remote",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Transport-injected remote sandbox provider adapter for brambo.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai-agent",
|
|
7
|
+
"brambo",
|
|
8
|
+
"sandbox",
|
|
9
|
+
"remote"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/SKANL/brambo#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/SKANL/brambo/issues"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/SKANL/brambo.git",
|
|
18
|
+
"directory": "packages/sandbox-remote"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"brambo-source": "./src/index.ts",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@skanl/brambo-contracts": "0.1.1"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^24.13.3",
|
|
40
|
+
"typescript": "~7.0.2",
|
|
41
|
+
"vitest": "^4.1.11"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"lint": "eslint .",
|
|
50
|
+
"build": "node ../../scripts/clean-dist.mjs && tsc -p tsconfig.build.json"
|
|
51
|
+
}
|
|
52
|
+
}
|