@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.
@@ -0,0 +1,1082 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
3
+ import http from 'node:http'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { Readable } from 'node:stream'
7
+ import { fileURLToPath } from 'node:url'
8
+ import {
9
+ HttpError,
10
+ clientIp,
11
+ cookieValue,
12
+ json,
13
+ readJson,
14
+ securityHeaders,
15
+ sendError,
16
+ serveStatic,
17
+ setCookie,
18
+ streamFile,
19
+ } from './lib/http.mjs'
20
+ import {
21
+ addWorkspace,
22
+ DATA_DIR,
23
+ dataPaths,
24
+ forgetWorkspace,
25
+ loadSecretsSafe,
26
+ loadState,
27
+ migrateLegacy,
28
+ resetAll,
29
+ resetSecrets,
30
+ saveChats,
31
+ saveSettings,
32
+ saveState,
33
+ secretSummary,
34
+ setDeploySecret,
35
+ setProviderSecret,
36
+ setSearchSecret,
37
+ } from './lib/store.mjs'
38
+ import {
39
+ isKnownProvider,
40
+ listModelStatus,
41
+ listModels,
42
+ llmTarget,
43
+ providerList,
44
+ PROVIDERS,
45
+ validateKeyFormat,
46
+ } from './lib/providers.mjs'
47
+ import {
48
+ browseDirectories,
49
+ checkpointSummary,
50
+ createWorkspaceEntry,
51
+ deleteWorkspaceEntry,
52
+ discoverWorkspace,
53
+ editWorkspaceFile,
54
+ executeTool,
55
+ gitSummary,
56
+ listTree,
57
+ readProjectInstructions,
58
+ readWorkspaceFile,
59
+ renameWorkspaceEntry,
60
+ resolveWorkspacePath,
61
+ restoreCheckpoint,
62
+ runGit,
63
+ runShell,
64
+ workspaceRoot,
65
+ writeWorkspaceFile,
66
+ } from './lib/tools.mjs'
67
+ import { deployToolStatus, runDeploy } from './lib/deploy.mjs'
68
+ import {
69
+ getPluginArchive,
70
+ installPlugin,
71
+ listPlugins,
72
+ requestPluginNetwork,
73
+ setPluginEnabled,
74
+ setPluginGrants,
75
+ setPluginSafe,
76
+ uninstallPlugin,
77
+ } from './lib/plugins.mjs'
78
+
79
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
80
+ const DIST = path.resolve(HERE, '..', 'dist')
81
+ const VERSION = '1.0.0'
82
+ const SESSION_COOKIE = 'maxcode_session'
83
+ const CSRF_HEADER = 'x-maxcode-csrf'
84
+ const SESSION_TTL = 14 * 24 * 60 * 60 * 1000
85
+ const sessions = new Map()
86
+ /** Unguessable per-session tokens that let a sandboxed preview iframe fetch workspace files. */
87
+ const previewTokens = new Map()
88
+
89
+ let port = 4174
90
+ let host = '127.0.0.1'
91
+
92
+ function allowedHosts() {
93
+ const ports = new Set([String(port), '80', '443'])
94
+ const hosts = new Set()
95
+ for (const value of ports) {
96
+ hosts.add(`localhost:${value}`)
97
+ hosts.add(`127.0.0.1:${value}`)
98
+ hosts.add(`[::1]:${value}`)
99
+ }
100
+ hosts.add('localhost')
101
+ hosts.add('127.0.0.1')
102
+ hosts.add('[::1]')
103
+ return hosts
104
+ }
105
+
106
+ function allowedOrigins() {
107
+ return new Set([
108
+ `http://127.0.0.1:${port}`,
109
+ `http://localhost:${port}`,
110
+ `http://[::1]:${port}`,
111
+ 'http://127.0.0.1:5173',
112
+ 'http://localhost:5173',
113
+ 'http://[::1]:5173',
114
+ `http://127.0.0.1`,
115
+ `http://localhost`,
116
+ ])
117
+ }
118
+
119
+ function guardRequest(req) {
120
+ const hostHeader = String(req.headers.host ?? '').toLowerCase()
121
+ if (!hostHeader || !allowedHosts().has(hostHeader)) {
122
+ throw new HttpError(403, 'OpenSol only answers requests addressed to localhost.', 'bad_host')
123
+ }
124
+ const origin = String(req.headers.origin ?? '')
125
+ if (origin && !allowedOrigins().has(origin.toLowerCase())) {
126
+ throw new HttpError(403, 'Cross-origin requests are blocked.', 'bad_origin')
127
+ }
128
+ const fetchSite = String(req.headers['sec-fetch-site'] ?? '')
129
+ if (fetchSite && !['same-origin', 'none'].includes(fetchSite)) {
130
+ throw new HttpError(403, 'Cross-site requests are blocked.', 'bad_origin')
131
+ }
132
+ }
133
+
134
+ function purgeSessions() {
135
+ const now = Date.now()
136
+ for (const [token, session] of sessions) {
137
+ if (now - session.lastSeen > SESSION_TTL) {
138
+ if (session.previewToken) previewTokens.delete(session.previewToken)
139
+ sessions.delete(token)
140
+ }
141
+ }
142
+ }
143
+
144
+ function sessionFrom(req) {
145
+ const token = cookieValue(req, SESSION_COOKIE)
146
+ if (!token) return null
147
+ const session = sessions.get(token)
148
+ if (!session) return null
149
+ if (Date.now() - session.created > SESSION_TTL) {
150
+ sessions.delete(token)
151
+ return null
152
+ }
153
+ session.lastSeen = Date.now()
154
+ return { token, ...session }
155
+ }
156
+
157
+ function requireSession(req) {
158
+ const session = sessionFrom(req)
159
+ if (!session) throw new HttpError(401, 'Open OpenSol from its local address to start a session.', 'no_session')
160
+ return session
161
+ }
162
+
163
+ function requireCsrf(req, session) {
164
+ const provided = String(req.headers[CSRF_HEADER] ?? '')
165
+ if (!provided || provided !== session.csrf) {
166
+ throw new HttpError(403, 'This request is missing a valid CSRF token.', 'bad_csrf')
167
+ }
168
+ const contentType = String(req.headers['content-type'] ?? '')
169
+ if (req.method !== 'DELETE' && !contentType.toLowerCase().startsWith('application/json')) {
170
+ throw new HttpError(415, 'Send JSON with Content-Type: application/json.', 'bad_content_type')
171
+ }
172
+ }
173
+
174
+ function createSession(res) {
175
+ const token = randomBytes(32).toString('base64url')
176
+ const csrf = randomBytes(32).toString('base64url')
177
+ sessions.set(token, { csrf, created: Date.now(), lastSeen: Date.now() })
178
+ setCookie(res, SESSION_COOKIE, token, { httpOnly: true, sameSite: 'Strict', path: '/', maxAge: SESSION_TTL / 1000 })
179
+ return { token, csrf }
180
+ }
181
+
182
+ function sanitizeString(value, max) {
183
+ return typeof value === 'string' ? value.slice(0, max) : undefined
184
+ }
185
+
186
+ const TOOL_NAMES = new Set([
187
+ 'shell',
188
+ 'read_file',
189
+ 'list_files',
190
+ 'write_file',
191
+ 'edit_file',
192
+ 'rewrite_file',
193
+ 'grep',
194
+ 'web_search',
195
+ 'read_url',
196
+ 'git',
197
+ 'subagent',
198
+ ])
199
+ const SEARCH_PROVIDERS = new Set(['duckduckgo', 'tavily', 'brave', 'serper'])
200
+
201
+ function sanitizeSettings(patch, current = {}) {
202
+ const source = patch && typeof patch === 'object' ? patch : {}
203
+ const next = { ...current }
204
+
205
+ if (typeof source.provider === 'string' && isKnownProvider(source.provider)) next.provider = source.provider
206
+ if (typeof source.model === 'string') next.model = source.model.slice(0, 300)
207
+ if (typeof source.effort === 'string' && /^[a-z]{2,12}$/.test(source.effort)) next.effort = source.effort
208
+ if (typeof source.webSearchEnabled === 'boolean') next.webSearchEnabled = source.webSearchEnabled
209
+ if (typeof source.showTokenEstimate === 'boolean') next.showTokenEstimate = source.showTokenEstimate
210
+ if (typeof source.temperature === 'number' && Number.isFinite(source.temperature)) {
211
+ next.temperature = Math.max(0, Math.min(2, source.temperature))
212
+ }
213
+ if (typeof source.maxTokens === 'number' && Number.isFinite(source.maxTokens)) {
214
+ next.maxTokens = Math.max(256, Math.min(200_000, Math.round(source.maxTokens)))
215
+ }
216
+ if (typeof source.systemPrompt === 'string') next.systemPrompt = source.systemPrompt.slice(0, 60_000)
217
+ if (typeof source.searchProvider === 'string' && SEARCH_PROVIDERS.has(source.searchProvider)) {
218
+ next.searchProvider = source.searchProvider
219
+ }
220
+ // Plugin themes are named by their plugin, so accept any bounded theme slug.
221
+ if (typeof source.theme === 'string' && /^[a-z0-9-]{1,64}$/.test(source.theme)) next.theme = source.theme
222
+ if (source.mode === 'build' || source.mode === 'plan') next.mode = source.mode
223
+
224
+ if (source.providers && typeof source.providers === 'object') {
225
+ const providers = { ...(current.providers ?? {}) }
226
+ for (const [id, value] of Object.entries(source.providers)) {
227
+ if (!isKnownProvider(id) || !value || typeof value !== 'object') continue
228
+ const baseUrl = sanitizeString(value.baseUrl, 600)
229
+ if (baseUrl !== undefined) {
230
+ providers[id] = { ...(providers[id] ?? {}), baseUrl: baseUrl.trim() }
231
+ }
232
+ }
233
+ next.providers = providers
234
+ }
235
+
236
+ if (source.tools && typeof source.tools === 'object') {
237
+ const tools = { ...(current.tools ?? {}) }
238
+ for (const [name, value] of Object.entries(source.tools)) {
239
+ if (!TOOL_NAMES.has(name) || !value || typeof value !== 'object') continue
240
+ const entry = { ...(tools[name] ?? {}) }
241
+ if (typeof value.enabled === 'boolean') entry.enabled = value.enabled
242
+ if (value.approval === 'auto' || value.approval === 'ask') entry.approval = value.approval
243
+ tools[name] = entry
244
+ }
245
+ next.tools = tools
246
+ }
247
+
248
+ if (Array.isArray(source.skills)) {
249
+ next.skills = source.skills.slice(0, 60).map((skill) => {
250
+ const item = skill && typeof skill === 'object' ? skill : {}
251
+ return {
252
+ id: typeof item.id === 'string' ? item.id.slice(0, 120) : `skill-${randomBytes(4).toString('hex')}`,
253
+ name: sanitizeString(item.name, 120) ?? 'Skill',
254
+ description: sanitizeString(item.description, 600) ?? '',
255
+ instructions: sanitizeString(item.instructions, 20_000) ?? '',
256
+ enabled: item.enabled === true,
257
+ builtIn: item.builtIn === true,
258
+ }
259
+ })
260
+ }
261
+
262
+ return next
263
+ }
264
+
265
+ function sanitizeChats(payload) {
266
+ const sessionsInput = Array.isArray(payload?.sessions) ? payload.sessions.slice(0, 500) : []
267
+ const sessionsOut = []
268
+ for (const raw of sessionsInput) {
269
+ if (!raw || typeof raw !== 'object') continue
270
+ if (typeof raw.id !== 'string' || !Array.isArray(raw.messages)) continue
271
+ sessionsOut.push({
272
+ ...raw,
273
+ id: raw.id.slice(0, 120),
274
+ title: typeof raw.title === 'string' ? raw.title.slice(0, 300) : 'Untitled',
275
+ // Keep the newest messages: slicing from the start dropped the latest replies.
276
+ messages: raw.messages.slice(-2000),
277
+ updatedAt: typeof raw.updatedAt === 'number' ? raw.updatedAt : Date.now(),
278
+ })
279
+ }
280
+ const activeId =
281
+ typeof payload?.activeId === 'string' && sessionsOut.some((session) => session.id === payload.activeId)
282
+ ? payload.activeId
283
+ : sessionsOut[0]?.id ?? null
284
+ return { sessions: sessionsOut, activeId }
285
+ }
286
+
287
+ async function workspacePayload() {
288
+ const state = await loadState()
289
+ let root = state.activeWorkspace ?? ''
290
+ let available = false
291
+ let error = ''
292
+ if (root) {
293
+ const info = await stat(root).catch(() => null)
294
+ available = Boolean(info?.isDirectory())
295
+ if (!available) error = 'The saved workspace folder is missing. Choose another folder.'
296
+ }
297
+ return {
298
+ root,
299
+ name: root ? path.basename(root) : '',
300
+ available,
301
+ error,
302
+ workspaces: state.workspaces ?? [],
303
+ }
304
+ }
305
+
306
+ async function buildStatePayload() {
307
+ const state = await loadState()
308
+ const { secrets, error } = await loadSecretsSafe()
309
+ const providers = await providerList(state.settings ?? {})
310
+ const [workspace, deployTools] = await Promise.all([workspacePayload(), deployToolStatus()])
311
+ return {
312
+ version: VERSION,
313
+ dataDir: DATA_DIR,
314
+ home: os.homedir(),
315
+ platform: `${os.platform()} ${os.release()}`,
316
+ node: process.version,
317
+ settings: state.settings ?? {},
318
+ chats: state.chats ?? { sessions: [], activeId: null },
319
+ workspace,
320
+ secrets: secretSummary(secrets),
321
+ secretsError: error,
322
+ providers,
323
+ deployTools,
324
+ deploySecrets: secretSummary(secrets).deploy,
325
+ migration: { migratedAt: state.legacyMigratedAt ?? null },
326
+ modelCatalog: {
327
+ providers: Object.keys(PROVIDERS),
328
+ },
329
+ }
330
+ }
331
+
332
+ function streamHeaders(res, contentType = 'text/event-stream') {
333
+ res.writeHead(200, {
334
+ 'Content-Type': `${contentType}; charset=utf-8`,
335
+ 'Cache-Control': 'no-store, no-transform',
336
+ Connection: 'keep-alive',
337
+ 'X-Accel-Buffering': 'no',
338
+ })
339
+ res.flushHeaders?.()
340
+ }
341
+
342
+ async function handleLlm(req, res) {
343
+ const payload = await readJson(req, 24 * 1024 * 1024)
344
+ const provider = String(payload.provider ?? '')
345
+ const kind = String(payload.kind ?? '')
346
+ const model = String(payload.model ?? '')
347
+ const body = payload.body && typeof payload.body === 'object' ? payload.body : null
348
+ if (!isKnownProvider(provider)) throw new HttpError(400, 'Unknown provider.', 'unknown_provider')
349
+ if (!body) throw new HttpError(400, 'The model request body is missing.', 'missing_body')
350
+ const state = await loadState()
351
+ const target = await llmTarget(provider, kind, model, state.settings ?? {})
352
+ const controller = new AbortController()
353
+ let closed = false
354
+ res.on('close', () => {
355
+ if (!res.writableEnded) {
356
+ closed = true
357
+ controller.abort()
358
+ }
359
+ })
360
+ let upstream
361
+ try {
362
+ // Gemini uses ?alt=sse in the URL for streaming; adding stream:true to the
363
+ // JSON body causes a 400 "Unknown name \"stream\"" error from the Google API.
364
+ const streamBody = target.kind === 'gemini' ? body : { ...body, stream: true }
365
+ upstream = await fetch(target.url, {
366
+ method: 'POST',
367
+ headers: target.headers,
368
+ body: JSON.stringify(streamBody),
369
+ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(10 * 60 * 1000)]),
370
+ })
371
+ } catch (error) {
372
+ if (closed) return
373
+ throw new HttpError(
374
+ 502,
375
+ `${PROVIDERS[provider].name} could not be reached. ${error instanceof Error ? error.message : ''}`.trim(),
376
+ 'provider_unreachable',
377
+ )
378
+ }
379
+
380
+ if (!upstream.ok) {
381
+ const raw = await upstream.text().catch(() => '')
382
+ let detail = raw.trim()
383
+ try {
384
+ const parsed = JSON.parse(raw)
385
+ const error = parsed.error
386
+ detail =
387
+ (typeof error === 'string' ? error : '') ||
388
+ error?.message ||
389
+ parsed.message ||
390
+ parsed.detail ||
391
+ detail
392
+ } catch {
393
+ // Keep the raw text.
394
+ }
395
+ detail = String(detail ?? '')
396
+ .replace(/<[^>]+>/g, ' ')
397
+ .replace(/\s+/g, ' ')
398
+ .trim()
399
+ .slice(0, 600)
400
+ const status = upstream.status === 401 || upstream.status === 403 ? upstream.status : 502
401
+ let friendly =
402
+ upstream.status === 401
403
+ ? `${PROVIDERS[provider].name} rejected the API key (401).`
404
+ : upstream.status === 403
405
+ ? `${PROVIDERS[provider].name} refused this request (403). The key may not have access to ${model}.`
406
+ : upstream.status === 429
407
+ ? `${PROVIDERS[provider].name} is rate limiting this key (429).`
408
+ : `${PROVIDERS[provider].name} returned HTTP ${upstream.status}.`
409
+
410
+ if (provider === 'openrouter' && upstream.status === 401 && detail.includes('Missing Authentication header')) {
411
+ friendly = 'OpenRouter rejected the API key (401: Missing Authentication header). OpenRouter keys must start with "sk-or-v1-". The key you configured does not match OpenRouter (it looks like a DeepSeek or OpenAI key). If you meant to use DeepSeek, please choose DeepSeek in Settings → Models.'
412
+ }
413
+ throw new HttpError(status, `${friendly}${detail && !friendly.includes(detail) ? ` ${detail}` : ''}`, 'provider_error')
414
+ }
415
+
416
+ streamHeaders(res)
417
+ res.write(': maxcode\n\n')
418
+ const bodyStream = upstream.body
419
+ if (!bodyStream) {
420
+ res.end()
421
+ throw new HttpError(502, 'The provider returned an empty response.', 'empty_provider_response')
422
+ }
423
+ const nodeStream = Readable.fromWeb(bodyStream)
424
+ nodeStream.on('error', () => {
425
+ if (!res.writableEnded) res.end()
426
+ })
427
+ nodeStream.pipe(res)
428
+ await new Promise((resolve) => {
429
+ res.on('finish', resolve)
430
+ res.on('close', resolve)
431
+ nodeStream.on('end', resolve)
432
+ })
433
+ }
434
+
435
+ async function handleDeploy(req, res) {
436
+ const payload = await readJson(req, 2 * 1024 * 1024)
437
+ const controller = new AbortController()
438
+ res.on('close', () => {
439
+ if (!res.writableEnded) controller.abort()
440
+ })
441
+ streamHeaders(res, 'application/x-ndjson')
442
+ const write = (event) => {
443
+ if (!res.writableEnded) res.write(`${JSON.stringify(event)}\n`)
444
+ }
445
+ write({ type: 'start', provider: payload.provider, path: payload.path })
446
+ try {
447
+ const result = await runDeploy(payload, {
448
+ signal: controller.signal,
449
+ onLog: (entry) => write({ type: 'log', ...entry }),
450
+ })
451
+ write({ type: 'done', result })
452
+ res.end()
453
+ } catch (error) {
454
+ if (res.writableEnded) return
455
+ write({
456
+ type: 'error',
457
+ message: error instanceof Error ? error.message : 'Deployment failed.',
458
+ code: error instanceof HttpError ? error.code : 'deploy_failed',
459
+ })
460
+ res.end()
461
+ }
462
+ }
463
+
464
+ async function handleProviderModels(res, url) {
465
+ const provider = url.searchParams.get('provider') ?? ''
466
+ if (!isKnownProvider(provider)) throw new HttpError(400, 'Unknown provider.', 'unknown_provider')
467
+ const state = await loadState()
468
+ const result = await listModels(provider, state.settings ?? {})
469
+ json(res, 200, result)
470
+ }
471
+
472
+ function providerFromPath(pathname, prefix) {
473
+ const rest = pathname.slice(prefix.length)
474
+ const [provider] = rest.split('/').filter(Boolean)
475
+ if (!provider || !isKnownProvider(provider)) throw new HttpError(400, 'Unknown provider.', 'unknown_provider')
476
+ return provider
477
+ }
478
+
479
+ const PREVIEW_CSP = [
480
+ // An opaque origin: the previewed page cannot read cookies or call the daemon API.
481
+ 'sandbox allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock',
482
+ "default-src 'self' data: blob: https:",
483
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https:",
484
+ "style-src 'self' 'unsafe-inline' https:",
485
+ 'img-src * data: blob:',
486
+ 'font-src * data:',
487
+ 'media-src * data: blob:',
488
+ "connect-src 'self' https: data: blob:",
489
+ "frame-ancestors 'self'",
490
+ ].join('; ')
491
+
492
+ function previewTokenFor(sessionToken) {
493
+ const record = sessions.get(sessionToken)
494
+ if (!record) throw new HttpError(401, 'Open OpenSol from its local address to start a session.', 'no_session')
495
+ if (!record.previewToken) {
496
+ record.previewToken = randomBytes(24).toString('base64url')
497
+ previewTokens.set(record.previewToken, sessionToken)
498
+ }
499
+ return record.previewToken
500
+ }
501
+
502
+ /** Serve one workspace file for the sandboxed preview. Auth is the token in the URL. */
503
+ async function handlePreview(req, res, rawPath) {
504
+ const hostHeader = String(req.headers.host ?? '').toLowerCase()
505
+ if (!hostHeader || !allowedHosts().has(hostHeader)) {
506
+ throw new HttpError(403, 'OpenSol only answers requests addressed to localhost.', 'bad_host')
507
+ }
508
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
509
+ throw new HttpError(405, 'Method not allowed.', 'method_not_allowed')
510
+ }
511
+ const match = /^\/preview\/([A-Za-z0-9_-]{16,64})(\/.*)?$/.exec(rawPath)
512
+ if (!match) throw new HttpError(404, 'Not found.', 'not_found')
513
+ const sessionToken = previewTokens.get(match[1])
514
+ const record = sessionToken ? sessions.get(sessionToken) : null
515
+ if (!record || Date.now() - record.created > SESSION_TTL) {
516
+ previewTokens.delete(match[1])
517
+ throw new HttpError(401, 'This preview link expired. Reopen the preview from OpenSol.', 'preview_expired')
518
+ }
519
+ let relative
520
+ try {
521
+ relative = decodeURIComponent(match[2] ?? '/')
522
+ } catch {
523
+ throw new HttpError(400, 'That path is not valid.', 'invalid_path')
524
+ }
525
+ if (relative.endsWith('/')) relative += 'index.html'
526
+ let target = await resolveWorkspacePath(relative, { allowRoot: false })
527
+ let info = await stat(target.absolute).catch(() => null)
528
+ if (info?.isDirectory()) {
529
+ target = await resolveWorkspacePath(`${relative}/index.html`, { allowRoot: false })
530
+ info = await stat(target.absolute).catch(() => null)
531
+ }
532
+ if (!info?.isFile()) throw new HttpError(404, `Not found: ${relative}`, 'not_found')
533
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN')
534
+ res.setHeader('Content-Security-Policy', PREVIEW_CSP)
535
+ streamFile(req, res, target.absolute, info.size, { 'Cache-Control': 'no-store' })
536
+ }
537
+
538
+ async function route(req, res) {
539
+ const rawPath = (req.url ?? '/').split('?')[0]
540
+ if (rawPath.startsWith('/preview/')) {
541
+ await handlePreview(req, res, rawPath)
542
+ return
543
+ }
544
+ guardRequest(req)
545
+ const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
546
+ const pathname = url.pathname
547
+ const method = req.method ?? 'GET'
548
+
549
+ if (pathname === '/api/health') {
550
+ if (method !== 'GET') throw new HttpError(405, 'Method not allowed.', 'method_not_allowed')
551
+ const distReady = await stat(path.join(DIST, 'index.html'))
552
+ .then((info) => info.isFile())
553
+ .catch(() => false)
554
+ json(res, 200, {
555
+ ok: true,
556
+ app: 'maxcode',
557
+ version: VERSION,
558
+ pid: process.pid,
559
+ port,
560
+ dataDir: DATA_DIR,
561
+ dist: distReady,
562
+ startedAt: new Date(startedAt).toISOString(),
563
+ })
564
+ return
565
+ }
566
+
567
+ if (pathname === '/api/session') {
568
+ if (method !== 'POST' && method !== 'GET') throw new HttpError(405, 'Method not allowed.', 'method_not_allowed')
569
+ purgeSessions()
570
+ const existing = sessionFrom(req)
571
+ if (method === 'POST') {
572
+ if (existing) {
573
+ json(res, 200, { authenticated: true, csrf: existing.csrf })
574
+ return
575
+ }
576
+ const session = createSession(res)
577
+ json(res, 200, { authenticated: true, csrf: session.csrf })
578
+ return
579
+ }
580
+ if (!existing) {
581
+ json(res, 200, { authenticated: false, csrf: '' })
582
+ return
583
+ }
584
+ json(res, 200, { authenticated: true, csrf: existing.csrf })
585
+ return
586
+ }
587
+
588
+ if (pathname.startsWith('/api/')) {
589
+ const session = requireSession(req)
590
+ if (!['GET', 'HEAD'].includes(method)) requireCsrf(req, session)
591
+
592
+ if (pathname === '/api/state' && method === 'GET') {
593
+ json(res, 200, await buildStatePayload())
594
+ return
595
+ }
596
+
597
+ if (pathname === '/api/plugins' && method === 'GET') {
598
+ json(res, 200, { plugins: await listPlugins() })
599
+ return
600
+ }
601
+
602
+ if (pathname === '/api/plugins' && method === 'POST') {
603
+ const body = await readJson(req, 8 * 1024 * 1024)
604
+ if (typeof body.archive !== 'string' || body.archive.length > 7_000_000) {
605
+ throw new HttpError(400, 'A local plugin archive is required.', 'missing_plugin_archive')
606
+ }
607
+ json(res, 201, { plugin: await installPlugin(Buffer.from(body.archive, 'base64')) })
608
+ return
609
+ }
610
+
611
+ const pluginRoute = /^\/api\/plugins\/([a-z0-9][a-z0-9-]{1,63})(?:\/(package|grants|safe|enabled|network))?$/.exec(pathname)
612
+ if (pluginRoute) {
613
+ const [, pluginId, action] = pluginRoute
614
+ if (action === 'package' && method === 'GET') {
615
+ const archive = await getPluginArchive(pluginId)
616
+ json(res, 200, { archive: archive.toString('base64') })
617
+ return
618
+ }
619
+ if (!action && method === 'DELETE') {
620
+ json(res, 200, await uninstallPlugin(pluginId))
621
+ return
622
+ }
623
+ if (action) {
624
+ const body = await readJson(req, action === 'network' ? 128 * 1024 : 64 * 1024)
625
+ if (action === 'grants' && method === 'PUT') {
626
+ json(res, 200, { plugin: await setPluginGrants(pluginId, body) })
627
+ return
628
+ }
629
+ if (action === 'safe' && method === 'PUT') {
630
+ json(res, 200, { plugin: await setPluginSafe(pluginId, body.safe) })
631
+ return
632
+ }
633
+ if (action === 'enabled' && method === 'PUT') {
634
+ json(res, 200, { plugin: await setPluginEnabled(pluginId, body.enabled) })
635
+ return
636
+ }
637
+ if (action === 'network' && method === 'POST') {
638
+ json(res, 200, await requestPluginNetwork(pluginId, body))
639
+ return
640
+ }
641
+ }
642
+ throw new HttpError(405, 'Method not allowed.', 'method_not_allowed')
643
+ }
644
+
645
+ if (pathname === '/api/settings' && method === 'PATCH') {
646
+ const state = await loadState()
647
+ const patch = await readJson(req)
648
+ const settings = sanitizeSettings(patch, state.settings ?? {})
649
+ await saveSettings(settings)
650
+ json(res, 200, { settings })
651
+ return
652
+ }
653
+
654
+ if (pathname === '/api/chats' && method === 'PUT') {
655
+ const payload = await readJson(req, 40 * 1024 * 1024)
656
+ const chats = sanitizeChats(payload)
657
+ await saveChats(chats)
658
+ json(res, 200, { sessions: chats.sessions.length, activeId: chats.activeId })
659
+ return
660
+ }
661
+
662
+ if (pathname === '/api/migrate' && method === 'POST') {
663
+ const payload = await readJson(req, 32 * 1024 * 1024)
664
+ const result = await migrateLegacy(payload)
665
+ json(res, 200, { ...result, state: await buildStatePayload() })
666
+ return
667
+ }
668
+
669
+ if (pathname === '/api/providers' && method === 'GET') {
670
+ const state = await loadState()
671
+ json(res, 200, { providers: await providerList(state.settings ?? {}) })
672
+ return
673
+ }
674
+
675
+ if (pathname === '/api/models' && method === 'GET') {
676
+ await handleProviderModels(res, url)
677
+ return
678
+ }
679
+
680
+ if (pathname.startsWith('/api/providers/') && pathname.endsWith('/key')) {
681
+ const provider = providerFromPath(pathname, '/api/providers/')
682
+ const body = await readJson(req)
683
+ if (method === 'PUT') {
684
+ const key = String(body.apiKey ?? '')
685
+ const formatError = validateKeyFormat(provider, key)
686
+ if (formatError) throw new HttpError(400, formatError, 'invalid_key_format')
687
+ const result = await setProviderSecret(provider, key)
688
+ json(res, 200, result)
689
+ return
690
+ }
691
+ if (method === 'DELETE') {
692
+ const result = await setProviderSecret(provider, '')
693
+ json(res, 200, result)
694
+ return
695
+ }
696
+ throw new HttpError(405, 'Method not allowed.', 'method_not_allowed')
697
+ }
698
+
699
+ if (pathname === '/api/search/key') {
700
+ const body = await readJson(req)
701
+ if (method === 'PUT') {
702
+ json(res, 200, await setSearchSecret(String(body.apiKey ?? '')))
703
+ return
704
+ }
705
+ throw new HttpError(405, 'Method not allowed.', 'method_not_allowed')
706
+ }
707
+
708
+ if (pathname.startsWith('/api/deploy/credentials/')) {
709
+ const provider = pathname.slice('/api/deploy/credentials/'.length)
710
+ if (!['cloudflare', 'netlify', 'vercel'].includes(provider)) {
711
+ throw new HttpError(400, 'Unknown deploy provider.', 'bad_deploy_provider')
712
+ }
713
+ if (method === 'PUT') {
714
+ const body = await readJson(req)
715
+ json(res, 200, await setDeploySecret(provider, body))
716
+ return
717
+ }
718
+ throw new HttpError(405, 'Method not allowed.', 'method_not_allowed')
719
+ }
720
+
721
+ if (pathname === '/api/deploy/status' && method === 'GET') {
722
+ const { secrets } = await loadSecretsSafe()
723
+ json(res, 200, { tools: await deployToolStatus(), secrets: secretSummary(secrets).deploy })
724
+ return
725
+ }
726
+
727
+ if (pathname === '/api/deploy' && method === 'POST') {
728
+ await handleDeploy(req, res)
729
+ return
730
+ }
731
+
732
+ if (pathname === '/api/llm' && method === 'POST') {
733
+ await handleLlm(req, res)
734
+ return
735
+ }
736
+
737
+ if (pathname === '/api/workspaces') {
738
+ if (method === 'GET') {
739
+ json(res, 200, await workspacePayload())
740
+ return
741
+ }
742
+ if (method === 'POST') {
743
+ const body = await readJson(req)
744
+ const found = await discoverWorkspace(String(body.path ?? ''))
745
+ const entry = await addWorkspace(found.path)
746
+ json(res, 200, { workspace: await workspacePayload(), entry })
747
+ return
748
+ }
749
+ if (method === 'DELETE') {
750
+ await forgetWorkspace()
751
+ json(res, 200, await workspacePayload())
752
+ return
753
+ }
754
+ throw new HttpError(405, 'Method not allowed.', 'method_not_allowed')
755
+ }
756
+
757
+ if (pathname === '/api/workspaces/browse') {
758
+ if (method === 'GET') {
759
+ json(res, 200, await browseDirectories(url.searchParams.get('path') ?? ''))
760
+ return
761
+ }
762
+ throw new HttpError(405, 'Method not allowed.', 'method_not_allowed')
763
+ }
764
+
765
+ if (pathname === '/api/workspace/tree' && method === 'GET') {
766
+ const depth = Number(url.searchParams.get('depth') ?? 3)
767
+ const showHidden = url.searchParams.get('hidden') === '1'
768
+ const result = await listTree(url.searchParams.get('path') ?? '', { depth, showHidden })
769
+ json(res, 200, { ...result, workspace: await workspacePayload() })
770
+ return
771
+ }
772
+
773
+ if (pathname === '/api/workspace/file') {
774
+ if (method === 'GET') {
775
+ const file = await readWorkspaceFile(url.searchParams.get('path') ?? '')
776
+ json(res, 200, file)
777
+ return
778
+ }
779
+ if (method === 'PUT') {
780
+ const body = await readJson(req, 20 * 1024 * 1024)
781
+ const result = await writeWorkspaceFile(String(body.path ?? ''), String(body.content ?? ''))
782
+ json(res, 200, result)
783
+ return
784
+ }
785
+ if (method === 'POST') {
786
+ const body = await readJson(req, 8 * 1024 * 1024)
787
+ const action = String(body.action ?? 'create')
788
+ if (action === 'create') {
789
+ const result = await createWorkspaceEntry(
790
+ String(body.path ?? ''),
791
+ body.type === 'dir' ? 'dir' : 'file',
792
+ String(body.content ?? ''),
793
+ )
794
+ json(res, 200, result)
795
+ return
796
+ }
797
+ if (action === 'rename') {
798
+ const result = await renameWorkspaceEntry(String(body.from ?? ''), String(body.to ?? ''))
799
+ json(res, 200, result)
800
+ return
801
+ }
802
+ if (action === 'delete') {
803
+ const result = await deleteWorkspaceEntry(String(body.path ?? ''))
804
+ json(res, 200, result)
805
+ return
806
+ }
807
+ throw new HttpError(400, 'Unknown file action.', 'bad_action')
808
+ }
809
+ throw new HttpError(405, 'Method not allowed.', 'method_not_allowed')
810
+ }
811
+
812
+ if (pathname === '/api/workspace/edit' && method === 'POST') {
813
+ const body = await readJson(req, 20 * 1024 * 1024)
814
+ const result = await editWorkspaceFile(
815
+ String(body.path ?? ''),
816
+ String(body.oldText ?? ''),
817
+ String(body.newText ?? ''),
818
+ { replaceAll: body.replaceAll === true },
819
+ )
820
+ json(res, 200, result)
821
+ return
822
+ }
823
+
824
+ if (pathname === '/api/preview/token' && method === 'GET') {
825
+ json(res, 200, { token: previewTokenFor(session.token) })
826
+ return
827
+ }
828
+
829
+ if (pathname === '/api/workspace/instructions' && method === 'GET') {
830
+ json(res, 200, await readProjectInstructions())
831
+ return
832
+ }
833
+
834
+ const checkpointRoute = /^\/api\/checkpoints\/([A-Za-z0-9_-]{1,120})(\/restore)?$/.exec(pathname)
835
+ if (checkpointRoute) {
836
+ const [, checkpointId, restore] = checkpointRoute
837
+ if (!restore && method === 'GET') {
838
+ const summary = await checkpointSummary(checkpointId)
839
+ if (!summary) throw new HttpError(404, 'This checkpoint has expired or does not exist.', 'checkpoint_missing')
840
+ json(res, 200, summary)
841
+ return
842
+ }
843
+ if (restore && method === 'POST') {
844
+ json(res, 200, await restoreCheckpoint(checkpointId))
845
+ return
846
+ }
847
+ throw new HttpError(405, 'Method not allowed.', 'method_not_allowed')
848
+ }
849
+
850
+ if (pathname === '/api/workspace/root' && method === 'GET') {
851
+ const root = await workspaceRoot()
852
+ json(res, 200, { root, workspace: await workspacePayload() })
853
+ return
854
+ }
855
+
856
+ if (pathname === '/api/git' && method === 'GET') {
857
+ json(res, 200, await gitSummary())
858
+ return
859
+ }
860
+
861
+ if (pathname === '/api/git' && method === 'POST') {
862
+ const body = await readJson(req)
863
+ const result = await runGit(String(body.action ?? 'status'), body.args ?? {}, {
864
+ mode: body.mode === 'plan' ? 'plan' : 'build',
865
+ })
866
+ json(res, 200, result)
867
+ return
868
+ }
869
+
870
+ if (pathname === '/api/tools/execute' && method === 'POST') {
871
+ const body = await readJson(req, 24 * 1024 * 1024)
872
+ const name = String(body.name ?? '')
873
+ if (!TOOL_NAMES.has(name) || name === 'subagent') {
874
+ throw new HttpError(400, `Unknown tool: ${name}`, 'unknown_tool')
875
+ }
876
+ const result = await executeTool(name, body.args ?? {}, {
877
+ mode: body.mode === 'plan' ? 'plan' : 'build',
878
+ checkpoint: typeof body.checkpoint === 'string' ? body.checkpoint : '',
879
+ })
880
+ json(res, 200, { name, result })
881
+ return
882
+ }
883
+
884
+ if (pathname === '/api/shell' && method === 'POST') {
885
+ const body = await readJson(req, 2 * 1024 * 1024)
886
+ json(res, 200, await runShell(String(body.command ?? ''), {
887
+ mode: body.mode === 'plan' ? 'plan' : 'build',
888
+ timeoutMs: body.timeoutMs,
889
+ }))
890
+ return
891
+ }
892
+
893
+ if (pathname === '/api/reset' && method === 'POST') {
894
+ const body = await readJson(req)
895
+ const scope = String(body.scope ?? '')
896
+ if (scope === 'history') {
897
+ await saveChats({ sessions: [], activeId: null })
898
+ } else if (scope === 'secrets') {
899
+ await resetSecrets()
900
+ } else if (scope === 'settings') {
901
+ await saveSettings({})
902
+ } else if (scope === 'all') {
903
+ await resetAll({ keepMaster: true })
904
+ } else {
905
+ throw new HttpError(400, 'Unknown reset scope.', 'bad_scope')
906
+ }
907
+ json(res, 200, await buildStatePayload())
908
+ return
909
+ }
910
+
911
+ if (pathname === '/api/export' && method === 'GET') {
912
+ const state = await loadState()
913
+ json(res, 200, {
914
+ app: 'maxcode',
915
+ version: VERSION,
916
+ exportedAt: new Date().toISOString(),
917
+ settings: state.settings,
918
+ chats: state.chats,
919
+ workspaces: state.workspaces,
920
+ })
921
+ return
922
+ }
923
+
924
+ if (pathname === '/api/shutdown' && method === 'POST') {
925
+ json(res, 200, { ok: true })
926
+ setTimeout(() => process.kill(process.pid, 'SIGTERM'), 100).unref()
927
+ return
928
+ }
929
+
930
+ throw new HttpError(404, 'Unknown API route.', 'not_found')
931
+ }
932
+
933
+ if (method !== 'GET' && method !== 'HEAD') {
934
+ throw new HttpError(405, 'Method not allowed.', 'method_not_allowed')
935
+ }
936
+ if (pathname === '/preview-host.html') {
937
+ // Hosts model-written pages inside a sandboxed iframe (opaque origin: no cookies, no
938
+ // access to OpenSol). It needs CDN scripts and inline code, so it gets its own CSP.
939
+ res.removeHeader('X-Frame-Options')
940
+ res.setHeader(
941
+ 'Content-Security-Policy',
942
+ [
943
+ "default-src 'none'",
944
+ "script-src * 'unsafe-inline' 'unsafe-eval' data: blob:",
945
+ "style-src * 'unsafe-inline' data:",
946
+ 'img-src * data: blob:',
947
+ 'media-src * data: blob:',
948
+ 'font-src * data:',
949
+ 'connect-src https: wss: data: blob:',
950
+ "frame-ancestors 'self'",
951
+ ].join('; '),
952
+ )
953
+ }
954
+ await serveStatic(req, res, DIST, pathname)
955
+ }
956
+
957
+ const startedAt = Date.now()
958
+
959
+ async function handler(req, res) {
960
+ securityHeaders(res)
961
+ try {
962
+ await route(req, res)
963
+ } catch (error) {
964
+ if (res.headersSent) {
965
+ res.destroy()
966
+ return
967
+ }
968
+ if (!(error instanceof HttpError)) {
969
+ console.error(`[opensol] ${clientIp(req)} ${req.method} ${req.url}`)
970
+ console.error(error)
971
+ }
972
+ sendError(res, error)
973
+ }
974
+ }
975
+
976
+ async function findPort(start) {
977
+ for (let candidate = start; candidate < start + 30; candidate += 1) {
978
+ const free = await new Promise((resolve) => {
979
+ const probe = http.createServer()
980
+ probe.once('error', () => resolve(false))
981
+ probe.once('listening', () => probe.close(() => resolve(true)))
982
+ probe.listen(candidate, '127.0.0.1')
983
+ })
984
+ if (free) return candidate
985
+ }
986
+ throw new Error(`No free port found between ${start} and ${start + 29}.`)
987
+ }
988
+
989
+ export async function startServer({ port: requested = 4174, open = false } = {}) {
990
+ await mkdir(DATA_DIR, { recursive: true, mode: 0o700 })
991
+ port = requested === 0 ? await findPort(4174) : requested
992
+ const server = http.createServer((req, res) => {
993
+ handler(req, res)
994
+ })
995
+ server.keepAliveTimeout = 65_000
996
+ server.headersTimeout = 70_000
997
+ await new Promise((resolve, reject) => {
998
+ server.once('error', reject)
999
+ server.listen(port, host, resolve)
1000
+ })
1001
+ const pidFile = path.join(DATA_DIR, 'daemon.json')
1002
+ await writeFile(
1003
+ pidFile,
1004
+ `${JSON.stringify({ pid: process.pid, port, host, url: `http://${host}:${port}`, startedAt: Date.now() }, null, 2)}\n`,
1005
+ { mode: 0o600 },
1006
+ )
1007
+
1008
+ const shutdown = async (signal) => {
1009
+ console.log(`[opensol] ${signal} received, shutting down.`)
1010
+ server.close()
1011
+ await rm(pidFile, { force: true }).catch(() => undefined)
1012
+ process.exit(0)
1013
+ }
1014
+ process.on('SIGTERM', () => void shutdown('SIGTERM'))
1015
+ process.on('SIGINT', () => void shutdown('SIGINT'))
1016
+ process.on('uncaughtException', (error) => console.error('[opensol] uncaught exception', error))
1017
+ process.on('unhandledRejection', (error) => console.error('[opensol] unhandled rejection', error))
1018
+
1019
+ const url = `http://${host}:${port}`
1020
+ console.log(`[opensol] daemon listening on ${url}`)
1021
+ console.log(`[opensol] data: ${DATA_DIR}`)
1022
+ console.log(`[opensol] dist: ${DIST}`)
1023
+ if (open) {
1024
+ const { spawn } = await import('node:child_process')
1025
+ const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
1026
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
1027
+ const child = spawn(opener, args, { detached: true, stdio: 'ignore' })
1028
+ child.on('error', () => console.log(`[opensol] open ${url} in your browser.`))
1029
+ child.unref()
1030
+ }
1031
+ return { server, url, port }
1032
+ }
1033
+
1034
+ export async function daemonStatus() {
1035
+ const pidFile = path.join(DATA_DIR, 'daemon.json')
1036
+ try {
1037
+ const raw = await readFile(pidFile, 'utf8')
1038
+ const info = JSON.parse(raw)
1039
+ const response = await fetch(`http://${info.host ?? host}:${info.port}/api/health`, {
1040
+ signal: AbortSignal.timeout(1500),
1041
+ })
1042
+ if (!response.ok) return { running: false, info }
1043
+ const health = await response.json()
1044
+ return { running: true, info: { ...info, ...health } }
1045
+ } catch {
1046
+ return { running: false, info: null }
1047
+ }
1048
+ }
1049
+
1050
+ export async function stopDaemon() {
1051
+ const status = await daemonStatus()
1052
+ if (!status.running) {
1053
+ await rm(path.join(DATA_DIR, 'daemon.json'), { force: true })
1054
+ return { stopped: false, reason: 'OpenSol is not running.' }
1055
+ }
1056
+ try {
1057
+ process.kill(status.info.pid, 'SIGTERM')
1058
+ } catch (error) {
1059
+ if (error.code !== 'ESRCH') throw error
1060
+ }
1061
+ for (let attempt = 0; attempt < 40; attempt += 1) {
1062
+ await new Promise((resolve) => setTimeout(resolve, 150))
1063
+ const next = await daemonStatus()
1064
+ if (!next.running) return { stopped: true, pid: status.info.pid }
1065
+ }
1066
+ try {
1067
+ process.kill(status.info.pid, 'SIGKILL')
1068
+ } catch {
1069
+ // It already exited.
1070
+ }
1071
+ return { stopped: true, pid: status.info.pid, forced: true }
1072
+ }
1073
+
1074
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
1075
+ const args = process.argv.slice(2)
1076
+ const portArg = args.find((value) => value.startsWith('--port='))
1077
+ const parsedPort = portArg ? Number(portArg.split('=')[1]) : 4174
1078
+ startServer({ port: parsedPort, open: args.includes('--open') }).catch((error) => {
1079
+ console.error(error)
1080
+ process.exit(1)
1081
+ })
1082
+ }