@hunterzhu/pulse-adapters 0.2.0 → 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.
- package/README.md +40 -0
- package/dist/tools/filesystem.d.ts +3 -1
- package/dist/tools/filesystem.js +51 -7
- package/dist/tools/sandbox-initialization.d.ts +2 -0
- package/dist/tools/sandbox-initialization.js +17 -0
- package/dist/tools/shell.d.ts +6 -0
- package/dist/tools/shell.js +198 -120
- package/dist/tools/windows-package-manager.d.ts +5 -0
- package/dist/tools/windows-package-manager.js +31 -0
- package/dist/tools/windows-read-policy.d.ts +2 -0
- package/dist/tools/windows-read-policy.js +28 -0
- package/package.json +3 -3
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# @hunterzhu/pulse-adapters
|
|
2
|
+
|
|
3
|
+
Provider and bridge adapters for Pulse — model providers plus filesystem, shell, and MCP bridges.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @hunterzhu/pulse-adapters
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Node.js 22+. This package is ESM-only.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
`MockAdapter` is a network-free provider that returns scripted responses, which is handy for tests and local runs.
|
|
16
|
+
|
|
17
|
+
Save as `example.mjs` and run `node example.mjs`.
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import { MockAdapter } from '@hunterzhu/pulse-adapters';
|
|
21
|
+
|
|
22
|
+
const provider = new MockAdapter();
|
|
23
|
+
provider.enqueue({ text: 'Hello, Pulse', toolCalls: [], finishReason: 'stop' });
|
|
24
|
+
|
|
25
|
+
const attempt = await provider.executeAttempt();
|
|
26
|
+
console.log(attempt.text); // "Hello, Pulse"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This example runs offline and needs no credentials. For real providers, use
|
|
30
|
+
`createProviderAdapter({ provider, baseURL, defaultModel, apiKey })`; read the key in your
|
|
31
|
+
application, for example `apiKey: process.env.DEEPSEEK_API_KEY`. The CLI's
|
|
32
|
+
`apiKeyEnv` setting is not an adapter option.
|
|
33
|
+
|
|
34
|
+
The same package also exposes filesystem and shell bridges plus MCP support
|
|
35
|
+
for wiring external tool servers into Pulse.
|
|
36
|
+
|
|
37
|
+
## Documentation
|
|
38
|
+
|
|
39
|
+
- [Documentation](https://github.com/zhuhengtan/Pulse#readme)
|
|
40
|
+
- [Source](https://github.com/zhuhengtan/Pulse/tree/main/packages/adapters)
|
|
@@ -15,6 +15,8 @@ export declare class FilesystemTool {
|
|
|
15
15
|
private writable;
|
|
16
16
|
read(path: string, signal?: AbortSignal): Promise<string>;
|
|
17
17
|
readLimited(path: string, maxBytes: number, signal?: AbortSignal): Promise<FilesystemReadResult>;
|
|
18
|
+
/** Resolve a one-based source line without confusing line numbers with byte offsets. */
|
|
19
|
+
offsetForLine(path: string, startLine: number, signal?: AbortSignal): Promise<number>;
|
|
18
20
|
readRange(path: string, maxBytes: number, offset?: number, signal?: AbortSignal): Promise<FilesystemReadResult & {
|
|
19
21
|
offset: number;
|
|
20
22
|
nextOffset: number | null;
|
|
@@ -26,6 +28,6 @@ export declare class FilesystemTool {
|
|
|
26
28
|
bytes: number;
|
|
27
29
|
}>;
|
|
28
30
|
hash(path: string, signal?: AbortSignal): Promise<string>;
|
|
29
|
-
writeIfUnchanged(path: string, content: string, expectedHash: string, signal?: AbortSignal): Promise<FilesystemWriteResult>;
|
|
31
|
+
writeIfUnchanged(path: string, content: string, expectedHash: string | null, signal?: AbortSignal): Promise<FilesystemWriteResult>;
|
|
30
32
|
private withLock;
|
|
31
33
|
}
|
package/dist/tools/filesystem.js
CHANGED
|
@@ -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) {
|
|
@@ -59,6 +59,40 @@ export class FilesystemTool {
|
|
|
59
59
|
const { content, truncated } = await this.readRange(path, maxBytes, 0, signal);
|
|
60
60
|
return { content, truncated };
|
|
61
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
|
+
}
|
|
62
96
|
async readRange(path, maxBytes, offset = 0, signal) {
|
|
63
97
|
if (signal?.aborted)
|
|
64
98
|
throw filesystemError('ABORTED');
|
|
@@ -68,6 +102,8 @@ export class FilesystemTool {
|
|
|
68
102
|
try {
|
|
69
103
|
const buffer = Buffer.alloc(maxBytes + 1);
|
|
70
104
|
const { bytesRead } = await handle.read(buffer, 0, maxBytes + 1, offset);
|
|
105
|
+
if (signal?.aborted)
|
|
106
|
+
throw filesystemError('ABORTED');
|
|
71
107
|
if (bytesRead && (buffer[0] & 0xc0) === 0x80)
|
|
72
108
|
throw filesystemError('INVALID_UTF8_OFFSET');
|
|
73
109
|
let end = Math.min(bytesRead, maxBytes);
|
|
@@ -120,7 +156,7 @@ export class FilesystemTool {
|
|
|
120
156
|
async writeIfUnchanged(path, content, expectedHash, signal) {
|
|
121
157
|
if (signal?.aborted)
|
|
122
158
|
throw filesystemError('ABORTED');
|
|
123
|
-
if (!/^[a-f0-9]{64}$/.test(expectedHash))
|
|
159
|
+
if (expectedHash !== null && !/^[a-f0-9]{64}$/.test(expectedHash))
|
|
124
160
|
throw filesystemError('INVALID_FILE_BASELINE_HASH');
|
|
125
161
|
const target = await this.writable(path);
|
|
126
162
|
return this.withLock(target, async () => {
|
|
@@ -131,11 +167,12 @@ export class FilesystemTool {
|
|
|
131
167
|
current = await readFile(target);
|
|
132
168
|
}
|
|
133
169
|
catch (cause) {
|
|
134
|
-
if (cause.code
|
|
135
|
-
throw
|
|
136
|
-
throw filesystemCause(cause);
|
|
170
|
+
if (cause.code !== 'ENOENT')
|
|
171
|
+
throw filesystemCause(cause);
|
|
137
172
|
}
|
|
138
|
-
|
|
173
|
+
if (current === undefined && expectedHash !== null)
|
|
174
|
+
throw filesystemError('FILE_BASELINE_MISSING');
|
|
175
|
+
const currentHash = current === undefined ? null : createHash('sha256').update(current).digest('hex');
|
|
139
176
|
if (currentHash !== expectedHash)
|
|
140
177
|
throw filesystemError('FILE_BASELINE_CONFLICT');
|
|
141
178
|
const bytes = Buffer.byteLength(content, 'utf8');
|
|
@@ -144,10 +181,17 @@ export class FilesystemTool {
|
|
|
144
181
|
try {
|
|
145
182
|
handle = await open(temporary, 'wx', 0o600);
|
|
146
183
|
await handle.writeFile(content, 'utf8');
|
|
184
|
+
if (current !== undefined)
|
|
185
|
+
await handle.chmod((await lstat(target)).mode & 0o777);
|
|
147
186
|
await handle.sync();
|
|
148
187
|
await handle.close();
|
|
149
188
|
handle = undefined;
|
|
150
|
-
|
|
189
|
+
if (signal?.aborted)
|
|
190
|
+
throw filesystemError('ABORTED');
|
|
191
|
+
if (expectedHash === null)
|
|
192
|
+
await link(temporary, target);
|
|
193
|
+
else
|
|
194
|
+
await rename(temporary, target);
|
|
151
195
|
}
|
|
152
196
|
finally {
|
|
153
197
|
if (handle)
|
|
@@ -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
|
+
}
|
package/dist/tools/shell.d.ts
CHANGED
|
@@ -6,6 +6,11 @@ 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
|
+
}>;
|
|
9
14
|
export declare function quoteWindowsArgument(value: string): string;
|
|
10
15
|
/** Build the command text expected by srt without interpreting model argv as shell syntax. */
|
|
11
16
|
export declare function encodeSandboxCommand(command: string, args: string[], platform?: NodeJS.Platform): string;
|
|
@@ -16,4 +21,5 @@ export declare function runShell(command: string, args?: string[], options?: {
|
|
|
16
21
|
timeoutMs?: number;
|
|
17
22
|
maxOutputBytes?: number;
|
|
18
23
|
env?: NodeJS.ProcessEnv;
|
|
24
|
+
allowedDomains?: string[];
|
|
19
25
|
}): Promise<ShellResult>;
|
package/dist/tools/shell.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
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';
|
|
6
|
-
import { spawn } from 'node:child_process';
|
|
7
|
-
import {
|
|
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';
|
|
8
12
|
function shellError(code, retryable = false, cause) {
|
|
9
13
|
return Object.assign(new Error(code), { code, retryable, ...(cause === undefined ? {} : { cause }) });
|
|
10
14
|
}
|
|
@@ -17,6 +21,14 @@ function withSandboxLease(work) {
|
|
|
17
21
|
sandboxTail = result.then(() => undefined, () => undefined);
|
|
18
22
|
return result;
|
|
19
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
|
+
}
|
|
20
32
|
function quotePosix(value) {
|
|
21
33
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
22
34
|
}
|
|
@@ -90,7 +102,7 @@ export function encodeSandboxCommand(command, args, platform = process.platform)
|
|
|
90
102
|
}
|
|
91
103
|
return `exec ${[command, ...args].map(quotePosix).join(' ')}`;
|
|
92
104
|
}
|
|
93
|
-
async function sandboxConfig(cwd) {
|
|
105
|
+
async function sandboxConfig(cwd, allowedDomains = [], toolchainRoots = [], scratch) {
|
|
94
106
|
// Linux invokes this trusted helper *inside* the read-restricted namespace.
|
|
95
107
|
// A user-local npm install otherwise hides it together with the home directory.
|
|
96
108
|
const seccompPath = process.platform === 'linux'
|
|
@@ -102,20 +114,25 @@ async function sandboxConfig(cwd) {
|
|
|
102
114
|
if (cwd === root || cwd === home || home.startsWith(cwd + sep) || parent === root) {
|
|
103
115
|
throw shellError('SANDBOX_WORKSPACE_TOO_BROAD');
|
|
104
116
|
}
|
|
105
|
-
|
|
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]);
|
|
106
122
|
// Close the common sibling-workspace and temporary-directory escape: deny
|
|
107
123
|
// the workspace's immediate container, then re-open only this invocation's
|
|
108
124
|
// cwd. Root workspaces cannot be carved this way and are rejected upstream.
|
|
109
125
|
if (parent !== parse(cwd).root && parent !== cwd)
|
|
110
126
|
denyRead.add(parent);
|
|
127
|
+
const allowRead = [cwd, ...toolchainRoots, ...(scratch ? [scratch] : []), ...(seccompPath ? [seccompPath] : [])];
|
|
111
128
|
// Reads default to the system toolchain plus this workspace. Writes are
|
|
112
129
|
// limited to the workspace; srt adds only its required stdio/temp paths.
|
|
113
130
|
return {
|
|
114
|
-
network: { allowedDomains
|
|
131
|
+
network: { allowedDomains, deniedDomains: [], strictAllowlist: true },
|
|
115
132
|
filesystem: {
|
|
116
|
-
denyRead: [...denyRead],
|
|
117
|
-
allowRead
|
|
118
|
-
allowWrite: [cwd],
|
|
133
|
+
denyRead: process.platform === 'win32' ? await carveWindowsReadDenies([...denyRead], allowRead) : [...denyRead],
|
|
134
|
+
allowRead,
|
|
135
|
+
allowWrite: [cwd, ...(scratch ? [scratch] : [])],
|
|
119
136
|
denyWrite: [],
|
|
120
137
|
},
|
|
121
138
|
...(seccompPath ? { seccomp: { applyPath: seccompPath } } : {}),
|
|
@@ -168,125 +185,186 @@ export function runShell(command, args = [], options = {}) {
|
|
|
168
185
|
catch (cause) {
|
|
169
186
|
throw shellError('INVALID_SHELL_CWD', false, cause);
|
|
170
187
|
}
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
let
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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));
|
|
188
205
|
}
|
|
189
|
-
catch { /* process group may already be gone */ }
|
|
190
206
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
tree.unref();
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
child.kill(signal);
|
|
198
|
-
};
|
|
199
|
-
const terminate = (reason) => {
|
|
200
|
-
if (closed)
|
|
201
|
-
return;
|
|
202
|
-
termination ??= reason;
|
|
203
|
-
signalProcessGroup('SIGTERM');
|
|
204
|
-
if (!killTimer)
|
|
205
|
-
killTimer = setTimeout(() => { if (!closed)
|
|
206
|
-
signalProcessGroup('SIGKILL'); }, 250);
|
|
207
|
-
};
|
|
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
|
-
};
|
|
207
|
+
catch { /* stale installation settings do not grant access */ }
|
|
208
|
+
}
|
|
209
|
+
const scratch = await realpath(await mkdtemp(join(tmpdir(), 'pulse-shell-')));
|
|
220
210
|
try {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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');
|
|
230
226
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
catch (cause) {
|
|
240
|
-
try {
|
|
241
|
-
await SandboxManager.reset();
|
|
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
|
+
}
|
|
242
233
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
child.kill(signal);
|
|
255
261
|
};
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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 () => {
|
|
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';
|
|
282
284
|
try {
|
|
283
|
-
|
|
285
|
+
// reset first in case an earlier initialize failed part-way through.
|
|
284
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
|
+
});
|
|
285
304
|
}
|
|
286
305
|
catch (cause) {
|
|
287
|
-
|
|
288
|
-
|
|
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
|
+
});
|
|
289
319
|
}
|
|
290
|
-
|
|
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 });
|
|
368
|
+
}
|
|
291
369
|
});
|
|
292
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,28 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, isAbsolute, sep } from 'node:path';
|
|
3
|
+
function within(path, root) {
|
|
4
|
+
const rel = relative(root, path);
|
|
5
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
6
|
+
}
|
|
7
|
+
/** Keep denied subtrees off the ancestors Git must stat to reach an allowed workspace. */
|
|
8
|
+
export async function carveWindowsReadDenies(denied, allowed) {
|
|
9
|
+
const result = new Set();
|
|
10
|
+
const visited = new Set();
|
|
11
|
+
const visit = async (path) => {
|
|
12
|
+
if (visited.has(path) || allowed.some(root => within(path, root)))
|
|
13
|
+
return;
|
|
14
|
+
visited.add(path);
|
|
15
|
+
if (!allowed.some(root => within(root, path))) {
|
|
16
|
+
result.add(path);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
// Windows inherited DENY ACEs are not POSIX allow-within-deny rules.
|
|
20
|
+
// Deny siblings along the allowed path, keeping only ancestor metadata
|
|
21
|
+
// accessible. An enumeration failure aborts setup rather than dropping a deny.
|
|
22
|
+
for (const entry of await readdir(path))
|
|
23
|
+
await visit(join(path, entry));
|
|
24
|
+
};
|
|
25
|
+
for (const path of denied)
|
|
26
|
+
await visit(path);
|
|
27
|
+
return [...result];
|
|
28
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hunterzhu/pulse-adapters",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/zhuhengtan/Pulse"
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"@anthropic-ai/sandbox-runtime": "0.0.77",
|
|
20
|
-
"@hunterzhu/pulse-runtime": "0.2.
|
|
21
|
-
"@hunterzhu/pulse-tool-sdk": "0.2.
|
|
20
|
+
"@hunterzhu/pulse-runtime": "0.2.1",
|
|
21
|
+
"@hunterzhu/pulse-tool-sdk": "0.2.1",
|
|
22
22
|
"exceljs": "4.4.0",
|
|
23
23
|
"pdfjs-dist": "4.10.38"
|
|
24
24
|
}
|