@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 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>): LLMResult;
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
- return { text, ...(parseStructured(text) === undefined ? {} : { structured: parseStructured(text) }), toolCalls, ...(refusal === undefined ? {} : { refusal }), finishReason, ...(usage === undefined ? {} : { usage }) };
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
- return { text, ...(parseStructured(text) === undefined ? {} : { structured: parseStructured(text) }), toolCalls, ...(refusal === undefined ? {} : { refusal }), finishReason, ...(usage === undefined ? {} : { usage }) };
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
- return { toolCallId: `pulse-tool-${index + 1}`, name: toolNameAliases?.get(providerName) ?? providerName, input: parseJson(fn.arguments) };
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 === undefined ? { latencyMs: Math.max(0, Date.now() - startedAt) } : { ...output.usage, 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) } : {}) };
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; increase maxOutputTokens before retrying.'), { retryable: false });
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
  }
@@ -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 === 'ENOENT')
135
- throw filesystemError('FILE_BASELINE_MISSING');
136
- throw filesystemCause(cause);
170
+ if (cause.code !== 'ENOENT')
171
+ throw filesystemCause(cause);
137
172
  }
138
- 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');
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
- 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);
151
195
  }
152
196
  finally {
153
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,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>;
@@ -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 { SandboxManager, VENDORED_SRT_WIN_EXE } from '@anthropic-ai/sandbox-runtime';
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
- const denyRead = new Set([home]);
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: [], deniedDomains: [], strictAllowlist: true },
131
+ network: { allowedDomains, deniedDomains: [], strictAllowlist: true },
115
132
  filesystem: {
116
- denyRead: [...denyRead],
117
- allowRead: [cwd, ...(seccompPath ? [seccompPath] : [])],
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
- const invocationId = randomUUID();
172
- const policy = await sandboxConfig(cwd);
173
- let child;
174
- const outputChunks = { stdout: [], stderr: [] };
175
- const outputBytes = { stdout: 0, stderr: 0 };
176
- let truncated = false;
177
- let closed = false;
178
- let termination;
179
- let timer;
180
- let killTimer;
181
- const signalProcessGroup = (signal) => {
182
- if (!child?.pid)
183
- return;
184
- if (process.platform !== 'win32') {
185
- try {
186
- process.kill(-child.pid, signal);
187
- return;
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
- if (process.platform === 'win32') {
192
- const tree = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true, env: shellEnvironment(options.env) });
193
- tree.once('error', () => child?.kill(signal));
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
- // reset first in case an earlier initialize failed part-way through.
222
- await SandboxManager.reset();
223
- await SandboxManager.initialize(policy, undefined, false);
224
- const commandText = encodeSandboxCommand(command, args);
225
- const descriptor = await SandboxManager.wrapWithSandboxArgv(commandText, undefined, undefined, options.signal, cwd, { commandId: invocationId, commandText: 'shell.exec' });
226
- if (options.signal?.aborted) {
227
- SandboxManager.cleanupAfterCommand();
228
- await SandboxManager.reset();
229
- return { code: null, stdout: '', stderr: '', truncated: false, timedOut: false, aborted: true };
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
- child = spawn(descriptor.argv[0], descriptor.argv.slice(1), {
232
- cwd,
233
- env: shellEnvironment(options.env ?? descriptor.env),
234
- shell: false,
235
- detached: process.platform !== 'win32',
236
- windowsHide: true,
237
- });
238
- }
239
- catch (cause) {
240
- try {
241
- await SandboxManager.reset();
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
- catch { /* preserve the setup failure */ }
244
- throw shellError('SANDBOX_SETUP_FAILED', false, cause);
245
- }
246
- // This API has no stdin payload; deliver EOF to readers inside the sandbox.
247
- child.stdin?.end();
248
- return await new Promise((resolve, reject) => {
249
- const cleanup = () => {
250
- if (timer)
251
- clearTimeout(timer);
252
- if (killTimer)
253
- clearTimeout(killTimer);
254
- options.signal?.removeEventListener('abort', abort);
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 abort = () => terminate('aborted');
257
- timer = options.timeoutMs && options.timeoutMs > 0 ? setTimeout(() => terminate('timeout'), options.timeoutMs) : undefined;
258
- if (options.signal)
259
- options.signal.addEventListener('abort', abort, { once: true });
260
- if (options.signal?.aborted)
261
- abort();
262
- child.stdout?.on('data', (chunk) => append('stdout', chunk));
263
- child.stderr?.on('data', (chunk) => append('stderr', chunk));
264
- child.once('error', (cause) => {
265
- closed = true;
266
- cleanup();
267
- const errorCode = cause && typeof cause === 'object' ? cause.code : undefined;
268
- const code = typeof errorCode === 'string' ? errorCode : 'SHELL_EXECUTION_ERROR';
269
- reject(shellError(code === 'ENOENT' || code === 'EACCES' ? code : 'SHELL_EXECUTION_ERROR', false, cause));
270
- });
271
- child.once('close', (code) => {
272
- closed = true;
273
- cleanup();
274
- const stdout = outputText('stdout');
275
- const stderr = outputText('stderr');
276
- const annotated = SandboxManager.annotateStderrWithSandboxFailures(invocationId, stderr);
277
- const boundedStderr = decodeUtf8WithinByteLimit(Buffer.from(annotated, 'utf8'), max);
278
- const outputWasTruncated = truncated || Buffer.byteLength(annotated, 'utf8') > max;
279
- resolve({ code, stdout, stderr: boundedStderr, truncated: outputWasTruncated, timedOut: termination === 'timeout', aborted: termination === 'aborted' });
280
- });
281
- }).finally(async () => {
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
- SandboxManager.cleanupAfterCommand();
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
- // A teardown failure must be visible and fail closed for the next call.
288
- throw shellError('SANDBOX_CLEANUP_FAILED', false, 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
+ });
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,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[]>;
@@ -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.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.2.0",
21
- "@hunterzhu/pulse-tool-sdk": "0.2.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
  }