vexi-cli 0.8.0 → 0.9.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 +105 -22
- package/dist/agent.js +252 -40
- package/dist/agent.test.js +66 -0
- package/dist/cli.js +17 -2
- package/dist/explain/index.js +1 -7
- package/dist/git/index.js +13 -1
- package/dist/graph/html.js +2 -8
- package/dist/i18n/index.js +4 -4
- package/dist/mcp/client.js +5 -1
- package/dist/providers/anthropic.js +171 -33
- package/dist/providers/index.js +19 -2
- package/dist/providers/manifest.js +96 -0
- package/dist/providers/manifest.test.js +71 -0
- package/dist/providers/openai-compat.js +202 -32
- package/dist/providers/openai-compat.test.js +66 -0
- package/dist/providers/types.js +1 -1
- package/dist/replay/export.js +1 -7
- package/dist/skills/index.js +12 -4
- package/dist/snapshots/index.js +21 -4
- package/dist/tools/index.js +280 -0
- package/dist/tools/index.test.js +131 -0
- package/dist/usage/index.js +95 -0
- package/dist/usage/index.test.js +51 -0
- package/dist/utils/html.js +8 -0
- package/package.json +1 -1
|
@@ -7,48 +7,218 @@
|
|
|
7
7
|
* Vexi dependency-free (plain `fetch`, no SDKs).
|
|
8
8
|
*/
|
|
9
9
|
import { ProviderError } from './types.js';
|
|
10
|
+
/** Report OpenAI-style `usage` ({prompt_tokens, completion_tokens}) if present. */
|
|
11
|
+
function reportUsage(json, onUsage) {
|
|
12
|
+
if (!onUsage)
|
|
13
|
+
return;
|
|
14
|
+
const u = json.usage;
|
|
15
|
+
if (u && (u.prompt_tokens || u.completion_tokens)) {
|
|
16
|
+
onUsage({ inputTokens: u.prompt_tokens ?? 0, outputTokens: u.completion_tokens ?? 0 });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Translate Vexi's neutral ChatMessage[] into OpenAI chat-completions format. */
|
|
20
|
+
function toOpenAIMessages(messages) {
|
|
21
|
+
return messages.map((m) => {
|
|
22
|
+
if (m.role === 'assistant' && m.toolCalls?.length) {
|
|
23
|
+
return {
|
|
24
|
+
role: 'assistant',
|
|
25
|
+
content: m.content || null,
|
|
26
|
+
tool_calls: m.toolCalls.map((tc) => ({
|
|
27
|
+
id: tc.id,
|
|
28
|
+
type: 'function',
|
|
29
|
+
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
|
|
30
|
+
})),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (m.role === 'tool') {
|
|
34
|
+
return { role: 'tool', tool_call_id: m.toolCallId ?? '', content: m.content };
|
|
35
|
+
}
|
|
36
|
+
return { role: m.role, content: m.content };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
10
39
|
export function createOpenAICompatProvider(opts) {
|
|
11
40
|
return {
|
|
12
41
|
id: opts.id,
|
|
13
42
|
model: opts.model,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
supportsTools: opts.supportsTools ?? false,
|
|
44
|
+
async streamTools(messages, tools, onText, onUsage) {
|
|
45
|
+
const guard = createStreamTimeoutGuard(opts.id);
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(`${opts.baseUrl}/chat/completions`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: {
|
|
50
|
+
'Content-Type': 'application/json',
|
|
51
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
52
|
+
...opts.extraHeaders,
|
|
53
|
+
},
|
|
54
|
+
body: JSON.stringify({
|
|
55
|
+
model: opts.model,
|
|
56
|
+
messages: toOpenAIMessages(messages),
|
|
57
|
+
tools: tools.map((t) => ({
|
|
58
|
+
type: 'function',
|
|
59
|
+
function: { name: t.name, description: t.description, parameters: t.inputSchema },
|
|
60
|
+
})),
|
|
61
|
+
stream: true,
|
|
62
|
+
stream_options: { include_usage: true },
|
|
63
|
+
...opts.extraBody,
|
|
64
|
+
}),
|
|
65
|
+
signal: guard.signal,
|
|
66
|
+
}).catch((err) => {
|
|
67
|
+
throw guard.wrapNetworkError(err);
|
|
68
|
+
});
|
|
69
|
+
if (!res.ok || !res.body) {
|
|
70
|
+
const body = await res.text().catch(() => '');
|
|
71
|
+
throw new ProviderError(`${opts.id} API error (HTTP ${res.status}): ${truncate(body, 300)}`, res.status);
|
|
72
|
+
}
|
|
73
|
+
let full = '';
|
|
74
|
+
// Streamed tool calls arrive as fragments keyed by their array index;
|
|
75
|
+
// accumulate id/name/arguments across deltas, then parse at the end.
|
|
76
|
+
const acc = new Map();
|
|
77
|
+
try {
|
|
78
|
+
for await (const data of sseEvents(res.body)) {
|
|
79
|
+
guard.resetIdleTimer();
|
|
80
|
+
if (data === '[DONE]')
|
|
81
|
+
break;
|
|
82
|
+
try {
|
|
83
|
+
const json = JSON.parse(data);
|
|
84
|
+
const delta = json.choices?.[0]?.delta;
|
|
85
|
+
if (delta?.content) {
|
|
86
|
+
full += delta.content;
|
|
87
|
+
onText(delta.content);
|
|
88
|
+
}
|
|
89
|
+
if (Array.isArray(delta?.tool_calls)) {
|
|
90
|
+
for (const tc of delta.tool_calls) {
|
|
91
|
+
const idx = tc.index ?? 0;
|
|
92
|
+
const cur = acc.get(idx) ?? { id: '', name: '', args: '' };
|
|
93
|
+
if (tc.id)
|
|
94
|
+
cur.id = tc.id;
|
|
95
|
+
if (tc.function?.name)
|
|
96
|
+
cur.name = tc.function.name;
|
|
97
|
+
if (tc.function?.arguments)
|
|
98
|
+
cur.args += tc.function.arguments;
|
|
99
|
+
acc.set(idx, cur);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
reportUsage(json, onUsage);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Ignore malformed/keep-alive chunks
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
throw guard.wrapStreamError(e);
|
|
111
|
+
}
|
|
112
|
+
const toolCalls = [...acc.entries()]
|
|
113
|
+
.sort((a, b) => a[0] - b[0])
|
|
114
|
+
.map(([idx, c]) => {
|
|
115
|
+
let args = {};
|
|
116
|
+
try {
|
|
117
|
+
args = c.args ? JSON.parse(c.args) : {};
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
args = {};
|
|
121
|
+
}
|
|
122
|
+
return { id: c.id || `call_${idx}`, name: c.name, arguments: args };
|
|
123
|
+
})
|
|
124
|
+
.filter((c) => c.name);
|
|
125
|
+
return { text: full, toolCalls };
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
guard.dispose();
|
|
34
129
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
130
|
+
},
|
|
131
|
+
async stream(messages, onText, onUsage) {
|
|
132
|
+
const guard = createStreamTimeoutGuard(opts.id);
|
|
133
|
+
try {
|
|
134
|
+
const res = await fetch(`${opts.baseUrl}/chat/completions`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: {
|
|
137
|
+
'Content-Type': 'application/json',
|
|
138
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
139
|
+
...opts.extraHeaders,
|
|
140
|
+
},
|
|
141
|
+
body: JSON.stringify({
|
|
142
|
+
model: opts.model,
|
|
143
|
+
messages: toOpenAIMessages(messages),
|
|
144
|
+
stream: true,
|
|
145
|
+
stream_options: { include_usage: true },
|
|
146
|
+
...opts.extraBody,
|
|
147
|
+
}),
|
|
148
|
+
signal: guard.signal,
|
|
149
|
+
}).catch((err) => {
|
|
150
|
+
throw guard.wrapNetworkError(err);
|
|
151
|
+
});
|
|
152
|
+
if (!res.ok || !res.body) {
|
|
153
|
+
const body = await res.text().catch(() => '');
|
|
154
|
+
throw new ProviderError(`${opts.id} API error (HTTP ${res.status}): ${truncate(body, 300)}`, res.status);
|
|
155
|
+
}
|
|
156
|
+
let full = '';
|
|
39
157
|
try {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
158
|
+
for await (const data of sseEvents(res.body)) {
|
|
159
|
+
guard.resetIdleTimer();
|
|
160
|
+
if (data === '[DONE]')
|
|
161
|
+
break;
|
|
162
|
+
try {
|
|
163
|
+
const json = JSON.parse(data);
|
|
164
|
+
const text = json.choices?.[0]?.delta?.content;
|
|
165
|
+
if (text) {
|
|
166
|
+
full += text;
|
|
167
|
+
onText(text);
|
|
168
|
+
}
|
|
169
|
+
reportUsage(json, onUsage);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// Ignore malformed/keep-alive chunks
|
|
173
|
+
}
|
|
45
174
|
}
|
|
46
175
|
}
|
|
47
|
-
catch {
|
|
48
|
-
|
|
176
|
+
catch (e) {
|
|
177
|
+
throw guard.wrapStreamError(e);
|
|
49
178
|
}
|
|
179
|
+
return full;
|
|
50
180
|
}
|
|
51
|
-
|
|
181
|
+
finally {
|
|
182
|
+
guard.dispose();
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/** Abort the connection if the server goes silent for this long between chunks. */
|
|
188
|
+
const IDLE_TIMEOUT_MS = 60_000;
|
|
189
|
+
/** Hard ceiling on total stream duration, regardless of activity. */
|
|
190
|
+
const MAX_STREAM_MS = 10 * 60 * 1000;
|
|
191
|
+
/**
|
|
192
|
+
* Shared guard against hung provider connections: aborts the fetch if no
|
|
193
|
+
* bytes arrive for IDLE_TIMEOUT_MS, or if the whole stream runs past
|
|
194
|
+
* MAX_STREAM_MS, so a stalled provider can never freeze the chat loop.
|
|
195
|
+
*/
|
|
196
|
+
export function createStreamTimeoutGuard(providerId) {
|
|
197
|
+
const controller = new AbortController();
|
|
198
|
+
let idleTimer;
|
|
199
|
+
const hardTimer = setTimeout(() => controller.abort(), MAX_STREAM_MS);
|
|
200
|
+
const resetIdleTimer = () => {
|
|
201
|
+
clearTimeout(idleTimer);
|
|
202
|
+
idleTimer = setTimeout(() => controller.abort(), IDLE_TIMEOUT_MS);
|
|
203
|
+
};
|
|
204
|
+
resetIdleTimer();
|
|
205
|
+
return {
|
|
206
|
+
signal: controller.signal,
|
|
207
|
+
resetIdleTimer,
|
|
208
|
+
wrapNetworkError(err) {
|
|
209
|
+
if (controller.signal.aborted)
|
|
210
|
+
return new ProviderError(`${providerId} request timed out (no response).`);
|
|
211
|
+
return new ProviderError(`Network error: ${err.message}`);
|
|
212
|
+
},
|
|
213
|
+
wrapStreamError(e) {
|
|
214
|
+
if (controller.signal.aborted) {
|
|
215
|
+
return new ProviderError(`${providerId} stream stalled or exceeded the time limit and was aborted.`);
|
|
216
|
+
}
|
|
217
|
+
return e;
|
|
218
|
+
},
|
|
219
|
+
dispose() {
|
|
220
|
+
clearTimeout(idleTimer);
|
|
221
|
+
clearTimeout(hardTimer);
|
|
52
222
|
},
|
|
53
223
|
};
|
|
54
224
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { createOpenAICompatProvider } from './openai-compat.js';
|
|
3
|
+
/** Build a fake fetch Response whose body streams the given SSE lines. */
|
|
4
|
+
function sseResponse(lines) {
|
|
5
|
+
const body = new ReadableStream({
|
|
6
|
+
start(controller) {
|
|
7
|
+
const enc = new TextEncoder();
|
|
8
|
+
for (const l of lines)
|
|
9
|
+
controller.enqueue(enc.encode(`data: ${l}\n`));
|
|
10
|
+
controller.close();
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
return { ok: true, status: 200, body };
|
|
14
|
+
}
|
|
15
|
+
afterEach(() => vi.restoreAllMocks());
|
|
16
|
+
describe('openai-compat streamTools', () => {
|
|
17
|
+
it('reassembles a tool call split across streamed deltas', async () => {
|
|
18
|
+
const chunks = [
|
|
19
|
+
JSON.stringify({ choices: [{ delta: { content: 'let me edit' } }] }),
|
|
20
|
+
JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'edit_file' } }] } }] }),
|
|
21
|
+
JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"path":"a.ts",' } }] } }] }),
|
|
22
|
+
JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '"old":"x","new":"y"}' } }] } }] }),
|
|
23
|
+
'[DONE]',
|
|
24
|
+
];
|
|
25
|
+
vi.stubGlobal('fetch', vi.fn(async () => sseResponse(chunks)));
|
|
26
|
+
const provider = createOpenAICompatProvider({
|
|
27
|
+
id: 'openai', baseUrl: 'https://x/v1', apiKey: 'k', model: 'gpt', supportsTools: true,
|
|
28
|
+
});
|
|
29
|
+
let streamed = '';
|
|
30
|
+
const { text, toolCalls } = await provider.streamTools([{ role: 'user', content: 'go' }], [{ name: 'edit_file', description: 'd', inputSchema: { type: 'object' } }], (c) => { streamed += c; });
|
|
31
|
+
expect(streamed).toBe('let me edit');
|
|
32
|
+
expect(text).toBe('let me edit');
|
|
33
|
+
expect(toolCalls).toEqual([
|
|
34
|
+
{ id: 'call_1', name: 'edit_file', arguments: { path: 'a.ts', old: 'x', new: 'y' } },
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
it('returns no tool calls for a plain text answer', async () => {
|
|
38
|
+
vi.stubGlobal('fetch', vi.fn(async () => sseResponse([
|
|
39
|
+
JSON.stringify({ choices: [{ delta: { content: 'hi' } }] }),
|
|
40
|
+
'[DONE]',
|
|
41
|
+
])));
|
|
42
|
+
const provider = createOpenAICompatProvider({
|
|
43
|
+
id: 'openai', baseUrl: 'https://x/v1', apiKey: 'k', model: 'gpt', supportsTools: true,
|
|
44
|
+
});
|
|
45
|
+
const { text, toolCalls } = await provider.streamTools([{ role: 'user', content: 'hi' }], [], () => { });
|
|
46
|
+
expect(text).toBe('hi');
|
|
47
|
+
expect(toolCalls).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
it('reports token usage from the final include_usage chunk', async () => {
|
|
50
|
+
vi.stubGlobal('fetch', vi.fn(async () => sseResponse([
|
|
51
|
+
JSON.stringify({ choices: [{ delta: { content: 'hi' } }] }),
|
|
52
|
+
JSON.stringify({ choices: [], usage: { prompt_tokens: 42, completion_tokens: 7 } }),
|
|
53
|
+
'[DONE]',
|
|
54
|
+
])));
|
|
55
|
+
const provider = createOpenAICompatProvider({ id: 'openai', baseUrl: 'x', apiKey: 'k', model: 'gpt' });
|
|
56
|
+
let usage = null;
|
|
57
|
+
await provider.stream([{ role: 'user', content: 'hi' }], () => { }, (u) => { usage = u; });
|
|
58
|
+
expect(usage).toEqual({ inputTokens: 42, outputTokens: 7 });
|
|
59
|
+
});
|
|
60
|
+
it('advertises native tool support via the flag', () => {
|
|
61
|
+
const p = createOpenAICompatProvider({ id: 'openai', baseUrl: 'x', apiKey: 'k', model: 'gpt', supportsTools: true });
|
|
62
|
+
expect(p.supportsTools).toBe(true);
|
|
63
|
+
const noTools = createOpenAICompatProvider({ id: 'minimax', baseUrl: 'x', apiKey: 'k', model: 'm' });
|
|
64
|
+
expect(noTools.supportsTools).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
package/dist/providers/types.js
CHANGED
|
@@ -17,7 +17,7 @@ export class ProviderError extends Error {
|
|
|
17
17
|
/** Human-friendly provider metadata. */
|
|
18
18
|
export const PROVIDER_INFO = {
|
|
19
19
|
// ── International providers ───────────────────────────────────────────
|
|
20
|
-
anthropic: { label: 'Anthropic (Claude)', defaultModel: 'claude-sonnet-
|
|
20
|
+
anthropic: { label: 'Anthropic (Claude)', defaultModel: 'claude-sonnet-5' },
|
|
21
21
|
openai: { label: 'OpenAI (GPT)', defaultModel: 'gpt-4o-mini' },
|
|
22
22
|
openrouter: { label: 'OpenRouter', defaultModel: 'openrouter/auto' },
|
|
23
23
|
groq: { label: 'Groq (free tier)', defaultModel: 'llama-3.3-70b-versatile', free: true },
|
package/dist/replay/export.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { promises as fs } from 'node:fs';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { loadSession } from './recorder.js';
|
|
19
|
+
import { escapeHtml } from '../utils/html.js';
|
|
19
20
|
/** UI labels for the generated page, per export language. */
|
|
20
21
|
const LABELS = {
|
|
21
22
|
en: {
|
|
@@ -306,10 +307,3 @@ document.getElementById('record').onclick = async () => {
|
|
|
306
307
|
</html>
|
|
307
308
|
`;
|
|
308
309
|
}
|
|
309
|
-
function escapeHtml(text) {
|
|
310
|
-
return text
|
|
311
|
-
.replaceAll('&', '&')
|
|
312
|
-
.replaceAll('<', '<')
|
|
313
|
-
.replaceAll('>', '>')
|
|
314
|
-
.replaceAll('"', '"');
|
|
315
|
-
}
|
package/dist/skills/index.js
CHANGED
|
@@ -50,7 +50,7 @@ export function skillsBlock(skills) {
|
|
|
50
50
|
* Add a skill from a local file path or a URL (GitHub URLs are converted
|
|
51
51
|
* to raw content automatically). Returns the saved skill name.
|
|
52
52
|
*/
|
|
53
|
-
export async function addSkill(root, source) {
|
|
53
|
+
export async function addSkill(root, source, opts = {}) {
|
|
54
54
|
let content;
|
|
55
55
|
let name;
|
|
56
56
|
if (/^https?:\/\//i.test(source)) {
|
|
@@ -61,14 +61,22 @@ export async function addSkill(root, source) {
|
|
|
61
61
|
}
|
|
62
62
|
content = await res.text();
|
|
63
63
|
name = skillNameFromUrl(source);
|
|
64
|
+
content = content.slice(0, MAX_SKILL_SIZE).trim();
|
|
65
|
+
if (!content)
|
|
66
|
+
throw new Error('Skill source is empty.');
|
|
67
|
+
if (opts.confirmRemoteContent) {
|
|
68
|
+
const proceed = await opts.confirmRemoteContent(content, sanitizeName(name), url);
|
|
69
|
+
if (!proceed)
|
|
70
|
+
throw new Error('Skill add cancelled.');
|
|
71
|
+
}
|
|
64
72
|
}
|
|
65
73
|
else {
|
|
66
74
|
content = await fs.readFile(source, 'utf8');
|
|
67
75
|
name = basename(source, extname(source));
|
|
76
|
+
content = content.slice(0, MAX_SKILL_SIZE).trim();
|
|
77
|
+
if (!content)
|
|
78
|
+
throw new Error('Skill source is empty.');
|
|
68
79
|
}
|
|
69
|
-
content = content.slice(0, MAX_SKILL_SIZE).trim();
|
|
70
|
-
if (!content)
|
|
71
|
-
throw new Error('Skill source is empty.');
|
|
72
80
|
name = sanitizeName(name);
|
|
73
81
|
const dir = skillsDir(root);
|
|
74
82
|
await fs.mkdir(dir, { recursive: true });
|
package/dist/snapshots/index.js
CHANGED
|
@@ -20,8 +20,21 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { promises as fs } from 'node:fs';
|
|
22
22
|
import { existsSync } from 'node:fs';
|
|
23
|
-
import { join, isAbsolute, relative, dirname } from 'node:path';
|
|
23
|
+
import { join, isAbsolute, relative, dirname, resolve, sep } from 'node:path';
|
|
24
24
|
import { writeJsonAtomic, readJson } from '../utils/fs-atomic.js';
|
|
25
|
+
/**
|
|
26
|
+
* Resolve `rel` against `root` and verify the result stays inside `root`.
|
|
27
|
+
* Blocks path traversal (e.g. a relative path containing `..` segments)
|
|
28
|
+
* from letting snapshot save/restore read or overwrite files outside the
|
|
29
|
+
* project — snapshots are only ever meant to cover project files.
|
|
30
|
+
*/
|
|
31
|
+
function resolveWithinRoot(root, rel) {
|
|
32
|
+
const absRoot = resolve(root);
|
|
33
|
+
const abs = isAbsolute(rel) ? resolve(rel) : resolve(absRoot, rel);
|
|
34
|
+
if (abs !== absRoot && !abs.startsWith(absRoot + sep))
|
|
35
|
+
return null;
|
|
36
|
+
return abs;
|
|
37
|
+
}
|
|
25
38
|
const MAX_SNAPSHOTS = 50;
|
|
26
39
|
function makeId() {
|
|
27
40
|
return Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
|
@@ -79,8 +92,8 @@ export class SnapshotManager {
|
|
|
79
92
|
await fs.mkdir(dir, { recursive: true });
|
|
80
93
|
const saved = [];
|
|
81
94
|
for (const rel of relPaths) {
|
|
82
|
-
const abs =
|
|
83
|
-
if (!existsSync(abs))
|
|
95
|
+
const abs = resolveWithinRoot(this.root, rel);
|
|
96
|
+
if (!abs || !existsSync(abs))
|
|
84
97
|
continue;
|
|
85
98
|
await fs.copyFile(abs, join(dir, encodeRelPath(rel)));
|
|
86
99
|
saved.push(rel);
|
|
@@ -90,7 +103,9 @@ export class SnapshotManager {
|
|
|
90
103
|
async restoreFiles(id, relPaths) {
|
|
91
104
|
const dir = join(this.sessionDir, id, 'files');
|
|
92
105
|
for (const rel of relPaths) {
|
|
93
|
-
const dest =
|
|
106
|
+
const dest = resolveWithinRoot(this.root, rel);
|
|
107
|
+
if (!dest)
|
|
108
|
+
continue;
|
|
94
109
|
try {
|
|
95
110
|
await fs.copyFile(join(dir, encodeRelPath(rel)), dest);
|
|
96
111
|
}
|
|
@@ -215,6 +230,8 @@ export class SnapshotManager {
|
|
|
215
230
|
if (!p || p.startsWith('-') || p.startsWith('http') || p === '/dev/null')
|
|
216
231
|
return;
|
|
217
232
|
const abs = isAbsolute(p) ? p : join(cwd, p);
|
|
233
|
+
if (!resolveWithinRoot(cwd, abs))
|
|
234
|
+
return; // never track files outside the project root
|
|
218
235
|
if (existsSync(abs)) {
|
|
219
236
|
found.add(relative(cwd, abs).replace(/\\/g, '/'));
|
|
220
237
|
}
|