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,280 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Anthropic OAuth — the per-provider flow registered with src/auth.js.
|
|
4
|
+
//
|
|
5
|
+
// Implements docs/decisions.md section 12. Shape mirrors the public
|
|
6
|
+
// `ant` CLI's `auth login` (https://github.com/anthropics/anthropic-cli
|
|
7
|
+
// pkg/cmd/cmd_auth.go) and the platform.claude.com OAuth docs:
|
|
8
|
+
// - Public client_id (no secret) from the official CLI source.
|
|
9
|
+
// - PKCE S256, 64-byte verifier, base64url. `code_challenge_method=S256`.
|
|
10
|
+
// - State: 32 random bytes, base64url. Validated server-side on the
|
|
11
|
+
// authorize redirect AND on the token exchange (the Python oauth
|
|
12
|
+
// server requires `state` on the token exchange as a bound CSRF
|
|
13
|
+
// check across both legs).
|
|
14
|
+
// - Authorize: GET https://platform.claude.com/oauth/authorize?...
|
|
15
|
+
// - Token: POST https://api.anthropic.com/v1/oauth/token
|
|
16
|
+
// - authorization_code grant: application/x-www-form-urlencoded
|
|
17
|
+
// - refresh_token grant: application/json
|
|
18
|
+
// - Header on user_oauth: anthropic-beta: oauth-2025-04-20
|
|
19
|
+
// - Default scope: "user:profile user:inference user:developer".
|
|
20
|
+
//
|
|
21
|
+
// Public surface:
|
|
22
|
+
//
|
|
23
|
+
// register() — call once at server startup; hooks the exchange function
|
|
24
|
+
// into auth.registerExchange('anthropic', fn). Idempotent.
|
|
25
|
+
|
|
26
|
+
const crypto = require('crypto');
|
|
27
|
+
const settings = require('./settings.js');
|
|
28
|
+
const auth = require('./auth.js');
|
|
29
|
+
|
|
30
|
+
// Public OAuth client_id used by the official `ant` CLI. Sourced from
|
|
31
|
+
// pkg/cmd/cmd_auth.go: const oauthClientIDProd. Public per the source
|
|
32
|
+
// comment; safe to embed in a third-party CLI.
|
|
33
|
+
const CLIENT_ID = '41077d10-94b8-4194-be48-d251e9eb21b4';
|
|
34
|
+
|
|
35
|
+
const DEFAULT_CONSOLE_URL = 'https://platform.claude.com';
|
|
36
|
+
// Test-only override. Production is locked to https://api.anthropic.com.
|
|
37
|
+
// Set MOUAIF_ANTHROPIC_API_BASE to redirect at runtime (e.g. in tests).
|
|
38
|
+
const DEFAULT_API_BASE = process.env.MOUAIF_ANTHROPIC_API_BASE || 'https://api.anthropic.com';
|
|
39
|
+
const DEFAULT_SCOPE = 'user:profile user:inference user:developer';
|
|
40
|
+
const BETA_HEADER = 'oauth-2025-04-20';
|
|
41
|
+
const GRANT_AUTHORIZATION_CODE = 'authorization_code';
|
|
42
|
+
const GRANT_REFRESH_TOKEN = 'refresh_token';
|
|
43
|
+
|
|
44
|
+
function randomUrlSafe(n) {
|
|
45
|
+
return crypto.randomBytes(n).toString('base64url');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pkceChallengeS256(verifier) {
|
|
49
|
+
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Public — these are stable helpers used by the mobile UI and tests.
|
|
53
|
+
function newState() { return randomUrlSafe(32); }
|
|
54
|
+
function newVerifier() { return randomUrlSafe(64); }
|
|
55
|
+
function challengeFor(verifier) { return pkceChallengeS256(verifier); }
|
|
56
|
+
|
|
57
|
+
// Build the /oauth/authorize URL the user's browser should open. Pure:
|
|
58
|
+
// takes a redirectUri and the parameters from auth.recordPending and
|
|
59
|
+
// returns a fully-formed URL string. The mobile UI uses this; the
|
|
60
|
+
// server's handleOAuthCallback is the one that does the *exchange*.
|
|
61
|
+
//
|
|
62
|
+
// `pending` is the record returned by auth.recordPending (or just the
|
|
63
|
+
// fields we need: redirectUri, scopes, accountHint). `state` is opaque
|
|
64
|
+
// and must match the `state` recorded by recordPending.
|
|
65
|
+
|
|
66
|
+
function buildAuthorizeUrl(opts) {
|
|
67
|
+
const {
|
|
68
|
+
consoleUrl = DEFAULT_CONSOLE_URL,
|
|
69
|
+
clientId = CLIENT_ID,
|
|
70
|
+
redirectUri,
|
|
71
|
+
state,
|
|
72
|
+
verifier,
|
|
73
|
+
scope = DEFAULT_SCOPE,
|
|
74
|
+
workspaceId
|
|
75
|
+
} = opts || {};
|
|
76
|
+
|
|
77
|
+
if (!redirectUri) throw new Error('redirectUri is required');
|
|
78
|
+
if (!state) throw new Error('state is required');
|
|
79
|
+
if (!verifier) throw new Error('verifier is required');
|
|
80
|
+
|
|
81
|
+
const u = new URL('/oauth/authorize', consoleUrl.replace(/\/+$/, '') + '/');
|
|
82
|
+
u.searchParams.set('client_id', clientId);
|
|
83
|
+
u.searchParams.set('redirect_uri', redirectUri);
|
|
84
|
+
u.searchParams.set('response_type', 'code');
|
|
85
|
+
u.searchParams.set('scope', scope);
|
|
86
|
+
u.searchParams.set('state', state);
|
|
87
|
+
u.searchParams.set('code_challenge', pkceChallengeS256(verifier));
|
|
88
|
+
u.searchParams.set('code_challenge_method', 'S256');
|
|
89
|
+
if (workspaceId) u.searchParams.set('workspace_id', workspaceId);
|
|
90
|
+
return u.toString();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// POST the authorization_code grant. application/x-www-form-urlencoded,
|
|
94
|
+
// no anthropic-beta header — the Python oauth_server routes that form
|
|
95
|
+
// to the AuthCodeTokenRequest handler.
|
|
96
|
+
|
|
97
|
+
async function exchangeAuthorizationCode(opts) {
|
|
98
|
+
const {
|
|
99
|
+
baseUrl = DEFAULT_API_BASE,
|
|
100
|
+
clientId = CLIENT_ID,
|
|
101
|
+
code,
|
|
102
|
+
verifier,
|
|
103
|
+
redirectUri,
|
|
104
|
+
state,
|
|
105
|
+
fetchImpl = globalThis.fetch
|
|
106
|
+
} = opts || {};
|
|
107
|
+
|
|
108
|
+
if (!code) throw new Error('code is required');
|
|
109
|
+
if (!verifier) throw new Error('verifier is required');
|
|
110
|
+
if (!redirectUri) throw new Error('redirectUri is required');
|
|
111
|
+
if (!state) throw new Error('state is required');
|
|
112
|
+
if (typeof fetchImpl !== 'function') throw new Error('global fetch is not available');
|
|
113
|
+
|
|
114
|
+
const body = new URLSearchParams({
|
|
115
|
+
grant_type: GRANT_AUTHORIZATION_CODE,
|
|
116
|
+
code,
|
|
117
|
+
code_verifier: verifier,
|
|
118
|
+
client_id: clientId,
|
|
119
|
+
redirect_uri: redirectUri,
|
|
120
|
+
state
|
|
121
|
+
});
|
|
122
|
+
const res = await fetchImpl(baseUrl.replace(/\/+$/, '') + '/v1/oauth/token', {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
125
|
+
body: body.toString()
|
|
126
|
+
});
|
|
127
|
+
return readTokenResponse(res, 'authorization_code exchange');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// POST the refresh_token grant. application/json WITH anthropic-beta
|
|
131
|
+
// header — the Python oauth_server requires the beta on the
|
|
132
|
+
// refresh_token grant for user_oauth credentials.
|
|
133
|
+
async function exchangeRefreshToken(opts) {
|
|
134
|
+
const {
|
|
135
|
+
baseUrl = DEFAULT_API_BASE,
|
|
136
|
+
clientId = CLIENT_ID,
|
|
137
|
+
refreshToken,
|
|
138
|
+
fetchImpl = globalThis.fetch
|
|
139
|
+
} = opts || {};
|
|
140
|
+
|
|
141
|
+
if (!refreshToken) throw new Error('refreshToken is required');
|
|
142
|
+
if (typeof fetchImpl !== 'function') throw new Error('global fetch is not available');
|
|
143
|
+
|
|
144
|
+
const res = await fetchImpl(baseUrl.replace(/\/+$/, '') + '/v1/oauth/token', {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: {
|
|
147
|
+
'Content-Type': 'application/json',
|
|
148
|
+
'anthropic-beta': BETA_HEADER
|
|
149
|
+
},
|
|
150
|
+
body: JSON.stringify({ grant_type: GRANT_REFRESH_TOKEN, refresh_token: refreshToken, client_id: clientId })
|
|
151
|
+
});
|
|
152
|
+
return readTokenResponse(res, 'refresh_token exchange');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function readTokenResponse(res, label) {
|
|
156
|
+
const text = await res.text().catch(() => '');
|
|
157
|
+
if (!res.ok) {
|
|
158
|
+
const err = new Error(`Anthropic ${label} failed: HTTP ${res.status} ${res.statusText}`);
|
|
159
|
+
err.code = 'EUPSTREAM';
|
|
160
|
+
err.status = res.status;
|
|
161
|
+
err.body = text.slice(0, 2000);
|
|
162
|
+
throw err;
|
|
163
|
+
}
|
|
164
|
+
let json;
|
|
165
|
+
try { json = JSON.parse(text); } catch (e) {
|
|
166
|
+
const err = new Error(`Anthropic ${label} returned non-JSON: ${e.message}`);
|
|
167
|
+
err.code = 'EPARSE';
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
if (!json.access_token) {
|
|
171
|
+
const err = new Error(`Anthropic ${label} returned 200 with no access_token`);
|
|
172
|
+
err.code = 'ETOKEN';
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
accessToken: json.access_token,
|
|
177
|
+
refreshToken: json.refresh_token || null,
|
|
178
|
+
expiresIn: typeof json.expires_in === 'number' ? json.expires_in : null,
|
|
179
|
+
scope: json.scope || null,
|
|
180
|
+
organization: json.organization || null,
|
|
181
|
+
account: json.account || null,
|
|
182
|
+
workspace: json.workspace || null
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The exchange function registered with auth.registerExchange.
|
|
187
|
+
// Signature: ({ pending, code, state }) -> { accessToken, refreshToken, ... }
|
|
188
|
+
// `pending.codeVerifier` is read from the pending record (stored by
|
|
189
|
+
// auth.recordPending) so the client never round-trips the verifier
|
|
190
|
+
// through the loopback callback.
|
|
191
|
+
async function exchange({ pending, code }) {
|
|
192
|
+
if (!pending || !pending.codeVerifier) {
|
|
193
|
+
const e = new Error('Anthropic pending record is missing codeVerifier — start a new sign-in');
|
|
194
|
+
e.code = 'EBADINPUT';
|
|
195
|
+
throw e;
|
|
196
|
+
}
|
|
197
|
+
const out = await exchangeAuthorizationCode({
|
|
198
|
+
code,
|
|
199
|
+
verifier: pending.codeVerifier,
|
|
200
|
+
redirectUri: pending.redirectUri,
|
|
201
|
+
state: pending.state
|
|
202
|
+
});
|
|
203
|
+
return {
|
|
204
|
+
accessToken: out.accessToken,
|
|
205
|
+
refreshToken: out.refreshToken,
|
|
206
|
+
expiresAt: out.expiresIn ? Date.now() + out.expiresIn * 1000 : null,
|
|
207
|
+
scope: out.scope,
|
|
208
|
+
account: (out.account && (out.account.email_address || out.account.emailAddress)) || pending.accountHint || 'default'
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let _registered = false;
|
|
213
|
+
function register() {
|
|
214
|
+
if (_registered) return;
|
|
215
|
+
// Guard against re-registration in test harnesses; auth.registerExchange
|
|
216
|
+
// itself overwrites, so this is just a guard for *our* bookkeeping.
|
|
217
|
+
if (!auth.getExchange('anthropic')) auth.registerExchange('anthropic', exchange);
|
|
218
|
+
if (!auth.getRefresher('anthropic')) auth.registerRefresher('anthropic', refresh);
|
|
219
|
+
_registered = true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Proactive refresher registered with auth.registerRefresher. Called
|
|
223
|
+
// by src/ai.js when a stored access token is within 60s of expiry.
|
|
224
|
+
// Posts the refresh_token grant per spec (JSON body, with the
|
|
225
|
+
// anthropic-beta header — see exchangeRefreshToken above for the
|
|
226
|
+
// rationale). Returns the same shape as a fresh login so the AI
|
|
227
|
+
// client can drop it straight into the keychain blob:
|
|
228
|
+
//
|
|
229
|
+
// { accessToken, refreshToken?, expiresAt?, scope?, account? }
|
|
230
|
+
//
|
|
231
|
+
// `account` is forwarded as-is from the caller; `expiresAt` is
|
|
232
|
+
// computed from `expires_in`. The token exchange must hit the same
|
|
233
|
+
// deployment as the Console that issued the code (per `ant auth
|
|
234
|
+
// status` / cmd_auth.go), so we prefer the model's `baseUrl` (lets
|
|
235
|
+
// test mocks and Anthropic-compatible proxies route the refresh) and
|
|
236
|
+
// fall back to the production default.
|
|
237
|
+
async function refresh({ provider, account, refreshToken, scope, baseUrl }) {
|
|
238
|
+
if (provider !== 'anthropic') {
|
|
239
|
+
const e = new Error('Anthropic refresher called for provider "' + provider + '"');
|
|
240
|
+
e.code = 'EBADINPUT';
|
|
241
|
+
throw e;
|
|
242
|
+
}
|
|
243
|
+
if (!refreshToken) {
|
|
244
|
+
const e = new Error('refreshToken is required');
|
|
245
|
+
e.code = 'EBADINPUT';
|
|
246
|
+
throw e;
|
|
247
|
+
}
|
|
248
|
+
const out = await exchangeRefreshToken({
|
|
249
|
+
baseUrl: baseUrl || DEFAULT_API_BASE,
|
|
250
|
+
refreshToken
|
|
251
|
+
});
|
|
252
|
+
return {
|
|
253
|
+
accessToken: out.accessToken,
|
|
254
|
+
refreshToken: out.refreshToken || refreshToken,
|
|
255
|
+
expiresAt: typeof out.expiresIn === 'number' ? Date.now() + out.expiresIn * 1000 : null,
|
|
256
|
+
scope: out.scope || scope || null,
|
|
257
|
+
account: (out.account && (out.account.email_address || out.account.emailAddress)) || account || null
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
module.exports = {
|
|
262
|
+
// introspection
|
|
263
|
+
CLIENT_ID,
|
|
264
|
+
DEFAULT_CONSOLE_URL,
|
|
265
|
+
DEFAULT_API_BASE,
|
|
266
|
+
DEFAULT_SCOPE,
|
|
267
|
+
BETA_HEADER,
|
|
268
|
+
// helpers
|
|
269
|
+
newState,
|
|
270
|
+
newVerifier,
|
|
271
|
+
challengeFor,
|
|
272
|
+
buildAuthorizeUrl,
|
|
273
|
+
exchangeAuthorizationCode,
|
|
274
|
+
exchangeRefreshToken,
|
|
275
|
+
// registration
|
|
276
|
+
register,
|
|
277
|
+
exchange,
|
|
278
|
+
refresh
|
|
279
|
+
};
|
|
280
|
+
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// GitHub Copilot OAuth + per-request Copilot token exchange.
|
|
4
|
+
//
|
|
5
|
+
// Implements docs/decisions.md section 12 (one provider per commit).
|
|
6
|
+
// This module is the per-provider flow for `github-copilot`; the
|
|
7
|
+
// generic auth skeleton (keyring, non-secret index, loopback
|
|
8
|
+
// callback) lives in src/auth.js. The `github-copilot` namespace is
|
|
9
|
+
// shared between the auth subsystem and the AI client (`AI_TO_AUTH_PROVIDER`
|
|
10
|
+
// in src/auth.js).
|
|
11
|
+
//
|
|
12
|
+
// The flow has two stages:
|
|
13
|
+
//
|
|
14
|
+
// 1. GitHub OAuth web flow (with PKCE). The user signs in at
|
|
15
|
+
// https://github.com/login/oauth/authorize?client_id=...&redirect_uri=...
|
|
16
|
+
// GitHub redirects back to our loopback callback with a one-time
|
|
17
|
+
// code; the server exchanges the code for a long-lived GitHub
|
|
18
|
+
// OAuth access token (no expiry unless revoked).
|
|
19
|
+
// This is the standard GitHub OAuth shape — same pattern as the
|
|
20
|
+
// Anthropic flow in src/oauth-anthropic.js.
|
|
21
|
+
//
|
|
22
|
+
// 2. Per-request Copilot token exchange. A GitHub OAuth token does
|
|
23
|
+
// not authenticate against api.githubcopilot.com. Every chat
|
|
24
|
+
// exchanges the GitHub token for a short-lived Copilot API
|
|
25
|
+
// token at https://api.github.com/copilot_internal/v2/token
|
|
26
|
+
// (Authorization: Bearer <github_token>). The response carries
|
|
27
|
+
// a `token` field and an `expires_at` timestamp; the AI client
|
|
28
|
+
// uses that token for the actual chat-completions call. The
|
|
29
|
+
// exchange is repeated proactively (decision §13) when the
|
|
30
|
+
// stored Copilot token is within OAUTH_REFRESH_LEAD_MS of
|
|
31
|
+
// expiry. The user never sees the short-lived token; it is
|
|
32
|
+
// derived at request time and lives only on the request.
|
|
33
|
+
//
|
|
34
|
+
// Storage: the keychain holds the GitHub OAuth token plus its
|
|
35
|
+
// `expiresAt`, `scope`, and the GitHub account login. The Copilot
|
|
36
|
+
// token is NOT stored — it is always re-derived on demand. This
|
|
37
|
+
// matches the public Copilot CLI's behavior and means a revocation
|
|
38
|
+
// is observed within minutes rather than at the next sign-in.
|
|
39
|
+
//
|
|
40
|
+
// Client ID: GitHub does not enable device flow for third-party
|
|
41
|
+
// OAuth apps, and web flow requires a registered redirect URI. The
|
|
42
|
+
// public github/gh-copilot client_id does not work for our loopback
|
|
43
|
+
// because the callback URL would need to be registered. We therefore
|
|
44
|
+
// support a per-install client_id configured at
|
|
45
|
+
// github.com/settings/developers. The user creates an OAuth app
|
|
46
|
+
// (one-time, ~1 minute), copies the client_id, and pastes it into
|
|
47
|
+
// Settings → Providers. A fallback default is provided for users
|
|
48
|
+
// who just want to try the flow with a public client.
|
|
49
|
+
//
|
|
50
|
+
// Public surface:
|
|
51
|
+
//
|
|
52
|
+
// const oauthCopilot = require('mouaif/src/oauth-github-copilot.js');
|
|
53
|
+
//
|
|
54
|
+
// oauthCopilot.register(); // call once at server startup
|
|
55
|
+
// oauthCopilot.buildAuthorizeUrl({ redirectUri, state, verifier }); // -> url string
|
|
56
|
+
// await oauthCopilot.exchangeAuthorizationCode({ code, verifier, redirectUri, state }); // -> { accessToken, ... }
|
|
57
|
+
// // Internal — the AI client (src/ai.js) calls exchangeCopilotToken
|
|
58
|
+
// // for the `github-copilot` namespace.
|
|
59
|
+
|
|
60
|
+
const crypto = require('crypto');
|
|
61
|
+
const settings = require('./settings.js');
|
|
62
|
+
const auth = require('./auth.js');
|
|
63
|
+
|
|
64
|
+
// github/gh-copilot publishes this client_id as the public OAuth app
|
|
65
|
+
// used by the official Copilot CLI. Treating it as public matches the
|
|
66
|
+
// `gh-copilot` reference implementation. This default is overridden
|
|
67
|
+
// if the user has configured their own client_id in app settings.
|
|
68
|
+
const DEFAULT_CLIENT_ID = 'Iv1.b507a08c87cfe66e';
|
|
69
|
+
|
|
70
|
+
// Endpoints. The GitHub OAuth endpoints are public; the Copilot
|
|
71
|
+
// token exchange endpoint is documented by GitHub as part of the
|
|
72
|
+
// Copilot IDE integration.
|
|
73
|
+
const GITHUB_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize';
|
|
74
|
+
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
|
75
|
+
const COPILOT_TOKEN_URL = 'https://api.github.com/copilot_internal/v2/token';
|
|
76
|
+
const COPILOT_USER_URL = 'https://api.github.com/user';
|
|
77
|
+
|
|
78
|
+
// Copilot API base for chat completions. Matches the reserved entry
|
|
79
|
+
// in src/ai.js; both default to api.githubcopilot.com.
|
|
80
|
+
const COPILOT_API_BASE = 'https://api.githubcopilot.com';
|
|
81
|
+
|
|
82
|
+
// Minimum GitHub OAuth scopes for Copilot. `read:user` is enough for
|
|
83
|
+
// the /user lookup that resolves the account login; Copilot itself
|
|
84
|
+
// only requires the user to have a subscription (no scope gate on
|
|
85
|
+
// the OAuth side). The Copilot token exchange uses the same token
|
|
86
|
+
// without a scope check, so any valid token works.
|
|
87
|
+
const DEFAULT_SCOPE = 'read:user';
|
|
88
|
+
|
|
89
|
+
function randomUrlSafe(n) {
|
|
90
|
+
return crypto.randomBytes(n).toString('base64url');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pkceChallengeS256(verifier) {
|
|
94
|
+
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Public — these are stable helpers used by the mobile UI and tests.
|
|
98
|
+
function newState() { return randomUrlSafe(32); }
|
|
99
|
+
function newVerifier() { return randomUrlSafe(64); }
|
|
100
|
+
function challengeFor(verifier) { return pkceChallengeS256(verifier); }
|
|
101
|
+
|
|
102
|
+
// Client ID lookup. Reads the per-app client_id from settings; falls
|
|
103
|
+
// back to the public default.
|
|
104
|
+
function getClientId() {
|
|
105
|
+
const app = settings.getApp();
|
|
106
|
+
const cfg = app && app.githubCopilot && typeof app.githubCopilot === 'object' ? app.githubCopilot : null;
|
|
107
|
+
if (cfg && typeof cfg.clientId === 'string' && cfg.clientId.trim()) {
|
|
108
|
+
return cfg.clientId.trim();
|
|
109
|
+
}
|
|
110
|
+
return DEFAULT_CLIENT_ID;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Build the /login/oauth/authorize URL the user's browser should
|
|
114
|
+
// open. Pure: takes a redirectUri + state + verifier and returns a
|
|
115
|
+
// fully-formed URL string. The mobile UI uses this; the server's
|
|
116
|
+
// handleOAuthCallback is the one that does the *exchange*.
|
|
117
|
+
function buildAuthorizeUrl(opts) {
|
|
118
|
+
const {
|
|
119
|
+
clientId = getClientId(),
|
|
120
|
+
redirectUri,
|
|
121
|
+
state,
|
|
122
|
+
verifier,
|
|
123
|
+
scope = DEFAULT_SCOPE,
|
|
124
|
+
allowSignup = 'true'
|
|
125
|
+
} = opts || {};
|
|
126
|
+
|
|
127
|
+
if (!redirectUri) throw new Error('redirectUri is required');
|
|
128
|
+
if (!state) throw new Error('state is required');
|
|
129
|
+
if (!verifier) throw new Error('verifier is required');
|
|
130
|
+
|
|
131
|
+
const u = new URL(GITHUB_AUTHORIZE_URL);
|
|
132
|
+
u.searchParams.set('client_id', clientId);
|
|
133
|
+
u.searchParams.set('redirect_uri', redirectUri);
|
|
134
|
+
u.searchParams.set('response_type', 'code');
|
|
135
|
+
u.searchParams.set('scope', scope);
|
|
136
|
+
u.searchParams.set('state', state);
|
|
137
|
+
u.searchParams.set('code_challenge', pkceChallengeS256(verifier));
|
|
138
|
+
u.searchParams.set('code_challenge_method', 'S256');
|
|
139
|
+
u.searchParams.set('allow_signup', allowSignup);
|
|
140
|
+
return u.toString();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// POST the authorization_code grant. Per GitHub's docs, the token
|
|
144
|
+
// endpoint accepts application/x-www-form-urlencoded with the
|
|
145
|
+
// grant_type, code, redirect_uri, client_id, and code_verifier
|
|
146
|
+
// (PKCE) fields. Response is JSON when Accept: application/json is
|
|
147
|
+
// set; we always request JSON.
|
|
148
|
+
async function exchangeAuthorizationCode(opts) {
|
|
149
|
+
const {
|
|
150
|
+
baseUrl = GITHUB_TOKEN_URL,
|
|
151
|
+
clientId = getClientId(),
|
|
152
|
+
code,
|
|
153
|
+
verifier,
|
|
154
|
+
redirectUri,
|
|
155
|
+
state,
|
|
156
|
+
fetchImpl = globalThis.fetch
|
|
157
|
+
} = opts || {};
|
|
158
|
+
|
|
159
|
+
if (!code) throw new Error('code is required');
|
|
160
|
+
if (!verifier) throw new Error('verifier is required');
|
|
161
|
+
if (!redirectUri) throw new Error('redirectUri is required');
|
|
162
|
+
if (!state) throw new Error('state is required');
|
|
163
|
+
if (typeof fetchImpl !== 'function') throw new Error('global fetch is not available');
|
|
164
|
+
|
|
165
|
+
const body = new URLSearchParams({
|
|
166
|
+
grant_type: 'authorization_code',
|
|
167
|
+
code,
|
|
168
|
+
code_verifier: verifier,
|
|
169
|
+
client_id: clientId,
|
|
170
|
+
redirect_uri: redirectUri,
|
|
171
|
+
state
|
|
172
|
+
});
|
|
173
|
+
const res = await fetchImpl(baseUrl, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: {
|
|
176
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
177
|
+
'Accept': 'application/json',
|
|
178
|
+
'User-Agent': 'mouaif/1.0'
|
|
179
|
+
},
|
|
180
|
+
body: body.toString()
|
|
181
|
+
});
|
|
182
|
+
return readTokenResponse(res, 'authorization_code exchange');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function readTokenResponse(res, label) {
|
|
186
|
+
const text = await res.text().catch(() => '');
|
|
187
|
+
if (!res.ok) {
|
|
188
|
+
let parsed = null;
|
|
189
|
+
try { parsed = JSON.parse(text); } catch { /* not JSON */ }
|
|
190
|
+
if (parsed && parsed.error) {
|
|
191
|
+
const e = new Error('GitHub ' + label + ': ' + parsed.error + (parsed.error_description ? ' (' + parsed.error_description + ')' : ''));
|
|
192
|
+
e.code = 'EOAUTH';
|
|
193
|
+
e.oauthError = parsed.error;
|
|
194
|
+
e.oauthDescription = parsed.error_description || null;
|
|
195
|
+
throw e;
|
|
196
|
+
}
|
|
197
|
+
const e = new Error('GitHub ' + label + ' failed: HTTP ' + res.status + ' ' + res.statusText);
|
|
198
|
+
e.code = 'EUPSTREAM';
|
|
199
|
+
e.status = res.status;
|
|
200
|
+
e.body = text.slice(0, 2000);
|
|
201
|
+
throw e;
|
|
202
|
+
}
|
|
203
|
+
let json;
|
|
204
|
+
try { json = JSON.parse(text); }
|
|
205
|
+
catch (err) {
|
|
206
|
+
const e = new Error('GitHub ' + label + ' returned non-JSON: ' + err.message);
|
|
207
|
+
e.code = 'EPARSE';
|
|
208
|
+
throw e;
|
|
209
|
+
}
|
|
210
|
+
if (json.error) {
|
|
211
|
+
const e = new Error('GitHub ' + label + ': ' + json.error);
|
|
212
|
+
e.code = 'EOAUTH';
|
|
213
|
+
e.oauthError = json.error;
|
|
214
|
+
e.oauthDescription = json.error_description || null;
|
|
215
|
+
throw e;
|
|
216
|
+
}
|
|
217
|
+
if (!json.access_token) {
|
|
218
|
+
const e = new Error('GitHub ' + label + ' returned 200 with no access_token');
|
|
219
|
+
e.code = 'ETOKEN';
|
|
220
|
+
throw e;
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
accessToken: json.access_token,
|
|
224
|
+
refreshToken: null, // GitHub OAuth does not issue refresh tokens for this flow
|
|
225
|
+
expiresIn: null, // GitHub OAuth tokens do not expire (revocation is the only invalidation)
|
|
226
|
+
scope: json.scope || null,
|
|
227
|
+
tokenType: json.token_type || null
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// GET https://api.github.com/copilot_internal/v2/token
|
|
232
|
+
// Header: Authorization: Bearer <github_access_token>
|
|
233
|
+
// Response: { token, expires_at, refresh_in, endpoints, ... }
|
|
234
|
+
// `token` is the short-lived bearer for api.githubcopilot.com;
|
|
235
|
+
// `expires_at` is a Unix timestamp (seconds, not ms).
|
|
236
|
+
async function exchangeCopilotToken({ githubToken, fetchImpl = globalThis.fetch } = {}) {
|
|
237
|
+
if (!githubToken) throw new Error('githubToken is required');
|
|
238
|
+
if (typeof fetchImpl !== 'function') throw new Error('global fetch is not available');
|
|
239
|
+
const res = await fetchImpl(COPILOT_TOKEN_URL, {
|
|
240
|
+
method: 'GET',
|
|
241
|
+
headers: {
|
|
242
|
+
'Authorization': 'Bearer ' + githubToken,
|
|
243
|
+
'Accept': 'application/json',
|
|
244
|
+
'User-Agent': 'mouaif/1.0'
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
const text = await res.text().catch(() => '');
|
|
248
|
+
if (!res.ok) {
|
|
249
|
+
let parsed = null;
|
|
250
|
+
try { parsed = JSON.parse(text); } catch { /* not JSON */ }
|
|
251
|
+
const message = (parsed && parsed.message) ? parsed.message : ('HTTP ' + res.status);
|
|
252
|
+
|
|
253
|
+
// Detect SAML/SSO required by an organization. GitHub returns 403 with
|
|
254
|
+
// a `X-GitHub-SSO` header when the OAuth token needs SSO authorization.
|
|
255
|
+
// The header value is "required; url=<sso_authorization_url>".
|
|
256
|
+
const ssoHeader = res.headers && res.headers.get ? res.headers.get('X-GitHub-SSO') : null;
|
|
257
|
+
if (ssoHeader && ssoHeader.startsWith('required;')) {
|
|
258
|
+
const ssoUrlMatch = ssoHeader.match(/url=(\S+)/);
|
|
259
|
+
const ssoUrl = ssoUrlMatch ? ssoUrlMatch[1] : null;
|
|
260
|
+
const e = new Error('GitHub Copilot token exchange failed: ' + message
|
|
261
|
+
+ (ssoUrl ? '\n\nYour organization requires SAML/SSO authorization.\nAuthorize the OAuth app here: ' + ssoUrl : ''));
|
|
262
|
+
e.code = 'ESSO_REQUIRED';
|
|
263
|
+
e.status = res.status;
|
|
264
|
+
e.body = text.slice(0, 2000);
|
|
265
|
+
e.ssoUrl = ssoUrl;
|
|
266
|
+
e.suggestion = 'The organization requires SAML/SSO authorization. Visit the URL below to authorize the OAuth app for your org, then try again.';
|
|
267
|
+
throw e;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const e = new Error('Copilot token exchange failed: ' + message);
|
|
271
|
+
e.code = 'EUPSTREAM';
|
|
272
|
+
e.status = res.status;
|
|
273
|
+
e.body = text.slice(0, 2000);
|
|
274
|
+
// 403/404 typically mean: the GitHub user has no active Copilot
|
|
275
|
+
// subscription. Surface that distinctly so the UI can prompt the
|
|
276
|
+
// user to enable a Copilot plan.
|
|
277
|
+
if (res.status === 404 || res.status === 403) {
|
|
278
|
+
e.code = 'ENOCOPILOT';
|
|
279
|
+
e.suggestion = 'A Copilot subscription is required on the signed-in GitHub account.';
|
|
280
|
+
}
|
|
281
|
+
throw e;
|
|
282
|
+
}
|
|
283
|
+
let json;
|
|
284
|
+
try { json = JSON.parse(text); }
|
|
285
|
+
catch (err) {
|
|
286
|
+
const e = new Error('Copilot token exchange returned non-JSON: ' + err.message);
|
|
287
|
+
e.code = 'EPARSE';
|
|
288
|
+
throw e;
|
|
289
|
+
}
|
|
290
|
+
if (!json.token) {
|
|
291
|
+
const e = new Error('Copilot token exchange returned no token');
|
|
292
|
+
e.code = 'ETOKEN';
|
|
293
|
+
throw e;
|
|
294
|
+
}
|
|
295
|
+
// expires_at is seconds-since-epoch; convert to ms.
|
|
296
|
+
const expiresAtMs = typeof json.expires_at === 'number' ? json.expires_at * 1000 : (Date.now() + 30 * 60 * 1000);
|
|
297
|
+
return {
|
|
298
|
+
token: json.token,
|
|
299
|
+
expiresAt: expiresAtMs,
|
|
300
|
+
refreshInMs: typeof json.refresh_in === 'number' ? json.refresh_in * 1000 : null,
|
|
301
|
+
endpoints: json.endpoints || null
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---- Account resolution ------------------------------------------------
|
|
306
|
+
|
|
307
|
+
// Read the account the user authorized with. Returns a normalized
|
|
308
|
+
// handle (`login`) plus display name; falls back to the token's
|
|
309
|
+
// first 8 chars if /user fails (e.g. when the scope is too narrow).
|
|
310
|
+
async function fetchAccount({ githubToken, fetchImpl = globalThis.fetch } = {}) {
|
|
311
|
+
if (!githubToken) throw new Error('githubToken is required');
|
|
312
|
+
if (typeof fetchImpl !== 'function') throw new Error('global fetch is not available');
|
|
313
|
+
try {
|
|
314
|
+
const res = await fetchImpl(COPILOT_USER_URL, {
|
|
315
|
+
method: 'GET',
|
|
316
|
+
headers: {
|
|
317
|
+
'Authorization': 'Bearer ' + githubToken,
|
|
318
|
+
'Accept': 'application/vnd.github+json',
|
|
319
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
320
|
+
'User-Agent': 'mouaif/1.0'
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
if (!res.ok) return null;
|
|
324
|
+
const json = await res.json().catch(() => null);
|
|
325
|
+
if (!json || typeof json.login !== 'string') return null;
|
|
326
|
+
return { login: json.login, name: json.name || null };
|
|
327
|
+
} catch { return null; }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---- Auth subsystem wiring ---------------------------------------------
|
|
331
|
+
|
|
332
|
+
// The exchange function registered with auth.registerExchange.
|
|
333
|
+
// Mirrors src/oauth-anthropic.js — the loopback callback calls
|
|
334
|
+
// exchange({ pending, code }) and the function returns the
|
|
335
|
+
// { accessToken, refreshToken?, expiresAt?, account } shape that
|
|
336
|
+
// finishOAuth expects.
|
|
337
|
+
async function exchange({ pending, code }) {
|
|
338
|
+
if (!pending || !pending.codeVerifier) {
|
|
339
|
+
const e = new Error('github-copilot pending record is missing codeVerifier — start a new sign-in');
|
|
340
|
+
e.code = 'EBADINPUT';
|
|
341
|
+
throw e;
|
|
342
|
+
}
|
|
343
|
+
const out = await exchangeAuthorizationCode({
|
|
344
|
+
code,
|
|
345
|
+
verifier: pending.codeVerifier,
|
|
346
|
+
redirectUri: pending.redirectUri,
|
|
347
|
+
state: pending.state
|
|
348
|
+
});
|
|
349
|
+
// The user has authorized; resolve the GitHub login so the
|
|
350
|
+
// keychain has a useful account name. fetchAccount is best-effort;
|
|
351
|
+
// a failure (e.g. rate limit) falls back to the accountHint.
|
|
352
|
+
const account = await fetchAccount({ githubToken: out.accessToken });
|
|
353
|
+
return {
|
|
354
|
+
accessToken: out.accessToken,
|
|
355
|
+
refreshToken: null, // GitHub OAuth does not issue refresh tokens for this client_id
|
|
356
|
+
expiresAt: null, // GitHub OAuth tokens do not expire unless revoked
|
|
357
|
+
scope: out.scope,
|
|
358
|
+
account: (account && account.login) || pending.accountHint || 'default'
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Refresher registered with auth.registerRefresher. GitHub OAuth
|
|
363
|
+
// tokens do not expire (revocation is the only invalidation), so
|
|
364
|
+
// this is a no-op. The AI client derives the short-lived Copilot
|
|
365
|
+
// token on demand via oauthCopilot.exchangeCopilotToken, and that
|
|
366
|
+
// derivation is what gives the user a usable credential for
|
|
367
|
+
// api.githubcopilot.com.
|
|
368
|
+
async function refresh({ provider, account, refreshToken, scope, baseUrl }) {
|
|
369
|
+
if (provider !== 'github-copilot') {
|
|
370
|
+
const e = new Error('github-copilot refresher called for provider "' + provider + '"');
|
|
371
|
+
e.code = 'EBADINPUT';
|
|
372
|
+
throw e;
|
|
373
|
+
}
|
|
374
|
+
// Return the same shape (without changes) so the auth subsystem's
|
|
375
|
+
// refresh path doesn't write a new blob (which would be identical
|
|
376
|
+
// to the existing one). expiresAt is null because GitHub tokens
|
|
377
|
+
// do not expire.
|
|
378
|
+
return {
|
|
379
|
+
accessToken: '',
|
|
380
|
+
refreshToken: null,
|
|
381
|
+
expiresAt: null,
|
|
382
|
+
scope: scope || DEFAULT_SCOPE,
|
|
383
|
+
account: account || null
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
let _registered = false;
|
|
388
|
+
function register() {
|
|
389
|
+
if (_registered) return;
|
|
390
|
+
if (!auth.getExchange('github-copilot')) auth.registerExchange('github-copilot', exchange);
|
|
391
|
+
if (!auth.getRefresher('github-copilot')) auth.registerRefresher('github-copilot', refresh);
|
|
392
|
+
_registered = true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
module.exports = {
|
|
396
|
+
// introspection
|
|
397
|
+
DEFAULT_CLIENT_ID,
|
|
398
|
+
COPILOT_API_BASE,
|
|
399
|
+
DEFAULT_SCOPE,
|
|
400
|
+
GITHUB_AUTHORIZE_URL,
|
|
401
|
+
GITHUB_TOKEN_URL,
|
|
402
|
+
COPILOT_TOKEN_URL,
|
|
403
|
+
// helpers
|
|
404
|
+
getClientId,
|
|
405
|
+
newState,
|
|
406
|
+
newVerifier,
|
|
407
|
+
challengeFor,
|
|
408
|
+
buildAuthorizeUrl,
|
|
409
|
+
exchangeAuthorizationCode,
|
|
410
|
+
exchangeCopilotToken,
|
|
411
|
+
fetchAccount,
|
|
412
|
+
// registration
|
|
413
|
+
register,
|
|
414
|
+
// exposed for tests
|
|
415
|
+
exchange,
|
|
416
|
+
refresh
|
|
417
|
+
};
|