mouaif 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- package/src/util.js +151 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared state + cross-cutting helpers for the HTTP server.
|
|
4
|
+
//
|
|
5
|
+
// The original single-file src/index.js (later src/http-server.js) held
|
|
6
|
+
// every route handler, the shared in-memory state (SSE clients, running
|
|
7
|
+
// chats, access-attempt tracking, the live model cache) and the helpers
|
|
8
|
+
// those handlers all reach for (sendJSON, cookie/origin authorization,
|
|
9
|
+
// settings redaction, model resolution). This module owns that shared
|
|
10
|
+
// surface so the per-domain handler modules stay focused and no file in
|
|
11
|
+
// the repo stays above ~2 000 lines.
|
|
12
|
+
//
|
|
13
|
+
// It deliberately does NOT require any handler module (no circular
|
|
14
|
+
// deps); it only requires the domain modules (settings, ai, auth, ...)
|
|
15
|
+
// that the helpers delegate to.
|
|
16
|
+
|
|
17
|
+
const http = require('http');
|
|
18
|
+
const url = require('url');
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
const { spawn } = require('node:child_process');
|
|
24
|
+
const { qs, safeDecode, projectModelRecord, firstStringValue, errCodeToHttpStatus } = require('./util.js');
|
|
25
|
+
const settings = require('./settings.js');
|
|
26
|
+
const projects = require('./projects.js');
|
|
27
|
+
const ai = require('./ai.js');
|
|
28
|
+
const auth = require('./auth.js');
|
|
29
|
+
const accessAuth = require('./access-auth.js');
|
|
30
|
+
const qr = require('./qr.js');
|
|
31
|
+
const oauthAnthropic = require('./oauth-anthropic.js');
|
|
32
|
+
const oauthCopilot = require('./oauth-github-copilot.js');
|
|
33
|
+
const oauthOpenRouter = require('./oauth-openrouter.js');
|
|
34
|
+
|
|
35
|
+
// Register each per-provider exchange function with the auth skeleton.
|
|
36
|
+
// Idempotent; safe to call from require-time side effects because
|
|
37
|
+
// auth.registerExchange overwrites cleanly. This is the require-time
|
|
38
|
+
// registration that used to live at the top of src/index.js (pre-split);
|
|
39
|
+
// the refactor that split http-server.js into handler modules dropped it,
|
|
40
|
+
// which made every POST /api/auth/sign-in/<provider> return 501
|
|
41
|
+
// "<Provider> OAuth is not registered in this build". Without it the
|
|
42
|
+
// OAuth sign-in button can never start a flow, loopback or domain-served.
|
|
43
|
+
oauthAnthropic.register();
|
|
44
|
+
oauthCopilot.register();
|
|
45
|
+
oauthOpenRouter.register();
|
|
46
|
+
const chats = require('./chats.js');
|
|
47
|
+
const messages = require('./messages.js');
|
|
48
|
+
const trace = require('./trace.js');
|
|
49
|
+
const inspector = require('./inspector.js');
|
|
50
|
+
const inspectorProfiles = require('./inspectorProfiles.js');
|
|
51
|
+
const prompts = require('./prompts.js');
|
|
52
|
+
const promptProfiles = require('./promptProfiles.js');
|
|
53
|
+
const tags = require('./tags.js');
|
|
54
|
+
const agentFiles = require('./agentFiles.js');
|
|
55
|
+
const agentSkills = require('./agentSkills.js');
|
|
56
|
+
const agentFeatures = require('./agentFeatures.js');
|
|
57
|
+
const agents = require('./agents.js');
|
|
58
|
+
const mcp = require('./mcp.js');
|
|
59
|
+
const usage = require('./usage.js');
|
|
60
|
+
const push = require('./push.js');
|
|
61
|
+
const shellTool = require('./tools/shell.js');
|
|
62
|
+
const files = require('./files.js');
|
|
63
|
+
|
|
64
|
+
const DEFAULT_PORT = 5732;
|
|
65
|
+
const SESSION_COOKIE = 'mouaif_session';
|
|
66
|
+
const ACCESS_COOKIE = 'mouaif_access';
|
|
67
|
+
// The mobile UI lives at frontend/ (repo root, not under src/). Vite
|
|
68
|
+
// builds it into frontend/dist/. The server serves that directory at
|
|
69
|
+
// the root /; it falls back to the pre-build frontend/ source for the
|
|
70
|
+
// dev cycle (no Vite build run yet).
|
|
71
|
+
const WEB_DIR = path.join(__dirname, '..', 'frontend');
|
|
72
|
+
const WEB_DIST = path.join(WEB_DIR, 'dist');
|
|
73
|
+
|
|
74
|
+
// ---- Socket + connection tracking --------------------------------------
|
|
75
|
+
|
|
76
|
+
// Track every open socket so the graceful-restart path can force-close
|
|
77
|
+
// keep-alive / SSE connections that would otherwise keep server.close()
|
|
78
|
+
// from resolving. Keyed by the socket object; value is always true.
|
|
79
|
+
const openSockets = new Set();
|
|
80
|
+
|
|
81
|
+
function trackSocket(socket) {
|
|
82
|
+
openSockets.add(socket);
|
|
83
|
+
socket.on('close', () => openSockets.delete(socket));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function destroyOpenSockets() {
|
|
87
|
+
for (const s of openSockets) {
|
|
88
|
+
try { s.destroy(); } catch (_) { /* best-effort */ }
|
|
89
|
+
}
|
|
90
|
+
openSockets.clear();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Store connected SSE clients
|
|
94
|
+
const sseClients = new Set();
|
|
95
|
+
|
|
96
|
+
// Chats with an in-flight streaming run. handleChatStream registers a
|
|
97
|
+
// chat here for the lifetime of its SSE response; GET /api/chats/:id
|
|
98
|
+
// surfaces it as a response-only `running` flag so a client that
|
|
99
|
+
// reloads mid-run can re-enter its busy/streaming state instead of
|
|
100
|
+
// showing the transcript frozen. In-memory (not persisted): a process
|
|
101
|
+
// restart ends every run anyway, so nothing survives to clear.
|
|
102
|
+
const runningChats = new Set();
|
|
103
|
+
const runningChatCancels = new Map();
|
|
104
|
+
const accessAttempts = new Map();
|
|
105
|
+
const liveChat = require('./live-chat.js');
|
|
106
|
+
function runningKey(projectDir, chatId) {
|
|
107
|
+
return String(projectDir) + '::' + String(chatId);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// In-memory store for REST demo
|
|
111
|
+
const store = { message: 'Hello from mouaif!', timestamp: new Date().toISOString() };
|
|
112
|
+
|
|
113
|
+
function sendJSON(res, status, data) {
|
|
114
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
115
|
+
res.end(JSON.stringify(data));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Settings responses are consumed by the browser, so secrets must never be
|
|
119
|
+
// serialized back after they have been stored. The UI only needs to know
|
|
120
|
+
// whether a key exists in order to render its masked "key: •••" hint.
|
|
121
|
+
function connectionForClient(connection) {
|
|
122
|
+
if (!connection || typeof connection !== 'object') return connection;
|
|
123
|
+
const safe = { ...connection };
|
|
124
|
+
safe.hasApiKey = typeof safe.apiKey === 'string' && safe.apiKey.length > 0;
|
|
125
|
+
delete safe.apiKey;
|
|
126
|
+
return safe;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function modelForClient(model) {
|
|
130
|
+
return connectionForClient(model);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A redacted settings snapshot contains `hasApiKey`, which is a
|
|
134
|
+
// response-only marker. Never persist it if a client round-trips the
|
|
135
|
+
// snapshot through a generic patch endpoint. Existing secrets are
|
|
136
|
+
// preserved unless the client explicitly submits a new `apiKey`.
|
|
137
|
+
// Shared by PUT /api/settings/app and PUT /api/settings/project so the
|
|
138
|
+
// redaction contract is symmetric across scopes.
|
|
139
|
+
function sanitizeClientEntries(patch, key, existing) {
|
|
140
|
+
if (!patch || !Array.isArray(patch[key])) return;
|
|
141
|
+
const prior = Array.isArray(existing) ? existing : [];
|
|
142
|
+
patch[key] = patch[key].map((entry) => {
|
|
143
|
+
if (!entry || typeof entry !== 'object') return entry;
|
|
144
|
+
const clean = { ...entry };
|
|
145
|
+
delete clean.hasApiKey;
|
|
146
|
+
if (!Object.prototype.hasOwnProperty.call(clean, 'apiKey')) {
|
|
147
|
+
const prev = prior.find(x => x && x.id === clean.id);
|
|
148
|
+
if (prev && typeof prev.apiKey === 'string') clean.apiKey = prev.apiKey;
|
|
149
|
+
}
|
|
150
|
+
return clean;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The app-level store accumulates server-only bookkeeping that the browser
|
|
155
|
+
// has no business seeing: in-flight OAuth flows (`authPending`, which carry a
|
|
156
|
+
// PKCE `codeVerifier` and CSRF `state` — real secrets), the CDP debugger URL,
|
|
157
|
+
// and anything a future feature stashes there. Rather than blocklist each new
|
|
158
|
+
// leak, we allowlist the exact keys the web UI consumes. Everything else is
|
|
159
|
+
// dropped before it ever hits the wire.
|
|
160
|
+
const CLIENT_SETTINGS_KEYS = Object.freeze([
|
|
161
|
+
'providers', // app-level provider connections (apiKey redacted below)
|
|
162
|
+
'models', // user-defined models
|
|
163
|
+
'projects', // registered project cards
|
|
164
|
+
'promptSize', // default prompt-size profile
|
|
165
|
+
'enterForNewline', // composer keyboard default (Enter newline vs send)
|
|
166
|
+
'autoRetry', // auto-retry failed turns before the stream starts
|
|
167
|
+
'fileOrbButton', // composer file button renders as the animated glass orb
|
|
168
|
+
// Which optional composer tools are drawn. Same class as the orb: app-level
|
|
169
|
+
// display preferences. A key that is stored but not allowlisted is dropped
|
|
170
|
+
// from every /api/settings response, which looks exactly like a toggle that
|
|
171
|
+
// will not save — and here it would be a button the user hides and the
|
|
172
|
+
// composer keeps showing. See docs/features/composer-tool-buttons.md.
|
|
173
|
+
'dictationButton', // the composer microphone button (`false` hides it)
|
|
174
|
+
'imageButton', // the composer image-attachment button (`false` hides it)
|
|
175
|
+
// The remembered dictation choice: `{ modelId, providerId }`, written by the
|
|
176
|
+
// dictation page and read by every surface that dictates — the page's picker
|
|
177
|
+
// and the composer microphone. No secret, but it must be allowlisted here:
|
|
178
|
+
// a key that is stored and then stripped on the way out is a key the UI can
|
|
179
|
+
// never read back, which made the pick look unsaved and left the microphone
|
|
180
|
+
// saying "No dictation model yet" however often one was chosen.
|
|
181
|
+
// See docs/features/dictation.md.
|
|
182
|
+
'dictation',
|
|
183
|
+
'prompts', // app-level custom prompts
|
|
184
|
+
'githubCopilot', // { clientId } for the custom OAuth app
|
|
185
|
+
'modelPricing', // per-model cost table
|
|
186
|
+
'authAccounts', // non-secret OAuth account index
|
|
187
|
+
'tools', // per-project tool config (e.g. tools.shell.enabled) — non-secret
|
|
188
|
+
'toolFeedbackMaxBytes', // model-facing tool-result byte cap
|
|
189
|
+
'toolOutput', // per-project tool output profile { size, structure } — non-secret
|
|
190
|
+
'notifications', // browser notification event preferences
|
|
191
|
+
'flags' // server-side feature toggles (non-secret)
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
// Keys that POST /api/settings/app/reset is allowed to drop from the app
|
|
195
|
+
// store. The DEFAULTS keys are the baseline; the extras are additive app
|
|
196
|
+
// keys that have no in-code default (their absence IS the default) but
|
|
197
|
+
// that the UI must still be able to clear — otherwise "reset" silently
|
|
198
|
+
// can't reach settings like the custom pricing table or the remembered
|
|
199
|
+
// dictation model.
|
|
200
|
+
const RESETTABLE_APP_KEYS = Object.freeze(
|
|
201
|
+
new Set([...Object.keys(settings.DEFAULTS), 'modelPricing', 'githubCopilot', 'dictation'])
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// Allowlist projection for the APP-level store only. Project payloads must
|
|
205
|
+
// use projectForClient() below — see the note there for why.
|
|
206
|
+
function settingsForClient(value) {
|
|
207
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
208
|
+
const safe = {};
|
|
209
|
+
for (const key of CLIENT_SETTINGS_KEYS) {
|
|
210
|
+
if (Object.prototype.hasOwnProperty.call(value, key)) safe[key] = value[key];
|
|
211
|
+
}
|
|
212
|
+
if (Array.isArray(safe.providers)) safe.providers = safe.providers.map(connectionForClient);
|
|
213
|
+
if (Array.isArray(safe.models)) safe.models = safe.models.map(modelForClient);
|
|
214
|
+
return safe;
|
|
215
|
+
}
|
|
216
|
+
// Project settings are user-authored and open-ended: `<projectDir>/.mouaif.json`
|
|
217
|
+
// is hand-editable and every feature that lands there adds a key (`name`,
|
|
218
|
+
// `agents`, `skills`, `agentFiles`, `hideFileContent`, `tags`,
|
|
219
|
+
// `customActions`, `totalCost`, ...). Filtering them through the app-level
|
|
220
|
+
// allowlist above dropped all of those from the wire, which made the
|
|
221
|
+
// Technical-details raw editor incomplete and froze project-scoped UI state
|
|
222
|
+
// (e.g. the hidden-file count, the agent-files / skills switches) at its
|
|
223
|
+
// default. So project payloads are sent whole and only two things are
|
|
224
|
+
// removed: secrets (no project file may carry an `apiKey` — see
|
|
225
|
+
// docs/decisions.md §3) and the internal `__dbBacked` storage marker.
|
|
226
|
+
function projectForClient(value) {
|
|
227
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
228
|
+
const safe = { ...value };
|
|
229
|
+
delete safe.__dbBacked;
|
|
230
|
+
if (Array.isArray(safe.providers)) safe.providers = safe.providers.map(connectionForClient);
|
|
231
|
+
if (Array.isArray(safe.models)) safe.models = safe.models.map(modelForClient);
|
|
232
|
+
return safe;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---- SSE ----------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
function handleSSE(req, res) {
|
|
238
|
+
res.writeHead(200, {
|
|
239
|
+
'Content-Type': 'text/event-stream',
|
|
240
|
+
'Cache-Control': 'no-cache',
|
|
241
|
+
Connection: 'keep-alive'
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Send initial connection event
|
|
245
|
+
res.write(`data: ${JSON.stringify({ type: 'connected', message: 'SSE connection established' })}\n\n`);
|
|
246
|
+
|
|
247
|
+
sseClients.add(res);
|
|
248
|
+
|
|
249
|
+
// Heartbeat every 30s to keep connection alive
|
|
250
|
+
const heartbeat = setInterval(() => {
|
|
251
|
+
res.write(': heartbeat\n\n');
|
|
252
|
+
}, 30000);
|
|
253
|
+
|
|
254
|
+
req.on('close', () => {
|
|
255
|
+
sseClients.delete(res);
|
|
256
|
+
clearInterval(heartbeat);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Broadcast an event to all SSE clients
|
|
261
|
+
function broadcast(event, data) {
|
|
262
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
263
|
+
for (const client of sseClients) {
|
|
264
|
+
client.write(payload);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---- Request authorization helpers -------------------------------------
|
|
269
|
+
|
|
270
|
+
function parseCookies(header) {
|
|
271
|
+
const out = {};
|
|
272
|
+
for (const part of String(header || '').split(';')) {
|
|
273
|
+
const at = part.indexOf('=');
|
|
274
|
+
if (at <= 0) continue;
|
|
275
|
+
const key = part.slice(0, at).trim();
|
|
276
|
+
const value = part.slice(at + 1).trim();
|
|
277
|
+
if (key) out[key] = value;
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function requestOrigin(req) {
|
|
283
|
+
const raw = typeof req.headers.origin === 'string' ? req.headers.origin : '';
|
|
284
|
+
if (!raw) return '';
|
|
285
|
+
try { return new URL(raw).origin; } catch { return null; }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function normalizePublicOrigin(value) {
|
|
289
|
+
if (!value) return null;
|
|
290
|
+
try {
|
|
291
|
+
const parsed = new URL(String(value));
|
|
292
|
+
if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) return null;
|
|
293
|
+
return parsed.origin;
|
|
294
|
+
} catch { return null; }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function expectedOrigin(req, publicOrigin) {
|
|
298
|
+
if (publicOrigin) return publicOrigin;
|
|
299
|
+
const host = typeof req.headers.host === 'string' ? req.headers.host.trim() : '';
|
|
300
|
+
if (!host || /[\r\n]/.test(host)) return null;
|
|
301
|
+
const protocol = req.socket && req.socket.encrypted ? 'https://' : 'http://';
|
|
302
|
+
try { return new URL(protocol + host).origin; } catch { return null; }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function sessionCookie(sessionToken, secure) {
|
|
306
|
+
return SESSION_COOKIE + '=' + sessionToken + '; Path=/; HttpOnly; SameSite=Strict' + (secure ? '; Secure' : '');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function accessCookie(token, secure, maxAge) {
|
|
310
|
+
return ACCESS_COOKIE + '=' + (token || '') + '; Path=/; HttpOnly; SameSite=Lax; Max-Age=' + Math.max(0, Math.floor(maxAge || 0)) + (secure ? '; Secure' : '');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function requestHasBrowserOrigin(req) {
|
|
314
|
+
return typeof req.headers.origin === 'string' || typeof req.headers['sec-fetch-site'] === 'string';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function authorizeBrowserRequest(req, res, sessionToken, publicOrigin) {
|
|
318
|
+
const origin = requestOrigin(req);
|
|
319
|
+
if (!origin) return true;
|
|
320
|
+
const expected = expectedOrigin(req, publicOrigin);
|
|
321
|
+
if (!expected || origin !== expected) {
|
|
322
|
+
sendJSON(res, 403, { error: 'Cross-origin requests are not allowed', code: 'EORIGIN' });
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
326
|
+
const actual = cookies[SESSION_COOKIE] || '';
|
|
327
|
+
const valid = actual.length === sessionToken.length
|
|
328
|
+
&& crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(sessionToken));
|
|
329
|
+
if (!valid) {
|
|
330
|
+
if (!actual) {
|
|
331
|
+
sendJSON(res, 401, { error: 'Browser session is missing or expired', code: 'ESESSION' });
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
// A server restart generates a new in-memory browser session token.
|
|
335
|
+
// The already-open mobile UI still has the old HttpOnly cookie and
|
|
336
|
+
// would otherwise get stuck with ESESSION until the user reloads
|
|
337
|
+
// /. Same-origin Origin validation above is the CSRF boundary; for
|
|
338
|
+
// same-origin browser traffic with a stale cookie, mint the fresh cookie
|
|
339
|
+
// and let the request continue.
|
|
340
|
+
res.setHeader('Set-Cookie', sessionCookie(sessionToken, !!expected && expected.startsWith('https://')));
|
|
341
|
+
}
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function authorizeAccessRequest(req, res, authEnabled) {
|
|
346
|
+
if (!authEnabled) return true;
|
|
347
|
+
// Access can be turned off from inside the app (one-time disable code).
|
|
348
|
+
// serverConfig.authEnabled was resolved at worker start; re-checking the
|
|
349
|
+
// store here makes the change effective immediately, without waiting for a
|
|
350
|
+
// restart — the /#/disable-access page is the point of no return.
|
|
351
|
+
if (accessAuth.disabled()) return true;
|
|
352
|
+
if (!accessAuth.configured()) {
|
|
353
|
+
// Preserve the existing loopback CLI/API workflow until the user opts in;
|
|
354
|
+
// browser traffic is held at setup so the web UI cannot expose app data.
|
|
355
|
+
if (!requestHasBrowserOrigin(req)) return true;
|
|
356
|
+
sendJSON(res, 401, { error: 'Complete access setup first', code: 'EAUTH_SETUP_REQUIRED' });
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
const token = parseCookies(req.headers.cookie)[ACCESS_COOKIE] || '';
|
|
360
|
+
if (accessAuth.session(token)) return true;
|
|
361
|
+
// Non-browser CLI/API clients can use standard HTTP Basic auth instead of
|
|
362
|
+
// first creating a cookie session.
|
|
363
|
+
const authorization = String(req.headers.authorization || '');
|
|
364
|
+
if (authorization.startsWith('Basic ')) {
|
|
365
|
+
try {
|
|
366
|
+
const decoded = Buffer.from(authorization.slice(6), 'base64').toString('utf8');
|
|
367
|
+
const split = decoded.indexOf(':');
|
|
368
|
+
if (split >= 0 && accessAuth.verifyPassword(decoded.slice(0, split), decoded.slice(split + 1))) return true;
|
|
369
|
+
} catch (_) { /* malformed Basic header falls through to 401 */ }
|
|
370
|
+
}
|
|
371
|
+
res.setHeader('WWW-Authenticate', 'Basic realm="mouaif", charset="UTF-8"');
|
|
372
|
+
const accept = String(req.headers.accept || '');
|
|
373
|
+
if (req.method === 'GET' && accept.includes('text/html')) {
|
|
374
|
+
res.writeHead(302, { Location: '/#/login' });
|
|
375
|
+
res.end();
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
sendJSON(res, 401, { error: 'Sign in is required', code: 'EAUTH_REQUIRED' });
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function readJsonBody(req) {
|
|
383
|
+
return new Promise((resolve, reject) => {
|
|
384
|
+
let body = '';
|
|
385
|
+
req.on('data', chunk => { body += chunk; });
|
|
386
|
+
req.on('end', () => {
|
|
387
|
+
if (!body) return resolve({});
|
|
388
|
+
try { resolve(JSON.parse(body)); }
|
|
389
|
+
catch (e) { reject(Object.assign(new Error('Invalid JSON'), { status: 400 })); }
|
|
390
|
+
});
|
|
391
|
+
req.on('error', reject);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// readJsonOr400(req, res) — readJsonBody with the uniform error guard
|
|
396
|
+
// that every POST/PUT/PATCH handler used to repeat inline:
|
|
397
|
+
// try { body = await readJsonBody(req); }
|
|
398
|
+
// catch (e) { return sendJSON(res, e.status || 400, { error: e.message }); }
|
|
399
|
+
// Returns the parsed body, or null after it has already sent a 400
|
|
400
|
+
// response. Callers can `const body = await readJsonOr400(req, res); if (!body) return;`
|
|
401
|
+
async function readJsonOr400(req, res) {
|
|
402
|
+
try {
|
|
403
|
+
return await readJsonBody(req);
|
|
404
|
+
} catch (e) {
|
|
405
|
+
sendJSON(res, e.status || 400, { error: e.message });
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---- AI model resolution ------------------------------------------------
|
|
411
|
+
|
|
412
|
+
function resolveModel(modelId, projectDir, providerId) {
|
|
413
|
+
if (!modelId || typeof modelId !== 'string') {
|
|
414
|
+
const e = new Error('modelId is required');
|
|
415
|
+
e.code = 'EBADINPUT';
|
|
416
|
+
throw e;
|
|
417
|
+
}
|
|
418
|
+
const resolved = settings.getResolved(projectDir || null);
|
|
419
|
+
const list = Array.isArray(resolved.models) ? resolved.models : [];
|
|
420
|
+
const wantedProvider = typeof providerId === 'string' ? providerId.trim() : '';
|
|
421
|
+
let m = list.find(x => x && x.id === modelId && (!wantedProvider || x.provider === wantedProvider));
|
|
422
|
+
let liveCatalogModel = false;
|
|
423
|
+
if (!m) {
|
|
424
|
+
// Live catalog entries are intentionally not persisted into the
|
|
425
|
+
// project's optional models array. The browser submits providerId so
|
|
426
|
+
// the server can build the same minimal model record on demand.
|
|
427
|
+
if (wantedProvider && ai.ENDPOINTS[wantedProvider]) {
|
|
428
|
+
m = { id: modelId, provider: wantedProvider };
|
|
429
|
+
liveCatalogModel = true;
|
|
430
|
+
} else {
|
|
431
|
+
const e = new Error('Model not found: ' + modelId);
|
|
432
|
+
e.code = 'EMODEL_NOT_FOUND';
|
|
433
|
+
throw e;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// Project models contain identity/selection metadata only. The app-level
|
|
437
|
+
// provider connection exclusively owns transport and credentials.
|
|
438
|
+
const app = settings.getApp();
|
|
439
|
+
const providers = Array.isArray(app.providers) ? app.providers : [];
|
|
440
|
+
const connection = providers.find(p => p && p.id === m.provider);
|
|
441
|
+
if (!connection && liveCatalogModel) {
|
|
442
|
+
const e = new Error('Provider connection not found: ' + m.provider);
|
|
443
|
+
e.code = 'EPROVIDER_NOT_FOUND';
|
|
444
|
+
throw e;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Live-catalog models (OpenRouter etc.) carry per-model pricing from the
|
|
448
|
+
// upstream /models list, which the client fetched into MODEL_LIST_CACHE
|
|
449
|
+
// before the user picked the model. Fold that pricing onto the record so
|
|
450
|
+
// the cost line uses the provider's real numbers instead of the
|
|
451
|
+
// best-effort built-in table. Project-level records keep their own
|
|
452
|
+
// `pricing` (most specific) — only the live path is enriched here.
|
|
453
|
+
if (liveCatalogModel && !m.pricing) {
|
|
454
|
+
const cached = MODEL_LIST_CACHE.get(modelListCacheKey(m.provider, credHashFor(m.provider), 'chat'));
|
|
455
|
+
if (cached && Array.isArray(cached.models)) {
|
|
456
|
+
const live = cached.models.find((x) => x && x.id === modelId);
|
|
457
|
+
if (live && live.pricing && typeof live.pricing === 'object') {
|
|
458
|
+
m = Object.assign({}, m, { pricing: live.pricing });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Never let committed project JSON redirect a global credential to an
|
|
464
|
+
// attacker-controlled endpoint or replace auth/account/header policy.
|
|
465
|
+
const safeModel = projectModelRecord(m);
|
|
466
|
+
|
|
467
|
+
const hydrated = Object.assign({}, connection || {}, safeModel, { provider: m.provider });
|
|
468
|
+
if (!hydrated.auth) hydrated.auth = 'apikey';
|
|
469
|
+
return hydrated;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// credHashFor(provider) — the same bucket key used by the /api/ai/models/live
|
|
473
|
+
// cache. Reuses credentialForProvider so resolveModel and the live endpoint
|
|
474
|
+
// agree on which cache entry is current.
|
|
475
|
+
function credHashFor(provider) {
|
|
476
|
+
let cred = null;
|
|
477
|
+
try { cred = credentialForProvider(provider); }
|
|
478
|
+
catch { /* listModels will surface ENO_APIKEY if the provider requires a credential */ }
|
|
479
|
+
return cred ? hashShort(cred) : '-';
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function credentialForProvider(provider) {
|
|
483
|
+
const app = settings.getApp();
|
|
484
|
+
const conn = (Array.isArray(app.providers) ? app.providers : []).find((p) => p && p.id === provider);
|
|
485
|
+
if (!conn) return null;
|
|
486
|
+
if (conn.apiKey) return conn.apiKey;
|
|
487
|
+
if (conn.auth === 'oauth') {
|
|
488
|
+
const token = auth.tokenForModel({ provider, auth: 'oauth', oauthAccount: conn.oauthAccount });
|
|
489
|
+
const parsedToken = token ? JSON.parse(token) : null;
|
|
490
|
+
return parsedToken && parsedToken.accessToken ? parsedToken.accessToken : null;
|
|
491
|
+
}
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// In-memory cache for /api/ai/models/live. Keyed by
|
|
496
|
+
// `${provider}:${credHash}` so a key rotation invalidates the entry.
|
|
497
|
+
// Cleared on process restart; the chat UI also has its own explicit
|
|
498
|
+
// "refresh" button that bypasses the cache (via cache-buster).
|
|
499
|
+
const MODEL_LIST_CACHE = new Map();
|
|
500
|
+
const MODEL_LIST_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
501
|
+
const MODEL_LIST_TIMEOUT_MS = 8000; // 8 s
|
|
502
|
+
|
|
503
|
+
// hashShort(s) — cheap 32-bit FNV-1a. Used to bucket per-credential
|
|
504
|
+
// cache entries without leaking the actual key.
|
|
505
|
+
function hashShort(s) {
|
|
506
|
+
let h = 0x811c9dc5;
|
|
507
|
+
for (let i = 0; i < s.length; i++) {
|
|
508
|
+
h ^= s.charCodeAt(i);
|
|
509
|
+
h = (h * 0x01000193) >>> 0;
|
|
510
|
+
}
|
|
511
|
+
return h.toString(16);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// modelListCacheKey(provider, credHash, purpose) — the cache key for one slice
|
|
515
|
+
// of a provider's model list.
|
|
516
|
+
//
|
|
517
|
+
// The same provider is asked more than one question — the chat catalog and the
|
|
518
|
+
// speech-to-text catalog — and the answers must not share an entry: serving
|
|
519
|
+
// the chat list to the dictation picker is what made every OpenRouter
|
|
520
|
+
// dictation attempt fail with `400 Model … does not exist`. The chat slice
|
|
521
|
+
// keeps the two-part key its existing callers (resolveModel, the test seeding
|
|
522
|
+
// hook, the live-list route) already use; any other slice appends its name.
|
|
523
|
+
function modelListCacheKey(provider, credHash, purpose) {
|
|
524
|
+
const p = purpose && purpose !== 'chat' ? ':' + purpose : '';
|
|
525
|
+
return provider + ':' + credHash + p;
|
|
526
|
+
}
|
|
527
|
+
// seedModelListCache(provider, models) — test-only hook: pre-fill the
|
|
528
|
+
// in-memory live model cache so resolveModel can pick up per-model
|
|
529
|
+
// pricing without a live upstream call.
|
|
530
|
+
function seedModelListCache(provider, models) {
|
|
531
|
+
if (provider) MODEL_LIST_CACHE.set(modelListCacheKey(provider, credHashFor(provider), 'chat'), { models, fetchedAt: Date.now() });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ---- App access authentication helpers ----------------------------------
|
|
535
|
+
|
|
536
|
+
function accessRequestOrigin(req, publicOrigin) {
|
|
537
|
+
const origin = expectedOrigin(req, publicOrigin);
|
|
538
|
+
if (!origin) throw Object.assign(new Error('Could not determine the app origin'), { code: 'EORIGIN' });
|
|
539
|
+
return origin;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function accessAttemptKey(req) {
|
|
543
|
+
return String(req.socket && req.socket.remoteAddress || 'unknown');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function checkAccessAttempts(req) {
|
|
547
|
+
const key = accessAttemptKey(req);
|
|
548
|
+
const now = Date.now();
|
|
549
|
+
const recent = (accessAttempts.get(key) || []).filter((at) => now - at < 60_000);
|
|
550
|
+
accessAttempts.set(key, recent);
|
|
551
|
+
return recent.length < 10;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function recordAccessFailure(req) {
|
|
555
|
+
const key = accessAttemptKey(req);
|
|
556
|
+
const recent = accessAttempts.get(key) || [];
|
|
557
|
+
recent.push(Date.now());
|
|
558
|
+
accessAttempts.set(key, recent);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function clearAccessFailures(req) {
|
|
562
|
+
accessAttempts.delete(accessAttemptKey(req));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function publicAccessStatus(authEnabled = true) {
|
|
566
|
+
const account = accessAuth.user();
|
|
567
|
+
const disabled = accessAuth.disabled();
|
|
568
|
+
// `armed` is whether the process was started with an auth flag at all,
|
|
569
|
+
// independent of the in-app disable switch. The UI uses it to decide
|
|
570
|
+
// between "protection is off because you turned it off" and "this server
|
|
571
|
+
// was never asked to protect anything".
|
|
572
|
+
const armed = !!authEnabled || disabled;
|
|
573
|
+
return {
|
|
574
|
+
armed,
|
|
575
|
+
enabled: !!authEnabled && !disabled,
|
|
576
|
+
disabled,
|
|
577
|
+
configured: !!account,
|
|
578
|
+
user: account ? account.username : null,
|
|
579
|
+
passkeyCount: account ? accessAuth.passkeys().length : 0
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ---- OAuth callback helpers --------------------------------------------
|
|
584
|
+
|
|
585
|
+
function htmlPage(title, body) {
|
|
586
|
+
const safe = (s) => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
587
|
+
return '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' + safe(title) + '</title><style>body{font:16px/1.5 system-ui,sans-serif;background:#111;color:#eee;margin:0;padding:24px;max-width:480px}h1{font-size:1.1rem;margin:0 0 12px}p{color:#aaa;margin:0 0 12px}.ok{color:#7bd88f}.err{color:#ff8a8a}</style></head><body><h1>' + safe(title) + '</h1>' + body + '</body></html>';
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Return-page snippet for OAuth callback pages so the page gets the user
|
|
591
|
+
// back into the app after sign-in completes. Critical on iOS PWA standalone
|
|
592
|
+
// mode where there is no tab bar and the user cannot manually "close this
|
|
593
|
+
// tab".
|
|
594
|
+
//
|
|
595
|
+
// When `closePopup` is set (success pages) the JS runs immediately (no 2s
|
|
596
|
+
// wait):
|
|
597
|
+
// - popup flow (window.open without noopener): close the popup via
|
|
598
|
+
// window.close(); the opener app polls the account list and reports
|
|
599
|
+
// success itself.
|
|
600
|
+
// - full-page flow (iOS PWA redirect-back): replace the current page
|
|
601
|
+
// with the app so the user lands straight back in the UI.
|
|
602
|
+
// Error pages pass no `closePopup`: the instant close would hide the
|
|
603
|
+
// failure message before the user could read it, so they keep the plain 2s
|
|
604
|
+
// meta refresh instead.
|
|
605
|
+
// The meta refresh is a no-JS fallback (identical destination), and a
|
|
606
|
+
// manual "Return to the app" link is always available as a tap target.
|
|
607
|
+
function redirectMeta(url, closePopup) {
|
|
608
|
+
const safe = String(url).replace(/["<>]/g, '');
|
|
609
|
+
let js = '';
|
|
610
|
+
if (closePopup) {
|
|
611
|
+
js = '<script>'
|
|
612
|
+
+ 'try{'
|
|
613
|
+
+ 'if(window.opener&&window.opener!==window&&!window.opener.closed){'
|
|
614
|
+
+ 'window.close();'
|
|
615
|
+
+ '}else{'
|
|
616
|
+
+ 'location.replace(' + JSON.stringify(safe) + ');'
|
|
617
|
+
+ '}'
|
|
618
|
+
+ '}catch(e){location.replace(' + JSON.stringify(safe) + ');}'
|
|
619
|
+
+ '</script>';
|
|
620
|
+
}
|
|
621
|
+
return '<meta http-equiv="refresh" content="2; url=' + safe + '">'
|
|
622
|
+
+ js
|
|
623
|
+
+ '<p>Redirecting back to the app… <a href="' + safe + '" style="color:#7bd88f">Return now</a>.</p>';
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function finishOAuth({ provider, state, code, errorParam, format }) {
|
|
627
|
+
// If provider wasn't in the URL query, try to extract it from the
|
|
628
|
+
// state prefix. Some OAuth providers (OpenRouter) don't forward
|
|
629
|
+
// query params from the callback_url, so the ?provider=openrouter
|
|
630
|
+
// param is lost. By encoding the provider as a prefix in the state
|
|
631
|
+
// (e.g. "openrouter:<random>"), we recover it.
|
|
632
|
+
if (!provider && typeof state === 'string' && state.includes(':')) {
|
|
633
|
+
const colonIdx = state.indexOf(':');
|
|
634
|
+
const candidate = state.slice(0, colonIdx);
|
|
635
|
+
if (auth.SUPPORTED_PROVIDERS.includes(candidate)) {
|
|
636
|
+
provider = candidate;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (!provider || !auth.SUPPORTED_PROVIDERS.includes(provider)) {
|
|
640
|
+
return { status: 400, error: 'Unknown or missing provider', code: 'EBADPROVIDER' };
|
|
641
|
+
}
|
|
642
|
+
if (errorParam) {
|
|
643
|
+
return { status: 400, error: errorParam, code: 'EPROVIDER_ERROR' };
|
|
644
|
+
}
|
|
645
|
+
if (!state || !code) {
|
|
646
|
+
return { status: 400, error: 'Missing state or code', code: 'EBADINPUT' };
|
|
647
|
+
}
|
|
648
|
+
const pending = auth.consumePending(provider, state);
|
|
649
|
+
if (!pending) {
|
|
650
|
+
return { status: 400, error: 'No pending sign-in matches this state. Start the sign-in again from the app.', code: 'ENOPENDING' };
|
|
651
|
+
}
|
|
652
|
+
const exchange = auth.getExchange(provider);
|
|
653
|
+
if (!exchange) {
|
|
654
|
+
return { status: 501, error: 'Sign-in for ' + provider + ' is not configured in this build.', code: 'ENOEXCHANGE' };
|
|
655
|
+
}
|
|
656
|
+
let out;
|
|
657
|
+
try {
|
|
658
|
+
out = await exchange({ pending, code });
|
|
659
|
+
} catch (e) {
|
|
660
|
+
return { status: 500, error: e.message || 'exchange threw', code: e.code || 'EEXCHANGE' };
|
|
661
|
+
}
|
|
662
|
+
if (!out || out.error) {
|
|
663
|
+
return { status: 400, error: (out && out.error) || 'exchange returned no token', code: 'EEXCHANGE' };
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
const blob = JSON.stringify({
|
|
667
|
+
accessToken: out.accessToken,
|
|
668
|
+
refreshToken: out.refreshToken || null,
|
|
669
|
+
expiresAt: out.expiresAt || null,
|
|
670
|
+
scope: out.scope || pending.scopes || null
|
|
671
|
+
});
|
|
672
|
+
const account = out.account || pending.accountHint || 'default';
|
|
673
|
+
await auth.setToken(provider, account, blob);
|
|
674
|
+
return { status: 200, account };
|
|
675
|
+
} catch (e) {
|
|
676
|
+
return { status: 500, error: e.message, code: e.code || 'EKEYRING' };
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function xyToText(xy) {
|
|
681
|
+
const map = {
|
|
682
|
+
M: 'Modified', A: 'Added', D: 'Deleted', R: 'Renamed', C: 'Copied',
|
|
683
|
+
U: 'Unmerged', '?': 'Untracked', '!': 'Ignored', T: 'Type changed'
|
|
684
|
+
};
|
|
685
|
+
return map[xy[1] !== ' ' ? xy[1] : xy[0]] || 'Changed';
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// firstStringValue() is shared from src/util.js.
|
|
689
|
+
|
|
690
|
+
module.exports = {
|
|
691
|
+
// constants
|
|
692
|
+
DEFAULT_PORT,
|
|
693
|
+
SESSION_COOKIE,
|
|
694
|
+
ACCESS_COOKIE,
|
|
695
|
+
WEB_DIR,
|
|
696
|
+
WEB_DIST,
|
|
697
|
+
CLIENT_SETTINGS_KEYS,
|
|
698
|
+
RESETTABLE_APP_KEYS,
|
|
699
|
+
MODEL_LIST_TTL_MS,
|
|
700
|
+
MODEL_LIST_TIMEOUT_MS,
|
|
701
|
+
// state
|
|
702
|
+
openSockets,
|
|
703
|
+
sseClients,
|
|
704
|
+
runningChats,
|
|
705
|
+
runningChatCancels,
|
|
706
|
+
accessAttempts,
|
|
707
|
+
liveChat,
|
|
708
|
+
store,
|
|
709
|
+
MODEL_LIST_CACHE,
|
|
710
|
+
modelListCacheKey,
|
|
711
|
+
// domain modules (re-exported so handlers + http-server share one instance)
|
|
712
|
+
settings,
|
|
713
|
+
projects,
|
|
714
|
+
ai,
|
|
715
|
+
auth,
|
|
716
|
+
accessAuth,
|
|
717
|
+
qr,
|
|
718
|
+
oauthAnthropic,
|
|
719
|
+
oauthCopilot,
|
|
720
|
+
oauthOpenRouter,
|
|
721
|
+
chats,
|
|
722
|
+
messages,
|
|
723
|
+
trace,
|
|
724
|
+
inspector,
|
|
725
|
+
inspectorProfiles,
|
|
726
|
+
prompts,
|
|
727
|
+
promptProfiles,
|
|
728
|
+
tags,
|
|
729
|
+
agentFiles,
|
|
730
|
+
agentSkills,
|
|
731
|
+
agentFeatures,
|
|
732
|
+
agents,
|
|
733
|
+
mcp,
|
|
734
|
+
usage,
|
|
735
|
+
push,
|
|
736
|
+
shellTool,
|
|
737
|
+
files,
|
|
738
|
+
// helpers
|
|
739
|
+
trackSocket,
|
|
740
|
+
destroyOpenSockets,
|
|
741
|
+
runningKey,
|
|
742
|
+
sendJSON,
|
|
743
|
+
connectionForClient,
|
|
744
|
+
modelForClient,
|
|
745
|
+
sanitizeClientEntries,
|
|
746
|
+
settingsForClient,
|
|
747
|
+
projectForClient,
|
|
748
|
+
handleSSE,
|
|
749
|
+
broadcast,
|
|
750
|
+
parseCookies,
|
|
751
|
+
requestOrigin,
|
|
752
|
+
normalizePublicOrigin,
|
|
753
|
+
expectedOrigin,
|
|
754
|
+
sessionCookie,
|
|
755
|
+
accessCookie,
|
|
756
|
+
requestHasBrowserOrigin,
|
|
757
|
+
authorizeBrowserRequest,
|
|
758
|
+
authorizeAccessRequest,
|
|
759
|
+
readJsonBody,
|
|
760
|
+
readJsonOr400,
|
|
761
|
+
resolveModel,
|
|
762
|
+
credHashFor,
|
|
763
|
+
credentialForProvider,
|
|
764
|
+
hashShort,
|
|
765
|
+
seedModelListCache,
|
|
766
|
+
accessRequestOrigin,
|
|
767
|
+
accessAttemptKey,
|
|
768
|
+
checkAccessAttempts,
|
|
769
|
+
recordAccessFailure,
|
|
770
|
+
clearAccessFailures,
|
|
771
|
+
publicAccessStatus,
|
|
772
|
+
htmlPage,
|
|
773
|
+
redirectMeta,
|
|
774
|
+
finishOAuth,
|
|
775
|
+
xyToText,
|
|
776
|
+
firstStringValue,
|
|
777
|
+
qs,
|
|
778
|
+
safeDecode,
|
|
779
|
+
errCodeToHttpStatus
|
|
780
|
+
};
|