@hunterzhu/pulse-adapters 0.1.11 → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { createReadStream } from 'node:fs';
3
- import { lstat, mkdir, open, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { link, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
4
4
  import { dirname, isAbsolute, relative, resolve } from 'node:path';
5
5
  import { pipeline } from 'node:stream/promises';
6
6
  function filesystemError(code, retryable = false, cause) {
@@ -56,13 +56,67 @@ export class FilesystemTool {
56
56
  async read(path, signal) { if (signal?.aborted)
57
57
  throw filesystemError('ABORTED'); return readFile(await this.existing(path), 'utf8'); }
58
58
  async readLimited(path, maxBytes, signal) {
59
+ const { content, truncated } = await this.readRange(path, maxBytes, 0, signal);
60
+ return { content, truncated };
61
+ }
62
+ /** Resolve a one-based source line without confusing line numbers with byte offsets. */
63
+ async offsetForLine(path, startLine, signal) {
64
+ if (!Number.isSafeInteger(startLine) || startLine < 1)
65
+ throw filesystemError('INVALID_START_LINE');
66
+ if (signal?.aborted)
67
+ throw filesystemError('ABORTED');
68
+ if (startLine === 1)
69
+ return 0;
70
+ const handle = await open(await this.existing(path), 'r');
71
+ try {
72
+ const buffer = Buffer.alloc(64 * 1024);
73
+ let offset = 0;
74
+ let line = 1;
75
+ const scanLimit = 16 * 1024 * 1024;
76
+ while (offset < scanLimit) {
77
+ if (signal?.aborted)
78
+ throw filesystemError('ABORTED');
79
+ const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, scanLimit - offset), offset);
80
+ if (signal?.aborted)
81
+ throw filesystemError('ABORTED');
82
+ if (!bytesRead)
83
+ return offset;
84
+ for (let index = 0; index < bytesRead; index++) {
85
+ if (buffer[index] === 10 && ++line === startLine)
86
+ return offset + index + 1;
87
+ }
88
+ offset += bytesRead;
89
+ }
90
+ throw filesystemError('READ_LINE_SCAN_LIMIT');
91
+ }
92
+ finally {
93
+ await handle.close();
94
+ }
95
+ }
96
+ async readRange(path, maxBytes, offset = 0, signal) {
59
97
  if (signal?.aborted)
60
98
  throw filesystemError('ABORTED');
99
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 1_000_000 || !Number.isSafeInteger(offset) || offset < 0)
100
+ throw filesystemError('INVALID_READ_RANGE');
61
101
  const handle = await open(await this.existing(path), 'r');
62
102
  try {
63
103
  const buffer = Buffer.alloc(maxBytes + 1);
64
- const { bytesRead } = await handle.read(buffer, 0, maxBytes + 1, 0);
65
- return { content: buffer.subarray(0, Math.min(bytesRead, maxBytes)).toString('utf8'), truncated: bytesRead > maxBytes };
104
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes + 1, offset);
105
+ if (signal?.aborted)
106
+ throw filesystemError('ABORTED');
107
+ if (bytesRead && (buffer[0] & 0xc0) === 0x80)
108
+ throw filesystemError('INVALID_UTF8_OFFSET');
109
+ let end = Math.min(bytesRead, maxBytes);
110
+ if (bytesRead > maxBytes) {
111
+ // The byte after the window is a continuation: drop the partial codepoint.
112
+ while (end > 0 && (buffer[end] & 0xc0) === 0x80)
113
+ end--;
114
+ }
115
+ if (end === 0 && bytesRead > 0)
116
+ throw filesystemError('READ_WINDOW_TOO_SMALL');
117
+ const content = buffer.subarray(0, end).toString('utf8');
118
+ const truncated = bytesRead > end;
119
+ return { content, truncated, offset, nextOffset: truncated ? offset + end : null };
66
120
  }
67
121
  finally {
68
122
  await handle.close();
@@ -102,7 +156,7 @@ export class FilesystemTool {
102
156
  async writeIfUnchanged(path, content, expectedHash, signal) {
103
157
  if (signal?.aborted)
104
158
  throw filesystemError('ABORTED');
105
- if (!/^[a-f0-9]{64}$/.test(expectedHash))
159
+ if (expectedHash !== null && !/^[a-f0-9]{64}$/.test(expectedHash))
106
160
  throw filesystemError('INVALID_FILE_BASELINE_HASH');
107
161
  const target = await this.writable(path);
108
162
  return this.withLock(target, async () => {
@@ -113,11 +167,12 @@ export class FilesystemTool {
113
167
  current = await readFile(target);
114
168
  }
115
169
  catch (cause) {
116
- if (cause.code === 'ENOENT')
117
- throw filesystemError('FILE_BASELINE_MISSING');
118
- throw filesystemCause(cause);
170
+ if (cause.code !== 'ENOENT')
171
+ throw filesystemCause(cause);
119
172
  }
120
- const currentHash = createHash('sha256').update(current).digest('hex');
173
+ if (current === undefined && expectedHash !== null)
174
+ throw filesystemError('FILE_BASELINE_MISSING');
175
+ const currentHash = current === undefined ? null : createHash('sha256').update(current).digest('hex');
121
176
  if (currentHash !== expectedHash)
122
177
  throw filesystemError('FILE_BASELINE_CONFLICT');
123
178
  const bytes = Buffer.byteLength(content, 'utf8');
@@ -126,10 +181,17 @@ export class FilesystemTool {
126
181
  try {
127
182
  handle = await open(temporary, 'wx', 0o600);
128
183
  await handle.writeFile(content, 'utf8');
184
+ if (current !== undefined)
185
+ await handle.chmod((await lstat(target)).mode & 0o777);
129
186
  await handle.sync();
130
187
  await handle.close();
131
188
  handle = undefined;
132
- await rename(temporary, target);
189
+ if (signal?.aborted)
190
+ throw filesystemError('ABORTED');
191
+ if (expectedHash === null)
192
+ await link(temporary, target);
193
+ else
194
+ await rename(temporary, target);
133
195
  }
134
196
  finally {
135
197
  if (handle)
@@ -0,0 +1,2 @@
1
+ import { type SandboxRuntimeConfig } from '@anthropic-ai/sandbox-runtime';
2
+ export declare function initializeShellSandbox(policy: SandboxRuntimeConfig, platform?: NodeJS.Platform): Promise<void>;
@@ -0,0 +1,17 @@
1
+ import { SandboxManager } from '@anthropic-ai/sandbox-runtime';
2
+ export async function initializeShellSandbox(policy, platform = process.platform) {
3
+ try {
4
+ await SandboxManager.initialize(policy, undefined, false);
5
+ }
6
+ catch (error) {
7
+ // A cold Windows sandbox account can time out during the WFP readiness
8
+ // probe. Retry that probe once after cleanup, before any command is run.
9
+ // A failed isolation check, ACL error, or second timeout still fails closed.
10
+ if (platform !== 'win32' || !error || typeof error !== 'object'
11
+ || !('code' in error) || error.code !== 'srt_win_timeout'
12
+ || !('subcommand' in error) || error.subcommand !== 'wfp')
13
+ throw error;
14
+ await SandboxManager.reset();
15
+ await SandboxManager.initialize(policy, undefined, false);
16
+ }
17
+ }
@@ -6,10 +6,20 @@ export interface ShellResult {
6
6
  timedOut: boolean;
7
7
  aborted: boolean;
8
8
  }
9
+ /** Read-only prerequisite probe; never provisions accounts or changes permissions. */
10
+ export declare function checkShellSandbox(): Promise<{
11
+ errors: string[];
12
+ warnings: string[];
13
+ }>;
14
+ export declare function quoteWindowsArgument(value: string): string;
15
+ /** Build the command text expected by srt without interpreting model argv as shell syntax. */
16
+ export declare function encodeSandboxCommand(command: string, args: string[], platform?: NodeJS.Platform): string;
17
+ export declare function decodeUtf8WithinByteLimit(value: Buffer, maxBytes: number): string;
9
18
  export declare function runShell(command: string, args?: string[], options?: {
10
19
  cwd?: string;
11
20
  signal?: AbortSignal;
12
21
  timeoutMs?: number;
13
22
  maxOutputBytes?: number;
14
23
  env?: NodeJS.ProcessEnv;
24
+ allowedDomains?: string[];
15
25
  }): Promise<ShellResult>;
@@ -1,81 +1,370 @@
1
- import { spawn } from 'node:child_process';
1
+ import { createRequire } from 'node:module';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { basename, delimiter, dirname, join, parse, resolve, sep } from 'node:path';
5
+ import { mkdtemp, realpath, rm } from 'node:fs/promises';
6
+ import { execFile, spawn } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { SandboxManager, VENDORED_SRT_WIN_EXE, checkWindowsDependenciesAsync, resolveSrtWin } from '@anthropic-ai/sandbox-runtime';
9
+ import { resolveWindowsPackageManager } from './windows-package-manager.js';
10
+ import { carveWindowsReadDenies } from './windows-read-policy.js';
11
+ import { initializeShellSandbox } from './sandbox-initialization.js';
2
12
  function shellError(code, retryable = false, cause) {
3
13
  return Object.assign(new Error(code), { code, retryable, ...(cause === undefined ? {} : { cause }) });
4
14
  }
15
+ // SandboxManager owns process-global proxy and policy state. A per-invocation
16
+ // policy is installed for exactly one child at a time to avoid one concurrent
17
+ // call widening another call's filesystem grants.
18
+ let sandboxTail = Promise.resolve();
19
+ function withSandboxLease(work) {
20
+ const result = sandboxTail.then(work, work);
21
+ sandboxTail = result.then(() => undefined, () => undefined);
22
+ return result;
23
+ }
24
+ /** Read-only prerequisite probe; never provisions accounts or changes permissions. */
25
+ export async function checkShellSandbox() {
26
+ // doctor runs before initialize(), so the manager has no Windows config yet.
27
+ // Probe the same packaged helper used for execution without provisioning it.
28
+ return withSandboxLease(() => process.platform === 'win32'
29
+ ? checkWindowsDependenciesAsync({ srtWin: resolveSrtWin({ path: VENDORED_SRT_WIN_EXE }) })
30
+ : SandboxManager.checkDependenciesAsync());
31
+ }
32
+ function quotePosix(value) {
33
+ return `'${value.replaceAll("'", "'\\''")}'`;
34
+ }
35
+ export function quoteWindowsArgument(value) {
36
+ if (value.length > 0 && !/[\s"]/u.test(value))
37
+ return value;
38
+ let output = '"';
39
+ let slashes = 0;
40
+ for (const character of value) {
41
+ if (character === '\\') {
42
+ slashes++;
43
+ continue;
44
+ }
45
+ if (character === '"')
46
+ output += '\\'.repeat(slashes * 2 + 1) + '"';
47
+ else
48
+ output += '\\'.repeat(slashes) + character;
49
+ slashes = 0;
50
+ }
51
+ return output + '\\'.repeat(slashes * 2) + '"';
52
+ }
53
+ const powershellArgvRunner = String.raw `
54
+ $ErrorActionPreference = 'Stop'
55
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
56
+ $payload = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__PULSE_ARGV_JSON__'))
57
+ $spec = ConvertFrom-Json -InputObject $payload
58
+ $psi = [System.Diagnostics.ProcessStartInfo]::new()
59
+ $psi.FileName = [string]$spec.command
60
+ $psi.Arguments = [string]$spec.arguments
61
+ $psi.UseShellExecute = $false
62
+ $psi.CreateNoWindow = $true
63
+ $psi.RedirectStandardOutput = $true
64
+ $psi.RedirectStandardError = $true
65
+ $psi.RedirectStandardInput = $true
66
+ $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
67
+ $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
68
+ $child = [System.Diagnostics.Process]::new()
69
+ $child.StartInfo = $psi
70
+ try { if (-not $child.Start()) { exit 127 } }
71
+ catch {
72
+ $cause = $_.Exception
73
+ while ($cause.InnerException) { $cause = $cause.InnerException }
74
+ [Console]::Error.WriteLine($cause.Message)
75
+ if ($cause -is [System.ComponentModel.Win32Exception] -and $cause.NativeErrorCode -in 2,3) { exit 127 }
76
+ exit 126
77
+ }
78
+ $outTask = $child.StandardOutput.ReadToEndAsync()
79
+ $errTask = $child.StandardError.ReadToEndAsync()
80
+ $inTask = [Console]::OpenStandardInput().CopyToAsync($child.StandardInput.BaseStream)
81
+ [void]$inTask.GetAwaiter().GetResult()
82
+ $child.StandardInput.Close()
83
+ $child.WaitForExit()
84
+ try { [Console]::Out.Write([string]$outTask.GetAwaiter().GetResult()) } catch {}
85
+ try { [Console]::Error.Write([string]$errTask.GetAwaiter().GetResult()) } catch {}
86
+ exit $child.ExitCode
87
+ `.trim();
88
+ /** Build the command text expected by srt without interpreting model argv as shell syntax. */
89
+ export function encodeSandboxCommand(command, args, platform = process.platform) {
90
+ if ([command, ...args].some((value) => value.includes('\0')))
91
+ throw shellError('INVALID_SHELL_ARGUMENT');
92
+ if (platform === 'win32') {
93
+ // The only bytes placed in cmd.exe's command string are fixed tokens and
94
+ // base64. PowerShell decodes JSON and ProcessStartInfo.Arguments uses a
95
+ // Windows CRT-compatible encoder, preserving argv without a shell parse.
96
+ const payload = Buffer.from(JSON.stringify({ command, arguments: args.map(quoteWindowsArgument).join(' ') }), 'utf8').toString('base64');
97
+ const encoded = Buffer.from(powershellArgvRunner.replace('__PULSE_ARGV_JSON__', payload), 'utf16le').toString('base64');
98
+ const commandText = `powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}`;
99
+ if (commandText.length > 24_000)
100
+ throw shellError('INVALID_SHELL_ARGUMENTS_TOO_LARGE');
101
+ return commandText;
102
+ }
103
+ return `exec ${[command, ...args].map(quotePosix).join(' ')}`;
104
+ }
105
+ async function sandboxConfig(cwd, allowedDomains = [], toolchainRoots = [], scratch) {
106
+ // Linux invokes this trusted helper *inside* the read-restricted namespace.
107
+ // A user-local npm install otherwise hides it together with the home directory.
108
+ const seccompPath = process.platform === 'linux'
109
+ ? await realpath(resolve(dirname(createRequire(import.meta.url).resolve('@anthropic-ai/sandbox-runtime')), '..', 'vendor', 'seccomp', process.arch, 'apply-seccomp'))
110
+ : undefined;
111
+ const home = homedir();
112
+ const parent = dirname(cwd);
113
+ const root = parse(cwd).root;
114
+ if (cwd === root || cwd === home || home.startsWith(cwd + sep) || parent === root) {
115
+ throw shellError('SANDBOX_WORKSPACE_TOO_BROAD');
116
+ }
117
+ // Windows runs as a separate srt-sandbox account, which has no access to
118
+ // private host-profile files without explicit grants. Stamping the entire
119
+ // profile recursively is both redundant and very expensive (registry/cache
120
+ // trees); only carve the workspace's sibling boundary on that platform.
121
+ const denyRead = new Set(process.platform === 'win32' ? [] : [home]);
122
+ // Close the common sibling-workspace and temporary-directory escape: deny
123
+ // the workspace's immediate container, then re-open only this invocation's
124
+ // cwd. Root workspaces cannot be carved this way and are rejected upstream.
125
+ if (parent !== parse(cwd).root && parent !== cwd)
126
+ denyRead.add(parent);
127
+ const allowRead = [cwd, ...toolchainRoots, ...(scratch ? [scratch] : []), ...(seccompPath ? [seccompPath] : [])];
128
+ // Reads default to the system toolchain plus this workspace. Writes are
129
+ // limited to the workspace; srt adds only its required stdio/temp paths.
130
+ return {
131
+ network: { allowedDomains, deniedDomains: [], strictAllowlist: true },
132
+ filesystem: {
133
+ denyRead: process.platform === 'win32' ? await carveWindowsReadDenies([...denyRead], allowRead) : [...denyRead],
134
+ allowRead,
135
+ allowWrite: [cwd, ...(scratch ? [scratch] : [])],
136
+ denyWrite: [],
137
+ },
138
+ ...(seccompPath ? { seccomp: { applyPath: seccompPath } } : {}),
139
+ ...(process.platform === 'win32' ? { windows: { srtWin: { path: VENDORED_SRT_WIN_EXE } } } : {}),
140
+ };
141
+ }
142
+ function cleanPath(value) {
143
+ return resolve(value);
144
+ }
145
+ export function decodeUtf8WithinByteLimit(value, maxBytes) {
146
+ if (!Number.isFinite(maxBytes) || maxBytes < 0)
147
+ throw shellError('INVALID_SHELL_OUTPUT_LIMIT');
148
+ const decoded = value.subarray(0, maxBytes).toString('utf8');
149
+ let used = 0;
150
+ const output = [];
151
+ for (const character of decoded) {
152
+ const bytes = Buffer.byteLength(character, 'utf8');
153
+ if (used + bytes > maxBytes)
154
+ break;
155
+ output.push(character);
156
+ used += bytes;
157
+ }
158
+ return output.join('');
159
+ }
160
+ function shellEnvironment(env) {
161
+ const source = env ?? process.env;
162
+ 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'));
163
+ }
5
164
  export function runShell(command, args = [], options = {}) {
6
165
  const max = options.maxOutputBytes ?? 256 * 1024;
7
166
  if (!Number.isFinite(max) || max < 0)
8
167
  return Promise.reject(shellError('INVALID_SHELL_OUTPUT_LIMIT'));
9
168
  if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 0))
10
169
  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;
170
+ if (!command || typeof command !== 'string' || !Array.isArray(args) || args.some((arg) => typeof arg !== 'string'))
171
+ return Promise.reject(shellError('INVALID_SHELL_ARGUMENT'));
172
+ try {
173
+ encodeSandboxCommand(command, args);
174
+ }
175
+ catch (error) {
176
+ return Promise.reject(error);
177
+ }
178
+ return withSandboxLease(async () => {
179
+ if (options.signal?.aborted)
180
+ return { code: null, stdout: '', stderr: '', truncated: false, timedOut: false, aborted: true };
181
+ let cwd;
182
+ try {
183
+ cwd = await realpath(cleanPath(options.cwd ?? process.cwd()));
23
184
  }
24
- else if (target === 'stdout')
25
- stdout = next;
26
- else
27
- stderr = next; };
28
- let closed = false;
29
- const signalProcessGroup = (signal) => {
30
- if (process.platform !== 'win32' && child.pid) {
31
- try {
32
- process.kill(-child.pid, signal);
185
+ catch (cause) {
186
+ throw shellError('INVALID_SHELL_CWD', false, cause);
187
+ }
188
+ // Trust only the installation hosting Pulse, never arbitrary PATH directories.
189
+ const nodeExecutable = await realpath(process.execPath);
190
+ const nodeBin = dirname(nodeExecutable);
191
+ const installation = basename(nodeBin) === 'bin' ? dirname(nodeBin) : nodeBin;
192
+ const toolchainRoots = installation !== homedir() && installation !== parse(installation).root ? [installation] : [nodeExecutable];
193
+ // PNPM_HOME is a host installation setting, never supplied by model argv.
194
+ let pnpmHome;
195
+ if (process.env.PNPM_HOME) {
196
+ try {
197
+ const candidate = await realpath(process.env.PNPM_HOME);
198
+ if (candidate !== homedir() && candidate !== parse(candidate).root && !homedir().startsWith(candidate + sep)) {
199
+ pnpmHome = candidate;
200
+ toolchainRoots.push(candidate);
201
+ // pnpm/action-setup exposes its executable shims from node_modules/.bin;
202
+ // their targets live in the adjacent node_modules tree.
203
+ if (basename(candidate) === '.bin')
204
+ toolchainRoots.push(dirname(candidate));
205
+ }
206
+ }
207
+ catch { /* stale installation settings do not grant access */ }
208
+ }
209
+ const scratch = await realpath(await mkdtemp(join(tmpdir(), 'pulse-shell-')));
210
+ try {
211
+ const baseEnv = shellEnvironment(options.env);
212
+ const pathKey = Object.keys(baseEnv).find((key) => key.toUpperCase() === 'PATH') ?? 'PATH';
213
+ const executionEnv = { [pathKey]: [...(pnpmHome ? [pnpmHome] : []), nodeBin, join(cwd, 'node_modules', '.bin'), baseEnv[pathKey] ?? ''].join(delimiter),
214
+ ...(process.platform === 'win32' ? {} : { HOME: scratch, TMPDIR: scratch, TMP: scratch, TEMP: scratch, XDG_CONFIG_HOME: scratch, XDG_CACHE_HOME: scratch, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' }) };
215
+ let executable = command;
216
+ let executableArgs = args;
217
+ if (command === 'node')
218
+ executable = nodeExecutable;
219
+ if (process.platform === 'darwin' && command === 'git') {
220
+ // /usr/bin/git is an xcrun shim that writes outside TMPDIR. Resolve the
221
+ // installed tool through Apple's fixed locator before entering isolation.
222
+ const located = await promisify(execFile)('/usr/bin/xcrun', ['--find', 'git'], { timeout: 5000, env: baseEnv });
223
+ executable = located.stdout.trim();
224
+ if (!executable.startsWith('/'))
225
+ throw shellError('GIT_TOOLCHAIN_NOT_FOUND');
226
+ }
227
+ if (process.platform === 'win32') {
228
+ const resolved = await resolveWindowsPackageManager(command, nodeExecutable, pnpmHome);
229
+ if (resolved) {
230
+ executable = resolved.executable;
231
+ executableArgs = [...resolved.prefixArgs, ...args];
232
+ }
233
+ }
234
+ const invocationId = randomUUID();
235
+ const policy = await sandboxConfig(cwd, options.allowedDomains ?? [], toolchainRoots, scratch);
236
+ let child;
237
+ const outputChunks = { stdout: [], stderr: [] };
238
+ const outputBytes = { stdout: 0, stderr: 0 };
239
+ let truncated = false;
240
+ let closed = false;
241
+ let termination;
242
+ let timer;
243
+ let killTimer;
244
+ const signalProcessGroup = (signal) => {
245
+ if (!child?.pid)
246
+ return;
247
+ if (process.platform !== 'win32') {
248
+ try {
249
+ process.kill(-child.pid, signal);
250
+ return;
251
+ }
252
+ catch { /* process group may already be gone */ }
253
+ }
254
+ if (process.platform === 'win32') {
255
+ const tree = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true, env: shellEnvironment(options.env) });
256
+ tree.once('error', () => child?.kill(signal));
257
+ tree.unref();
33
258
  return;
34
259
  }
35
- catch { /* process group may already be gone */ }
260
+ child.kill(signal);
261
+ };
262
+ const terminate = (reason) => {
263
+ if (closed)
264
+ return;
265
+ termination ??= reason;
266
+ signalProcessGroup('SIGTERM');
267
+ if (!killTimer)
268
+ killTimer = setTimeout(() => { if (!closed)
269
+ signalProcessGroup('SIGKILL'); }, 250);
270
+ };
271
+ const append = (target, chunk) => {
272
+ const remaining = Math.max(0, max - outputBytes[target]);
273
+ const kept = Math.min(chunk.byteLength, remaining);
274
+ if (kept > 0)
275
+ outputChunks[target].push(chunk.subarray(0, kept));
276
+ outputBytes[target] += kept;
277
+ if (kept < chunk.byteLength)
278
+ truncated = true;
279
+ };
280
+ const outputText = (target) => {
281
+ return decodeUtf8WithinByteLimit(Buffer.concat(outputChunks[target]), max);
282
+ };
283
+ let setupStage = 'reset';
284
+ try {
285
+ // reset first in case an earlier initialize failed part-way through.
286
+ await SandboxManager.reset();
287
+ setupStage = 'initialize';
288
+ await initializeShellSandbox(policy);
289
+ const commandText = encodeSandboxCommand(executable, executableArgs);
290
+ setupStage = 'wrap';
291
+ const descriptor = await SandboxManager.wrapWithSandboxArgv(commandText, undefined, undefined, options.signal, cwd, { commandId: invocationId, commandText: 'shell.exec' });
292
+ if (options.signal?.aborted) {
293
+ SandboxManager.cleanupAfterCommand();
294
+ await SandboxManager.reset();
295
+ return { code: null, stdout: '', stderr: '', truncated: false, timedOut: false, aborted: true };
296
+ }
297
+ child = spawn(descriptor.argv[0], descriptor.argv.slice(1), {
298
+ cwd,
299
+ env: { ...baseEnv, ...shellEnvironment(descriptor.env), ...executionEnv },
300
+ shell: false,
301
+ detached: process.platform !== 'win32',
302
+ windowsHide: true,
303
+ });
36
304
  }
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));
42
- tree.unref();
43
- return;
305
+ catch (cause) {
306
+ try {
307
+ await SandboxManager.reset();
308
+ }
309
+ catch { /* preserve the setup failure */ }
310
+ // Persist only classified diagnostics, never raw subprocess output or credentials.
311
+ const message = cause instanceof Error ? cause.message : '';
312
+ const nativeCode = cause && typeof cause === 'object' && 'code' in cause ? String(cause.code) : '';
313
+ const reason = /windows-install|not provisioned/i.test(message) ? 'WINDOWS_SANDBOX_NOT_PROVISIONED'
314
+ : ['ENOENT', 'EACCES', 'EPERM'].includes(nativeCode) ? nativeCode : 'SANDBOX_INITIALIZATION_ERROR';
315
+ throw Object.assign(shellError('SANDBOX_SETUP_FAILED', false, cause), {
316
+ message: `SANDBOX_SETUP_FAILED (${setupStage}: ${reason})`,
317
+ details: { platform: process.platform, stage: setupStage, reason },
318
+ });
44
319
  }
45
- child.kill(signal);
46
- };
47
- let termination;
48
- let killTimer;
49
- const terminate = (reason) => {
50
- if (closed)
51
- return;
52
- termination ??= reason;
53
- signalProcessGroup('SIGTERM');
54
- if (!killTimer)
55
- killTimer = setTimeout(() => { if (!closed)
56
- signalProcessGroup('SIGKILL'); }, 250);
57
- };
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 });
320
+ // This API has no stdin payload; deliver EOF to readers inside the sandbox.
321
+ child.stdin?.end();
322
+ return await new Promise((resolve, reject) => {
323
+ const cleanup = () => {
324
+ if (timer)
325
+ clearTimeout(timer);
326
+ if (killTimer)
327
+ clearTimeout(killTimer);
328
+ options.signal?.removeEventListener('abort', abort);
329
+ };
330
+ const abort = () => terminate('aborted');
331
+ timer = options.timeoutMs && options.timeoutMs > 0 ? setTimeout(() => terminate('timeout'), options.timeoutMs) : undefined;
332
+ if (options.signal)
333
+ options.signal.addEventListener('abort', abort, { once: true });
334
+ if (options.signal?.aborted)
335
+ abort();
336
+ child.stdout?.on('data', (chunk) => append('stdout', chunk));
337
+ child.stderr?.on('data', (chunk) => append('stderr', chunk));
338
+ child.once('error', (cause) => {
339
+ closed = true;
340
+ cleanup();
341
+ const errorCode = cause && typeof cause === 'object' ? cause.code : undefined;
342
+ const code = typeof errorCode === 'string' ? errorCode : 'SHELL_EXECUTION_ERROR';
343
+ reject(shellError(code === 'ENOENT' || code === 'EACCES' ? code : 'SHELL_EXECUTION_ERROR', false, cause));
344
+ });
345
+ child.once('close', (code) => {
346
+ closed = true;
347
+ cleanup();
348
+ const stdout = outputText('stdout');
349
+ const stderr = outputText('stderr');
350
+ const annotated = SandboxManager.annotateStderrWithSandboxFailures(invocationId, stderr);
351
+ const boundedStderr = decodeUtf8WithinByteLimit(Buffer.from(annotated, 'utf8'), max);
352
+ const outputWasTruncated = truncated || Buffer.byteLength(annotated, 'utf8') > max;
353
+ resolve({ code, stdout, stderr: boundedStderr, truncated: outputWasTruncated, timedOut: termination === 'timeout', aborted: termination === 'aborted' });
354
+ });
355
+ }).finally(async () => {
356
+ try {
357
+ SandboxManager.cleanupAfterCommand();
358
+ await SandboxManager.reset();
359
+ }
360
+ catch (cause) {
361
+ // A teardown failure must be visible and fail closed for the next call.
362
+ throw shellError('SANDBOX_CLEANUP_FAILED', false, cause);
363
+ }
364
+ });
365
+ }
366
+ finally {
367
+ await rm(scratch, { recursive: true, force: true });
65
368
  }
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));
76
- });
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
369
  });
81
370
  }
@@ -0,0 +1,5 @@
1
+ /** Resolve trusted installations without executing .cmd shims or fetching via Corepack first. */
2
+ export declare function resolveWindowsPackageManager(command: string, nodeExecutable: string, pnpmHome?: string): Promise<{
3
+ executable: string;
4
+ prefixArgs: string[];
5
+ } | undefined>;
@@ -0,0 +1,31 @@
1
+ import { realpath, stat } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ /** Resolve trusted installations without executing .cmd shims or fetching via Corepack first. */
4
+ export async function resolveWindowsPackageManager(command, nodeExecutable, pnpmHome) {
5
+ if (!['npm', 'npx', 'pnpm', 'pnpx', 'corepack'].includes(command))
6
+ return undefined;
7
+ const nodeBin = dirname(nodeExecutable);
8
+ const candidates = [];
9
+ if (command === 'pnpm' || command === 'pnpx') {
10
+ // pnpm 11+ ships a native executable and an .mjs wrapper; older versions use .cjs.
11
+ // action-setup installs its package next to PNPM_HOME (.bin).
12
+ const roots = [...(pnpmHome ? [pnpmHome, join(dirname(pnpmHome), 'pnpm')] : []), join(nodeBin, 'node_modules', 'pnpm')];
13
+ for (const root of roots) {
14
+ candidates.push({ path: join(root, 'pnpm.exe'), native: true, dlx: command === 'pnpx' }, { path: join(root, 'bin', `${command}.mjs`) }, { path: join(root, 'bin', 'pnpm.cjs'), dlx: command === 'pnpx' });
15
+ }
16
+ }
17
+ else {
18
+ candidates.push({ path: join(nodeBin, 'node_modules', command === 'corepack' ? 'corepack/dist/corepack.js' : `npm/bin/${command}-cli.js`) });
19
+ }
20
+ candidates.push({ path: join(nodeBin, 'node_modules', 'corepack', 'dist', `${command}.js`) });
21
+ for (const candidate of candidates) {
22
+ try {
23
+ const path = await realpath(candidate.path);
24
+ if (!(await stat(path)).isFile())
25
+ continue;
26
+ return { executable: candidate.native ? path : nodeExecutable, prefixArgs: [...(candidate.native ? [] : [path]), ...(candidate.dlx ? ['dlx'] : [])] };
27
+ }
28
+ catch { /* Missing installations fall through to the next trusted candidate. */ }
29
+ }
30
+ return undefined;
31
+ }
@@ -0,0 +1,2 @@
1
+ /** Keep denied subtrees off the ancestors Git must stat to reach an allowed workspace. */
2
+ export declare function carveWindowsReadDenies(denied: string[], allowed: string[]): Promise<string[]>;