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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. 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
+ };