@bibobonking/opensol 3.0.1 → 3.0.2
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/package.json +1 -1
- package/server/README.md +75 -0
- package/server/daemon.mjs +1082 -0
- package/server/lib/checkpoints.mjs +124 -0
- package/server/lib/checkpoints.test.mjs +110 -0
- package/server/lib/deploy.mjs +436 -0
- package/server/lib/http.mjs +237 -0
- package/server/lib/plugins.mjs +321 -0
- package/server/lib/plugins.test.mjs +140 -0
- package/server/lib/providers.mjs +594 -0
- package/server/lib/store.mjs +491 -0
- package/server/lib/tools.mjs +990 -0
- package/server/opensol.mjs +202 -0
- package/server/package.json +42 -0
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { lookup } from 'node:dns/promises'
|
|
4
|
+
import {
|
|
5
|
+
chmod,
|
|
6
|
+
lstat,
|
|
7
|
+
mkdir,
|
|
8
|
+
readdir,
|
|
9
|
+
readFile,
|
|
10
|
+
realpath,
|
|
11
|
+
rename,
|
|
12
|
+
rm,
|
|
13
|
+
stat,
|
|
14
|
+
writeFile,
|
|
15
|
+
} from 'node:fs/promises'
|
|
16
|
+
import { isIP } from 'node:net'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { HttpError } from './http.mjs'
|
|
19
|
+
import { loadState, loadSecretsSafe } from './store.mjs'
|
|
20
|
+
import { describeCheckpoint, dropCheckpoint, readCheckpoint, recordBefore } from './checkpoints.mjs'
|
|
21
|
+
|
|
22
|
+
const MAX_READ = 2 * 1024 * 1024
|
|
23
|
+
const MAX_WRITE = 8 * 1024 * 1024
|
|
24
|
+
const MAX_OUTPUT = 200_000
|
|
25
|
+
const SKIP_DIRS = new Set([
|
|
26
|
+
'.git',
|
|
27
|
+
'node_modules',
|
|
28
|
+
'dist',
|
|
29
|
+
'build',
|
|
30
|
+
'.next',
|
|
31
|
+
'.nuxt',
|
|
32
|
+
'.svelte-kit',
|
|
33
|
+
'coverage',
|
|
34
|
+
'.venv',
|
|
35
|
+
'venv',
|
|
36
|
+
'__pycache__',
|
|
37
|
+
'.idea',
|
|
38
|
+
'.turbo',
|
|
39
|
+
'.cache',
|
|
40
|
+
])
|
|
41
|
+
const TEXT_PROBE = 8000
|
|
42
|
+
|
|
43
|
+
export async function workspaceRoot() {
|
|
44
|
+
const state = await loadState()
|
|
45
|
+
if (!state.activeWorkspace) {
|
|
46
|
+
throw new HttpError(
|
|
47
|
+
409,
|
|
48
|
+
'No workspace is open. Choose a folder in Workspace before using tools.',
|
|
49
|
+
'no_workspace',
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
const info = await stat(state.activeWorkspace).catch(() => null)
|
|
53
|
+
if (!info?.isDirectory()) {
|
|
54
|
+
throw new HttpError(
|
|
55
|
+
409,
|
|
56
|
+
`The workspace folder no longer exists: ${state.activeWorkspace}`,
|
|
57
|
+
'workspace_missing',
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
return realpath(state.activeWorkspace)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function relativeLabel(root, absolute) {
|
|
64
|
+
const rel = path.relative(root, absolute)
|
|
65
|
+
return rel ? rel.split(path.sep).join('/') : '.'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function cleanRelative(input) {
|
|
69
|
+
if (typeof input !== 'string') throw new HttpError(400, 'A path is required.', 'bad_path')
|
|
70
|
+
if (input.includes('\0')) throw new HttpError(400, 'That path is not valid.', 'bad_path')
|
|
71
|
+
const trimmed = input.trim()
|
|
72
|
+
if (!trimmed || trimmed === '.' || trimmed === './') return ''
|
|
73
|
+
return trimmed.replace(/^\.\//, '').replace(/^\/+/, '')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function insideRoot(root, candidate) {
|
|
77
|
+
const rootReal = root
|
|
78
|
+
const parent = path.dirname(candidate)
|
|
79
|
+
const existing = await realpath(candidate).catch(() => null)
|
|
80
|
+
const anchor = existing ?? (await realpath(parent).catch(() => parent))
|
|
81
|
+
const prefix = rootReal.endsWith(path.sep) ? rootReal : `${rootReal}${path.sep}`
|
|
82
|
+
if (anchor !== rootReal && !anchor.startsWith(prefix)) {
|
|
83
|
+
throw new HttpError(403, 'That path is outside the open workspace.', 'outside_workspace')
|
|
84
|
+
}
|
|
85
|
+
if (existing && existing !== rootReal && !existing.startsWith(prefix)) {
|
|
86
|
+
throw new HttpError(403, 'That path is outside the open workspace.', 'outside_workspace')
|
|
87
|
+
}
|
|
88
|
+
return existing ?? candidate
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function resolveWorkspacePath(input, { allowRoot = true } = {}) {
|
|
92
|
+
const root = await workspaceRoot()
|
|
93
|
+
const rel = cleanRelative(input)
|
|
94
|
+
if (!rel && !allowRoot) {
|
|
95
|
+
throw new HttpError(400, 'Choose a file or folder inside the workspace.', 'bad_path')
|
|
96
|
+
}
|
|
97
|
+
const candidate = path.resolve(root, rel)
|
|
98
|
+
const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`
|
|
99
|
+
if (candidate !== root && !candidate.startsWith(prefix)) {
|
|
100
|
+
throw new HttpError(403, 'That path is outside the open workspace.', 'outside_workspace')
|
|
101
|
+
}
|
|
102
|
+
if (/^(.*\/)?\.git(\/|$)/.test(rel)) {
|
|
103
|
+
throw new HttpError(403, 'OpenSol does not edit the .git directory.', 'git_protected')
|
|
104
|
+
}
|
|
105
|
+
const absolute = await insideRoot(root, candidate)
|
|
106
|
+
return { root, absolute, relative: rel || '.' }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function displayPath(root, absolute) {
|
|
110
|
+
return relativeLabel(root, absolute)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isProbablyBinary(buffer) {
|
|
114
|
+
const probe = buffer.subarray(0, TEXT_PROBE)
|
|
115
|
+
for (let i = 0; i < probe.length; i += 1) {
|
|
116
|
+
if (probe[i] === 0) return true
|
|
117
|
+
}
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function snapshot(buffer) {
|
|
122
|
+
return {
|
|
123
|
+
hash: createHash('sha256').update(buffer).digest('hex').slice(0, 16),
|
|
124
|
+
size: buffer.length,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function listTree(input = '', { depth = 2, showHidden = false, limit = 1200 } = {}) {
|
|
129
|
+
const root = await workspaceRoot()
|
|
130
|
+
const rel = cleanRelative(input)
|
|
131
|
+
const start = rel ? await resolveWorkspacePath(rel) : { root, absolute: root, relative: '' }
|
|
132
|
+
const info = await stat(start.absolute).catch(() => null)
|
|
133
|
+
if (!info) throw new HttpError(404, `Not found: ${rel || '.'}`, 'not_found')
|
|
134
|
+
if (!info.isDirectory()) {
|
|
135
|
+
return { root, path: rel, entries: [], truncated: false }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let count = 0
|
|
139
|
+
let truncated = false
|
|
140
|
+
const entries = []
|
|
141
|
+
const maxDepth = Math.max(0, Math.min(8, depth))
|
|
142
|
+
|
|
143
|
+
async function walk(directory, relativeDir, level) {
|
|
144
|
+
let children
|
|
145
|
+
try {
|
|
146
|
+
children = await readdir(directory, { withFileTypes: true })
|
|
147
|
+
} catch {
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
children.sort((a, b) => {
|
|
151
|
+
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1
|
|
152
|
+
return a.name.localeCompare(b.name)
|
|
153
|
+
})
|
|
154
|
+
for (const child of children) {
|
|
155
|
+
if (count >= limit) {
|
|
156
|
+
truncated = true
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
if (!showHidden && child.name.startsWith('.')) continue
|
|
160
|
+
if (SKIP_DIRS.has(child.name)) continue
|
|
161
|
+
const absolute = path.join(directory, child.name)
|
|
162
|
+
const relative = relativeDir ? `${relativeDir}/${child.name}` : child.name
|
|
163
|
+
const childInfo = await lstat(absolute).catch(() => null)
|
|
164
|
+
if (!childInfo) continue
|
|
165
|
+
count += 1
|
|
166
|
+
if (childInfo.isSymbolicLink()) {
|
|
167
|
+
entries.push({ name: child.name, path: relative, type: 'link', size: 0, mtime: childInfo.mtimeMs })
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
if (childInfo.isDirectory()) {
|
|
171
|
+
entries.push({ name: child.name, path: relative, type: 'dir', size: 0, mtime: childInfo.mtimeMs })
|
|
172
|
+
if (level + 1 < maxDepth) await walk(absolute, relative, level + 1)
|
|
173
|
+
} else if (childInfo.isFile()) {
|
|
174
|
+
entries.push({
|
|
175
|
+
name: child.name,
|
|
176
|
+
path: relative,
|
|
177
|
+
type: 'file',
|
|
178
|
+
size: childInfo.size,
|
|
179
|
+
mtime: childInfo.mtimeMs,
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await walk(start.absolute, rel, 0)
|
|
186
|
+
return { root, path: rel || '.', entries, truncated }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function readWorkspaceFile(input, { maxBytes = MAX_READ } = {}) {
|
|
190
|
+
const { root, absolute, relative } = await resolveWorkspacePath(input, { allowRoot: false })
|
|
191
|
+
const info = await stat(absolute).catch(() => null)
|
|
192
|
+
if (!info) throw new HttpError(404, `File not found: ${relative}`, 'not_found')
|
|
193
|
+
if (!info.isFile()) throw new HttpError(400, `${relative} is not a file.`, 'not_a_file')
|
|
194
|
+
const buffer = await readFile(absolute)
|
|
195
|
+
const binary = isProbablyBinary(buffer)
|
|
196
|
+
const clipped = buffer.subarray(0, maxBytes)
|
|
197
|
+
return {
|
|
198
|
+
path: relative,
|
|
199
|
+
absolute: displayPath(root, absolute),
|
|
200
|
+
size: info.size,
|
|
201
|
+
mtime: info.mtimeMs,
|
|
202
|
+
binary,
|
|
203
|
+
truncated: buffer.length > maxBytes,
|
|
204
|
+
content: binary ? '' : clipped.toString('utf8'),
|
|
205
|
+
base64: binary ? clipped.toString('base64') : '',
|
|
206
|
+
hash: snapshot(buffer).hash,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function writeWorkspaceFile(input, content, { create = true } = {}) {
|
|
211
|
+
const { root, absolute, relative } = await resolveWorkspacePath(input, { allowRoot: false })
|
|
212
|
+
if (typeof content !== 'string') throw new HttpError(400, 'File content must be text.', 'bad_content')
|
|
213
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_WRITE) {
|
|
214
|
+
throw new HttpError(413, 'That file is too large to write from OpenSol.', 'file_too_large')
|
|
215
|
+
}
|
|
216
|
+
const existing = await stat(absolute).catch(() => null)
|
|
217
|
+
if (!existing && !create) throw new HttpError(404, `File not found: ${relative}`, 'not_found')
|
|
218
|
+
if (existing?.isDirectory()) throw new HttpError(400, `${relative} is a folder.`, 'is_directory')
|
|
219
|
+
await mkdir(path.dirname(absolute), { recursive: true })
|
|
220
|
+
const temp = `${absolute}.${process.pid}.maxcode.tmp`
|
|
221
|
+
await writeFile(temp, content, 'utf8')
|
|
222
|
+
try {
|
|
223
|
+
// Keep the original permissions (e.g. the executable bit) across the atomic swap.
|
|
224
|
+
if (existing) await chmod(temp, existing.mode & 0o777).catch(() => undefined)
|
|
225
|
+
await rename(temp, absolute)
|
|
226
|
+
} catch (error) {
|
|
227
|
+
await rm(temp, { force: true }).catch(() => undefined)
|
|
228
|
+
throw error
|
|
229
|
+
}
|
|
230
|
+
const info = await stat(absolute)
|
|
231
|
+
return {
|
|
232
|
+
path: relative,
|
|
233
|
+
size: info.size,
|
|
234
|
+
mtime: info.mtimeMs,
|
|
235
|
+
created: !existing,
|
|
236
|
+
bytes: info.size,
|
|
237
|
+
hash: snapshot(Buffer.from(content, 'utf8')).hash,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function editWorkspaceFile(input, oldText, newText, { replaceAll = false } = {}) {
|
|
242
|
+
if (typeof oldText !== 'string' || !oldText) {
|
|
243
|
+
throw new HttpError(400, 'The text to replace is empty.', 'bad_edit')
|
|
244
|
+
}
|
|
245
|
+
if (typeof newText !== 'string') throw new HttpError(400, 'The replacement text is invalid.', 'bad_edit')
|
|
246
|
+
const file = await readWorkspaceFile(input, { maxBytes: MAX_READ })
|
|
247
|
+
if (file.binary) throw new HttpError(400, 'That file is binary.', 'binary_file')
|
|
248
|
+
if (file.truncated) throw new HttpError(400, 'That file is too large to edit precisely.', 'file_too_large')
|
|
249
|
+
const occurrences = file.content.split(oldText).length - 1
|
|
250
|
+
if (occurrences === 0) {
|
|
251
|
+
throw new HttpError(409, `The search text was not found in ${file.path}.`, 'no_match')
|
|
252
|
+
}
|
|
253
|
+
if (occurrences > 1 && !replaceAll) {
|
|
254
|
+
throw new HttpError(
|
|
255
|
+
409,
|
|
256
|
+
`The search text appears ${occurrences} times in ${file.path}. Add more context or set replaceAll.`,
|
|
257
|
+
'ambiguous_edit',
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
const next = replaceAll
|
|
261
|
+
? file.content.split(oldText).join(newText)
|
|
262
|
+
: file.content.replace(oldText, () => newText)
|
|
263
|
+
const written = await writeWorkspaceFile(input, next)
|
|
264
|
+
return { ...written, replacements: replaceAll ? occurrences : 1 }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function createWorkspaceEntry(input, type = 'file', content = '') {
|
|
268
|
+
const { root, absolute, relative } = await resolveWorkspacePath(input, { allowRoot: false })
|
|
269
|
+
if (type === 'dir') {
|
|
270
|
+
const existing = await stat(absolute).catch(() => null)
|
|
271
|
+
if (existing) throw new HttpError(409, `${relative} already exists.`, 'already_exists')
|
|
272
|
+
await mkdir(absolute, { recursive: false })
|
|
273
|
+
return { path: relative, type: 'dir', created: true }
|
|
274
|
+
}
|
|
275
|
+
const existing = await stat(absolute).catch(() => null)
|
|
276
|
+
if (existing) throw new HttpError(409, `${relative} already exists.`, 'already_exists')
|
|
277
|
+
await mkdir(path.dirname(absolute), { recursive: true })
|
|
278
|
+
await writeFile(absolute, typeof content === 'string' ? content : '', 'utf8')
|
|
279
|
+
const info = await stat(absolute)
|
|
280
|
+
return { path: relative, type: 'file', size: info.size, created: true, root: displayPath(root, root) }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function renameWorkspaceEntry(from, to) {
|
|
284
|
+
const source = await resolveWorkspacePath(from, { allowRoot: false })
|
|
285
|
+
const target = await resolveWorkspacePath(to, { allowRoot: false })
|
|
286
|
+
const existing = await stat(target.absolute).catch(() => null)
|
|
287
|
+
if (existing) throw new HttpError(409, `${target.relative} already exists.`, 'already_exists')
|
|
288
|
+
await mkdir(path.dirname(target.absolute), { recursive: true })
|
|
289
|
+
await rename(source.absolute, target.absolute)
|
|
290
|
+
return { from: source.relative, to: target.relative }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function deleteWorkspaceEntry(input) {
|
|
294
|
+
const { absolute, relative } = await resolveWorkspacePath(input, { allowRoot: false })
|
|
295
|
+
const info = await stat(absolute).catch(() => null)
|
|
296
|
+
if (!info) throw new HttpError(404, `${relative} does not exist.`, 'not_found')
|
|
297
|
+
await rm(absolute, { recursive: info.isDirectory(), force: false })
|
|
298
|
+
return { path: relative, deleted: true, type: info.isDirectory() ? 'dir' : 'file' }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function chmodWorkspaceFile(input, mode) {
|
|
302
|
+
const { absolute, relative } = await resolveWorkspacePath(input, { allowRoot: false })
|
|
303
|
+
const parsed = Number.parseInt(String(mode), 8)
|
|
304
|
+
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 0o777) {
|
|
305
|
+
throw new HttpError(400, 'Use a valid octal mode such as 755.', 'bad_mode')
|
|
306
|
+
}
|
|
307
|
+
await chmod(absolute, parsed)
|
|
308
|
+
return { path: relative, mode: parsed.toString(8) }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function walkFiles(root, { maxFiles = 4000, maxBytes = 12 * 1024 * 1024 } = {}) {
|
|
312
|
+
const files = []
|
|
313
|
+
let bytes = 0
|
|
314
|
+
async function walk(directory) {
|
|
315
|
+
let children
|
|
316
|
+
try {
|
|
317
|
+
children = await readdir(directory, { withFileTypes: true })
|
|
318
|
+
} catch {
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
for (const child of children) {
|
|
322
|
+
if (files.length >= maxFiles || bytes >= maxBytes) return
|
|
323
|
+
if (child.name.startsWith('.') || SKIP_DIRS.has(child.name)) continue
|
|
324
|
+
const absolute = path.join(directory, child.name)
|
|
325
|
+
if (child.isDirectory()) {
|
|
326
|
+
await walk(absolute)
|
|
327
|
+
} else if (child.isFile()) {
|
|
328
|
+
const info = await stat(absolute).catch(() => null)
|
|
329
|
+
if (!info || info.size > 512 * 1024) continue
|
|
330
|
+
files.push({ absolute, size: info.size })
|
|
331
|
+
bytes += info.size
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
await walk(root)
|
|
336
|
+
return files
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function grepWorkspace(pattern, { path: sub = '', ignoreCase = false, maxResults = 200 } = {}) {
|
|
340
|
+
if (typeof pattern !== 'string' || !pattern.trim()) {
|
|
341
|
+
throw new HttpError(400, 'A search pattern is required.', 'bad_pattern')
|
|
342
|
+
}
|
|
343
|
+
const root = await workspaceRoot()
|
|
344
|
+
const start = sub
|
|
345
|
+
? (await resolveWorkspacePath(sub)).absolute
|
|
346
|
+
: root
|
|
347
|
+
const info = await stat(start).catch(() => null)
|
|
348
|
+
if (!info) throw new HttpError(404, `Not found: ${sub || '.'}`, 'not_found')
|
|
349
|
+
|
|
350
|
+
const rg = await runProcess(
|
|
351
|
+
'rg',
|
|
352
|
+
[
|
|
353
|
+
'--line-number',
|
|
354
|
+
'--no-heading',
|
|
355
|
+
'--color',
|
|
356
|
+
'never',
|
|
357
|
+
'--hidden',
|
|
358
|
+
'--glob',
|
|
359
|
+
'!node_modules',
|
|
360
|
+
'--glob',
|
|
361
|
+
'!.git',
|
|
362
|
+
'--glob',
|
|
363
|
+
'!dist',
|
|
364
|
+
'--glob',
|
|
365
|
+
'!build',
|
|
366
|
+
...(ignoreCase ? ['--ignore-case'] : []),
|
|
367
|
+
'--',
|
|
368
|
+
pattern,
|
|
369
|
+
'.',
|
|
370
|
+
],
|
|
371
|
+
{ cwd: info.isDirectory() ? start : path.dirname(start), timeoutMs: 20_000, maxOutput: 80_000 },
|
|
372
|
+
).catch(() => null)
|
|
373
|
+
|
|
374
|
+
if (rg) {
|
|
375
|
+
if (rg.code === 1 && !rg.stdout.trim()) {
|
|
376
|
+
return { pattern, matches: [], truncated: false, engine: 'rg' }
|
|
377
|
+
}
|
|
378
|
+
if (rg.code === 0) {
|
|
379
|
+
const lines = rg.stdout.split('\n').filter(Boolean)
|
|
380
|
+
const matches = lines.slice(0, maxResults).map((line) => {
|
|
381
|
+
const [file, lineNumber, ...rest] = line.split(':')
|
|
382
|
+
return { file: file.replace(/^\.\//, ''), line: Number(lineNumber) || 0, text: rest.join(':') }
|
|
383
|
+
})
|
|
384
|
+
return { pattern, matches, truncated: lines.length > maxResults, engine: 'rg' }
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const files = info.isDirectory() ? await walkFiles(start) : [{ absolute: start, size: info.size }]
|
|
389
|
+
const regex = new RegExp(
|
|
390
|
+
pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
|
|
391
|
+
ignoreCase ? 'i' : '',
|
|
392
|
+
)
|
|
393
|
+
const matches = []
|
|
394
|
+
let truncated = false
|
|
395
|
+
outer: for (const file of files) {
|
|
396
|
+
const buffer = await readFile(file.absolute).catch(() => null)
|
|
397
|
+
if (!buffer || isProbablyBinary(buffer)) continue
|
|
398
|
+
const lines = buffer.toString('utf8').split('\n')
|
|
399
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
400
|
+
if (!regex.test(lines[index])) continue
|
|
401
|
+
matches.push({
|
|
402
|
+
file: displayPath(root, file.absolute),
|
|
403
|
+
line: index + 1,
|
|
404
|
+
text: lines[index].slice(0, 400),
|
|
405
|
+
})
|
|
406
|
+
if (matches.length >= maxResults) {
|
|
407
|
+
truncated = true
|
|
408
|
+
break outer
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return { pattern, matches, truncated, engine: 'js' }
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function runProcess(command, args, { cwd, env, timeoutMs = 120_000, maxOutput = MAX_OUTPUT, stdin = '' } = {}) {
|
|
416
|
+
return new Promise((resolve) => {
|
|
417
|
+
const started = Date.now()
|
|
418
|
+
let stdout = ''
|
|
419
|
+
let stderr = ''
|
|
420
|
+
let timedOut = false
|
|
421
|
+
let killed = false
|
|
422
|
+
const child = spawn(command, args, {
|
|
423
|
+
cwd,
|
|
424
|
+
env: { ...process.env, ...(env ?? {}) },
|
|
425
|
+
shell: false,
|
|
426
|
+
detached: process.platform !== 'win32',
|
|
427
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
428
|
+
})
|
|
429
|
+
const terminate = () => {
|
|
430
|
+
killed = true
|
|
431
|
+
try {
|
|
432
|
+
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, 'SIGKILL')
|
|
433
|
+
else child.kill('SIGKILL')
|
|
434
|
+
} catch {
|
|
435
|
+
child.kill('SIGKILL')
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const timer = setTimeout(() => {
|
|
439
|
+
timedOut = true
|
|
440
|
+
terminate()
|
|
441
|
+
}, timeoutMs)
|
|
442
|
+
|
|
443
|
+
const collect = (target) => (chunk) => {
|
|
444
|
+
const text = chunk.toString('utf8')
|
|
445
|
+
if (target === 'stdout') {
|
|
446
|
+
if (stdout.length < maxOutput) stdout += text
|
|
447
|
+
} else if (stderr.length < maxOutput) {
|
|
448
|
+
stderr += text
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
child.stdout.on('data', collect('stdout'))
|
|
452
|
+
child.stderr.on('data', collect('stderr'))
|
|
453
|
+
child.stdin.on('error', () => undefined)
|
|
454
|
+
if (stdin) child.stdin.end(stdin)
|
|
455
|
+
else child.stdin.end()
|
|
456
|
+
|
|
457
|
+
child.on('error', (error) => {
|
|
458
|
+
clearTimeout(timer)
|
|
459
|
+
resolve({
|
|
460
|
+
stdout,
|
|
461
|
+
stderr: `${stderr}${error.message}`,
|
|
462
|
+
code: 127,
|
|
463
|
+
timedOut,
|
|
464
|
+
killed,
|
|
465
|
+
durationMs: Date.now() - started,
|
|
466
|
+
})
|
|
467
|
+
})
|
|
468
|
+
child.on('close', (code) => {
|
|
469
|
+
clearTimeout(timer)
|
|
470
|
+
const clip = (value) =>
|
|
471
|
+
value.length > maxOutput
|
|
472
|
+
? `${value.slice(0, maxOutput)}\n… output truncated (${value.length - maxOutput} more characters)`
|
|
473
|
+
: value
|
|
474
|
+
resolve({
|
|
475
|
+
stdout: clip(stdout),
|
|
476
|
+
stderr: clip(stderr),
|
|
477
|
+
code: typeof code === 'number' ? code : -1,
|
|
478
|
+
timedOut,
|
|
479
|
+
killed,
|
|
480
|
+
durationMs: Date.now() - started,
|
|
481
|
+
})
|
|
482
|
+
})
|
|
483
|
+
})
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function planShellAllowed(command) {
|
|
487
|
+
if (/[;&|><`$(){}[\]\n]/.test(command)) return false
|
|
488
|
+
const tokens = command.trim().split(/\s+/).filter(Boolean)
|
|
489
|
+
if (tokens.length === 0) return false
|
|
490
|
+
const head = tokens[0]
|
|
491
|
+
const safe = new Set([
|
|
492
|
+
'pwd',
|
|
493
|
+
'ls',
|
|
494
|
+
'cat',
|
|
495
|
+
'head',
|
|
496
|
+
'tail',
|
|
497
|
+
'wc',
|
|
498
|
+
'grep',
|
|
499
|
+
'rg',
|
|
500
|
+
'find',
|
|
501
|
+
'tree',
|
|
502
|
+
'file',
|
|
503
|
+
'stat',
|
|
504
|
+
'du',
|
|
505
|
+
'df',
|
|
506
|
+
'date',
|
|
507
|
+
'whoami',
|
|
508
|
+
'git',
|
|
509
|
+
])
|
|
510
|
+
if (!safe.has(head)) return false
|
|
511
|
+
if (head === 'git') {
|
|
512
|
+
const sub = tokens[1] ?? ''
|
|
513
|
+
if (!['status', 'diff', 'log', 'show', 'branch', 'remote', 'rev-parse', 'ls-files'].includes(sub)) {
|
|
514
|
+
return false
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return true
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export async function runShell(command, { mode = 'build', timeoutMs = 120_000 } = {}) {
|
|
521
|
+
if (typeof command !== 'string' || !command.trim()) {
|
|
522
|
+
throw new HttpError(400, 'A command is required.', 'bad_command')
|
|
523
|
+
}
|
|
524
|
+
if (command.length > 20_000) throw new HttpError(400, 'That command is too long.', 'bad_command')
|
|
525
|
+
if (mode === 'plan' && !planShellAllowed(command)) {
|
|
526
|
+
throw new HttpError(
|
|
527
|
+
403,
|
|
528
|
+
'Plan mode is read-only. Use read_file, list_files, grep or a read-only shell command.',
|
|
529
|
+
'plan_readonly',
|
|
530
|
+
)
|
|
531
|
+
}
|
|
532
|
+
const root = await workspaceRoot()
|
|
533
|
+
const result = await runProcess('/bin/bash', ['-lc', command], {
|
|
534
|
+
cwd: root,
|
|
535
|
+
timeoutMs: Math.max(1000, Math.min(Number(timeoutMs) || 120_000, 600_000)),
|
|
536
|
+
env: { MAXCODE: '1', MAXCODE_WORKSPACE: root },
|
|
537
|
+
})
|
|
538
|
+
return { ...result, command, cwd: root }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const GIT_ACTIONS = new Set(['status', 'diff', 'log', 'show', 'branch', 'add', 'commit', 'init', 'checkout'])
|
|
542
|
+
|
|
543
|
+
function safeRef(value) {
|
|
544
|
+
return typeof value === 'string' && /^[A-Za-z0-9._~^/@{}-]+$/.test(value) ? value : ''
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export async function runGit(action, args = {}, { mode = 'build' } = {}) {
|
|
548
|
+
if (!GIT_ACTIONS.has(action)) throw new HttpError(400, `Unsupported git action: ${action}`, 'bad_git')
|
|
549
|
+
const write = ['add', 'commit', 'init', 'checkout'].includes(action)
|
|
550
|
+
if (mode === 'plan' && write) {
|
|
551
|
+
throw new HttpError(403, 'Plan mode cannot change git state.', 'plan_readonly')
|
|
552
|
+
}
|
|
553
|
+
const root = await workspaceRoot()
|
|
554
|
+
const git = (argv, timeoutMs = 60_000) =>
|
|
555
|
+
runProcess('git', ['-C', root, ...argv], { cwd: root, timeoutMs, maxOutput: 150_000 })
|
|
556
|
+
|
|
557
|
+
if (action === 'status') {
|
|
558
|
+
const porcelain = await git(['status', '--porcelain=v1', '-b'])
|
|
559
|
+
return { action, ...porcelain, cwd: root }
|
|
560
|
+
}
|
|
561
|
+
if (action === 'diff') {
|
|
562
|
+
const argv = ['diff', '--no-color']
|
|
563
|
+
if (args.staged === true) argv.push('--staged')
|
|
564
|
+
const file = typeof args.path === 'string' ? args.path.trim() : ''
|
|
565
|
+
if (file) {
|
|
566
|
+
const resolved = await resolveWorkspacePath(file, { allowRoot: false })
|
|
567
|
+
argv.push('--', resolved.absolute)
|
|
568
|
+
}
|
|
569
|
+
const result = await git(argv)
|
|
570
|
+
return { action, ...result, cwd: root }
|
|
571
|
+
}
|
|
572
|
+
if (action === 'log') {
|
|
573
|
+
const limit = Math.max(1, Math.min(Number(args.limit) || 30, 100))
|
|
574
|
+
return { action, ...(await git(['log', `--max-count=${limit}`, '--pretty=format:%h %ad %s', '--date=short'])), cwd: root }
|
|
575
|
+
}
|
|
576
|
+
if (action === 'show') {
|
|
577
|
+
const ref = safeRef(args.ref) || 'HEAD'
|
|
578
|
+
return { action, ...(await git(['show', '--stat', '--oneline', ref])), cwd: root }
|
|
579
|
+
}
|
|
580
|
+
if (action === 'branch') {
|
|
581
|
+
const current = await git(['branch', '--show-current'])
|
|
582
|
+
const all = await git(['branch', '--all', '--no-color'])
|
|
583
|
+
return { action, current: current.stdout.trim(), branches: all.stdout, cwd: root, code: all.code }
|
|
584
|
+
}
|
|
585
|
+
if (action === 'add') {
|
|
586
|
+
const paths = Array.isArray(args.paths) ? args.paths : []
|
|
587
|
+
if (paths.length === 0) throw new HttpError(400, 'Choose files to stage.', 'bad_git')
|
|
588
|
+
const resolved = []
|
|
589
|
+
for (const entry of paths.slice(0, 200)) {
|
|
590
|
+
resolved.push((await resolveWorkspacePath(String(entry), { allowRoot: false })).absolute)
|
|
591
|
+
}
|
|
592
|
+
return { action, ...(await git(['add', '--', ...resolved])), cwd: root }
|
|
593
|
+
}
|
|
594
|
+
if (action === 'commit') {
|
|
595
|
+
const message = typeof args.message === 'string' ? args.message.trim() : ''
|
|
596
|
+
if (!message) throw new HttpError(400, 'A commit message is required.', 'bad_git')
|
|
597
|
+
if (message.length > 4000) throw new HttpError(400, 'That commit message is too long.', 'bad_git')
|
|
598
|
+
return { action, ...(await git(['commit', '-m', message])), cwd: root }
|
|
599
|
+
}
|
|
600
|
+
if (action === 'init') {
|
|
601
|
+
return { action, ...(await git(['init'])), cwd: root }
|
|
602
|
+
}
|
|
603
|
+
const branch = typeof args.branch === 'string' ? args.branch.trim() : ''
|
|
604
|
+
if (!branch || !/^[A-Za-z0-9._/-]+$/.test(branch) || branch.startsWith('-')) {
|
|
605
|
+
throw new HttpError(400, 'Use a simple branch name.', 'bad_git')
|
|
606
|
+
}
|
|
607
|
+
const argv = args.create === false ? ['checkout', branch] : ['checkout', '-b', branch]
|
|
608
|
+
return { action, ...(await git(argv)), cwd: root }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export async function gitSummary() {
|
|
612
|
+
const root = await workspaceRoot().catch(() => '')
|
|
613
|
+
if (!root) return { available: false, root: '', branch: '', entries: [], error: 'No workspace is open.' }
|
|
614
|
+
const check = await runProcess('git', ['-C', root, 'rev-parse', '--is-inside-work-tree'], {
|
|
615
|
+
cwd: root,
|
|
616
|
+
timeoutMs: 8000,
|
|
617
|
+
})
|
|
618
|
+
if (check.code !== 0) {
|
|
619
|
+
return { available: false, root, branch: '', entries: [], error: check.stderr.trim() || 'Not a git repository.' }
|
|
620
|
+
}
|
|
621
|
+
const statusRun = await runProcess('git', ['-C', root, 'status', '--porcelain=v1', '-b'], {
|
|
622
|
+
cwd: root,
|
|
623
|
+
timeoutMs: 15_000,
|
|
624
|
+
})
|
|
625
|
+
const lines = statusRun.stdout.split('\n').filter(Boolean)
|
|
626
|
+
const branchLine = lines.find((line) => line.startsWith('## ')) ?? ''
|
|
627
|
+
const branch = branchLine.replace(/^##\s+/, '').split('...')[0] ?? ''
|
|
628
|
+
const entries = lines
|
|
629
|
+
.filter((line) => !line.startsWith('## '))
|
|
630
|
+
.slice(0, 300)
|
|
631
|
+
.map((line) => ({ status: line.slice(0, 2).trim() || '?', path: line.slice(3).trim() }))
|
|
632
|
+
return { available: true, root, branch, entries, error: '' }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function decodeEntities(text) {
|
|
636
|
+
// `&` must be decoded last, otherwise "&lt;" is decoded twice into "<".
|
|
637
|
+
return text
|
|
638
|
+
.replace(/ /g, ' ')
|
|
639
|
+
.replace(/</g, '<')
|
|
640
|
+
.replace(/>/g, '>')
|
|
641
|
+
.replace(/"/g, '"')
|
|
642
|
+
.replace(/'|'/g, "'")
|
|
643
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
|
644
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)))
|
|
645
|
+
.replace(/&/g, '&')
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function stripHtml(html) {
|
|
649
|
+
return decodeEntities(
|
|
650
|
+
html
|
|
651
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
652
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
653
|
+
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
|
|
654
|
+
.replace(/<\/(p|div|section|article|li|h[1-6]|tr|pre|blockquote)>/gi, '\n')
|
|
655
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
656
|
+
.replace(/<[^>]+>/g, ' ')
|
|
657
|
+
.replace(/[ \t]+/g, ' ')
|
|
658
|
+
.replace(/\n\s*\n\s*\n+/g, '\n\n'),
|
|
659
|
+
).trim()
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function searchKeyFor(provider) {
|
|
663
|
+
const { secrets } = await loadSecretsSafe()
|
|
664
|
+
return {
|
|
665
|
+
apiKey: secrets.search?.apiKey ?? '',
|
|
666
|
+
provider: secrets.search?.provider ?? '',
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function duckduckgo(query, signal) {
|
|
671
|
+
const response = await fetch('https://html.duckduckgo.com/html/', {
|
|
672
|
+
method: 'POST',
|
|
673
|
+
headers: {
|
|
674
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
675
|
+
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 OpenSol/1.0',
|
|
676
|
+
},
|
|
677
|
+
body: new URLSearchParams({ q: query }).toString(),
|
|
678
|
+
signal,
|
|
679
|
+
})
|
|
680
|
+
if (!response.ok) throw new HttpError(502, `DuckDuckGo returned ${response.status}.`, 'search_failed')
|
|
681
|
+
const html = await response.text()
|
|
682
|
+
const results = []
|
|
683
|
+
const pattern = /<a[^>]+class="[^"]*result__a[^"]*"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi
|
|
684
|
+
let match
|
|
685
|
+
while ((match = pattern.exec(html)) && results.length < 8) {
|
|
686
|
+
let url = decodeEntities(match[1])
|
|
687
|
+
if (url.startsWith('//duckduckgo.com/l/?uddg=')) {
|
|
688
|
+
const target = new URL(`https:${url}`).searchParams.get('uddg')
|
|
689
|
+
if (target) url = target
|
|
690
|
+
}
|
|
691
|
+
if (!/^https?:/i.test(url)) continue
|
|
692
|
+
results.push({ title: stripHtml(match[2]), url, snippet: '' })
|
|
693
|
+
}
|
|
694
|
+
return results
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async function tavily(query, key, signal) {
|
|
698
|
+
const response = await fetch('https://api.tavily.com/search', {
|
|
699
|
+
method: 'POST',
|
|
700
|
+
headers: { 'Content-Type': 'application/json' },
|
|
701
|
+
body: JSON.stringify({ api_key: key, query, search_depth: 'basic', max_results: 6 }),
|
|
702
|
+
signal,
|
|
703
|
+
})
|
|
704
|
+
if (!response.ok) throw new HttpError(502, `Tavily returned ${response.status}.`, 'search_failed')
|
|
705
|
+
const payload = await response.json()
|
|
706
|
+
return (payload.results ?? []).slice(0, 6).map((item) => ({
|
|
707
|
+
title: String(item.title ?? ''),
|
|
708
|
+
url: String(item.url ?? ''),
|
|
709
|
+
snippet: String(item.content ?? ''),
|
|
710
|
+
}))
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function brave(query, key, signal) {
|
|
714
|
+
const url = new URL('https://api.search.brave.com/res/v1/web/search')
|
|
715
|
+
url.searchParams.set('q', query)
|
|
716
|
+
url.searchParams.set('count', '8')
|
|
717
|
+
const response = await fetch(url, {
|
|
718
|
+
headers: { Accept: 'application/json', 'X-Subscription-Token': key },
|
|
719
|
+
signal,
|
|
720
|
+
})
|
|
721
|
+
if (!response.ok) throw new HttpError(502, `Brave Search returned ${response.status}.`, 'search_failed')
|
|
722
|
+
const payload = await response.json()
|
|
723
|
+
return (payload.web?.results ?? []).slice(0, 8).map((item) => ({
|
|
724
|
+
title: String(item.title ?? ''),
|
|
725
|
+
url: String(item.url ?? ''),
|
|
726
|
+
snippet: stripHtml(String(item.description ?? '')),
|
|
727
|
+
}))
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function serper(query, key, signal) {
|
|
731
|
+
const response = await fetch('https://google.serper.dev/search', {
|
|
732
|
+
method: 'POST',
|
|
733
|
+
headers: { 'Content-Type': 'application/json', 'X-API-KEY': key },
|
|
734
|
+
body: JSON.stringify({ q: query }),
|
|
735
|
+
signal,
|
|
736
|
+
})
|
|
737
|
+
if (!response.ok) throw new HttpError(502, `Serper returned ${response.status}.`, 'search_failed')
|
|
738
|
+
const payload = await response.json()
|
|
739
|
+
return (payload.organic ?? []).slice(0, 8).map((item) => ({
|
|
740
|
+
title: String(item.title ?? ''),
|
|
741
|
+
url: String(item.link ?? ''),
|
|
742
|
+
snippet: String(item.snippet ?? ''),
|
|
743
|
+
}))
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export async function searchWeb(query, { provider = 'duckduckgo' } = {}) {
|
|
747
|
+
if (typeof query !== 'string' || !query.trim()) {
|
|
748
|
+
throw new HttpError(400, 'A search query is required.', 'bad_query')
|
|
749
|
+
}
|
|
750
|
+
const stored = await searchKeyFor(provider)
|
|
751
|
+
const key = stored.apiKey
|
|
752
|
+
const chosen = key && stored.provider ? stored.provider : provider
|
|
753
|
+
if (chosen !== 'duckduckgo' && !key) {
|
|
754
|
+
throw new HttpError(
|
|
755
|
+
400,
|
|
756
|
+
`Add a ${chosen} API key in Settings → Tools, or switch to DuckDuckGo.`,
|
|
757
|
+
'missing_search_key',
|
|
758
|
+
)
|
|
759
|
+
}
|
|
760
|
+
const signal = AbortSignal.timeout(25_000)
|
|
761
|
+
if (chosen === 'tavily') return { provider: chosen, results: await tavily(query, key, signal) }
|
|
762
|
+
if (chosen === 'brave') return { provider: chosen, results: await brave(query, key, signal) }
|
|
763
|
+
if (chosen === 'serper') return { provider: chosen, results: await serper(query, key, signal) }
|
|
764
|
+
return { provider: 'duckduckgo', results: await duckduckgo(query, signal) }
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function privateAddress(address) {
|
|
768
|
+
if (isIP(address) === 4) {
|
|
769
|
+
const parts = address.split('.').map(Number)
|
|
770
|
+
if (parts[0] === 10 || parts[0] === 127 || parts[0] === 0) return true
|
|
771
|
+
if (parts[0] === 169 && parts[1] === 254) return true
|
|
772
|
+
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true
|
|
773
|
+
if (parts[0] === 192 && parts[1] === 168) return true
|
|
774
|
+
if (parts[0] >= 224) return true
|
|
775
|
+
return false
|
|
776
|
+
}
|
|
777
|
+
if (isIP(address) === 6) {
|
|
778
|
+
const lower = address.toLowerCase()
|
|
779
|
+
if (lower === '::1' || lower === '::') return true
|
|
780
|
+
if (lower.startsWith('fc') || lower.startsWith('fd')) return true
|
|
781
|
+
if (lower.startsWith('fe80')) return true
|
|
782
|
+
if (lower.startsWith('::ffff:')) return privateAddress(lower.slice(7))
|
|
783
|
+
return false
|
|
784
|
+
}
|
|
785
|
+
return false
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function assertPublicUrl(url) {
|
|
789
|
+
if (!/^https?:$/.test(url.protocol)) throw new HttpError(400, 'Only http and https URLs can be read.', 'bad_url')
|
|
790
|
+
if (url.hostname === 'localhost' || url.hostname.endsWith('.localhost')) {
|
|
791
|
+
throw new HttpError(403, 'That host is private.', 'private_url')
|
|
792
|
+
}
|
|
793
|
+
const literal = isIP(url.hostname)
|
|
794
|
+
const addresses = literal ? [{ address: url.hostname }] : await lookup(url.hostname, { all: true }).catch(() => [])
|
|
795
|
+
if (addresses.length === 0) throw new HttpError(400, 'That host could not be resolved.', 'dns_failed')
|
|
796
|
+
if (addresses.some((entry) => privateAddress(entry.address))) {
|
|
797
|
+
throw new HttpError(403, 'That host is on a private network.', 'private_url')
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export async function readUrl(rawUrl, { maxBytes = 400_000 } = {}) {
|
|
802
|
+
let url
|
|
803
|
+
try {
|
|
804
|
+
url = new URL(String(rawUrl))
|
|
805
|
+
} catch {
|
|
806
|
+
throw new HttpError(400, 'That URL is not valid.', 'bad_url')
|
|
807
|
+
}
|
|
808
|
+
// Follow redirects by hand so every hop is checked against the private-network
|
|
809
|
+
// rules; `redirect: 'follow'` would let a public page bounce into localhost/LAN.
|
|
810
|
+
const signal = AbortSignal.timeout(25_000)
|
|
811
|
+
let response
|
|
812
|
+
for (let hop = 0; ; hop += 1) {
|
|
813
|
+
await assertPublicUrl(url)
|
|
814
|
+
response = await fetch(url, {
|
|
815
|
+
redirect: 'manual',
|
|
816
|
+
headers: {
|
|
817
|
+
Accept: 'text/html,text/plain,application/json;q=0.9,*/*;q=0.5',
|
|
818
|
+
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 OpenSol/1.0',
|
|
819
|
+
},
|
|
820
|
+
signal,
|
|
821
|
+
})
|
|
822
|
+
if (response.status < 300 || response.status >= 400) break
|
|
823
|
+
const location = response.headers.get('location')
|
|
824
|
+
if (!location || hop >= 5) throw new HttpError(502, 'Too many redirects.', 'fetch_failed')
|
|
825
|
+
try {
|
|
826
|
+
url = new URL(location, url)
|
|
827
|
+
} catch {
|
|
828
|
+
throw new HttpError(502, 'The page redirected to an invalid URL.', 'fetch_failed')
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (!response.ok) {
|
|
832
|
+
throw new HttpError(502, `That page returned HTTP ${response.status}.`, 'fetch_failed')
|
|
833
|
+
}
|
|
834
|
+
const type = response.headers.get('content-type') ?? ''
|
|
835
|
+
if (!/text\/|json|xml|javascript/i.test(type)) {
|
|
836
|
+
throw new HttpError(415, `OpenSol cannot read ${type || 'that content type'}.`, 'unsupported_type')
|
|
837
|
+
}
|
|
838
|
+
const raw = (await response.text()).slice(0, maxBytes)
|
|
839
|
+
const text = /html/i.test(type) ? stripHtml(raw) : raw
|
|
840
|
+
return {
|
|
841
|
+
url: response.url,
|
|
842
|
+
title: (() => {
|
|
843
|
+
const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(raw)
|
|
844
|
+
return match ? stripHtml(match[1]) : ''
|
|
845
|
+
})(),
|
|
846
|
+
content: text.slice(0, 60_000),
|
|
847
|
+
truncated: text.length > 60_000,
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const INSTRUCTION_FILES = ['OPENSOL.md', 'MAXCODE.md', 'AGENTS.md', 'CLAUDE.md', '.opensol/instructions.md', '.maxcode/instructions.md']
|
|
852
|
+
const MAX_INSTRUCTIONS = 20_000
|
|
853
|
+
|
|
854
|
+
/** The first project instruction file found in the open workspace, if any. */
|
|
855
|
+
export async function readProjectInstructions() {
|
|
856
|
+
const empty = { file: '', content: '', truncated: false }
|
|
857
|
+
try {
|
|
858
|
+
await workspaceRoot()
|
|
859
|
+
} catch {
|
|
860
|
+
return empty
|
|
861
|
+
}
|
|
862
|
+
for (const name of INSTRUCTION_FILES) {
|
|
863
|
+
try {
|
|
864
|
+
const { absolute } = await resolveWorkspacePath(name, { allowRoot: false })
|
|
865
|
+
const info = await stat(absolute)
|
|
866
|
+
if (!info.isFile()) continue
|
|
867
|
+
const buffer = await readFile(absolute)
|
|
868
|
+
if (isProbablyBinary(buffer)) continue
|
|
869
|
+
const text = buffer.toString('utf8')
|
|
870
|
+
return { file: name, content: text.slice(0, MAX_INSTRUCTIONS), truncated: text.length > MAX_INSTRUCTIONS }
|
|
871
|
+
} catch {
|
|
872
|
+
// Missing or unreadable: try the next candidate.
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return empty
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const FILE_MUTATIONS = new Set(['write_file', 'rewrite_file', 'edit_file'])
|
|
879
|
+
|
|
880
|
+
/** Undo the file edits recorded for one turn. Shell changes are not part of a checkpoint. */
|
|
881
|
+
export async function restoreCheckpoint(id) {
|
|
882
|
+
const checkpoint = await readCheckpoint(id)
|
|
883
|
+
if (!checkpoint) throw new HttpError(404, 'This checkpoint has expired or does not exist.', 'checkpoint_missing')
|
|
884
|
+
const root = await workspaceRoot()
|
|
885
|
+
if (checkpoint.root !== root) {
|
|
886
|
+
throw new HttpError(409, 'This checkpoint belongs to a different workspace.', 'checkpoint_workspace')
|
|
887
|
+
}
|
|
888
|
+
const restored = []
|
|
889
|
+
const removed = []
|
|
890
|
+
const skipped = []
|
|
891
|
+
for (const [relative, entry] of Object.entries(checkpoint.files)) {
|
|
892
|
+
const { absolute } = await resolveWorkspacePath(relative, { allowRoot: false })
|
|
893
|
+
if (entry.skipped) {
|
|
894
|
+
skipped.push(relative)
|
|
895
|
+
continue
|
|
896
|
+
}
|
|
897
|
+
if (!entry.existed) {
|
|
898
|
+
const info = await stat(absolute).catch(() => null)
|
|
899
|
+
if (info?.isFile()) await rm(absolute, { force: true })
|
|
900
|
+
removed.push(relative)
|
|
901
|
+
continue
|
|
902
|
+
}
|
|
903
|
+
await writeWorkspaceFile(relative, entry.content ?? '')
|
|
904
|
+
if (typeof entry.mode === 'number') await chmod(absolute, entry.mode).catch(() => undefined)
|
|
905
|
+
restored.push(relative)
|
|
906
|
+
}
|
|
907
|
+
await dropCheckpoint(id)
|
|
908
|
+
return { id, restored, removed, skipped }
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
export async function checkpointSummary(id) {
|
|
912
|
+
const checkpoint = await readCheckpoint(id)
|
|
913
|
+
return checkpoint ? describeCheckpoint(checkpoint) : null
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
export async function executeTool(name, args = {}, { mode = 'build', checkpoint = '' } = {}) {
|
|
917
|
+
const input = args && typeof args === 'object' ? args : {}
|
|
918
|
+
// The prompt promises the daemon enforces plan mode, so enforce it here and not only in the UI.
|
|
919
|
+
if (mode === 'plan' && FILE_MUTATIONS.has(name)) {
|
|
920
|
+
throw new HttpError(
|
|
921
|
+
403,
|
|
922
|
+
'Plan mode is read-only. Use read_file, list_files, grep or a read-only shell command.',
|
|
923
|
+
'plan_readonly',
|
|
924
|
+
)
|
|
925
|
+
}
|
|
926
|
+
if (checkpoint && FILE_MUTATIONS.has(name)) {
|
|
927
|
+
try {
|
|
928
|
+
const { root, absolute, relative } = await resolveWorkspacePath(String(input.path ?? ''), { allowRoot: false })
|
|
929
|
+
await recordBefore(checkpoint, root, relative, absolute)
|
|
930
|
+
} catch {
|
|
931
|
+
// A failed snapshot must not block the edit; the tool reports its own path errors.
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
switch (name) {
|
|
935
|
+
case 'shell':
|
|
936
|
+
return runShell(String(input.command ?? ''), { mode, timeoutMs: Number(input.timeoutMs) })
|
|
937
|
+
case 'read_file':
|
|
938
|
+
return readWorkspaceFile(String(input.path ?? ''))
|
|
939
|
+
case 'list_files': {
|
|
940
|
+
const result = await listTree(String(input.path ?? ''), { depth: Number(input.depth) || 2 })
|
|
941
|
+
return { ...result, files: result.entries.map((entry) => entry.path) }
|
|
942
|
+
}
|
|
943
|
+
case 'write_file':
|
|
944
|
+
return writeWorkspaceFile(String(input.path ?? ''), String(input.content ?? ''))
|
|
945
|
+
case 'rewrite_file':
|
|
946
|
+
return writeWorkspaceFile(String(input.path ?? ''), String(input.content ?? ''))
|
|
947
|
+
case 'edit_file':
|
|
948
|
+
return editWorkspaceFile(String(input.path ?? ''), String(input.oldText ?? ''), String(input.newText ?? ''), {
|
|
949
|
+
replaceAll: input.replaceAll === true,
|
|
950
|
+
})
|
|
951
|
+
case 'grep':
|
|
952
|
+
return grepWorkspace(String(input.pattern ?? ''), {
|
|
953
|
+
path: typeof input.path === 'string' ? input.path : '',
|
|
954
|
+
ignoreCase: input.ignoreCase === true,
|
|
955
|
+
})
|
|
956
|
+
case 'web_search':
|
|
957
|
+
return searchWeb(String(input.query ?? ''), { provider: String(input.provider ?? 'duckduckgo') })
|
|
958
|
+
case 'read_url':
|
|
959
|
+
return readUrl(String(input.url ?? ''))
|
|
960
|
+
case 'git':
|
|
961
|
+
return runGit(String(input.action ?? 'status'), input, { mode })
|
|
962
|
+
default:
|
|
963
|
+
throw new HttpError(400, `Unknown tool: ${name}`, 'unknown_tool')
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
export async function discoverWorkspace(rawPath) {
|
|
968
|
+
const info = await stat(path.resolve(String(rawPath))).catch(() => null)
|
|
969
|
+
if (!info?.isDirectory()) throw new HttpError(400, 'That folder does not exist.', 'bad_workspace')
|
|
970
|
+
const resolved = await realpath(String(rawPath))
|
|
971
|
+
return { path: resolved, name: path.basename(resolved) || resolved }
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
export async function browseDirectories(rawPath) {
|
|
975
|
+
const target = path.resolve(rawPath || process.env.HOME || '/')
|
|
976
|
+
const info = await stat(target).catch(() => null)
|
|
977
|
+
if (!info?.isDirectory()) throw new HttpError(400, 'That folder does not exist.', 'bad_folder')
|
|
978
|
+
const parent = path.dirname(target)
|
|
979
|
+
const children = await readdir(target, { withFileTypes: true }).catch(() => [])
|
|
980
|
+
return {
|
|
981
|
+
path: target,
|
|
982
|
+
parent: parent === target ? '' : parent,
|
|
983
|
+
home: process.env.HOME || '',
|
|
984
|
+
entries: children
|
|
985
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
986
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
987
|
+
.slice(0, 300)
|
|
988
|
+
.map((entry) => ({ name: entry.name, path: path.join(target, entry.name) })),
|
|
989
|
+
}
|
|
990
|
+
}
|