@bibobonking/opensol 0.0.0-stage → 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/.github/workflows/build.yml +25 -0
- package/README.md +74 -2
- package/dist/assets/index-BmN7pBji.css +1 -0
- package/dist/assets/index-DgNDjkOz.js +301 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +55 -0
- package/dist/preview-host.html +33 -0
- package/index.html +54 -0
- package/package.json +43 -4
- package/postcss.config.js +6 -0
- package/public/favicon.svg +15 -0
- package/public/preview-host.html +33 -0
- 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
- package/shared/plugin-builder.test.mjs +43 -0
- package/shared/plugin-package.d.mts +34 -0
- package/shared/plugin-package.mjs +197 -0
- package/shared/plugin-package.test.mjs +94 -0
- package/src/App.tsx +152 -0
- package/src/agent/adapters/anthropic.ts +233 -0
- package/src/agent/adapters/gemini.ts +225 -0
- package/src/agent/adapters/openai-responses.ts +215 -0
- package/src/agent/adapters/openai.ts +200 -0
- package/src/agent/adapters/shared.ts +96 -0
- package/src/agent/approval.ts +20 -0
- package/src/agent/compact.ts +234 -0
- package/src/agent/index.ts +36 -0
- package/src/agent/model.ts +20 -0
- package/src/agent/prompt.ts +107 -0
- package/src/agent/queue.test.mjs +111 -0
- package/src/agent/queue.ts +84 -0
- package/src/agent/subagent.ts +109 -0
- package/src/agent/tools.ts +541 -0
- package/src/components/ChatView.tsx +403 -0
- package/src/components/CommandPalette.tsx +416 -0
- package/src/components/Composer.tsx +678 -0
- package/src/components/ContextBar.tsx +58 -0
- package/src/components/DeployModal.tsx +512 -0
- package/src/components/ErrorBoundary.tsx +75 -0
- package/src/components/HomeDashboard.tsx +189 -0
- package/src/components/Logo.tsx +41 -0
- package/src/components/Markdown.tsx +232 -0
- package/src/components/MessageItem.tsx +655 -0
- package/src/components/ModelPicker.tsx +487 -0
- package/src/components/PixleSol.tsx +243 -0
- package/src/components/PluginBuilder.tsx +285 -0
- package/src/components/PluginManager.tsx +341 -0
- package/src/components/PreviewPane.tsx +166 -0
- package/src/components/SettingsModal.tsx +2052 -0
- package/src/components/Sidebar.tsx +504 -0
- package/src/components/Splash.tsx +104 -0
- package/src/components/ToolCallCard.tsx +177 -0
- package/src/components/TopBar.tsx +182 -0
- package/src/components/TrajectoryView.tsx +178 -0
- package/src/components/WorkspacePanel.tsx +710 -0
- package/src/components/ui.tsx +311 -0
- package/src/core/kernel.ts +45 -0
- package/src/core/plugins/tools/fs.ts +219 -0
- package/src/core/plugins/tools/git.ts +83 -0
- package/src/core/plugins/tools/grep.ts +98 -0
- package/src/core/plugins/tools/shell.ts +88 -0
- package/src/core/plugins/tools/subagent.ts +36 -0
- package/src/core/plugins/tools/web.ts +97 -0
- package/src/core/services/agent-loop.ts +417 -0
- package/src/core/services/approval.ts +66 -0
- package/src/core/services/compaction.ts +45 -0
- package/src/core/services/llm.ts +32 -0
- package/src/core/services/sessions.ts +47 -0
- package/src/core/services/subagents.ts +15 -0
- package/src/core/services/system-prompt.ts +177 -0
- package/src/core/services/tools.ts +135 -0
- package/src/core/services/ui.ts +109 -0
- package/src/core/types.ts +139 -0
- package/src/data/providers.ts +125 -0
- package/src/data/skills.ts +68 -0
- package/src/index.css +776 -0
- package/src/lib/boot.ts +94 -0
- package/src/lib/context.ts +75 -0
- package/src/lib/conversation.test.mjs +64 -0
- package/src/lib/conversation.ts +59 -0
- package/src/lib/daemon.ts +475 -0
- package/src/lib/hooks.ts +33 -0
- package/src/lib/permissions.ts +60 -0
- package/src/lib/pixlesol.test.mjs +51 -0
- package/src/lib/pixlesol.ts +55 -0
- package/src/lib/plugin-folder.test.mjs +145 -0
- package/src/lib/plugin-folder.ts +96 -0
- package/src/lib/reasoning.ts +87 -0
- package/src/lib/sse.ts +75 -0
- package/src/lib/themes.test.mjs +72 -0
- package/src/lib/themes.ts +52 -0
- package/src/lib/usage.test.mjs +72 -0
- package/src/lib/usage.ts +155 -0
- package/src/lib/utils.ts +73 -0
- package/src/main.tsx +16 -0
- package/src/plugins/api.test.mjs +77 -0
- package/src/plugins/api.ts +113 -0
- package/src/plugins/bridge.d.mts +1 -0
- package/src/plugins/bridge.mjs +26 -0
- package/src/plugins/bridge.test.mjs +17 -0
- package/src/plugins/index.ts +52 -0
- package/src/plugins/preview.test.mjs +18 -0
- package/src/plugins/preview.ts +34 -0
- package/src/plugins/runtime.ts +194 -0
- package/src/plugins/types.ts +40 -0
- package/src/store/chats.ts +278 -0
- package/src/store/models.ts +120 -0
- package/src/store/settings.ts +445 -0
- package/src/store/ui.ts +132 -0
- package/src/store/workspace.ts +299 -0
- package/src/types.ts +337 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +83 -0
- package/tsconfig.json +22 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vite.config.ts +19 -0
|
@@ -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
|
+
}
|