@myagentroam/agent 0.11.35 → 0.11.37

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.
@@ -0,0 +1,11 @@
1
+ import { type ChildProcessWithoutNullStreams } from 'node:child_process';
2
+ export interface LaunchSpec {
3
+ readonly executable: string;
4
+ readonly arguments: readonly string[];
5
+ readonly command: readonly string[];
6
+ readonly seccomp: Buffer;
7
+ readonly detached: boolean;
8
+ readonly signal?: AbortSignal;
9
+ }
10
+ /** Owns startup files, descriptors and the child until successful handoff. */
11
+ export declare function launchBubblewrap(spec: LaunchSpec): Promise<ChildProcessWithoutNullStreams>;
@@ -0,0 +1,202 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { closeSync, openSync } from 'node:fs';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { AgentError } from '../../error.js';
7
+ import { unavailable, withCleanupDiagnostics } from './sandbox-error.js';
8
+ import { terminateSandboxChild } from './sandbox-process.js';
9
+ /** Owns startup files, descriptors and the child until successful handoff. */
10
+ export async function launchBubblewrap(spec) {
11
+ spec.signal?.throwIfAborted();
12
+ let directory;
13
+ let argumentFd;
14
+ let seccompFd;
15
+ let child;
16
+ let observedClose;
17
+ let detachObservers;
18
+ let childExited = false;
19
+ let childClosed = false;
20
+ let childError = false;
21
+ let failure;
22
+ let failed = false;
23
+ const cleanupFailures = [];
24
+ try {
25
+ directory = await mkdtemp(join(tmpdir(), 'mar-agent-bwrap-launch-'));
26
+ const argumentPath = join(directory, 'arguments');
27
+ const seccompPath = join(directory, 'seccomp.bpf');
28
+ const writes = await Promise.allSettled([
29
+ writeFile(argumentPath, `${spec.arguments.join('\0')}\0`, { mode: 0o600, flag: 'wx' }),
30
+ writeFile(seccompPath, spec.seccomp, { mode: 0o600, flag: 'wx' })
31
+ ]);
32
+ const rejected = writes.find((result) => result.status === 'rejected');
33
+ if (rejected?.status === 'rejected')
34
+ throw rejected.reason;
35
+ spec.signal?.throwIfAborted();
36
+ argumentFd = openSync(argumentPath, 'r');
37
+ seccompFd = openSync(seccompPath, 'r');
38
+ await rm(directory, { recursive: true, force: true });
39
+ directory = undefined;
40
+ spec.signal?.throwIfAborted();
41
+ const spawnedChild = spawn(spec.executable, ['--args', '3', '--', ...spec.command], {
42
+ cwd: '/',
43
+ shell: false,
44
+ stdio: ['pipe', 'pipe', 'pipe', argumentFd, seccompFd],
45
+ windowsHide: true,
46
+ detached: spec.detached,
47
+ env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8' }
48
+ });
49
+ child = spawnedChild;
50
+ let resolveClose;
51
+ observedClose = new Promise((resolve) => {
52
+ resolveClose = resolve;
53
+ });
54
+ const exited = () => {
55
+ childExited = true;
56
+ };
57
+ const closed = () => {
58
+ childExited = true;
59
+ childClosed = true;
60
+ resolveClose();
61
+ };
62
+ // Keep an error listener until ownership transfers, including after a spawn error.
63
+ const ignoreError = () => {
64
+ childError = true;
65
+ };
66
+ child.once('exit', exited);
67
+ child.once('close', closed);
68
+ child.on('error', ignoreError);
69
+ detachObservers = () => {
70
+ spawnedChild.off('exit', exited);
71
+ spawnedChild.off('close', closed);
72
+ spawnedChild.off('error', ignoreError);
73
+ };
74
+ await new Promise((resolve, reject) => {
75
+ const cleanup = () => {
76
+ spawnedChild.off('spawn', onSpawn);
77
+ spawnedChild.off('error', onError);
78
+ spawnedChild.off('exit', onExit);
79
+ spawnedChild.off('close', onExit);
80
+ spec.signal?.removeEventListener('abort', onAbort);
81
+ };
82
+ const onSpawn = () => {
83
+ cleanup();
84
+ resolve();
85
+ };
86
+ const onError = () => {
87
+ cleanup();
88
+ reject(unavailable());
89
+ };
90
+ const onExit = () => {
91
+ cleanup();
92
+ reject(unavailable());
93
+ };
94
+ const onAbort = () => {
95
+ cleanup();
96
+ reject(spec.signal?.reason);
97
+ };
98
+ spawnedChild.once('spawn', onSpawn);
99
+ spawnedChild.once('error', onError);
100
+ spawnedChild.once('exit', onExit);
101
+ spawnedChild.once('close', onExit);
102
+ spec.signal?.addEventListener('abort', onAbort, { once: true });
103
+ if (spec.signal?.aborted)
104
+ onAbort();
105
+ });
106
+ spec.signal?.throwIfAborted();
107
+ if (childExited || childError || child.exitCode !== null || child.signalCode !== null)
108
+ throw unavailable();
109
+ }
110
+ catch (error) {
111
+ failed = true;
112
+ failure = spec.signal?.aborted ? spec.signal.reason : unavailable();
113
+ }
114
+ for (const fd of [argumentFd, seccompFd]) {
115
+ if (fd === undefined)
116
+ continue;
117
+ try {
118
+ closeSync(fd);
119
+ }
120
+ catch {
121
+ cleanupFailures.push('close');
122
+ }
123
+ }
124
+ if (directory !== undefined) {
125
+ try {
126
+ await rm(directory, { recursive: true, force: true });
127
+ }
128
+ catch {
129
+ cleanupFailures.push('remove');
130
+ }
131
+ }
132
+ if (!failed && spec.signal?.aborted) {
133
+ failed = true;
134
+ failure = spec.signal.reason;
135
+ }
136
+ if (!failed && (childExited || childError)) {
137
+ failed = true;
138
+ failure = unavailable();
139
+ }
140
+ if (!failed && cleanupFailures.length > 0) {
141
+ failed = true;
142
+ failure = unavailable();
143
+ }
144
+ if (failed) {
145
+ try {
146
+ if (child !== undefined) {
147
+ // A non-detached child shares the caller's group, which we must not signal.
148
+ // Direct child termination cannot establish that its descendants exited.
149
+ if (!spec.detached)
150
+ cleanupFailures.push('terminate');
151
+ try {
152
+ if (!terminateSandboxChild(child, spec.detached))
153
+ cleanupFailures.push('terminate');
154
+ }
155
+ catch {
156
+ cleanupFailures.push('terminate');
157
+ }
158
+ for (const stream of [child.stdin, child.stdout, child.stderr]) {
159
+ try {
160
+ stream.destroy();
161
+ }
162
+ catch {
163
+ cleanupFailures.push('close');
164
+ }
165
+ }
166
+ if (!childClosed && observedClose !== undefined) {
167
+ let timer;
168
+ const exited = await Promise.race([
169
+ observedClose.then(() => true),
170
+ new Promise((resolve) => {
171
+ timer = setTimeout(() => resolve(false), 1_000);
172
+ })
173
+ ]);
174
+ if (timer !== undefined)
175
+ clearTimeout(timer);
176
+ if (!exited) {
177
+ cleanupFailures.push('exit_timeout');
178
+ try {
179
+ child.unref();
180
+ }
181
+ catch {
182
+ cleanupFailures.push('terminate');
183
+ }
184
+ }
185
+ }
186
+ }
187
+ }
188
+ finally {
189
+ detachObservers?.();
190
+ // A process that could not be confirmed dead may still emit an error after return.
191
+ if (child !== undefined && !childClosed)
192
+ child.on('error', () => { });
193
+ }
194
+ if (spec.signal?.aborted && failure === spec.signal.reason)
195
+ throw failure;
196
+ if (failure instanceof AgentError)
197
+ throw withCleanupDiagnostics(failure, cleanupFailures);
198
+ throw failure;
199
+ }
200
+ detachObservers?.();
201
+ return child;
202
+ }
@@ -0,0 +1,5 @@
1
+ import type { LocalProcessSpawnRequest } from '../../process/contracts.js';
2
+ import type { LaunchSpec } from './bubblewrap-launch.js';
3
+ import { type BubblewrapSandboxProfile, type BubblewrapSandboxExecutorOptions } from './profile.js';
4
+ export declare function compileBubblewrapPolicy(options: BubblewrapSandboxExecutorOptions, bubblewrapPath: string, request: LocalProcessSpawnRequest): Promise<LaunchSpec>;
5
+ export declare function snapshotSandboxProfile(inputProfile: BubblewrapSandboxProfile): BubblewrapSandboxProfile;
@@ -0,0 +1,285 @@
1
+ import { existsSync, lstatSync, readlinkSync } from 'node:fs';
2
+ import { lstat } from 'node:fs/promises';
3
+ import { isAbsolute, posix, relative, resolve, sep } from 'node:path';
4
+ import { AgentError } from '../../error.js';
5
+ import { prepareLocalShellProcess } from '../../host/local-shell.js';
6
+ import { seccompFilter } from './bubblewrap-seccomp.js';
7
+ import { policyInvalid, unavailable } from './sandbox-error.js';
8
+ import { resolveMounts, workingDirectory, trustedRuntimeMounts, sandboxDirectoryArguments, within } from './sandbox-mounts.js';
9
+ import { LINUX_BUBBLEWRAP_SANDBOX_POLICY_VERSION } from './profile.js';
10
+ const SANDBOX_PATH = '/runtime/node/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
11
+ const CURSOR_SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/u;
12
+ const SANDBOX_CPU_SECONDS = 300;
13
+ const SANDBOX_PROCESS_LIMIT = 64;
14
+ const SANDBOX_OPEN_FILE_LIMIT = 256;
15
+ const SANDBOX_FILE_SIZE_BYTES = 1024 * 1024 * 1024;
16
+ const SAFE_ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/u;
17
+ // Fontconfig and LibreOffice resolve required host configuration through absolute /etc paths
18
+ // and /usr symlinks. Keep this allowlist read-only and never broaden it to the full /etc tree.
19
+ const READ_ONLY_SYSTEM_CONFIGURATION_DIRECTORIES = ['/etc/fonts', '/etc/libreoffice'];
20
+ const FORBIDDEN_ENVIRONMENT_NAMES = new Set([
21
+ 'PATH',
22
+ 'HOME',
23
+ 'TMPDIR',
24
+ 'LD_PRELOAD',
25
+ 'LD_LIBRARY_PATH',
26
+ 'NODE_OPTIONS',
27
+ 'PYTHONPATH',
28
+ 'PYTHONHOME',
29
+ 'BASH_ENV',
30
+ 'ENV',
31
+ 'SSH_AUTH_SOCK'
32
+ ]);
33
+ export async function compileBubblewrapPolicy(options, bubblewrapPath, request) {
34
+ request.signal?.throwIfAborted();
35
+ if (process.platform !== 'linux')
36
+ throw unavailable();
37
+ const profile = snapshotSandboxProfile(options.profile);
38
+ request = {
39
+ ...request,
40
+ ...(request.args === undefined ? {} : { args: [...request.args] }),
41
+ ...(request.runtimeDirectories === undefined || !Array.isArray(request.runtimeDirectories)
42
+ ? {}
43
+ : { runtimeDirectories: [...request.runtimeDirectories] }),
44
+ ...(request.owner === undefined ? {} : { owner: { ...request.owner } })
45
+ };
46
+ // Apply the sandbox policy before creating private startup-override variables.
47
+ const environment = Object.fromEntries(explicitEnvironment(request));
48
+ const shellRequest = prepareLocalShellProcess({
49
+ ...request,
50
+ environment,
51
+ environmentKeys: Object.keys(environment),
52
+ environmentOverrides: Object.fromEntries(Object.entries(request.environmentOverrides ?? {})
53
+ .filter(([name, value]) => SAFE_ENVIRONMENT_NAME.test(name) &&
54
+ !FORBIDDEN_ENVIRONMENT_NAMES.has(name) &&
55
+ (value === undefined || Object.hasOwn(environment, name)))
56
+ .map(([name, value]) => [name, value === undefined ? undefined : environment[name]]))
57
+ });
58
+ await validateBackend(bubblewrapPath);
59
+ const mounts = await resolveMounts(profile);
60
+ const runtimeMounts = await trustedRuntimeMounts(request.runtimeDirectories ?? [], mounts);
61
+ const cwd = await workingDirectory(request, mounts, profile.workingDirectory);
62
+ const command = sandboxCommand(shellRequest, mounts);
63
+ return {
64
+ executable: bubblewrapPath,
65
+ arguments: compileArguments(profile, mounts, cwd, shellRequest, runtimeMounts),
66
+ command,
67
+ seccomp: seccompFilter(process.arch),
68
+ detached: request.detached,
69
+ ...(request.signal === undefined ? {} : { signal: request.signal })
70
+ };
71
+ }
72
+ export function snapshotSandboxProfile(inputProfile) {
73
+ validateProfile(inputProfile);
74
+ return {
75
+ ...inputProfile,
76
+ ...(inputProfile.mounts === undefined
77
+ ? {}
78
+ : { mounts: inputProfile.mounts.map((mount) => ({ ...mount })) }),
79
+ ...(inputProfile.temporaryDirectory === undefined
80
+ ? {}
81
+ : { temporaryDirectory: { ...inputProfile.temporaryDirectory } }),
82
+ ...(inputProfile.workingDirectory === undefined
83
+ ? {}
84
+ : { workingDirectory: { ...inputProfile.workingDirectory } })
85
+ };
86
+ }
87
+ function compileArguments(profile, mounts, cwd, request, runtimeMounts) {
88
+ const networkMode = profile.networkMode ?? 'NONE';
89
+ const temporaryDirectory = profile.temporaryDirectory ?? { kind: 'EPHEMERAL' };
90
+ const args = [
91
+ '--unshare-user',
92
+ '--unshare-pid',
93
+ ...(networkMode === 'UNRESTRICTED' ? [] : ['--unshare-net']),
94
+ '--unshare-uts',
95
+ '--unshare-ipc',
96
+ '--unshare-cgroup-try',
97
+ '--disable-userns',
98
+ '--die-with-parent',
99
+ '--new-session',
100
+ '--cap-drop',
101
+ 'ALL',
102
+ '--hostname',
103
+ 'mar-hosted',
104
+ '--clearenv',
105
+ '--setenv',
106
+ 'PATH',
107
+ SANDBOX_PATH,
108
+ '--setenv',
109
+ 'HOME',
110
+ '/home/mar',
111
+ '--setenv',
112
+ 'LANG',
113
+ 'C.UTF-8',
114
+ '--setenv',
115
+ 'LC_ALL',
116
+ 'C.UTF-8',
117
+ '--setenv',
118
+ 'TMPDIR',
119
+ '/tmp',
120
+ '--tmpfs',
121
+ '/',
122
+ ...runtimeMountArguments(),
123
+ ...sandboxDirectoryArguments(runtimeMounts),
124
+ ...runtimeMounts.flatMap((mount) => ['--ro-bind', mount.source, mount.destination]),
125
+ ...sandboxDirectoryArguments(mounts),
126
+ ...(temporaryDirectory.kind === 'EPHEMERAL' ? ['--tmpfs', '/tmp'] : []),
127
+ '--proc',
128
+ '/proc',
129
+ '--dev',
130
+ '/dev',
131
+ '--tmpfs',
132
+ '/run'
133
+ ];
134
+ for (const mount of mounts)
135
+ args.push(mount.access === 'READ_WRITE' ? '--bind' : '--ro-bind', mount.source, mount.destination);
136
+ if (request.owner !== undefined) {
137
+ if (!CURSOR_SAFE_ID.test(request.owner.sessionId) ||
138
+ (request.owner.executionId !== undefined && !CURSOR_SAFE_ID.test(request.owner.executionId)))
139
+ throw policyInvalid();
140
+ args.push('--setenv', 'MAR_AGENT_SESSION_ID', request.owner.sessionId);
141
+ if (request.owner.executionId !== undefined)
142
+ args.push('--setenv', 'MAR_AGENT_EXECUTION_ID', request.owner.executionId);
143
+ }
144
+ for (const [name, value] of explicitEnvironment(request))
145
+ args.push('--setenv', name, value);
146
+ args.push('--seccomp', '4', '--chdir', cwd);
147
+ return args;
148
+ }
149
+ async function validateBackend(path) {
150
+ try {
151
+ const metadata = await lstat(path);
152
+ if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o111) === 0)
153
+ throw unavailable();
154
+ }
155
+ catch (error) {
156
+ if (error instanceof AgentError)
157
+ throw error;
158
+ throw unavailable(error);
159
+ }
160
+ }
161
+ function validateProfile(profile) {
162
+ if (profile === null ||
163
+ typeof profile !== 'object' ||
164
+ profile.policyVersion !== LINUX_BUBBLEWRAP_SANDBOX_POLICY_VERSION ||
165
+ (profile.networkMode !== undefined &&
166
+ profile.networkMode !== 'NONE' &&
167
+ profile.networkMode !== 'UNRESTRICTED') ||
168
+ (profile.mounts !== undefined && !Array.isArray(profile.mounts)))
169
+ throw policyInvalid();
170
+ for (const mount of profile.mounts ?? []) {
171
+ if (mount === null ||
172
+ typeof mount !== 'object' ||
173
+ typeof mount.source !== 'string' ||
174
+ typeof mount.destination !== 'string' ||
175
+ (mount.access !== 'READ_ONLY' && mount.access !== 'READ_WRITE'))
176
+ throw policyInvalid();
177
+ }
178
+ const temporaryDirectory = profile.temporaryDirectory;
179
+ if (temporaryDirectory !== undefined &&
180
+ (temporaryDirectory === null ||
181
+ typeof temporaryDirectory !== 'object' ||
182
+ (temporaryDirectory.kind !== 'EPHEMERAL' &&
183
+ (temporaryDirectory.kind !== 'MOUNT' || typeof temporaryDirectory.source !== 'string'))))
184
+ throw policyInvalid();
185
+ const workingDirectory = profile.workingDirectory;
186
+ if (workingDirectory !== undefined &&
187
+ (workingDirectory === null ||
188
+ typeof workingDirectory !== 'object' ||
189
+ (workingDirectory.kind !== 'REQUEST' &&
190
+ (workingDirectory.kind !== 'SANDBOX' || typeof workingDirectory.path !== 'string'))))
191
+ throw policyInvalid();
192
+ }
193
+ function sandboxCommand(request, mounts) {
194
+ const command = request.shell === undefined || request.shell === false
195
+ ? [sandboxExecutable(request.command, mounts), ...(request.args ?? [])]
196
+ : [
197
+ sandboxExecutable(typeof request.shell === 'string' ? request.shell : '/bin/sh', mounts),
198
+ '-c',
199
+ request.command,
200
+ ...(request.args ?? [])
201
+ ];
202
+ return [
203
+ '/usr/bin/prlimit',
204
+ `--cpu=${SANDBOX_CPU_SECONDS}:${SANDBOX_CPU_SECONDS}`,
205
+ `--nproc=${SANDBOX_PROCESS_LIMIT}:${SANDBOX_PROCESS_LIMIT}`,
206
+ `--nofile=${SANDBOX_OPEN_FILE_LIMIT}:${SANDBOX_OPEN_FILE_LIMIT}`,
207
+ `--fsize=${SANDBOX_FILE_SIZE_BYTES}:${SANDBOX_FILE_SIZE_BYTES}`,
208
+ '--',
209
+ ...command
210
+ ];
211
+ }
212
+ function sandboxExecutable(command, mounts) {
213
+ if (!isAbsolute(command))
214
+ return command;
215
+ const canonical = resolve(command);
216
+ const nodeRuntimeDirectory = resolve(process.execPath, '..', '..');
217
+ if (nodeRuntimeDirectory !== '/usr' &&
218
+ (canonical === nodeRuntimeDirectory || within(nodeRuntimeDirectory, canonical))) {
219
+ const suffix = relative(nodeRuntimeDirectory, canonical).split(sep).filter(Boolean);
220
+ return suffix.length === 0 ? '/runtime/node' : posix.join('/runtime/node', ...suffix);
221
+ }
222
+ const mount = [...mounts]
223
+ .sort((left, right) => right.source.length - left.source.length)
224
+ .find((candidate) => canonical === candidate.source || within(candidate.source, canonical));
225
+ if (mount !== undefined) {
226
+ const suffix = relative(mount.source, canonical).split(sep).filter(Boolean);
227
+ return suffix.length === 0 ? mount.destination : posix.join(mount.destination, ...suffix);
228
+ }
229
+ if (['/usr', '/bin', '/sbin', '/lib', '/lib64'].some((root) => canonical === root || within(root, canonical)))
230
+ return canonical;
231
+ throw policyInvalid();
232
+ }
233
+ function explicitEnvironment(request) {
234
+ const entries = [];
235
+ const seen = new Set();
236
+ for (const name of request.environmentKeys ?? []) {
237
+ if (seen.has(name))
238
+ continue;
239
+ seen.add(name);
240
+ if (!SAFE_ENVIRONMENT_NAME.test(name))
241
+ throw policyInvalid();
242
+ if (FORBIDDEN_ENVIRONMENT_NAMES.has(name))
243
+ continue;
244
+ const value = request.environment[name];
245
+ if (typeof value !== 'string' || value.includes('\0') || Buffer.byteLength(value) > 32_768)
246
+ throw policyInvalid();
247
+ entries.push([name, value]);
248
+ }
249
+ return entries;
250
+ }
251
+ function runtimeMountArguments() {
252
+ const args = [];
253
+ const nodeRuntimeDirectory = resolve(process.execPath, '..', '..');
254
+ if (nodeRuntimeDirectory !== '/usr')
255
+ args.push('--dir', '/runtime', '--dir', '/runtime/node', '--ro-bind', nodeRuntimeDirectory, '/runtime/node');
256
+ for (const path of ['/usr', '/bin', '/sbin', '/lib', '/lib64']) {
257
+ if (!existsSync(path))
258
+ continue;
259
+ const metadata = lstatSync(path);
260
+ if (metadata.isSymbolicLink())
261
+ args.push('--symlink', readlinkSync(path), path);
262
+ else if (metadata.isDirectory())
263
+ args.push('--ro-bind', path, path);
264
+ }
265
+ for (const path of READ_ONLY_SYSTEM_CONFIGURATION_DIRECTORIES) {
266
+ if (!existsSync(path) || !lstatSync(path).isDirectory())
267
+ continue;
268
+ args.push('--ro-bind', path, path);
269
+ }
270
+ for (const path of [
271
+ '/etc/host.conf',
272
+ '/etc/hosts',
273
+ '/etc/ld.so.cache',
274
+ '/etc/resolv.conf',
275
+ '/etc/passwd',
276
+ '/etc/group',
277
+ '/etc/nsswitch.conf',
278
+ '/etc/localtime',
279
+ '/etc/ssl/certs'
280
+ ]) {
281
+ if (existsSync(path))
282
+ args.push('--ro-bind', path, path);
283
+ }
284
+ return args;
285
+ }
@@ -0,0 +1,11 @@
1
+ import { type ChildProcessWithoutNullStreams } from 'node:child_process';
2
+ import { type BubblewrapSandboxExecutorOptions } from './profile.js';
3
+ export declare function probeBubblewrapSandbox(options?: {
4
+ readonly bubblewrapPath?: string;
5
+ readonly profile?: BubblewrapSandboxExecutorOptions['profile'];
6
+ readonly cwd?: string;
7
+ }): Promise<{
8
+ readonly version: string;
9
+ readonly policyVersion: number;
10
+ }>;
11
+ export declare function waitForProbe(child: ChildProcessWithoutNullStreams): Promise<number | null>;
@@ -0,0 +1,154 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { BubblewrapSandboxExecutor } from './bubblewrap-sandbox-executor.js';
3
+ import { snapshotSandboxProfile } from './bubblewrap-policy.js';
4
+ import { LINUX_BUBBLEWRAP_SANDBOX_POLICY_VERSION } from './profile.js';
5
+ import { policyInvalid, unavailable, withCleanupDiagnostics } from './sandbox-error.js';
6
+ import { terminateSandboxChild } from './sandbox-process.js';
7
+ const DEFAULT_BUBBLEWRAP_PATH = '/usr/bin/bwrap';
8
+ export async function probeBubblewrapSandbox(options = {}) {
9
+ if (process.platform !== 'linux')
10
+ throw unavailable();
11
+ let bubblewrapPath;
12
+ let cwd;
13
+ let profile;
14
+ try {
15
+ bubblewrapPath = options.bubblewrapPath ?? DEFAULT_BUBBLEWRAP_PATH;
16
+ cwd = options.cwd ?? '/';
17
+ profile = snapshotSandboxProfile(options.profile ?? {
18
+ policyVersion: LINUX_BUBBLEWRAP_SANDBOX_POLICY_VERSION
19
+ });
20
+ }
21
+ catch {
22
+ throw policyInvalid();
23
+ }
24
+ const versionOutput = await probeVersion(bubblewrapPath);
25
+ const version = /^bubblewrap\s+([0-9]+(?:\.[0-9]+){1,3})\s*$/u.exec(versionOutput)?.[1];
26
+ if (version === undefined)
27
+ throw unavailable();
28
+ const executor = new BubblewrapSandboxExecutor({
29
+ bubblewrapPath,
30
+ profile
31
+ });
32
+ const child = await executor.spawn({
33
+ command: '/bin/true',
34
+ cwd,
35
+ shell: false,
36
+ environment: {},
37
+ detached: true,
38
+ owner: { sessionId: 'sandbox-probe', executionId: 'sandbox-probe' }
39
+ });
40
+ const result = await waitForProbe(child);
41
+ if (result !== 0)
42
+ throw unavailable();
43
+ return { version, policyVersion: LINUX_BUBBLEWRAP_SANDBOX_POLICY_VERSION };
44
+ }
45
+ async function probeVersion(path) {
46
+ let child;
47
+ try {
48
+ child = spawn(path, ['--version'], {
49
+ cwd: '/',
50
+ shell: false,
51
+ stdio: ['pipe', 'pipe', 'pipe'],
52
+ detached: true,
53
+ windowsHide: true,
54
+ env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8' }
55
+ });
56
+ }
57
+ catch {
58
+ throw unavailable();
59
+ }
60
+ const chunks = [];
61
+ let length = 0;
62
+ const onData = (chunk) => {
63
+ length += chunk.length;
64
+ if (length <= 4_096)
65
+ chunks.push(chunk);
66
+ };
67
+ child.stdout.on('data', onData);
68
+ try {
69
+ const result = await waitForProbe(child);
70
+ if (result !== 0 || length > 4_096)
71
+ throw unavailable();
72
+ return Buffer.concat(chunks).toString('utf8');
73
+ }
74
+ finally {
75
+ child.stdout.off('data', onData);
76
+ }
77
+ }
78
+ export async function waitForProbe(child) {
79
+ child.stdout.resume();
80
+ child.stderr.resume();
81
+ child.stdin.end();
82
+ return await new Promise((resolve, reject) => {
83
+ let failed = false;
84
+ let settled = false;
85
+ let deadline;
86
+ const cleanupFailures = [];
87
+ const finish = (code) => {
88
+ if (settled)
89
+ return;
90
+ settled = true;
91
+ clearTimeout(timer);
92
+ if (deadline !== undefined)
93
+ clearTimeout(deadline);
94
+ child.off('error', onError);
95
+ child.off('close', onClose);
96
+ if (failed)
97
+ reject(withCleanupDiagnostics(unavailable(), cleanupFailures));
98
+ else
99
+ resolve(code);
100
+ };
101
+ const stop = () => {
102
+ if (failed || settled)
103
+ return;
104
+ failed = true;
105
+ deadline = setTimeout(() => {
106
+ cleanupFailures.push('exit_timeout');
107
+ for (const release of [
108
+ () => child.stdin.destroy(),
109
+ () => child.stdout.destroy(),
110
+ () => child.stderr.destroy(),
111
+ () => child.unref()
112
+ ]) {
113
+ try {
114
+ release();
115
+ }
116
+ catch {
117
+ cleanupFailures.push('terminate');
118
+ }
119
+ }
120
+ child.on('error', () => { });
121
+ finish(null);
122
+ }, 1_000);
123
+ try {
124
+ if (!terminateSandboxChild(child, true))
125
+ cleanupFailures.push('terminate');
126
+ }
127
+ catch {
128
+ cleanupFailures.push('terminate');
129
+ }
130
+ };
131
+ const timer = setTimeout(() => {
132
+ stop();
133
+ }, 5_000);
134
+ timer.unref();
135
+ const onError = () => stop();
136
+ const onClose = (code) => {
137
+ if (!failed) {
138
+ try {
139
+ if (!terminateSandboxChild(child, true)) {
140
+ failed = true;
141
+ cleanupFailures.push('terminate');
142
+ }
143
+ }
144
+ catch {
145
+ failed = true;
146
+ cleanupFailures.push('terminate');
147
+ }
148
+ }
149
+ finish(code);
150
+ };
151
+ child.on('error', onError);
152
+ child.once('close', onClose);
153
+ });
154
+ }