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
package/src/ai.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// AI client core — facade.
|
|
4
|
+
//
|
|
5
|
+
// This module is the public entry point for the AI client. The actual
|
|
6
|
+
// implementation lives in focused sub-modules so the largest file in the
|
|
7
|
+
// repo stays under ~2 000 lines each:
|
|
8
|
+
//
|
|
9
|
+
// src/ai-endpoints.js — provider endpoints (ENDPOINTS), model-list
|
|
10
|
+
// adapters (listModels / listTranscriptionModels / listImageModels),
|
|
11
|
+
// request builders
|
|
12
|
+
// (BUILDERS), event parsers (PARSERS), and the
|
|
13
|
+
// shared helpers they use.
|
|
14
|
+
// src/ai-stream.js — the multi-turn streaming loop (streamChat,
|
|
15
|
+
// runSingleToolCall, runUpstreamTurn).
|
|
16
|
+
// src/ai-chat.js — non-streaming convenience wrapper (chat).
|
|
17
|
+
//
|
|
18
|
+
// The public surface is unchanged: streamChat, chat, runSingleToolCall,
|
|
19
|
+
// ENDPOINTS, listModels, listTranscriptionModels, listImageModels, plus the test-facing helpers
|
|
20
|
+
// parseSSEFrame / readSSE / readNDJSON / BUILDERS / PARSERS / copilotCacheClear.
|
|
21
|
+
// See the module-level comment below for the request/response contract.
|
|
22
|
+
//
|
|
23
|
+
// Implements docs/decisions.md section 10: server-side proxy with SSE
|
|
24
|
+
// streaming for the configured providers. The mobile UI never holds an
|
|
25
|
+
// API key — it POSTs to /api/ai/chat and reads the SSE stream back.
|
|
26
|
+
//
|
|
27
|
+
// Public surface:
|
|
28
|
+
//
|
|
29
|
+
// streamChat({ model, messages, signal, onEvent }) -> Promise<{ ok, usage, error? }>
|
|
30
|
+
//
|
|
31
|
+
// `model` is the resolved model record (id, provider, baseUrl, apiKey,
|
|
32
|
+
// contextWindow, ...). `messages` is the OpenAI-style array:
|
|
33
|
+
// [{ role: 'system'|'user'|'assistant', content: '...' }, ...]
|
|
34
|
+
// `signal` is an AbortSignal so the HTTP layer can cancel mid-stream.
|
|
35
|
+
// `onEvent(eventName, data)` is called for every SSE event as it
|
|
36
|
+
// arrives. The function returns once the upstream has finished (or
|
|
37
|
+
// failed). Usage is reported in `{ promptTokens, completionTokens }`.
|
|
38
|
+
|
|
39
|
+
const {
|
|
40
|
+
ENDPOINTS,
|
|
41
|
+
listModels,
|
|
42
|
+
listTranscriptionModels,
|
|
43
|
+
listImageModels,
|
|
44
|
+
BUILDERS,
|
|
45
|
+
PARSERS,
|
|
46
|
+
copilotCacheClear
|
|
47
|
+
} = require('./ai-endpoints.js');
|
|
48
|
+
const { streamChat, runSingleToolCall, parseSSEFrame, readSSE, readNDJSON } = require('./ai-stream.js');
|
|
49
|
+
const { chat } = require('./ai-chat.js');
|
|
50
|
+
|
|
51
|
+
module.exports = {
|
|
52
|
+
// public
|
|
53
|
+
streamChat,
|
|
54
|
+
chat,
|
|
55
|
+
runSingleToolCall,
|
|
56
|
+
ENDPOINTS,
|
|
57
|
+
listModels,
|
|
58
|
+
listTranscriptionModels,
|
|
59
|
+
listImageModels,
|
|
60
|
+
// exposed for tests
|
|
61
|
+
parseSSEFrame,
|
|
62
|
+
readSSE,
|
|
63
|
+
readNDJSON,
|
|
64
|
+
BUILDERS,
|
|
65
|
+
PARSERS,
|
|
66
|
+
// exposed for tests + the OAuth module's refresher path
|
|
67
|
+
copilotCacheClear
|
|
68
|
+
};
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Auth — @napi-rs/keyring wrapper + non-secret account index.
|
|
4
|
+
//
|
|
5
|
+
// Implements docs/decisions.md section 11. OAuth tokens live in the OS
|
|
6
|
+
// keychain; the app SQLite store keeps a non-secret index of which
|
|
7
|
+
// provider/account pairs are signed in, so the UI can list "logged in
|
|
8
|
+
// as ..." without ever touching the keychain.
|
|
9
|
+
//
|
|
10
|
+
// In this commit, the keyring is the only auth storage. Provider-specific
|
|
11
|
+
// OAuth flows (authorization endpoints, code exchange, refresh) land in
|
|
12
|
+
// later commits — one per provider. The loopback callback in src/index.js
|
|
13
|
+
// is wired but provider-neutral: it routes by `provider` and stores the
|
|
14
|
+
// token blob returned by the provider's exchange.
|
|
15
|
+
//
|
|
16
|
+
// Public surface:
|
|
17
|
+
//
|
|
18
|
+
// const auth = require('./auth.js');
|
|
19
|
+
//
|
|
20
|
+
// await auth.setToken('openai', 'me@example.com', JSON.stringify(blob));
|
|
21
|
+
// const blob = await auth.getToken('openai', 'me@example.com'); // string | null
|
|
22
|
+
// await auth.deleteToken('openai', 'me@example.com');
|
|
23
|
+
//
|
|
24
|
+
// auth.listAccounts(); // -> { openai: ['me@example.com'], anthropic: [], ... }
|
|
25
|
+
// auth.resolveAccount(model); // -> account string or null, for a model with auth='oauth'
|
|
26
|
+
// auth.tokenForModel(model); // -> blob string or null, the credential the AI client needs
|
|
27
|
+
//
|
|
28
|
+
// auth.recordPending(provider, { state, codeVerifier?, redirectUri, scopes, accountHint? });
|
|
29
|
+
// auth.consumePending(provider, state); // -> the pending record or null
|
|
30
|
+
// auth.clearPending(provider, state);
|
|
31
|
+
//
|
|
32
|
+
// Errors are typed: EKEYRING (OS keychain unavailable), ENOENT (no such
|
|
33
|
+
// account), EBADINPUT (validation), EPROVIDER (unknown provider).
|
|
34
|
+
|
|
35
|
+
const { Entry } = require('@napi-rs/keyring');
|
|
36
|
+
const settings = require('./settings.js');
|
|
37
|
+
|
|
38
|
+
// 'mouaif' is the keyring service prefix; per-provider accounts are
|
|
39
|
+
// namespaced under 'mouaif/<provider>'. Linux/Windows Credential Manager
|
|
40
|
+
// will show entries with the full name; we do not strip the prefix.
|
|
41
|
+
const SERVICE_PREFIX = 'mouaif';
|
|
42
|
+
|
|
43
|
+
const SUPPORTED_PROVIDERS = ['openai', 'anthropic', 'google', 'github-copilot', 'openrouter'];
|
|
44
|
+
|
|
45
|
+
function serviceName(provider) {
|
|
46
|
+
if (!SUPPORTED_PROVIDERS.includes(provider)) {
|
|
47
|
+
const e = new Error('Unknown provider: ' + provider);
|
|
48
|
+
e.code = 'EPROVIDER';
|
|
49
|
+
throw e;
|
|
50
|
+
}
|
|
51
|
+
return SERVICE_PREFIX + '/' + provider;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validateAccount(provider, account) {
|
|
55
|
+
if (!SUPPORTED_PROVIDERS.includes(provider)) {
|
|
56
|
+
const e = new Error('Unknown provider: ' + provider);
|
|
57
|
+
e.code = 'EPROVIDER';
|
|
58
|
+
throw e;
|
|
59
|
+
}
|
|
60
|
+
if (!account || typeof account !== 'string' || !account.length) {
|
|
61
|
+
const e = new Error('Account is required');
|
|
62
|
+
e.code = 'EBADINPUT';
|
|
63
|
+
throw e;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- Token CRUD --------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
async function setToken(provider, account, blob) {
|
|
70
|
+
validateAccount(provider, account);
|
|
71
|
+
if (typeof blob !== 'string') {
|
|
72
|
+
const e = new Error('Token blob must be a string');
|
|
73
|
+
e.code = 'EBADINPUT';
|
|
74
|
+
throw e;
|
|
75
|
+
}
|
|
76
|
+
let entry;
|
|
77
|
+
try {
|
|
78
|
+
entry = new Entry(serviceName(provider), account);
|
|
79
|
+
entry.setPassword(blob);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
const wrapped = new Error('Keyring set failed: ' + (e && e.message || e));
|
|
82
|
+
wrapped.code = 'EKEYRING';
|
|
83
|
+
wrapped.cause = e;
|
|
84
|
+
throw wrapped;
|
|
85
|
+
}
|
|
86
|
+
// Update the non-secret index only after the keychain write succeeded.
|
|
87
|
+
indexAdd(provider, account);
|
|
88
|
+
return { provider, account };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getToken(provider, account) {
|
|
92
|
+
validateAccount(provider, account);
|
|
93
|
+
let entry;
|
|
94
|
+
try {
|
|
95
|
+
entry = new Entry(serviceName(provider), account);
|
|
96
|
+
return entry.getPassword();
|
|
97
|
+
} catch (e) {
|
|
98
|
+
// @napi-rs/keyring throws when the entry does not exist. That's our
|
|
99
|
+
// "no such account" signal; map it to null so callers don't have to
|
|
100
|
+
// distinguish "absent" from "wrong password".
|
|
101
|
+
const msg = (e && e.message) || String(e);
|
|
102
|
+
if (/no matching entry|not found|No such file/i.test(msg)) return null;
|
|
103
|
+
const wrapped = new Error('Keyring get failed: ' + msg);
|
|
104
|
+
wrapped.code = 'EKEYRING';
|
|
105
|
+
wrapped.cause = e;
|
|
106
|
+
throw wrapped;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function deleteToken(provider, account) {
|
|
111
|
+
validateAccount(provider, account);
|
|
112
|
+
try {
|
|
113
|
+
const entry = new Entry(serviceName(provider), account);
|
|
114
|
+
entry.deletePassword();
|
|
115
|
+
indexRemove(provider, account);
|
|
116
|
+
return { provider, account, deleted: true };
|
|
117
|
+
} catch (e) {
|
|
118
|
+
// deletePassword throws if the entry does not exist; we treat that
|
|
119
|
+
// as a successful no-op (idempotent delete).
|
|
120
|
+
const msg = (e && e.message) || String(e);
|
|
121
|
+
if (/no matching entry|not found|No such file/i.test(msg)) {
|
|
122
|
+
indexRemove(provider, account);
|
|
123
|
+
return { provider, account, deleted: false };
|
|
124
|
+
}
|
|
125
|
+
const wrapped = new Error('Keyring delete failed: ' + msg);
|
|
126
|
+
wrapped.code = 'EKEYRING';
|
|
127
|
+
wrapped.cause = e;
|
|
128
|
+
throw wrapped;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- Non-secret account index ------------------------------------------
|
|
133
|
+
// The keychain is the source of truth for credentials; this index is a
|
|
134
|
+
// hint so the UI can list "signed in accounts" without round-tripping
|
|
135
|
+
// the keychain. Out of sync is recoverable: getToken/deleteToken still
|
|
136
|
+
// work, and the index can be rebuilt by walking the keychain (later
|
|
137
|
+
// commit, when @napi-rs/keyring exposes a find-by-service API).
|
|
138
|
+
|
|
139
|
+
function readIndex() {
|
|
140
|
+
const app = settings.getApp();
|
|
141
|
+
const raw = app.authAccounts;
|
|
142
|
+
const out = {};
|
|
143
|
+
for (const p of SUPPORTED_PROVIDERS) {
|
|
144
|
+
out[p] = Array.isArray(raw && raw[p]) ? raw[p].slice() : [];
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function writeIndex(idx) {
|
|
150
|
+
settings.setApp({ authAccounts: idx });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function indexAdd(provider, account) {
|
|
154
|
+
const idx = readIndex();
|
|
155
|
+
if (!idx[provider].includes(account)) idx[provider].push(account);
|
|
156
|
+
idx[provider].sort();
|
|
157
|
+
writeIndex(idx);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function indexRemove(provider, account) {
|
|
161
|
+
const idx = readIndex();
|
|
162
|
+
idx[provider] = idx[provider].filter(a => a !== account);
|
|
163
|
+
writeIndex(idx);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function listAccounts() {
|
|
167
|
+
return readIndex();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- Model integration -------------------------------------------------
|
|
171
|
+
|
|
172
|
+
// Map an AI client provider (the value stored on the model record's
|
|
173
|
+
// `provider` field) to the auth provider that the keychain is keyed
|
|
174
|
+
// under. The model record keeps the AI client provider verbatim per
|
|
175
|
+
// decision §10; the mapping lives here, in the auth module, because
|
|
176
|
+
// that is the subsystem that owns the keyring namespace.
|
|
177
|
+
//
|
|
178
|
+
// Today's pairs are 1:1 except for the OpenAI-compatible family, which
|
|
179
|
+
// uses the openai keyring namespace so any signed-in OpenAI account
|
|
180
|
+
// can serve an openai-compatible model. New AI clients that should
|
|
181
|
+
// re-use an existing auth flow register here.
|
|
182
|
+
const AI_TO_AUTH_PROVIDER = Object.freeze({
|
|
183
|
+
'openai-compatible': 'openai',
|
|
184
|
+
'anthropic': 'anthropic',
|
|
185
|
+
'gemini': 'google',
|
|
186
|
+
'ollama': 'ollama', // no keychain; rejected upstream as a non-OAuth model
|
|
187
|
+
'github-copilot': 'github-copilot',
|
|
188
|
+
// Azure, Mistral, Groq, DeepSeek and the other OpenAI-shaped
|
|
189
|
+
// providers take API keys only; they map to their own keyring
|
|
190
|
+
// namespaces so a key stored under one provider is never treated
|
|
191
|
+
// as a credential for another (the settings UI never offers an
|
|
192
|
+
// OAuth option for them anyway).
|
|
193
|
+
'azure': 'azure',
|
|
194
|
+
'mistral': 'mistral',
|
|
195
|
+
'groq': 'groq',
|
|
196
|
+
'deepseek': 'deepseek',
|
|
197
|
+
// OpenRouter has its own keyring namespace ('openrouter'). The
|
|
198
|
+
// PKCE sign-in flow (src/oauth-openrouter.js) stores the issued
|
|
199
|
+
// API key under that namespace; the apikey path uses
|
|
200
|
+
// model.apiKey directly and does not touch the keychain. Two
|
|
201
|
+
// signed-in OpenRouter accounts therefore live as separate rows
|
|
202
|
+
// in the OAuth account picker; an OpenAI key in the 'openai'
|
|
203
|
+
// namespace is NOT a valid OpenRouter credential — the two
|
|
204
|
+
// services have different billing.
|
|
205
|
+
'openrouter': 'openrouter'
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
function authProviderFor(model) {
|
|
209
|
+
if (!model || !model.provider) return null;
|
|
210
|
+
if (Object.prototype.hasOwnProperty.call(AI_TO_AUTH_PROVIDER, model.provider)) {
|
|
211
|
+
return AI_TO_AUTH_PROVIDER[model.provider];
|
|
212
|
+
}
|
|
213
|
+
// Unknown AI provider: best-effort fall through. The AI client will
|
|
214
|
+
// surface this as EUNKNOWN_PROVIDER before we ever read a token.
|
|
215
|
+
return model.provider;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function resolveAccount(model) {
|
|
219
|
+
if (!model || model.auth !== 'oauth') return null;
|
|
220
|
+
if (model.oauthAccount) return model.oauthAccount;
|
|
221
|
+
// Fallback: if the model has no explicit account but the user has a
|
|
222
|
+
// single account on the auth provider, use it. Multi-account users
|
|
223
|
+
// must set oauthAccount explicitly.
|
|
224
|
+
const idx = readIndex();
|
|
225
|
+
const authProv = authProviderFor(model);
|
|
226
|
+
const list = idx[authProv] || [];
|
|
227
|
+
if (list.length === 1) return list[0];
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function tokenForModel(model) {
|
|
232
|
+
const account = resolveAccount(model);
|
|
233
|
+
if (!account) return null;
|
|
234
|
+
const authProv = authProviderFor(model);
|
|
235
|
+
if (!authProv) return null;
|
|
236
|
+
return getToken(authProv, account);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---- Pending OAuth state ----------------------------------------------
|
|
240
|
+
// Used by the loopback callback to remember which (state, code_verifier,
|
|
241
|
+
// redirect_uri, scopes) tuple the client started, so the callback can
|
|
242
|
+
// finish the exchange. The state is opaque, generated by the client and
|
|
243
|
+
// echoed by the provider. Persisted in the app store (not the keychain —
|
|
244
|
+
// these are not secrets per se, but the code_verifier is a PKCE secret
|
|
245
|
+
// and we treat it as one).
|
|
246
|
+
//
|
|
247
|
+
// In-memory would be enough for a single-process server, but the
|
|
248
|
+
// callback is a separate HTTP request and the rest of the auth commit's
|
|
249
|
+
// code can land across processes (CLI + server). SQLite is the right
|
|
250
|
+
// home; we use the same app_kv table with a 'auth_pending' key.
|
|
251
|
+
|
|
252
|
+
function readPending() {
|
|
253
|
+
const app = settings.getApp();
|
|
254
|
+
return Array.isArray(app.authPending) ? app.authPending : [];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function writePending(list) {
|
|
258
|
+
settings.setApp({ authPending: list });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function recordPending(provider, rec) {
|
|
262
|
+
if (!SUPPORTED_PROVIDERS.includes(provider)) {
|
|
263
|
+
const e = new Error('Unknown provider: ' + provider);
|
|
264
|
+
e.code = 'EPROVIDER';
|
|
265
|
+
throw e;
|
|
266
|
+
}
|
|
267
|
+
if (!rec || !rec.state) {
|
|
268
|
+
const e = new Error('state is required');
|
|
269
|
+
e.code = 'EBADINPUT';
|
|
270
|
+
throw e;
|
|
271
|
+
}
|
|
272
|
+
const list = readPending().filter(p => !(p.provider === provider && p.state === rec.state));
|
|
273
|
+
list.push(Object.assign({ provider, createdAt: new Date().toISOString() }, rec));
|
|
274
|
+
writePending(list);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function consumePending(provider, state) {
|
|
278
|
+
const list = readPending();
|
|
279
|
+
const idx = list.findIndex(p => p.provider === provider && p.state === state);
|
|
280
|
+
if (idx === -1) return null;
|
|
281
|
+
const [rec] = list.splice(idx, 1);
|
|
282
|
+
writePending(list);
|
|
283
|
+
return rec;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function clearPending(provider, state) {
|
|
287
|
+
writePending(readPending().filter(p => !(p.provider === provider && p.state === state)));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// A pending record is only removed on success (consumePending) or explicit
|
|
291
|
+
// cancel (clearPending). If the user closes the OAuth tab, the record leaks
|
|
292
|
+
// forever and the app store grows unbounded. Drop anything older than
|
|
293
|
+
// maxAgeMs (default 1h — far longer than any real OAuth round-trip). Returns
|
|
294
|
+
// the number of records pruned. Records with a missing/unparseable createdAt
|
|
295
|
+
// are treated as stale and pruned.
|
|
296
|
+
const PENDING_MAX_AGE_MS = 60 * 60 * 1000;
|
|
297
|
+
|
|
298
|
+
function prunePending(maxAgeMs = PENDING_MAX_AGE_MS) {
|
|
299
|
+
const now = Date.now();
|
|
300
|
+
const list = readPending();
|
|
301
|
+
const kept = list.filter((p) => {
|
|
302
|
+
const t = p && p.createdAt ? Date.parse(p.createdAt) : NaN;
|
|
303
|
+
return Number.isFinite(t) && (now - t) < maxAgeMs;
|
|
304
|
+
});
|
|
305
|
+
if (kept.length !== list.length) writePending(kept);
|
|
306
|
+
return list.length - kept.length;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = {
|
|
310
|
+
// introspection
|
|
311
|
+
SUPPORTED_PROVIDERS,
|
|
312
|
+
SERVICE_PREFIX,
|
|
313
|
+
// token CRUD
|
|
314
|
+
setToken,
|
|
315
|
+
getToken,
|
|
316
|
+
deleteToken,
|
|
317
|
+
// index
|
|
318
|
+
listAccounts,
|
|
319
|
+
// model helpers (used by src/ai.js)
|
|
320
|
+
resolveAccount,
|
|
321
|
+
tokenForModel,
|
|
322
|
+
// pending OAuth state
|
|
323
|
+
recordPending,
|
|
324
|
+
consumePending,
|
|
325
|
+
clearPending,
|
|
326
|
+
prunePending,
|
|
327
|
+
// provider exchange + refresher registration (used by per-provider OAuth commits)
|
|
328
|
+
registerExchange,
|
|
329
|
+
getExchange,
|
|
330
|
+
registerRefresher,
|
|
331
|
+
getRefresher,
|
|
332
|
+
// model integration
|
|
333
|
+
authProviderFor,
|
|
334
|
+
AI_TO_AUTH_PROVIDER
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// ---- Provider exchange registration -----------------------------------
|
|
338
|
+
// Each per-provider OAuth commit calls registerExchange('openai', async fn)
|
|
339
|
+
// where `fn` takes the pending record + the authorization code and
|
|
340
|
+
// returns { accessToken, refreshToken?, expiresAt?, account } or
|
|
341
|
+
// { error: '...' }. The callback in src/index.js calls getExchange(provider)
|
|
342
|
+
// to find the right fn. If none is registered, the callback returns 501
|
|
343
|
+
// with a clear message — that is the expected state in this commit.
|
|
344
|
+
|
|
345
|
+
const _exchanges = Object.create(null);
|
|
346
|
+
const _refreshers = Object.create(null);
|
|
347
|
+
|
|
348
|
+
function registerExchange(provider, fn) {
|
|
349
|
+
if (!SUPPORTED_PROVIDERS.includes(provider)) {
|
|
350
|
+
const e = new Error('Unknown provider: ' + provider);
|
|
351
|
+
e.code = 'EPROVIDER';
|
|
352
|
+
throw e;
|
|
353
|
+
}
|
|
354
|
+
if (typeof fn !== 'function') {
|
|
355
|
+
const e = new Error('Exchange must be a function');
|
|
356
|
+
e.code = 'EBADINPUT';
|
|
357
|
+
throw e;
|
|
358
|
+
}
|
|
359
|
+
_exchanges[provider] = fn;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function getExchange(provider) {
|
|
363
|
+
return _exchanges[provider] || null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// registerRefresher / getRefresher — same shape as the exchange
|
|
367
|
+
// registry, but for proactive refresh. A refresher takes
|
|
368
|
+
// ({ provider, account, refreshToken, scope, baseUrl }) and returns
|
|
369
|
+
// the same shape as a fresh login:
|
|
370
|
+
// { accessToken, refreshToken?, expiresAt?, scope?, account? }
|
|
371
|
+
// The AI client (src/ai.js) calls this just before an OAuth request
|
|
372
|
+
// if the stored access_token expires within the next minute, so a
|
|
373
|
+
// long chat doesn't hit a 401 mid-stream. Persisting the new blob
|
|
374
|
+
// is the refresh path's responsibility; see oauth-anthropic.js.
|
|
375
|
+
function registerRefresher(provider, fn) {
|
|
376
|
+
if (!SUPPORTED_PROVIDERS.includes(provider)) {
|
|
377
|
+
const e = new Error('Unknown provider: ' + provider);
|
|
378
|
+
e.code = 'EPROVIDER';
|
|
379
|
+
throw e;
|
|
380
|
+
}
|
|
381
|
+
if (typeof fn !== 'function') {
|
|
382
|
+
const e = new Error('Refresher must be a function');
|
|
383
|
+
e.code = 'EBADINPUT';
|
|
384
|
+
throw e;
|
|
385
|
+
}
|
|
386
|
+
_refreshers[provider] = fn;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function getRefresher(provider) {
|
|
390
|
+
return _refreshers[provider] || null;
|
|
391
|
+
}
|