@hunterzhu/pulse-adapters 0.1.11 → 0.2.0

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.
@@ -1,51 +1,201 @@
1
+ import { createRequire } from 'node:module';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, parse, resolve, sep } from 'node:path';
5
+ import { realpath } from 'node:fs/promises';
1
6
  import { spawn } from 'node:child_process';
7
+ import { SandboxManager, VENDORED_SRT_WIN_EXE } from '@anthropic-ai/sandbox-runtime';
2
8
  function shellError(code, retryable = false, cause) {
3
9
  return Object.assign(new Error(code), { code, retryable, ...(cause === undefined ? {} : { cause }) });
4
10
  }
11
+ // SandboxManager owns process-global proxy and policy state. A per-invocation
12
+ // policy is installed for exactly one child at a time to avoid one concurrent
13
+ // call widening another call's filesystem grants.
14
+ let sandboxTail = Promise.resolve();
15
+ function withSandboxLease(work) {
16
+ const result = sandboxTail.then(work, work);
17
+ sandboxTail = result.then(() => undefined, () => undefined);
18
+ return result;
19
+ }
20
+ function quotePosix(value) {
21
+ return `'${value.replaceAll("'", "'\\''")}'`;
22
+ }
23
+ export function quoteWindowsArgument(value) {
24
+ if (value.length > 0 && !/[\s"]/u.test(value))
25
+ return value;
26
+ let output = '"';
27
+ let slashes = 0;
28
+ for (const character of value) {
29
+ if (character === '\\') {
30
+ slashes++;
31
+ continue;
32
+ }
33
+ if (character === '"')
34
+ output += '\\'.repeat(slashes * 2 + 1) + '"';
35
+ else
36
+ output += '\\'.repeat(slashes) + character;
37
+ slashes = 0;
38
+ }
39
+ return output + '\\'.repeat(slashes * 2) + '"';
40
+ }
41
+ const powershellArgvRunner = String.raw `
42
+ $ErrorActionPreference = 'Stop'
43
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
44
+ $payload = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__PULSE_ARGV_JSON__'))
45
+ $spec = ConvertFrom-Json -InputObject $payload
46
+ $psi = [System.Diagnostics.ProcessStartInfo]::new()
47
+ $psi.FileName = [string]$spec.command
48
+ $psi.Arguments = [string]$spec.arguments
49
+ $psi.UseShellExecute = $false
50
+ $psi.CreateNoWindow = $true
51
+ $psi.RedirectStandardOutput = $true
52
+ $psi.RedirectStandardError = $true
53
+ $psi.RedirectStandardInput = $true
54
+ $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
55
+ $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
56
+ $child = [System.Diagnostics.Process]::new()
57
+ $child.StartInfo = $psi
58
+ try { if (-not $child.Start()) { exit 127 } }
59
+ catch {
60
+ $cause = $_.Exception
61
+ while ($cause.InnerException) { $cause = $cause.InnerException }
62
+ [Console]::Error.WriteLine($cause.Message)
63
+ if ($cause -is [System.ComponentModel.Win32Exception] -and $cause.NativeErrorCode -in 2,3) { exit 127 }
64
+ exit 126
65
+ }
66
+ $outTask = $child.StandardOutput.ReadToEndAsync()
67
+ $errTask = $child.StandardError.ReadToEndAsync()
68
+ $inTask = [Console]::OpenStandardInput().CopyToAsync($child.StandardInput.BaseStream)
69
+ [void]$inTask.GetAwaiter().GetResult()
70
+ $child.StandardInput.Close()
71
+ $child.WaitForExit()
72
+ try { [Console]::Out.Write([string]$outTask.GetAwaiter().GetResult()) } catch {}
73
+ try { [Console]::Error.Write([string]$errTask.GetAwaiter().GetResult()) } catch {}
74
+ exit $child.ExitCode
75
+ `.trim();
76
+ /** Build the command text expected by srt without interpreting model argv as shell syntax. */
77
+ export function encodeSandboxCommand(command, args, platform = process.platform) {
78
+ if ([command, ...args].some((value) => value.includes('\0')))
79
+ throw shellError('INVALID_SHELL_ARGUMENT');
80
+ if (platform === 'win32') {
81
+ // The only bytes placed in cmd.exe's command string are fixed tokens and
82
+ // base64. PowerShell decodes JSON and ProcessStartInfo.Arguments uses a
83
+ // Windows CRT-compatible encoder, preserving argv without a shell parse.
84
+ const payload = Buffer.from(JSON.stringify({ command, arguments: args.map(quoteWindowsArgument).join(' ') }), 'utf8').toString('base64');
85
+ const encoded = Buffer.from(powershellArgvRunner.replace('__PULSE_ARGV_JSON__', payload), 'utf16le').toString('base64');
86
+ const commandText = `powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}`;
87
+ if (commandText.length > 24_000)
88
+ throw shellError('INVALID_SHELL_ARGUMENTS_TOO_LARGE');
89
+ return commandText;
90
+ }
91
+ return `exec ${[command, ...args].map(quotePosix).join(' ')}`;
92
+ }
93
+ async function sandboxConfig(cwd) {
94
+ // Linux invokes this trusted helper *inside* the read-restricted namespace.
95
+ // A user-local npm install otherwise hides it together with the home directory.
96
+ const seccompPath = process.platform === 'linux'
97
+ ? await realpath(resolve(dirname(createRequire(import.meta.url).resolve('@anthropic-ai/sandbox-runtime')), '..', 'vendor', 'seccomp', process.arch, 'apply-seccomp'))
98
+ : undefined;
99
+ const home = homedir();
100
+ const parent = dirname(cwd);
101
+ const root = parse(cwd).root;
102
+ if (cwd === root || cwd === home || home.startsWith(cwd + sep) || parent === root) {
103
+ throw shellError('SANDBOX_WORKSPACE_TOO_BROAD');
104
+ }
105
+ const denyRead = new Set([home]);
106
+ // Close the common sibling-workspace and temporary-directory escape: deny
107
+ // the workspace's immediate container, then re-open only this invocation's
108
+ // cwd. Root workspaces cannot be carved this way and are rejected upstream.
109
+ if (parent !== parse(cwd).root && parent !== cwd)
110
+ denyRead.add(parent);
111
+ // Reads default to the system toolchain plus this workspace. Writes are
112
+ // limited to the workspace; srt adds only its required stdio/temp paths.
113
+ return {
114
+ network: { allowedDomains: [], deniedDomains: [], strictAllowlist: true },
115
+ filesystem: {
116
+ denyRead: [...denyRead],
117
+ allowRead: [cwd, ...(seccompPath ? [seccompPath] : [])],
118
+ allowWrite: [cwd],
119
+ denyWrite: [],
120
+ },
121
+ ...(seccompPath ? { seccomp: { applyPath: seccompPath } } : {}),
122
+ ...(process.platform === 'win32' ? { windows: { srtWin: { path: VENDORED_SRT_WIN_EXE } } } : {}),
123
+ };
124
+ }
125
+ function cleanPath(value) {
126
+ return resolve(value);
127
+ }
128
+ export function decodeUtf8WithinByteLimit(value, maxBytes) {
129
+ if (!Number.isFinite(maxBytes) || maxBytes < 0)
130
+ throw shellError('INVALID_SHELL_OUTPUT_LIMIT');
131
+ const decoded = value.subarray(0, maxBytes).toString('utf8');
132
+ let used = 0;
133
+ const output = [];
134
+ for (const character of decoded) {
135
+ const bytes = Buffer.byteLength(character, 'utf8');
136
+ if (used + bytes > maxBytes)
137
+ break;
138
+ output.push(character);
139
+ used += bytes;
140
+ }
141
+ return output.join('');
142
+ }
143
+ function shellEnvironment(env) {
144
+ const source = env ?? process.env;
145
+ return Object.fromEntries(Object.entries(source).filter(([key]) => !/(API_KEY|TOKEN|SECRET|PASSWORD|PRIVATE_KEY|AUTHORIZATION|BEARER|CREDENTIAL|COOKIE)/i.test(key) && key !== 'NODE_OPTIONS'));
146
+ }
5
147
  export function runShell(command, args = [], options = {}) {
6
148
  const max = options.maxOutputBytes ?? 256 * 1024;
7
149
  if (!Number.isFinite(max) || max < 0)
8
150
  return Promise.reject(shellError('INVALID_SHELL_OUTPUT_LIMIT'));
9
151
  if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 0))
10
152
  return Promise.reject(shellError('INVALID_SHELL_TIMEOUT'));
11
- return new Promise((resolve, reject) => {
12
- const child = spawn(command, args, { cwd: options.cwd, env: options.env, shell: false, detached: process.platform !== 'win32', windowsHide: true });
13
- let stdout = '';
14
- let stderr = '';
15
- let truncated = false;
16
- const append = (target, chunk) => { const value = chunk.toString(); const current = target === 'stdout' ? stdout : stderr; const next = current + value; if (Buffer.byteLength(next) > max) {
17
- truncated = true;
18
- const limited = next.slice(0, max);
19
- if (target === 'stdout')
20
- stdout = limited;
21
- else
22
- stderr = limited;
153
+ if (!command || typeof command !== 'string' || !Array.isArray(args) || args.some((arg) => typeof arg !== 'string'))
154
+ return Promise.reject(shellError('INVALID_SHELL_ARGUMENT'));
155
+ try {
156
+ encodeSandboxCommand(command, args);
157
+ }
158
+ catch (error) {
159
+ return Promise.reject(error);
160
+ }
161
+ return withSandboxLease(async () => {
162
+ if (options.signal?.aborted)
163
+ return { code: null, stdout: '', stderr: '', truncated: false, timedOut: false, aborted: true };
164
+ let cwd;
165
+ try {
166
+ cwd = await realpath(cleanPath(options.cwd ?? process.cwd()));
23
167
  }
24
- else if (target === 'stdout')
25
- stdout = next;
26
- else
27
- stderr = next; };
168
+ catch (cause) {
169
+ throw shellError('INVALID_SHELL_CWD', false, cause);
170
+ }
171
+ const invocationId = randomUUID();
172
+ const policy = await sandboxConfig(cwd);
173
+ let child;
174
+ const outputChunks = { stdout: [], stderr: [] };
175
+ const outputBytes = { stdout: 0, stderr: 0 };
176
+ let truncated = false;
28
177
  let closed = false;
178
+ let termination;
179
+ let timer;
180
+ let killTimer;
29
181
  const signalProcessGroup = (signal) => {
30
- if (process.platform !== 'win32' && child.pid) {
182
+ if (!child?.pid)
183
+ return;
184
+ if (process.platform !== 'win32') {
31
185
  try {
32
186
  process.kill(-child.pid, signal);
33
187
  return;
34
188
  }
35
189
  catch { /* process group may already be gone */ }
36
190
  }
37
- if (process.platform === 'win32' && child.pid) {
38
- // child.kill() only terminates the direct process on Windows; kill the
39
- // owned process tree so cancelled shell tools cannot leave descendants.
40
- const tree = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
41
- tree.once('error', () => child.kill(signal));
191
+ if (process.platform === 'win32') {
192
+ const tree = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true, env: shellEnvironment(options.env) });
193
+ tree.once('error', () => child?.kill(signal));
42
194
  tree.unref();
43
195
  return;
44
196
  }
45
197
  child.kill(signal);
46
198
  };
47
- let termination;
48
- let killTimer;
49
199
  const terminate = (reason) => {
50
200
  if (closed)
51
201
  return;
@@ -55,27 +205,88 @@ export function runShell(command, args = [], options = {}) {
55
205
  killTimer = setTimeout(() => { if (!closed)
56
206
  signalProcessGroup('SIGKILL'); }, 250);
57
207
  };
58
- const timer = options.timeoutMs && options.timeoutMs > 0 ? setTimeout(() => terminate('timeout'), options.timeoutMs) : undefined;
59
- const abort = () => terminate('aborted');
60
- if (options.signal) {
61
- if (options.signal.aborted)
62
- abort();
63
- else
64
- options.signal.addEventListener('abort', abort, { once: true });
208
+ const append = (target, chunk) => {
209
+ const remaining = Math.max(0, max - outputBytes[target]);
210
+ const kept = Math.min(chunk.byteLength, remaining);
211
+ if (kept > 0)
212
+ outputChunks[target].push(chunk.subarray(0, kept));
213
+ outputBytes[target] += kept;
214
+ if (kept < chunk.byteLength)
215
+ truncated = true;
216
+ };
217
+ const outputText = (target) => {
218
+ return decodeUtf8WithinByteLimit(Buffer.concat(outputChunks[target]), max);
219
+ };
220
+ try {
221
+ // reset first in case an earlier initialize failed part-way through.
222
+ await SandboxManager.reset();
223
+ await SandboxManager.initialize(policy, undefined, false);
224
+ const commandText = encodeSandboxCommand(command, args);
225
+ const descriptor = await SandboxManager.wrapWithSandboxArgv(commandText, undefined, undefined, options.signal, cwd, { commandId: invocationId, commandText: 'shell.exec' });
226
+ if (options.signal?.aborted) {
227
+ SandboxManager.cleanupAfterCommand();
228
+ await SandboxManager.reset();
229
+ return { code: null, stdout: '', stderr: '', truncated: false, timedOut: false, aborted: true };
230
+ }
231
+ child = spawn(descriptor.argv[0], descriptor.argv.slice(1), {
232
+ cwd,
233
+ env: shellEnvironment(options.env ?? descriptor.env),
234
+ shell: false,
235
+ detached: process.platform !== 'win32',
236
+ windowsHide: true,
237
+ });
238
+ }
239
+ catch (cause) {
240
+ try {
241
+ await SandboxManager.reset();
242
+ }
243
+ catch { /* preserve the setup failure */ }
244
+ throw shellError('SANDBOX_SETUP_FAILED', false, cause);
65
245
  }
66
- child.stdout.on('data', (chunk) => append('stdout', chunk));
67
- child.stderr.on('data', (chunk) => append('stderr', chunk));
68
- child.on('error', (cause) => {
69
- closed = true;
70
- if (timer)
71
- clearTimeout(timer);
72
- if (killTimer)
73
- clearTimeout(killTimer);
74
- const code = cause && typeof cause === 'object' && typeof cause.code === 'string' ? String(cause.code) : 'SHELL_EXECUTION_ERROR';
75
- reject(shellError(code === 'ENOENT' || code === 'EACCES' ? code : 'SHELL_EXECUTION_ERROR', false, cause));
246
+ // This API has no stdin payload; deliver EOF to readers inside the sandbox.
247
+ child.stdin?.end();
248
+ return await new Promise((resolve, reject) => {
249
+ const cleanup = () => {
250
+ if (timer)
251
+ clearTimeout(timer);
252
+ if (killTimer)
253
+ clearTimeout(killTimer);
254
+ options.signal?.removeEventListener('abort', abort);
255
+ };
256
+ const abort = () => terminate('aborted');
257
+ timer = options.timeoutMs && options.timeoutMs > 0 ? setTimeout(() => terminate('timeout'), options.timeoutMs) : undefined;
258
+ if (options.signal)
259
+ options.signal.addEventListener('abort', abort, { once: true });
260
+ if (options.signal?.aborted)
261
+ abort();
262
+ child.stdout?.on('data', (chunk) => append('stdout', chunk));
263
+ child.stderr?.on('data', (chunk) => append('stderr', chunk));
264
+ child.once('error', (cause) => {
265
+ closed = true;
266
+ cleanup();
267
+ const errorCode = cause && typeof cause === 'object' ? cause.code : undefined;
268
+ const code = typeof errorCode === 'string' ? errorCode : 'SHELL_EXECUTION_ERROR';
269
+ reject(shellError(code === 'ENOENT' || code === 'EACCES' ? code : 'SHELL_EXECUTION_ERROR', false, cause));
270
+ });
271
+ child.once('close', (code) => {
272
+ closed = true;
273
+ cleanup();
274
+ const stdout = outputText('stdout');
275
+ const stderr = outputText('stderr');
276
+ const annotated = SandboxManager.annotateStderrWithSandboxFailures(invocationId, stderr);
277
+ const boundedStderr = decodeUtf8WithinByteLimit(Buffer.from(annotated, 'utf8'), max);
278
+ const outputWasTruncated = truncated || Buffer.byteLength(annotated, 'utf8') > max;
279
+ resolve({ code, stdout, stderr: boundedStderr, truncated: outputWasTruncated, timedOut: termination === 'timeout', aborted: termination === 'aborted' });
280
+ });
281
+ }).finally(async () => {
282
+ try {
283
+ SandboxManager.cleanupAfterCommand();
284
+ await SandboxManager.reset();
285
+ }
286
+ catch (cause) {
287
+ // A teardown failure must be visible and fail closed for the next call.
288
+ throw shellError('SANDBOX_CLEANUP_FAILED', false, cause);
289
+ }
76
290
  });
77
- child.on('close', (code) => { closed = true; if (timer)
78
- clearTimeout(timer); if (killTimer)
79
- clearTimeout(killTimer); resolve({ code, stdout, stderr, truncated, timedOut: termination === 'timeout', aborted: termination === 'aborted' }); });
80
291
  });
81
292
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hunterzhu/pulse-adapters",
3
- "version": "0.1.11",
3
+ "version": "0.2.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/zhuhengtan/Pulse"
@@ -16,7 +16,10 @@
16
16
  "registry": "https://registry.npmjs.org"
17
17
  },
18
18
  "dependencies": {
19
- "@hunterzhu/pulse-runtime": "0.1.11",
20
- "@hunterzhu/pulse-tool-sdk": "0.1.11"
19
+ "@anthropic-ai/sandbox-runtime": "0.0.77",
20
+ "@hunterzhu/pulse-runtime": "0.2.0",
21
+ "@hunterzhu/pulse-tool-sdk": "0.2.0",
22
+ "exceljs": "4.4.0",
23
+ "pdfjs-dist": "4.10.38"
21
24
  }
22
25
  }