@hunterzhu/pulse-adapters 0.2.0 → 0.3.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.
- package/README.md +40 -0
- package/dist/providers/anthropic.js +4 -0
- package/dist/providers/normalize.d.ts +1 -1
- package/dist/providers/normalize.js +13 -6
- package/dist/providers/openai-compat.d.ts +1 -5
- package/dist/providers/openai-compat.js +25 -7
- package/dist/providers/runtime-executor.js +3 -3
- 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)
|
|
@@ -29,6 +29,10 @@ export class AnthropicAdapter {
|
|
|
29
29
|
for (const block of params.request.blocks) {
|
|
30
30
|
if (block.kind === 'system' || block.kind === 'policy' || block.kind === 'tools')
|
|
31
31
|
continue;
|
|
32
|
+
// Provider-native tool history is currently projected only by compatible
|
|
33
|
+
// OpenAI adapters. Keep Anthropic on the legacy history projection.
|
|
34
|
+
if (block.kind === 'provider_history')
|
|
35
|
+
continue;
|
|
32
36
|
if (block.kind === 'conversation' && Array.isArray(block.content)) {
|
|
33
37
|
for (const item of block.content) {
|
|
34
38
|
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
@@ -23,5 +23,5 @@ export declare function providerResponseError(detail: string): Error & {
|
|
|
23
23
|
export declare function parseProviderJson(response: Response): Promise<unknown>;
|
|
24
24
|
/** Read provider SSE frames without treating incomplete tool arguments as executable input. */
|
|
25
25
|
export declare function consumeProviderSse(response: Response, onEvent?: (event: ProviderSseEvent) => void): Promise<ProviderSseEvent[]>;
|
|
26
|
-
export declare function normalizeOpenAIResponse(response: any, toolNameAliases?: ReadonlyMap<string, string
|
|
26
|
+
export declare function normalizeOpenAIResponse(response: any, toolNameAliases?: ReadonlyMap<string, string>, provider?: string): LLMResult;
|
|
27
27
|
export declare function normalizeAnthropicResponse(response: any): LLMResult;
|
|
@@ -110,7 +110,7 @@ export async function consumeProviderSse(response, onEvent) {
|
|
|
110
110
|
flush();
|
|
111
111
|
return events;
|
|
112
112
|
}
|
|
113
|
-
export function normalizeOpenAIResponse(response, toolNameAliases) {
|
|
113
|
+
export function normalizeOpenAIResponse(response, toolNameAliases, provider = 'openai') {
|
|
114
114
|
const root = providerRecord(response, 'OpenAI response');
|
|
115
115
|
if (!Array.isArray(root.choices) || root.choices.length === 0)
|
|
116
116
|
throw providerResponseError('OpenAI response must contain at least one choice');
|
|
@@ -122,8 +122,10 @@ export function normalizeOpenAIResponse(response, toolNameAliases) {
|
|
|
122
122
|
const finishReason = normalizeOpenAIFinishReason(choice.finish_reason, refusal, toolCalls.length > 0);
|
|
123
123
|
const rawUsage = root.usage === undefined ? undefined : providerRecord(root.usage, 'OpenAI usage');
|
|
124
124
|
const promptDetails = rawUsage?.prompt_tokens_details === undefined ? undefined : providerRecord(rawUsage.prompt_tokens_details, 'OpenAI prompt token details');
|
|
125
|
-
const usage = normalizeUsage(rawUsage === undefined ? undefined : { ...rawUsage, cached_tokens: rawUsage.cached_tokens ?? promptDetails?.cached_tokens ?? rawUsage.cache_read_input_tokens }, { input: 'prompt_tokens', output: 'completion_tokens', cached: 'cached_tokens' }, 'OpenAI');
|
|
126
|
-
|
|
125
|
+
const usage = normalizeUsage(rawUsage === undefined ? undefined : { ...rawUsage, cached_tokens: rawUsage.cached_tokens ?? promptDetails?.cached_tokens ?? rawUsage.cache_read_input_tokens, reasoning_tokens: rawUsage.completion_tokens_details?.reasoning_tokens ?? rawUsage.reasoning_tokens }, { input: 'prompt_tokens', output: 'completion_tokens', cached: 'cached_tokens', reasoning: 'reasoning_tokens' }, 'OpenAI');
|
|
126
|
+
const reasoningContent = typeof message.reasoning_content === 'string' ? message.reasoning_content : undefined;
|
|
127
|
+
const measuredUsage = usage === undefined ? undefined : { ...usage, visibleOutputChars: text.length };
|
|
128
|
+
return { text, ...(parseStructured(text) === undefined ? {} : { structured: parseStructured(text) }), toolCalls, ...(refusal === undefined ? {} : { refusal }), finishReason, ...(measuredUsage === undefined ? {} : { usage: measuredUsage }), ...(provider !== 'deepseek' || reasoningContent === undefined ? {} : { providerContinuation: { provider, reasoningContent } }) };
|
|
127
129
|
}
|
|
128
130
|
export function normalizeAnthropicResponse(response) {
|
|
129
131
|
const root = providerRecord(response, 'Anthropic response');
|
|
@@ -140,7 +142,8 @@ export function normalizeAnthropicResponse(response) {
|
|
|
140
142
|
const refusal = refusalBlock?.text;
|
|
141
143
|
const finishReason = normalizeAnthropicFinishReason(root.stop_reason, refusal, toolCalls.length > 0);
|
|
142
144
|
const usage = normalizeUsage(root.usage, { input: 'input_tokens', output: 'output_tokens', cached: 'cache_read_input_tokens' }, 'Anthropic');
|
|
143
|
-
|
|
145
|
+
const measuredUsage = usage === undefined ? undefined : { ...usage, visibleOutputChars: text.length };
|
|
146
|
+
return { text, ...(parseStructured(text) === undefined ? {} : { structured: parseStructured(text) }), toolCalls, ...(refusal === undefined ? {} : { refusal }), finishReason, ...(measuredUsage === undefined ? {} : { usage: measuredUsage }) };
|
|
144
147
|
}
|
|
145
148
|
function providerRecord(value, label) {
|
|
146
149
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
@@ -180,10 +183,11 @@ function normalizeUsage(raw, fields, provider) {
|
|
|
180
183
|
const inputTokens = providerMetric(value[fields.input], `${provider} input tokens`);
|
|
181
184
|
const outputTokens = providerMetric(value[fields.output], `${provider} output tokens`);
|
|
182
185
|
const cachedInputTokens = providerMetric(value[fields.cached], `${provider} cached input tokens`);
|
|
186
|
+
const reasoningTokens = fields.reasoning === undefined ? undefined : providerMetric(value[fields.reasoning], `${provider} reasoning tokens`);
|
|
183
187
|
if (inputTokens !== undefined && cachedInputTokens !== undefined && cachedInputTokens > inputTokens)
|
|
184
188
|
throw providerResponseError(`${provider} cached input tokens exceed input tokens`);
|
|
185
189
|
const cost = value.cost === undefined ? undefined : normalizeCost(value.cost, provider);
|
|
186
|
-
return { ...(inputTokens === undefined ? {} : { inputTokens }), ...(outputTokens === undefined ? {} : { outputTokens }), ...(cachedInputTokens === undefined ? {} : { cachedInputTokens }), ...(inputTokens !== undefined && cachedInputTokens !== undefined ? { uncachedInputTokens: inputTokens - cachedInputTokens } : {}), ...(cost === undefined ? {} : { cost }) };
|
|
190
|
+
return { ...(inputTokens === undefined ? {} : { inputTokens }), ...(outputTokens === undefined ? {} : { outputTokens }), ...(cachedInputTokens === undefined ? {} : { cachedInputTokens }), ...(reasoningTokens === undefined ? {} : { reasoningTokens }), ...(inputTokens !== undefined && cachedInputTokens !== undefined ? { uncachedInputTokens: inputTokens - cachedInputTokens } : {}), ...(cost === undefined ? {} : { cost }) };
|
|
187
191
|
}
|
|
188
192
|
function normalizeCost(raw, provider) {
|
|
189
193
|
const value = providerRecord(raw, `${provider} cost`);
|
|
@@ -202,7 +206,10 @@ function normalizeOpenAIToolCalls(raw, toolNameAliases) {
|
|
|
202
206
|
const value = providerRecord(call, 'OpenAI tool call');
|
|
203
207
|
const fn = providerRecord(value.function, 'OpenAI tool function');
|
|
204
208
|
const providerName = requiredProviderString(fn.name, 'OpenAI tool name');
|
|
205
|
-
|
|
209
|
+
const providerToolCallId = typeof value.id === 'string' && value.id.length > 0 ? value.id : undefined;
|
|
210
|
+
if (typeof fn.arguments !== 'string')
|
|
211
|
+
throw providerResponseError('OpenAI tool arguments must be a JSON string');
|
|
212
|
+
return { toolCallId: `pulse-tool-${index + 1}`, ...(providerToolCallId === undefined ? {} : { providerToolCallId }), providerToolName: providerName, providerToolArguments: fn.arguments, name: toolNameAliases?.get(providerName) ?? providerName, input: parseJson(fn.arguments) };
|
|
206
213
|
});
|
|
207
214
|
}
|
|
208
215
|
function normalizeOpenAIFinishReason(raw, refusal, hasTools) {
|
|
@@ -16,8 +16,4 @@ export declare class OpenAICompatibleAdapter implements ProviderAdapter {
|
|
|
16
16
|
maxOutputTokens?: number;
|
|
17
17
|
}): Promise<LLMResult>;
|
|
18
18
|
}
|
|
19
|
-
export declare function toOpenAIMessages(request: LLMRequestProjection): Array<
|
|
20
|
-
role: 'system' | 'user' | 'assistant';
|
|
21
|
-
content: string;
|
|
22
|
-
name?: string;
|
|
23
|
-
}>;
|
|
19
|
+
export declare function toOpenAIMessages(request: LLMRequestProjection, provider?: string): Array<Record<string, unknown>>;
|
|
@@ -22,7 +22,7 @@ export class OpenAICompatibleAdapter {
|
|
|
22
22
|
const streaming = params.onObservation !== undefined;
|
|
23
23
|
const { definitions: tools, aliases: toolNameAliases } = toolDefinitions(params.request);
|
|
24
24
|
const includeReasoning = Boolean(this.config.reasoningEffort) && !this.reasoningUnsupported;
|
|
25
|
-
const messages = toMessages(params.request);
|
|
25
|
+
const messages = toMessages(params.request, this.config.provider);
|
|
26
26
|
const deepSeekJsonMode = params.outputSchema !== undefined && this.config.provider === 'deepseek';
|
|
27
27
|
if (deepSeekJsonMode)
|
|
28
28
|
messages.unshift({ role: 'system', content: `Return only a JSON object matching this schema. The application will validate the result:\n${JSON.stringify(params.outputSchema)}` });
|
|
@@ -35,7 +35,7 @@ export class OpenAICompatibleAdapter {
|
|
|
35
35
|
if (!response.ok)
|
|
36
36
|
throw await providerHttpErrorFromResponse(response);
|
|
37
37
|
if (!streaming || !response.headers.get('content-type')?.includes('text/event-stream'))
|
|
38
|
-
return normalizeOpenAIResponse(await parseProviderJson(response), toolNameAliases);
|
|
38
|
+
return normalizeOpenAIResponse(await parseProviderJson(response), toolNameAliases, this.config.provider);
|
|
39
39
|
const events = await consumeProviderSse(response, (event) => {
|
|
40
40
|
const delta = event.data?.choices?.[0]?.delta;
|
|
41
41
|
if (typeof delta?.content === 'string')
|
|
@@ -44,6 +44,7 @@ export class OpenAICompatibleAdapter {
|
|
|
44
44
|
params.onObservation?.(delta.refusal);
|
|
45
45
|
});
|
|
46
46
|
const content = [];
|
|
47
|
+
const reasoningContent = [];
|
|
47
48
|
const refusals = [];
|
|
48
49
|
const toolCalls = new Map();
|
|
49
50
|
let finishReason;
|
|
@@ -56,6 +57,8 @@ export class OpenAICompatibleAdapter {
|
|
|
56
57
|
if (typeof delta?.content === 'string') {
|
|
57
58
|
content.push(delta.content);
|
|
58
59
|
}
|
|
60
|
+
if (typeof delta?.reasoning_content === 'string')
|
|
61
|
+
reasoningContent.push(delta.reasoning_content);
|
|
59
62
|
if (typeof delta?.refusal === 'string') {
|
|
60
63
|
refusals.push(delta.refusal);
|
|
61
64
|
}
|
|
@@ -67,7 +70,7 @@ export class OpenAICompatibleAdapter {
|
|
|
67
70
|
if (event.data.usage !== undefined)
|
|
68
71
|
usage = event.data.usage;
|
|
69
72
|
}
|
|
70
|
-
return normalizeOpenAIResponse({ choices: [{ message: { content: content.join('') || null, ...(refusals.length ? { refusal: refusals.join('') } : {}), ...(toolCalls.size ? { tool_calls: [...toolCalls.entries()].sort(([left], [right]) => left - right).map(([, call]) => ({ id: call.id, function: { name: call.name, arguments: call.arguments } })) } : {}) }, finish_reason: finishReason ?? 'stop' }], ...(usage === undefined ? {} : { usage }) }, toolNameAliases);
|
|
73
|
+
return normalizeOpenAIResponse({ choices: [{ message: { content: content.join('') || null, ...(reasoningContent.length ? { reasoning_content: reasoningContent.join('') } : {}), ...(refusals.length ? { refusal: refusals.join('') } : {}), ...(toolCalls.size ? { tool_calls: [...toolCalls.entries()].sort(([left], [right]) => left - right).map(([, call]) => ({ id: call.id, function: { name: call.name, arguments: call.arguments } })) } : {}) }, finish_reason: finishReason ?? 'stop' }], ...(usage === undefined ? {} : { usage }) }, toolNameAliases, this.config.provider);
|
|
71
74
|
}
|
|
72
75
|
catch (cause) {
|
|
73
76
|
if (params.signal.aborted)
|
|
@@ -82,7 +85,7 @@ export class OpenAICompatibleAdapter {
|
|
|
82
85
|
}
|
|
83
86
|
}
|
|
84
87
|
}
|
|
85
|
-
export function toOpenAIMessages(request) {
|
|
88
|
+
export function toOpenAIMessages(request, provider = 'openai') {
|
|
86
89
|
const messages = [];
|
|
87
90
|
for (const block of request.blocks) {
|
|
88
91
|
const content = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
|
|
@@ -102,15 +105,30 @@ export function toOpenAIMessages(request) {
|
|
|
102
105
|
messages.push({ role: 'system', content });
|
|
103
106
|
continue;
|
|
104
107
|
}
|
|
105
|
-
if (block.kind === 'history')
|
|
108
|
+
if (block.kind === 'history' && request.contextSpec.providerHistory !== undefined)
|
|
109
|
+
continue;
|
|
110
|
+
if (block.kind === 'provider_history' && request.contextSpec.providerHistory !== undefined) {
|
|
111
|
+
for (const item of request.contextSpec.providerHistory) {
|
|
112
|
+
if (item.role === 'user')
|
|
113
|
+
messages.push({ role: 'user', content: item.content });
|
|
114
|
+
else if (item.role === 'assistant')
|
|
115
|
+
messages.push({ role: 'assistant', content: item.content, ...(provider === 'deepseek' && item.reasoningContent !== undefined ? { reasoning_content: item.reasoningContent } : {}), ...(item.toolCalls === undefined ? {} : { tool_calls: item.toolCalls.map((call) => ({ id: call.id, type: 'function', function: { name: call.name, arguments: call.arguments } })) }) });
|
|
116
|
+
else
|
|
117
|
+
messages.push({ role: 'tool', tool_call_id: item.toolCallId, name: item.name, content: item.content });
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
else if (block.kind === 'history')
|
|
106
122
|
messages.push({ role: 'assistant', content });
|
|
123
|
+
else if (block.kind === 'provider_history')
|
|
124
|
+
continue;
|
|
107
125
|
else
|
|
108
126
|
messages.push({ role: 'user', name: block.kind, content });
|
|
109
127
|
}
|
|
110
128
|
return messages;
|
|
111
129
|
}
|
|
112
|
-
function toMessages(request) {
|
|
113
|
-
return toOpenAIMessages(request);
|
|
130
|
+
function toMessages(request, provider) {
|
|
131
|
+
return toOpenAIMessages(request, provider);
|
|
114
132
|
}
|
|
115
133
|
function nextToolIndex(toolCalls) {
|
|
116
134
|
let next = 0;
|
|
@@ -82,7 +82,7 @@ function toJson(value, seen = new Set()) {
|
|
|
82
82
|
function candidateMetadata(candidate, attempts, usage, slotWaitMs, routes) {
|
|
83
83
|
return { selected: { id: candidate.id, providerId: candidate.providerId }, routes, attempts: attempts.map((attempt) => {
|
|
84
84
|
const recorded = usage.get(attempt.attemptId);
|
|
85
|
-
const usageJson = recorded === undefined ? undefined : { ...(recorded.inputTokens === undefined ? {} : { inputTokens: recorded.inputTokens }), ...(recorded.outputTokens === undefined ? {} : { outputTokens: recorded.outputTokens }), ...(recorded.cachedInputTokens === undefined ? {} : { cachedInputTokens: recorded.cachedInputTokens }), ...(recorded.uncachedInputTokens === undefined ? {} : { uncachedInputTokens: recorded.uncachedInputTokens }), ...(recorded.latencyMs === undefined ? {} : { latencyMs: recorded.latencyMs }), ...(recorded.cost === undefined ? {} : { cost: recorded.cost }) };
|
|
85
|
+
const usageJson = recorded === undefined ? undefined : { ...(recorded.inputTokens === undefined ? {} : { inputTokens: recorded.inputTokens }), ...(recorded.outputTokens === undefined ? {} : { outputTokens: recorded.outputTokens }), ...(recorded.reasoningTokens === undefined ? {} : { reasoningTokens: recorded.reasoningTokens }), ...(recorded.visibleOutputChars === undefined ? {} : { visibleOutputChars: recorded.visibleOutputChars }), ...(recorded.cachedInputTokens === undefined ? {} : { cachedInputTokens: recorded.cachedInputTokens }), ...(recorded.uncachedInputTokens === undefined ? {} : { uncachedInputTokens: recorded.uncachedInputTokens }), ...(recorded.latencyMs === undefined ? {} : { latencyMs: recorded.latencyMs }), ...(recorded.cost === undefined ? {} : { cost: recorded.cost }) };
|
|
86
86
|
const waited = slotWaitMs.get(attempt.attemptId);
|
|
87
87
|
return { effectId: attempt.effectId, attemptId: attempt.attemptId, attemptNo: attempt.attemptNo, modelId: attempt.candidate.id, providerId: attempt.candidate.providerId, ...(waited === undefined ? {} : { slotWaitMs: waited }), ...(usageJson === undefined ? {} : { usage: usageJson }) };
|
|
88
88
|
}) };
|
|
@@ -163,10 +163,10 @@ export function createModelEffectExecutor(config) {
|
|
|
163
163
|
observations.push(observation);
|
|
164
164
|
};
|
|
165
165
|
const output = assignRuntimeToolCallIds(validateAdapterResult(await provider.executeAttempt({ request: projection, signal, model: attempt.candidate.id, ...(input.outputSchema === undefined ? {} : { outputSchema: input.outputSchema }), ...(typeof routeRequirements.maxOutputTokens === 'number' ? { maxOutputTokens: routeRequirements.maxOutputTokens } : {}), onObservation })), effect.id);
|
|
166
|
-
const measuredUsage = output.usage
|
|
166
|
+
const measuredUsage = { ...(output.usage ?? {}), visibleOutputChars: output.text.length, latencyMs: output.usage?.latencyMs ?? Math.max(0, Date.now() - startedAt), ...(output.usage?.uncachedInputTokens === undefined && output.usage?.inputTokens !== undefined && output.usage.cachedInputTokens !== undefined ? { uncachedInputTokens: Math.max(0, output.usage.inputTokens - output.usage.cachedInputTokens) } : {}) };
|
|
167
167
|
usage.set(attempt.attemptId, measuredUsage);
|
|
168
168
|
if (output.finishReason === 'length' && input.outputSchema !== undefined)
|
|
169
|
-
throw Object.assign(new OutputValidationError('adapter', 'OUTPUT_TRUNCATED', 'Model output reached its token limit;
|
|
169
|
+
throw Object.assign(new OutputValidationError('adapter', 'OUTPUT_TRUNCATED', 'Model output reached its token limit; incomplete output was rejected.', { outputTokens: output.usage?.outputTokens ?? null, reasoningTokens: output.usage?.reasoningTokens ?? null, visibleOutputChars: output.text.length, maxOutputTokens: routeRequirements.maxOutputTokens ?? config.requirements?.maxOutputTokens ?? null }), { retryable: false });
|
|
170
170
|
if (output.finishReason === 'refusal') {
|
|
171
171
|
recordFeedback('refused', 0);
|
|
172
172
|
throw new OutputValidationError('adapter', 'MODEL_REFUSAL', output.refusal ?? 'Provider refused the request.');
|
|
@@ -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.
|
|
3
|
+
"version": "0.3.0",
|
|
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.
|
|
21
|
-
"@hunterzhu/pulse-tool-sdk": "0.
|
|
20
|
+
"@hunterzhu/pulse-runtime": "0.3.0",
|
|
21
|
+
"@hunterzhu/pulse-tool-sdk": "0.3.0",
|
|
22
22
|
"exceljs": "4.4.0",
|
|
23
23
|
"pdfjs-dist": "4.10.38"
|
|
24
24
|
}
|