@hunterzhu/pulse-server 0.1.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/dist/index.d.ts +113 -0
- package/dist/index.js +362 -0
- package/dist/security.d.ts +19 -0
- package/dist/security.js +181 -0
- package/package.json +24 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { type JsonValue, type Outcome } from '@hunterzhu/pulse-runtime';
|
|
2
|
+
import { type ProviderPresetConfig } from '@hunterzhu/pulse-adapters';
|
|
3
|
+
export type ApprovalMode = 'read-only' | 'ask' | 'auto';
|
|
4
|
+
export interface LocalHostOptions {
|
|
5
|
+
cwd?: string;
|
|
6
|
+
dataDir?: string;
|
|
7
|
+
provider?: ProviderPresetConfig;
|
|
8
|
+
mockResponse?: string;
|
|
9
|
+
mockToolCalls?: Array<{
|
|
10
|
+
name: string;
|
|
11
|
+
input?: JsonValue;
|
|
12
|
+
toolCallId?: string;
|
|
13
|
+
}>;
|
|
14
|
+
mockAfterToolResponse?: string;
|
|
15
|
+
approvalMode?: ApprovalMode;
|
|
16
|
+
allowNetwork?: boolean;
|
|
17
|
+
networkHosts?: string[];
|
|
18
|
+
maxRuntimeMs?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface CreateConversationInput {
|
|
21
|
+
cwd?: string;
|
|
22
|
+
title?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface ArtifactSummary {
|
|
25
|
+
path: string;
|
|
26
|
+
hash: string;
|
|
27
|
+
bytes: number;
|
|
28
|
+
mediaType?: string;
|
|
29
|
+
label?: string;
|
|
30
|
+
runId: string;
|
|
31
|
+
}
|
|
32
|
+
export interface ConversationSummary {
|
|
33
|
+
id: string;
|
|
34
|
+
title: string;
|
|
35
|
+
cwd: string;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
updatedAt: string;
|
|
38
|
+
activeRunId?: string;
|
|
39
|
+
artifacts?: ArtifactSummary[];
|
|
40
|
+
}
|
|
41
|
+
export interface UserMessageInput {
|
|
42
|
+
text: string;
|
|
43
|
+
format?: 'text' | 'jsonl';
|
|
44
|
+
}
|
|
45
|
+
export interface AssistantEvent {
|
|
46
|
+
schemaVersion: 1;
|
|
47
|
+
type: 'text' | 'fact' | 'observation' | 'waiting' | 'complete' | 'error' | 'gap';
|
|
48
|
+
conversationId: string;
|
|
49
|
+
runId: string;
|
|
50
|
+
seq: number;
|
|
51
|
+
data?: JsonValue;
|
|
52
|
+
}
|
|
53
|
+
export interface RunHandle {
|
|
54
|
+
readonly id: string;
|
|
55
|
+
readonly conversationId: string;
|
|
56
|
+
readonly events: AsyncIterable<AssistantEvent>;
|
|
57
|
+
outcome(): Promise<Outcome & {
|
|
58
|
+
text?: string;
|
|
59
|
+
}>;
|
|
60
|
+
cancel(reason?: string): Promise<void>;
|
|
61
|
+
reply(effectId: string, value: JsonValue): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
export interface ConversationHandle {
|
|
64
|
+
readonly id: string;
|
|
65
|
+
readonly summary: ConversationSummary;
|
|
66
|
+
}
|
|
67
|
+
export declare class LocalHost {
|
|
68
|
+
private readonly root;
|
|
69
|
+
private readonly dataDir;
|
|
70
|
+
private readonly options;
|
|
71
|
+
private readonly approvedToolCalls;
|
|
72
|
+
private readonly active;
|
|
73
|
+
private readonly conversationLocks;
|
|
74
|
+
constructor(options?: LocalHostOptions);
|
|
75
|
+
init(): Promise<void>;
|
|
76
|
+
private conversationDir;
|
|
77
|
+
private manifestPath;
|
|
78
|
+
private messagesPath;
|
|
79
|
+
private runDir;
|
|
80
|
+
private lockPath;
|
|
81
|
+
private acquireConversationLock;
|
|
82
|
+
private releaseConversationLock;
|
|
83
|
+
private readManifest;
|
|
84
|
+
createConversation(input?: CreateConversationInput): Promise<ConversationHandle>;
|
|
85
|
+
listConversations(): Promise<ConversationSummary[]>;
|
|
86
|
+
getConversation(id: string): Promise<ConversationHandle>;
|
|
87
|
+
listArtifacts(id: string): Promise<ArtifactSummary[]>;
|
|
88
|
+
private appendMessage;
|
|
89
|
+
private runtimeFor;
|
|
90
|
+
private restoreRuntimeFor;
|
|
91
|
+
private makeRunHandle;
|
|
92
|
+
sendMessage(conversationId: string, input: UserMessageInput): Promise<RunHandle>;
|
|
93
|
+
resumeRun(conversationId: string): Promise<RunHandle>;
|
|
94
|
+
private resultText;
|
|
95
|
+
private projectEvents;
|
|
96
|
+
close(): Promise<void>;
|
|
97
|
+
doctor(options?: {
|
|
98
|
+
live?: boolean;
|
|
99
|
+
}): Promise<{
|
|
100
|
+
ok: boolean;
|
|
101
|
+
cwd: string;
|
|
102
|
+
dataDir: string;
|
|
103
|
+
node: string;
|
|
104
|
+
tools: string[];
|
|
105
|
+
provider: string;
|
|
106
|
+
errors: string[];
|
|
107
|
+
live?: {
|
|
108
|
+
ok: boolean;
|
|
109
|
+
message: string;
|
|
110
|
+
};
|
|
111
|
+
}>;
|
|
112
|
+
}
|
|
113
|
+
export declare function createLocalHost(options?: LocalHostOptions): LocalHost;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { assertPublicNetworkUrl, conversationDirectory, publicUrl, safeShellEnv, searchFiles, within } from './security.js';
|
|
7
|
+
import { FileRuntimePersistenceBackend, ModelRouter, InMemoryModelRegistry, PulseRuntime, defineReActLane, } from '@hunterzhu/pulse-runtime';
|
|
8
|
+
import { createModelEffectExecutor, createProviderAdapter, createToolEffectExecutor, createToolEffectSubmissionPreparer, MockAdapter, runShell, FilesystemTool, } from '@hunterzhu/pulse-adapters';
|
|
9
|
+
import { defineTool, ToolRegistry } from '@hunterzhu/pulse-tool-sdk';
|
|
10
|
+
const textLimit = 48_000;
|
|
11
|
+
const json = (value) => {
|
|
12
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
13
|
+
return value;
|
|
14
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
15
|
+
return value;
|
|
16
|
+
if (Array.isArray(value))
|
|
17
|
+
return value.map((item) => json(item));
|
|
18
|
+
if (typeof value === 'object' && value !== null)
|
|
19
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, json(item)]));
|
|
20
|
+
return String(value);
|
|
21
|
+
};
|
|
22
|
+
async function fetchText(raw, signal, allowHosts) {
|
|
23
|
+
const url = await assertPublicNetworkUrl(raw, allowHosts);
|
|
24
|
+
const response = await fetch(url, { signal, redirect: 'manual' });
|
|
25
|
+
if (response.status >= 300 && response.status < 400)
|
|
26
|
+
throw new Error('REDIRECT_REQUIRES_EXPLICIT_FETCH');
|
|
27
|
+
if (!response.ok)
|
|
28
|
+
throw new Error(`HTTP_${response.status}`);
|
|
29
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
30
|
+
if (!contentType.includes('text/') && !contentType.includes('json') && !contentType.includes('xml'))
|
|
31
|
+
throw new Error('UNSUPPORTED_WEB_CONTENT_TYPE');
|
|
32
|
+
const source = await response.text();
|
|
33
|
+
const text = source.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
34
|
+
const title = source.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, ' ').trim() ?? url.hostname;
|
|
35
|
+
const limit = 32_000;
|
|
36
|
+
return { url: url.toString(), title, text: text.slice(0, limit), truncated: text.length > limit, fetchedAt: new Date().toISOString() };
|
|
37
|
+
}
|
|
38
|
+
function registerBuiltIns(registry, root, approvalMode, allowNetwork = false, isApprovedToolCall = () => false, networkHosts) {
|
|
39
|
+
const fsTool = new FilesystemTool(root);
|
|
40
|
+
registry.register(defineTool({
|
|
41
|
+
name: 'fs.list', description: 'List files in the workspace.', tags: ['files', 'read'], input: z.object({ path: z.string().default('.') }), output: z.object({ path: z.string(), entries: z.array(z.string()) }), sideEffectPolicy: 'read', permissions: { workspaceRoots: [root] }, execute: async ({ path }) => { const safePath = path ?? '.'; return { path: safePath, entries: await fsTool.list(safePath) }; }, summarize: (output) => ({ path: output.path ?? '.', entries: output.entries.slice(0, 100) }),
|
|
42
|
+
}));
|
|
43
|
+
registry.register(defineTool({
|
|
44
|
+
name: 'fs.read', description: 'Read a UTF-8 text file from the workspace.', tags: ['files', 'read'], input: z.object({ path: z.string(), maxBytes: z.number().int().positive().max(200_000).optional() }), output: z.object({ path: z.string(), content: z.string(), truncated: z.boolean() }), sideEffectPolicy: 'read', permissions: { workspaceRoots: [root] }, execute: async ({ path, maxBytes }) => { const limit = maxBytes ?? 64_000; const read = await fsTool.readLimited(path, limit); return { path, content: read.content, truncated: read.truncated }; }, summarize: (output) => ({ path: output.path, content: output.content.slice(0, 1_000), truncated: output.truncated }),
|
|
45
|
+
}));
|
|
46
|
+
registry.register(defineTool({
|
|
47
|
+
name: 'fs.search', description: 'Search text files in the workspace.', tags: ['files', 'search'], input: z.object({ query: z.string().min(1), path: z.string().default('.') }), output: z.object({ matches: z.array(z.object({ path: z.string(), line: z.number(), text: z.string() })) }), sideEffectPolicy: 'read', permissions: { workspaceRoots: [root] }, execute: async ({ query, path }) => ({ matches: await searchFiles(root, query, path) }), summarize: (output) => ({ matches: output.matches.slice(0, 20) }),
|
|
48
|
+
}));
|
|
49
|
+
registry.register(defineTool({
|
|
50
|
+
name: 'fs.write', description: 'Write a UTF-8 text file after authorization.', tags: ['files', 'write'], input: z.object({ path: z.string(), content: z.string().max(500_000), expectedHash: z.string().regex(/^[a-f0-9]{64}$/).optional() }), output: z.object({ path: z.string(), bytes: z.number(), hash: z.string() }), sideEffectPolicy: 'write', retrySafety: 'unsafe', permissions: { workspaceRoots: [root] }, execute: async ({ path, content, expectedHash }, context) => { if (approvalMode === 'read-only')
|
|
51
|
+
throw new Error('WRITE_DISABLED_READ_ONLY'); if (approvalMode === 'ask' && !isApprovedToolCall(context.toolCallId))
|
|
52
|
+
throw new Error('APPROVAL_REQUIRED:fs.write'); if (expectedHash)
|
|
53
|
+
return { path, ...(await fsTool.writeIfUnchanged(path, content, expectedHash)) }; await fsTool.write(path, content); const bytes = Buffer.byteLength(content); return { path, bytes, hash: await fsTool.hash(path) }; }, summarize: (output) => output,
|
|
54
|
+
}));
|
|
55
|
+
registry.register(defineTool({
|
|
56
|
+
name: 'fs.apply_patch', description: 'Replace an exact text fragment in a UTF-8 file after authorization.', tags: ['files', 'write', 'patch'], input: z.object({ path: z.string(), find: z.string().min(1), replace: z.string(), all: z.boolean().default(false), expectedHash: z.string().regex(/^[a-f0-9]{64}$/).optional() }), output: z.object({ path: z.string(), replacements: z.number(), bytes: z.number(), hash: z.string() }), sideEffectPolicy: 'write', retrySafety: 'unsafe', permissions: { workspaceRoots: [root] }, execute: async ({ path, find, replace, all, expectedHash }, context) => { if (approvalMode === 'read-only')
|
|
57
|
+
throw new Error('WRITE_DISABLED_READ_ONLY'); if (approvalMode === 'ask' && !isApprovedToolCall(context.toolCallId))
|
|
58
|
+
throw new Error('APPROVAL_REQUIRED:fs.apply_patch'); const source = await fsTool.readLimited(path, 500_000); if (source.truncated)
|
|
59
|
+
throw new Error('FILE_TOO_LARGE'); const count = source.content.split(find).length - 1; if (count === 0)
|
|
60
|
+
throw new Error('PATCH_CONTEXT_NOT_FOUND'); if (!all && count !== 1)
|
|
61
|
+
throw new Error('PATCH_CONTEXT_AMBIGUOUS'); const content = all ? source.content.split(find).join(replace) : source.content.replace(find, replace); if (expectedHash)
|
|
62
|
+
await fsTool.writeIfUnchanged(path, content, expectedHash);
|
|
63
|
+
else
|
|
64
|
+
await fsTool.write(path, content); return { path, replacements: all ? count : 1, bytes: Buffer.byteLength(content), hash: await fsTool.hash(path) }; }, summarize: (output) => output,
|
|
65
|
+
}));
|
|
66
|
+
registry.register(defineTool({
|
|
67
|
+
name: 'fs.move', description: 'Move a file without overwriting an existing destination.', tags: ['files', 'write', 'organize'], input: z.object({ source: z.string(), destination: z.string(), expectedHash: z.string().regex(/^[a-f0-9]{64}$/).optional() }), output: z.object({ source: z.string(), destination: z.string(), bytes: z.number(), hash: z.string() }), sideEffectPolicy: 'write', retrySafety: 'unsafe', permissions: { workspaceRoots: [root] }, execute: async ({ source, destination, expectedHash }, context) => { if (approvalMode === 'read-only')
|
|
68
|
+
throw new Error('MOVE_DISABLED_READ_ONLY'); if (approvalMode === 'ask' && !isApprovedToolCall(context.toolCallId))
|
|
69
|
+
throw new Error('APPROVAL_REQUIRED:fs.move'); const moved = await fsTool.move(source, destination, expectedHash, context.signal); return { source, destination, ...moved }; }, summarize: (output) => output,
|
|
70
|
+
}));
|
|
71
|
+
registry.register(defineTool({
|
|
72
|
+
name: 'artifact.record', description: 'Record a bounded text file as a user-visible artifact.', tags: ['artifact', 'files', 'read'], input: z.object({ path: z.string(), mediaType: z.string().default('text/plain'), label: z.string().max(200).optional() }), output: z.object({ path: z.string(), mediaType: z.string(), label: z.string(), bytes: z.number(), hash: z.string() }), sideEffectPolicy: 'read', permissions: { workspaceRoots: [root] }, execute: async ({ path, mediaType, label }) => { const read = await fsTool.readLimited(path, 200_000); if (read.truncated)
|
|
73
|
+
throw new Error('FILE_TOO_LARGE'); return { path, mediaType: mediaType ?? 'text/plain', label: label ?? path, bytes: Buffer.byteLength(read.content), hash: await fsTool.hash(path) }; }, summarize: (output) => output,
|
|
74
|
+
}));
|
|
75
|
+
registry.register(defineTool({
|
|
76
|
+
name: 'shell.exec', description: 'Run an authorized local command with argv arguments.', tags: ['shell', 'system'], input: z.object({ command: z.string().min(1), args: z.array(z.string()).default([]), cwd: z.string().default('.'), timeoutMs: z.number().int().positive().max(300_000).optional() }), output: z.object({ code: z.number().nullable(), stdout: z.string(), stderr: z.string(), truncated: z.boolean(), timedOut: z.boolean(), aborted: z.boolean() }), sideEffectPolicy: 'external', retrySafety: 'unsafe', permissions: { workspaceRoots: [root] }, execute: async ({ command, args, cwd, timeoutMs }, context) => { if (approvalMode === 'read-only')
|
|
77
|
+
throw new Error('SHELL_DISABLED_READ_ONLY'); if (approvalMode === 'ask' && !isApprovedToolCall(context.toolCallId))
|
|
78
|
+
throw new Error('APPROVAL_REQUIRED:shell.exec'); const options = { cwd: await within(root, cwd ?? '.'), signal: context.signal, env: safeShellEnv(), maxOutputBytes: 64 * 1024, ...(timeoutMs === undefined ? {} : { timeoutMs }) }; return runShell(command, args, options); }, summarize: (output) => ({ code: output.code, stdout: output.stdout.slice(0, 2_000), stderr: output.stderr.slice(0, 2_000), truncated: output.truncated }),
|
|
79
|
+
}));
|
|
80
|
+
if (allowNetwork) {
|
|
81
|
+
registry.register(defineTool({
|
|
82
|
+
name: 'web.fetch', description: 'Fetch a public HTTP(S) page and return bounded text.', tags: ['web', 'research'], input: z.object({ url: z.string().url() }), output: z.object({ url: z.string(), title: z.string(), text: z.string(), truncated: z.boolean(), fetchedAt: z.string() }), sideEffectPolicy: 'external', retrySafety: 'read_only', permissions: { networkHosts: networkHosts ?? ['*'] }, execute: async ({ url }, context) => fetchText(url, context.signal, networkHosts), summarize: (output) => ({ url: output.url, title: output.title, text: output.text.slice(0, 2_000), truncated: output.truncated, fetchedAt: output.fetchedAt }),
|
|
83
|
+
}));
|
|
84
|
+
registry.register(defineTool({
|
|
85
|
+
name: 'web.search', description: 'Search public web pages using the configured DuckDuckGo HTML endpoint.', tags: ['web', 'research'], input: z.object({ query: z.string().min(1).max(500), limit: z.number().int().positive().max(10).default(5) }), output: z.object({ query: z.string(), results: z.array(z.object({ title: z.string(), url: z.string(), snippet: z.string() })), fetchedAt: z.string() }), sideEffectPolicy: 'external', retrySafety: 'read_only', permissions: { networkHosts: ['html.duckduckgo.com'] }, execute: async ({ query, limit }, context) => { const endpoint = await assertPublicNetworkUrl(process.env.PULSE_SEARCH_URL ?? 'https://html.duckduckgo.com/html/'); endpoint.searchParams.set('q', query); const response = await fetch(endpoint, { signal: context.signal }); if (!response.ok)
|
|
86
|
+
throw new Error(`HTTP_${response.status}`); const page = await response.text(); const results = []; const pattern = /result__a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?result__snippet[^>]*>([\s\S]*?)<\//g; for (const match of page.matchAll(pattern)) {
|
|
87
|
+
if (results.length >= (limit ?? 5))
|
|
88
|
+
break;
|
|
89
|
+
const url = publicUrl(match[1] ?? '', networkHosts).toString();
|
|
90
|
+
results.push({ title: (match[2] ?? '').replace(/<[^>]+>/g, '').trim(), url, snippet: (match[3] ?? '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim() });
|
|
91
|
+
} return { query, results, fetchedAt: new Date().toISOString() }; }, summarize: (output) => ({ query: output.query, results: output.results, fetchedAt: output.fetchedAt }),
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function buildProgram(toolNames) {
|
|
96
|
+
return (approvalMode = 'ask') => defineReActLane({ id: 'pulse.assistant', version: '1', system: 'You are Pulse, a careful general task assistant. Use available tools when they help. Explain what you did and cite workspace paths. Never claim an action succeeded unless its tool result confirms it.', toolSet: 'pulse.default', task: 'reason', instruction: ({ goal }) => goal, toolAllow: toolNames, maxTurns: 12, ...(approvalMode === 'ask' ? { toolApproval: { prompt: () => 'Reply with approved=true to continue or approved=false to deny.' } } : {}) });
|
|
97
|
+
}
|
|
98
|
+
function providerFromOptions(options) {
|
|
99
|
+
const config = options.provider ?? { provider: 'mock', defaultModel: 'mock' };
|
|
100
|
+
const adapter = createProviderAdapter(config);
|
|
101
|
+
if (config.provider === 'mock' && adapter instanceof MockAdapter) {
|
|
102
|
+
const toolCalls = options.mockToolCalls ?? [];
|
|
103
|
+
if (toolCalls.length)
|
|
104
|
+
adapter.enqueue({ text: '', toolCalls: toolCalls.map((call, index) => ({ toolCallId: call.toolCallId ?? `mock-call-${index + 1}`, name: call.name, input: call.input ?? {} })), finishReason: 'tool_calls' });
|
|
105
|
+
adapter.enqueue({ text: options.mockAfterToolResponse ?? options.mockResponse ?? process.env.PULSE_MOCK_RESPONSE ?? 'Mock provider is ready. Configure a real provider for model-generated answers.', toolCalls: [], finishReason: 'stop' });
|
|
106
|
+
}
|
|
107
|
+
const local = config.provider === 'mock' || config.provider === 'ollama';
|
|
108
|
+
return { adapter, model: { id: config.defaultModel ?? `${config.provider}-default`, providerId: adapter.id, tasks: ['reason', 'plan', 'merge'], priority: 10, capabilities: { toolCalling: true, structuredOutput: true, reasoning: 'medium', maxContextTokens: 32_000, maxOutputTokens: config.maxOutputTokens ?? 4_096, local }, adapter } };
|
|
109
|
+
}
|
|
110
|
+
export class LocalHost {
|
|
111
|
+
root;
|
|
112
|
+
dataDir;
|
|
113
|
+
options;
|
|
114
|
+
approvedToolCalls = new Map();
|
|
115
|
+
active = new Map();
|
|
116
|
+
conversationLocks = new Map();
|
|
117
|
+
constructor(options = {}) { this.root = resolve(options.cwd ?? process.cwd()); this.dataDir = resolve(options.dataDir ?? process.env.PULSE_DATA_DIR ?? join(homedir(), '.local', 'share', 'pulse')); this.options = options; }
|
|
118
|
+
async init() { await mkdir(this.dataDir, { recursive: true }); await stat(this.root); }
|
|
119
|
+
conversationDir(id) { return conversationDirectory(this.dataDir, id); }
|
|
120
|
+
manifestPath(id) { return join(this.conversationDir(id), 'manifest.json'); }
|
|
121
|
+
messagesPath(id) { return join(this.conversationDir(id), 'messages.jsonl'); }
|
|
122
|
+
runDir(conversationId, runId) { return join(this.conversationDir(conversationId), 'runs', runId); }
|
|
123
|
+
lockPath(conversationId) { return join(this.conversationDir(conversationId), 'conversation.lock'); }
|
|
124
|
+
async acquireConversationLock(conversationId, runId) {
|
|
125
|
+
const path = this.lockPath(conversationId);
|
|
126
|
+
await mkdir(this.conversationDir(conversationId), { recursive: true });
|
|
127
|
+
for (;;) {
|
|
128
|
+
try {
|
|
129
|
+
const handle = await open(path, 'wx', 0o600);
|
|
130
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, runId, acquiredAt: new Date().toISOString() }));
|
|
131
|
+
this.conversationLocks.set(conversationId, handle);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (error.code !== 'EEXIST')
|
|
136
|
+
throw error;
|
|
137
|
+
const body = await readFile(path, 'utf8').catch(() => undefined);
|
|
138
|
+
let owner;
|
|
139
|
+
try {
|
|
140
|
+
owner = body ? JSON.parse(body) : undefined;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
owner = undefined;
|
|
144
|
+
}
|
|
145
|
+
if (typeof owner?.pid === 'number') {
|
|
146
|
+
try {
|
|
147
|
+
process.kill(owner.pid, 0);
|
|
148
|
+
throw new Error('CONVERSATION_BUSY');
|
|
149
|
+
}
|
|
150
|
+
catch (probeError) {
|
|
151
|
+
if (probeError.code !== 'ESRCH')
|
|
152
|
+
throw probeError;
|
|
153
|
+
}
|
|
154
|
+
const current = await readFile(path, 'utf8').catch(() => undefined);
|
|
155
|
+
if (current === body) {
|
|
156
|
+
await rm(path, { force: true });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
throw new Error('CONVERSATION_BUSY');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async releaseConversationLock(conversationId) { const handle = this.conversationLocks.get(conversationId); if (!handle)
|
|
165
|
+
return; this.conversationLocks.delete(conversationId); await handle.close().catch(() => undefined); await rm(this.lockPath(conversationId), { force: true }).catch(() => undefined); }
|
|
166
|
+
async readManifest(id) { return JSON.parse(await readFile(this.manifestPath(id), 'utf8')); }
|
|
167
|
+
async createConversation(input = {}) { const id = `conv-${randomUUID()}`; const now = new Date().toISOString(); const cwd = resolve(input.cwd ?? this.root); const manifest = { schemaVersion: 1, id, title: input.title ?? 'New conversation', cwd, createdAt: now, updatedAt: now, runs: [], artifacts: [] }; await mkdir(this.conversationDir(id), { recursive: true }); await writeFile(this.manifestPath(id), JSON.stringify(manifest, null, 2)); return { id, summary: manifest }; }
|
|
168
|
+
async listConversations() { await this.init(); const entries = await readdir(join(this.dataDir, 'conversations'), { withFileTypes: true }).catch(() => []); const summaries = []; for (const entry of entries) {
|
|
169
|
+
if (!entry.isDirectory())
|
|
170
|
+
continue;
|
|
171
|
+
try {
|
|
172
|
+
const manifest = await this.readManifest(entry.name);
|
|
173
|
+
summaries.push(manifest);
|
|
174
|
+
}
|
|
175
|
+
catch { /* ignore incomplete directories */ }
|
|
176
|
+
} return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); }
|
|
177
|
+
async getConversation(id) { const manifest = await this.readManifest(id); return { id, summary: manifest }; }
|
|
178
|
+
async listArtifacts(id) { return [...((await this.readManifest(id)).artifacts ?? [])]; }
|
|
179
|
+
async appendMessage(id, message) { await writeFile(this.messagesPath(id), `${JSON.stringify(message)}\n`, { flag: 'a' }); }
|
|
180
|
+
runtimeFor(conversationId, runId, cwd) {
|
|
181
|
+
const registry = new ToolRegistry({ workspaceRoots: [cwd], allowNetwork: this.options.allowNetwork === true, ...(this.options.networkHosts === undefined ? {} : { networkHosts: this.options.networkHosts }) });
|
|
182
|
+
registerBuiltIns(registry, cwd, this.options.approvalMode ?? 'ask', this.options.allowNetwork === true, (toolCallId) => this.approvedToolCalls.get(runId)?.has(toolCallId) === true, this.options.networkHosts);
|
|
183
|
+
const provider = providerFromOptions(this.options);
|
|
184
|
+
const models = new InMemoryModelRegistry();
|
|
185
|
+
models.register(provider.model);
|
|
186
|
+
const router = new ModelRouter(models);
|
|
187
|
+
router.register({ task: 'reason', candidates: [provider.model.id] });
|
|
188
|
+
router.register({ task: 'plan', candidates: [provider.model.id] });
|
|
189
|
+
router.register({ task: 'merge', candidates: [provider.model.id] });
|
|
190
|
+
const backend = new FileRuntimePersistenceBackend(join(this.runDir(conversationId, runId), 'runtime.json'));
|
|
191
|
+
const toolVersions = Object.fromEntries(registry.list().map((tool) => [tool.name, tool.version]));
|
|
192
|
+
const runtime = new PulseRuntime({ sessionId: runId, maxRuntimeMs: this.options.maxRuntimeMs ?? 15 * 60_000, programs: [], models, modelRouter: router, toolVersions, builtinHumanEffects: true, effectExecutor: async (effect, signal, observe) => { if (effect.kind === 'llm')
|
|
193
|
+
return createModelEffectExecutor({ router, providers: new Map([[provider.adapter.id, provider.adapter]]) })(effect, signal, observe); if (effect.kind === 'tool')
|
|
194
|
+
return createToolEffectExecutor(registry)(effect, signal, observe); throw new Error(`UNSUPPORTED_EFFECT_KIND:${effect.kind}`); }, effectSubmissionPreparer: createToolEffectSubmissionPreparer(registry), persistenceBackend: backend });
|
|
195
|
+
return { runtime, registry };
|
|
196
|
+
}
|
|
197
|
+
async restoreRuntimeFor(conversationId, runId, cwd) {
|
|
198
|
+
const registry = new ToolRegistry({ workspaceRoots: [cwd], allowNetwork: this.options.allowNetwork === true, ...(this.options.networkHosts === undefined ? {} : { networkHosts: this.options.networkHosts }) });
|
|
199
|
+
registerBuiltIns(registry, cwd, this.options.approvalMode ?? 'ask', this.options.allowNetwork === true, (toolCallId) => this.approvedToolCalls.get(runId)?.has(toolCallId) === true, this.options.networkHosts);
|
|
200
|
+
const provider = providerFromOptions(this.options);
|
|
201
|
+
const models = new InMemoryModelRegistry();
|
|
202
|
+
models.register(provider.model);
|
|
203
|
+
const router = new ModelRouter(models);
|
|
204
|
+
router.register({ task: 'reason', candidates: [provider.model.id] });
|
|
205
|
+
router.register({ task: 'plan', candidates: [provider.model.id] });
|
|
206
|
+
router.register({ task: 'merge', candidates: [provider.model.id] });
|
|
207
|
+
const backend = new FileRuntimePersistenceBackend(join(this.runDir(conversationId, runId), 'runtime.json'));
|
|
208
|
+
const program = buildProgram(registry.list().map((tool) => tool.name))(this.options.approvalMode ?? 'ask');
|
|
209
|
+
const toolVersions = Object.fromEntries(registry.list().map((tool) => [tool.name, tool.version]));
|
|
210
|
+
const runtime = await PulseRuntime.restore(backend, { sessionId: runId, maxRuntimeMs: this.options.maxRuntimeMs ?? 15 * 60_000, programs: [program], models, modelRouter: router, toolVersions, builtinHumanEffects: true, effectExecutor: async (effect, signal, observe) => { if (effect.kind === 'llm')
|
|
211
|
+
return createModelEffectExecutor({ router, providers: new Map([[provider.adapter.id, provider.adapter]]) })(effect, signal, observe); if (effect.kind === 'tool')
|
|
212
|
+
return createToolEffectExecutor(registry)(effect, signal, observe); throw new Error(`UNSUPPORTED_EFFECT_KIND:${effect.kind}`); }, effectSubmissionPreparer: createToolEffectSubmissionPreparer(registry), persistenceBackend: backend });
|
|
213
|
+
return { runtime, registry };
|
|
214
|
+
}
|
|
215
|
+
makeRunHandle(conversationId, runId, runtime, session) {
|
|
216
|
+
let finalized;
|
|
217
|
+
const finish = () => finalized ??= (async () => { const outcome = await session.outcome(); const text = this.resultText(runtime, outcome.resultRef); await this.appendMessage(conversationId, { id: `msg-${randomUUID()}`, role: 'assistant', text: text ?? '', runId, createdAt: new Date().toISOString() }); const current = await this.readManifest(conversationId); const artifacts = [...runtime.state.results.values()].flatMap((result) => { const value = result.value; if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
218
|
+
return []; const record = value; if (typeof record.path !== 'string' || typeof record.hash !== 'string' || typeof record.bytes !== 'number')
|
|
219
|
+
return []; return [{ path: record.path, hash: record.hash, bytes: record.bytes, ...(typeof record.mediaType === 'string' ? { mediaType: record.mediaType } : {}), ...(typeof record.label === 'string' ? { label: record.label } : {}), runId }]; }); current.artifacts = [...(current.artifacts ?? []).filter((item) => item.runId !== runId), ...artifacts]; if (current.activeRunId === runId)
|
|
220
|
+
delete current.activeRunId; current.updatedAt = new Date().toISOString(); await writeFile(this.manifestPath(conversationId), JSON.stringify(current, null, 2)); this.active.delete(runId); this.approvedToolCalls.delete(runId); await runtime.flushPersistence(); await writeFile(join(this.runDir(conversationId, runId), 'outcome.json'), JSON.stringify({ schemaVersion: 1, ...outcome, ...(text === undefined ? {} : { text }), completedAt: new Date().toISOString() }, null, 2)); return { ...outcome, ...(text === undefined ? {} : { text }) }; })().finally(async () => { await this.releaseConversationLock(conversationId); });
|
|
221
|
+
const events = this.projectEvents(conversationId, runId, session, finish);
|
|
222
|
+
return { id: runId, conversationId, events, outcome: finish, cancel: async (reason = 'USER_REQUESTED') => { await session.cancel(reason); }, reply: async (effectId, value) => { const effect = runtime.state.effects.get(effectId); const approved = value && typeof value === 'object' && !Array.isArray(value) && value.approved === true; if (approved && effect?.kind === 'human' && effect.input && typeof effect.input === 'object' && !Array.isArray(effect.input)) {
|
|
223
|
+
const calls = effect.input.tools;
|
|
224
|
+
if (Array.isArray(calls)) {
|
|
225
|
+
const approvedIds = this.approvedToolCalls.get(runId) ?? new Set();
|
|
226
|
+
this.approvedToolCalls.set(runId, approvedIds);
|
|
227
|
+
for (const call of calls)
|
|
228
|
+
if (call && typeof call === 'object' && !Array.isArray(call) && typeof call.toolCallId === 'string')
|
|
229
|
+
approvedIds.add(call.toolCallId);
|
|
230
|
+
}
|
|
231
|
+
} await session.reply(effectId, value); } };
|
|
232
|
+
}
|
|
233
|
+
async sendMessage(conversationId, input) {
|
|
234
|
+
if (!input.text.trim())
|
|
235
|
+
throw new Error('MESSAGE_REQUIRED');
|
|
236
|
+
const runId = `run-${randomUUID()}`;
|
|
237
|
+
await this.acquireConversationLock(conversationId, runId);
|
|
238
|
+
try {
|
|
239
|
+
const manifest = await this.readManifest(conversationId);
|
|
240
|
+
if (manifest.activeRunId)
|
|
241
|
+
throw new Error('CONVERSATION_BUSY');
|
|
242
|
+
const previous = await readFile(this.messagesPath(conversationId), 'utf8').catch(() => '');
|
|
243
|
+
const context = previous.split('\n').filter(Boolean).slice(-8).map((line) => { try {
|
|
244
|
+
const message = JSON.parse(line);
|
|
245
|
+
return `${message.role}: ${message.text.slice(0, 4_000)}`;
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return '';
|
|
249
|
+
} }).filter(Boolean).join('\n');
|
|
250
|
+
const goal = context ? `Conversation context:\n${context}\n\nuser: ${input.text}` : input.text;
|
|
251
|
+
const now = new Date().toISOString();
|
|
252
|
+
await this.appendMessage(conversationId, { id: `msg-${randomUUID()}`, role: 'user', text: input.text, runId, createdAt: now });
|
|
253
|
+
await mkdir(this.runDir(conversationId, runId), { recursive: true });
|
|
254
|
+
await writeFile(join(this.runDir(conversationId, runId), 'input.json'), JSON.stringify({ schemaVersion: 1, conversationId, runId, goal: input.text, cwd: manifest.cwd, provider: this.options.provider?.provider ?? 'mock', approvalMode: this.options.approvalMode ?? 'ask', createdAt: now }, null, 2));
|
|
255
|
+
const { runtime, registry } = this.runtimeFor(conversationId, runId, manifest.cwd);
|
|
256
|
+
const program = buildProgram(registry.list().map((tool) => tool.name))(this.options.approvalMode ?? 'ask');
|
|
257
|
+
runtime.register(program);
|
|
258
|
+
const { agentId } = runtime.createAgent({ goal, program });
|
|
259
|
+
const session = runtime.start(agentId);
|
|
260
|
+
this.active.set(runId, { runtime, session, conversationId, runId });
|
|
261
|
+
manifest.activeRunId = runId;
|
|
262
|
+
manifest.runs.push(runId);
|
|
263
|
+
manifest.updatedAt = now;
|
|
264
|
+
await writeFile(this.manifestPath(conversationId), JSON.stringify(manifest, null, 2));
|
|
265
|
+
return this.makeRunHandle(conversationId, runId, runtime, session);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
await this.releaseConversationLock(conversationId);
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async resumeRun(conversationId) {
|
|
273
|
+
const manifest = await this.readManifest(conversationId);
|
|
274
|
+
const runId = manifest.activeRunId;
|
|
275
|
+
if (!runId)
|
|
276
|
+
throw new Error('NO_ACTIVE_RUN');
|
|
277
|
+
const existing = this.active.get(runId);
|
|
278
|
+
if (existing)
|
|
279
|
+
return this.makeRunHandle(conversationId, runId, existing.runtime, existing.session);
|
|
280
|
+
await this.acquireConversationLock(conversationId, runId);
|
|
281
|
+
try {
|
|
282
|
+
const { runtime } = await this.restoreRuntimeFor(conversationId, runId, manifest.cwd);
|
|
283
|
+
const agent = [...runtime.state.agents.values()][0];
|
|
284
|
+
if (!agent)
|
|
285
|
+
throw new Error('RESTORED_AGENT_NOT_FOUND');
|
|
286
|
+
const session = runtime.start(agent.id);
|
|
287
|
+
this.active.set(runId, { runtime, session, conversationId, runId });
|
|
288
|
+
return this.makeRunHandle(conversationId, runId, runtime, session);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
await this.releaseConversationLock(conversationId);
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
resultText(runtime, ref) { if (!ref)
|
|
296
|
+
return undefined; const first = runtime.state.results.get(ref)?.value; if (typeof first === 'string')
|
|
297
|
+
return first; if (!first || typeof first !== 'object' || Array.isArray(first))
|
|
298
|
+
return JSON.stringify(first); const firstRecord = first; const textRef = firstRecord.textRef; const value = typeof textRef === 'string' ? runtime.state.results.get(textRef)?.value : first; if (typeof value === 'string')
|
|
299
|
+
return value; if (value && typeof value === 'object' && !Array.isArray(value) && typeof value.text === 'string')
|
|
300
|
+
return value.text; return value === undefined ? undefined : JSON.stringify(value, null, 2); }
|
|
301
|
+
async *projectEvents(conversationId, runId, session, finish) { let seq = 0; for await (const event of session.stream()) {
|
|
302
|
+
seq++;
|
|
303
|
+
if (event.kind === 'observation') {
|
|
304
|
+
const observation = event.observation;
|
|
305
|
+
if (observation.type === 'chunk')
|
|
306
|
+
yield { schemaVersion: 1, type: 'text', conversationId, runId, seq, data: observation.data ?? '' };
|
|
307
|
+
else
|
|
308
|
+
yield { schemaVersion: 1, type: 'observation', conversationId, runId, seq, data: event.observation ?? null };
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (event.kind === 'gap') {
|
|
312
|
+
yield { schemaVersion: 1, type: 'gap', conversationId, runId, seq, data: { fromSeq: event.fromSeq ?? 0, toSeq: event.toSeq ?? 0 } };
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (event.event?.type === 'human.requested') {
|
|
316
|
+
const liveEffect = event.event.effectId === undefined ? undefined : this.active.get(runId)?.runtime.state.effects.get(event.event.effectId);
|
|
317
|
+
if (liveEffect?.state !== 'running' || liveEffect.outcome !== undefined)
|
|
318
|
+
continue;
|
|
319
|
+
yield { schemaVersion: 1, type: 'waiting', conversationId, runId, seq, data: { effectId: event.event.effectId ?? null, input: event.event.data ?? null } };
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
yield { schemaVersion: 1, type: 'fact', conversationId, runId, seq, data: event.event?.data ?? event.event?.type ?? null };
|
|
323
|
+
} try {
|
|
324
|
+
const outcome = await finish();
|
|
325
|
+
yield { schemaVersion: 1, type: 'complete', conversationId, runId, seq: seq + 1, data: { status: outcome.status } };
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
yield { schemaVersion: 1, type: 'error', conversationId, runId, seq: seq + 1, data: String(error) };
|
|
329
|
+
} }
|
|
330
|
+
async close() { for (const active of this.active.values())
|
|
331
|
+
await active.runtime.shutdown(); this.active.clear(); this.approvedToolCalls.clear(); for (const conversationId of [...this.conversationLocks.keys()])
|
|
332
|
+
await this.releaseConversationLock(conversationId); }
|
|
333
|
+
async doctor(options = {}) {
|
|
334
|
+
const errors = [];
|
|
335
|
+
try {
|
|
336
|
+
await this.init();
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
340
|
+
}
|
|
341
|
+
const registry = new ToolRegistry({ allowNetwork: this.options.allowNetwork === true });
|
|
342
|
+
registerBuiltIns(registry, this.root, this.options.approvalMode ?? 'ask', this.options.allowNetwork === true);
|
|
343
|
+
let live;
|
|
344
|
+
if (options.live) {
|
|
345
|
+
const provider = providerFromOptions(this.options);
|
|
346
|
+
const controller = new AbortController();
|
|
347
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
348
|
+
try {
|
|
349
|
+
await provider.adapter.executeAttempt({ model: provider.model.id, maxOutputTokens: 8, signal: controller.signal, request: { contextSpec: { globalSnapshotVersion: 0, laneSnapshotVersion: 0, resultRefs: [], eventIds: [], toolSetId: 'pulse.doctor', instruction: 'health check', privacy: 'cloud_allowed', privacyRefs: [] }, blocks: [{ kind: 'instruction', content: 'Reply with OK.' }], prefixHash: 'doctor', projectionHash: 'doctor', builderVersion: 'doctor', policyVersion: 'doctor', toolSetVersion: 'doctor', privacy: 'cloud_allowed', privacyRefs: [] } });
|
|
350
|
+
live = { ok: true, message: 'provider request succeeded' };
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
live = { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
clearTimeout(timer);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return { ok: errors.length === 0 && (live?.ok ?? true), cwd: this.root, dataDir: this.dataDir, node: process.version, tools: registry.list().map((tool) => tool.name), provider: this.options.provider?.provider ?? 'mock', errors, ...(live === undefined ? {} : { live }) };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
export function createLocalHost(options = {}) { return new LocalHost(options); }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const CONVERSATION_ID: RegExp;
|
|
2
|
+
export declare function isConversationId(id: string): boolean;
|
|
3
|
+
export declare function conversationDirectory(dataDir: string, id: string): string;
|
|
4
|
+
export declare function within(root: string, path: string): Promise<string>;
|
|
5
|
+
export declare function ipv4FromDottedOrInteger(host: string): string | undefined;
|
|
6
|
+
export declare function mappedIpv4(host: string): string | undefined;
|
|
7
|
+
export declare function isPrivateIp(address: string): boolean;
|
|
8
|
+
export declare function isBlockedHostname(host: string): boolean;
|
|
9
|
+
export declare function hostAllowed(host: string, allowHosts: string[] | undefined): boolean;
|
|
10
|
+
export declare function publicUrl(raw: string, allowHosts?: string[]): URL;
|
|
11
|
+
export declare function assertPublicNetworkUrl(raw: string, allowHosts?: string[]): Promise<URL>;
|
|
12
|
+
export declare function searchFiles(root: string, query: string, directory?: string, depth?: number, visited?: {
|
|
13
|
+
count: number;
|
|
14
|
+
}): Promise<Array<{
|
|
15
|
+
path: string;
|
|
16
|
+
line: number;
|
|
17
|
+
text: string;
|
|
18
|
+
}>>;
|
|
19
|
+
export declare function safeShellEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
package/dist/security.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { lstat, readdir, readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { isIP } from 'node:net';
|
|
4
|
+
import { promises as dns } from 'node:dns';
|
|
5
|
+
export const CONVERSATION_ID = /^conv-[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
6
|
+
const SEARCH_MAX_DEPTH = 8;
|
|
7
|
+
const SEARCH_MAX_VISITED = 2_000;
|
|
8
|
+
const SEARCH_MAX_RESULTS = 100;
|
|
9
|
+
export function isConversationId(id) {
|
|
10
|
+
return CONVERSATION_ID.test(id);
|
|
11
|
+
}
|
|
12
|
+
export function conversationDirectory(dataDir, id) {
|
|
13
|
+
if (!isConversationId(id))
|
|
14
|
+
throw new Error('INVALID_CONVERSATION_ID');
|
|
15
|
+
const root = resolve(dataDir, 'conversations');
|
|
16
|
+
const target = resolve(root, id);
|
|
17
|
+
const rel = relative(root, target);
|
|
18
|
+
if (rel.startsWith('..') || isAbsolute(rel))
|
|
19
|
+
throw new Error('INVALID_CONVERSATION_ID');
|
|
20
|
+
return target;
|
|
21
|
+
}
|
|
22
|
+
function lexicalWithin(root, path) {
|
|
23
|
+
if (isAbsolute(path))
|
|
24
|
+
throw new Error('PATH_OUTSIDE_WORKSPACE');
|
|
25
|
+
const base = resolve(root);
|
|
26
|
+
const absolute = resolve(base, path);
|
|
27
|
+
const rel = relative(base, absolute);
|
|
28
|
+
if (rel === '..' || rel.startsWith(`..${sep}`))
|
|
29
|
+
throw new Error('PATH_OUTSIDE_WORKSPACE');
|
|
30
|
+
return absolute;
|
|
31
|
+
}
|
|
32
|
+
export async function within(root, path) {
|
|
33
|
+
const target = lexicalWithin(root, path);
|
|
34
|
+
const resolvedRoot = await realpath(root);
|
|
35
|
+
try {
|
|
36
|
+
const resolved = await realpath(target);
|
|
37
|
+
const rel = relative(resolvedRoot, resolved);
|
|
38
|
+
if (rel.startsWith('..') || isAbsolute(rel))
|
|
39
|
+
throw new Error('PATH_OUTSIDE_WORKSPACE');
|
|
40
|
+
return resolved;
|
|
41
|
+
}
|
|
42
|
+
catch (cause) {
|
|
43
|
+
if (cause.code !== 'ENOENT')
|
|
44
|
+
throw cause;
|
|
45
|
+
const parent = await realpath(resolve(target, '..'));
|
|
46
|
+
const rel = relative(resolvedRoot, parent);
|
|
47
|
+
if (rel.startsWith('..') || isAbsolute(rel))
|
|
48
|
+
throw new Error('PATH_OUTSIDE_WORKSPACE');
|
|
49
|
+
return target;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function ipv4FromDottedOrInteger(host) {
|
|
53
|
+
if (/^\d+$/.test(host)) {
|
|
54
|
+
const value = Number(host);
|
|
55
|
+
if (!Number.isInteger(value) || value < 0 || value > 0xffffffff)
|
|
56
|
+
return undefined;
|
|
57
|
+
return [24, 16, 8, 0].map((shift) => (value >>> shift) & 255).join('.');
|
|
58
|
+
}
|
|
59
|
+
if (/^\d{1,3}(\.\d{1,3}){0,3}$/.test(host)) {
|
|
60
|
+
const parts = host.split('.').map(Number);
|
|
61
|
+
if (parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
|
|
62
|
+
return undefined;
|
|
63
|
+
if (parts.length === 1)
|
|
64
|
+
return undefined;
|
|
65
|
+
if (parts.length === 2)
|
|
66
|
+
return `${parts[0]}.0.0.${parts[1]}`;
|
|
67
|
+
if (parts.length === 3)
|
|
68
|
+
return `${parts[0]}.${parts[1]}.0.${parts[2]}`;
|
|
69
|
+
return parts.join('.');
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
export function mappedIpv4(host) {
|
|
74
|
+
const unwrapped = host.replace(/^\[|\]$/g, '');
|
|
75
|
+
const match = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(unwrapped) ?? /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(unwrapped);
|
|
76
|
+
if (!match)
|
|
77
|
+
return undefined;
|
|
78
|
+
if (match[1]?.includes('.'))
|
|
79
|
+
return match[1];
|
|
80
|
+
const high = Number.parseInt(match[1] ?? '0', 16);
|
|
81
|
+
const low = Number.parseInt(match[2] ?? '0', 16);
|
|
82
|
+
return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
|
|
83
|
+
}
|
|
84
|
+
export function isPrivateIp(address) {
|
|
85
|
+
const mapped = mappedIpv4(address);
|
|
86
|
+
if (mapped)
|
|
87
|
+
return isPrivateIp(mapped);
|
|
88
|
+
const ipv4 = ipv4FromDottedOrInteger(address) ?? (isIP(address) === 4 ? address : undefined);
|
|
89
|
+
if (ipv4) {
|
|
90
|
+
const [a = 0, b = 0] = ipv4.split('.').map(Number);
|
|
91
|
+
return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 255;
|
|
92
|
+
}
|
|
93
|
+
const host = address.replace(/^\[|\]$/g, '').toLocaleLowerCase();
|
|
94
|
+
if (isIP(host) !== 6)
|
|
95
|
+
return false;
|
|
96
|
+
if (host === '::' || host === '::1')
|
|
97
|
+
return true;
|
|
98
|
+
if (host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd'))
|
|
99
|
+
return true;
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
export function isBlockedHostname(host) {
|
|
103
|
+
const normalized = host.toLocaleLowerCase().replace(/^\[|\]$/g, '');
|
|
104
|
+
if (normalized === 'localhost' || normalized.endsWith('.localhost') || normalized === 'metadata.google.internal' || normalized === 'metadata.internal')
|
|
105
|
+
return true;
|
|
106
|
+
if (isPrivateIp(normalized))
|
|
107
|
+
return true;
|
|
108
|
+
const dotted = ipv4FromDottedOrInteger(normalized);
|
|
109
|
+
return dotted !== undefined && isPrivateIp(dotted);
|
|
110
|
+
}
|
|
111
|
+
export function hostAllowed(host, allowHosts) {
|
|
112
|
+
if (!allowHosts || allowHosts.includes('*'))
|
|
113
|
+
return true;
|
|
114
|
+
const normalized = host.toLocaleLowerCase().replace(/^\[|\]$/g, '');
|
|
115
|
+
return allowHosts.some((allowed) => {
|
|
116
|
+
const rule = allowed.toLocaleLowerCase();
|
|
117
|
+
return rule === normalized || (rule.startsWith('*.') && (normalized === rule.slice(2) || normalized.endsWith(`.${rule.slice(2)}`)));
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
export function publicUrl(raw, allowHosts) {
|
|
121
|
+
const url = new URL(raw);
|
|
122
|
+
if (!['http:', 'https:'].includes(url.protocol))
|
|
123
|
+
throw new Error('URL_SCHEME_NOT_ALLOWED');
|
|
124
|
+
const host = url.hostname;
|
|
125
|
+
if (isBlockedHostname(host))
|
|
126
|
+
throw new Error('PRIVATE_NETWORK_URL_NOT_ALLOWED');
|
|
127
|
+
if (!hostAllowed(host, allowHosts))
|
|
128
|
+
throw new Error('NETWORK_HOST_NOT_ALLOWED');
|
|
129
|
+
return url;
|
|
130
|
+
}
|
|
131
|
+
export async function assertPublicNetworkUrl(raw, allowHosts) {
|
|
132
|
+
const url = publicUrl(raw, allowHosts);
|
|
133
|
+
if (isIP(url.hostname.replace(/^\[|\]$/g, '')) || ipv4FromDottedOrInteger(url.hostname) !== undefined)
|
|
134
|
+
return url;
|
|
135
|
+
const lookup = await dns.lookup(url.hostname, { all: true, verbatim: true }).catch(() => {
|
|
136
|
+
throw new Error('NETWORK_HOST_UNRESOLVABLE');
|
|
137
|
+
});
|
|
138
|
+
if (lookup.length === 0 || lookup.some((entry) => isPrivateIp(entry.address)))
|
|
139
|
+
throw new Error('PRIVATE_NETWORK_URL_NOT_ALLOWED');
|
|
140
|
+
return url;
|
|
141
|
+
}
|
|
142
|
+
export async function searchFiles(root, query, directory = '.', depth = 0, visited = { count: 0 }) {
|
|
143
|
+
if (depth > SEARCH_MAX_DEPTH || visited.count >= SEARCH_MAX_VISITED)
|
|
144
|
+
return [];
|
|
145
|
+
const base = await within(root, directory);
|
|
146
|
+
const baseStat = await lstat(base).catch(() => undefined);
|
|
147
|
+
if (!baseStat || baseStat.isSymbolicLink() || !baseStat.isDirectory())
|
|
148
|
+
return [];
|
|
149
|
+
const entries = await readdir(base, { withFileTypes: true });
|
|
150
|
+
const result = [];
|
|
151
|
+
const needle = query.toLocaleLowerCase();
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules' || result.length >= SEARCH_MAX_RESULTS || visited.count >= SEARCH_MAX_VISITED)
|
|
154
|
+
break;
|
|
155
|
+
visited.count++;
|
|
156
|
+
const relativePath = directory === '.' ? entry.name : join(directory, entry.name);
|
|
157
|
+
if (entry.isSymbolicLink())
|
|
158
|
+
continue;
|
|
159
|
+
if (entry.isDirectory()) {
|
|
160
|
+
result.push(...await searchFiles(root, query, relativePath, depth + 1, visited));
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (!entry.isFile())
|
|
164
|
+
continue;
|
|
165
|
+
const filePath = await within(root, relativePath).catch(() => undefined);
|
|
166
|
+
if (!filePath)
|
|
167
|
+
continue;
|
|
168
|
+
const file = await readFile(filePath).catch(() => undefined);
|
|
169
|
+
if (!file || file.includes('\u0000') || file.byteLength > 1_000_000)
|
|
170
|
+
continue;
|
|
171
|
+
const lines = file.toString('utf8').split(/\r?\n/);
|
|
172
|
+
lines.forEach((line, index) => {
|
|
173
|
+
if (line.toLocaleLowerCase().includes(needle) && result.length < SEARCH_MAX_RESULTS)
|
|
174
|
+
result.push({ path: relativePath, line: index + 1, text: line.slice(0, 500) });
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return result.slice(0, SEARCH_MAX_RESULTS);
|
|
178
|
+
}
|
|
179
|
+
export function safeShellEnv(env = process.env) {
|
|
180
|
+
return Object.fromEntries(Object.entries(env).filter(([key]) => !/(API_KEY|TOKEN|SECRET|PASSWORD|PRIVATE_KEY|AUTHORIZATION|BEARER|CREDENTIAL|COOKIE)/i.test(key) && key !== 'NODE_OPTIONS'));
|
|
181
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hunterzhu/pulse-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "https://github.com/zhuhengtan/Pulse"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -p tsconfig.json"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public",
|
|
16
|
+
"registry": "https://registry.npmjs.org"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@hunterzhu/pulse-adapters": "0.1.0",
|
|
20
|
+
"@hunterzhu/pulse-runtime": "0.1.0",
|
|
21
|
+
"@hunterzhu/pulse-tool-sdk": "0.1.0",
|
|
22
|
+
"zod": "^3.24.1"
|
|
23
|
+
}
|
|
24
|
+
}
|