borgmcp 1.0.5 → 1.0.7

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 (157) hide show
  1. package/dist/assimilate-cmd.js +39 -497
  2. package/dist/assimilate-deps.js +3 -177
  3. package/dist/assimilate-welcome.js +2 -24
  4. package/dist/auth-env.js +1 -107
  5. package/dist/auth.js +23 -612
  6. package/dist/claude.js +11 -281
  7. package/dist/cli-help.js +29 -50
  8. package/dist/cli-platform.js +4 -94
  9. package/dist/codex-app-server.js +4 -228
  10. package/dist/codex-app-wake.js +2 -122
  11. package/dist/codex-launch.js +1 -81
  12. package/dist/codex-remote.js +1 -250
  13. package/dist/config-utils.js +3 -385
  14. package/dist/config.js +1 -190
  15. package/dist/console-prefix.js +1 -86
  16. package/dist/cube-name.js +1 -65
  17. package/dist/cubes.js +4 -269
  18. package/dist/debug.js +1 -71
  19. package/dist/device-auth.js +1 -167
  20. package/dist/direct-log.js +1 -11
  21. package/dist/health-beat.js +1 -168
  22. package/dist/inbox-monitor.js +1 -129
  23. package/dist/index.js +26 -1378
  24. package/dist/lifecycle-log-guard.js +2 -93
  25. package/dist/list-roles-render.js +6 -39
  26. package/dist/log-audit.js +3 -186
  27. package/dist/log-stream.js +9 -848
  28. package/dist/name-validator.js +1 -22
  29. package/dist/parse-assimilate-args.js +1 -82
  30. package/dist/postinstall.js +8 -22
  31. package/dist/regen-format.js +11 -329
  32. package/dist/regen.js +5 -83
  33. package/dist/remote-client.js +1 -695
  34. package/dist/role-resolver.js +1 -36
  35. package/dist/role-section.js +8 -208
  36. package/dist/roster-render.js +3 -96
  37. package/dist/setup.js +36 -251
  38. package/dist/shell-escape.js +1 -22
  39. package/dist/spawn.js +10 -29
  40. package/dist/stale-version-check.js +1 -102
  41. package/dist/stream-owner.js +2 -202
  42. package/dist/stream-status.js +3 -211
  43. package/dist/subscription-retry.js +1 -23
  44. package/dist/sync-roles-render.js +3 -118
  45. package/dist/sync.js +22 -286
  46. package/dist/templates.js +120 -563
  47. package/dist/terminal-title.js +1 -68
  48. package/dist/token-crypto.js +1 -91
  49. package/dist/token-store.js +1 -222
  50. package/dist/types.js +0 -5
  51. package/dist/version.js +2 -78
  52. package/dist/worktree-lifecycle.js +2 -173
  53. package/package.json +11 -2
  54. package/dist/assimilate-cmd.d.ts.map +0 -1
  55. package/dist/assimilate-cmd.js.map +0 -1
  56. package/dist/assimilate-deps.d.ts.map +0 -1
  57. package/dist/assimilate-deps.js.map +0 -1
  58. package/dist/assimilate-welcome.d.ts.map +0 -1
  59. package/dist/assimilate-welcome.js.map +0 -1
  60. package/dist/auth-env.d.ts.map +0 -1
  61. package/dist/auth-env.js.map +0 -1
  62. package/dist/auth.d.ts.map +0 -1
  63. package/dist/auth.js.map +0 -1
  64. package/dist/claude.d.ts.map +0 -1
  65. package/dist/claude.js.map +0 -1
  66. package/dist/cli-help.d.ts.map +0 -1
  67. package/dist/cli-help.js.map +0 -1
  68. package/dist/cli-platform.d.ts.map +0 -1
  69. package/dist/cli-platform.js.map +0 -1
  70. package/dist/codex-app-server.d.ts.map +0 -1
  71. package/dist/codex-app-server.js.map +0 -1
  72. package/dist/codex-app-wake.d.ts.map +0 -1
  73. package/dist/codex-app-wake.js.map +0 -1
  74. package/dist/codex-launch.d.ts.map +0 -1
  75. package/dist/codex-launch.js.map +0 -1
  76. package/dist/codex-remote.d.ts.map +0 -1
  77. package/dist/codex-remote.js.map +0 -1
  78. package/dist/config-utils.d.ts.map +0 -1
  79. package/dist/config-utils.js.map +0 -1
  80. package/dist/config.d.ts.map +0 -1
  81. package/dist/config.js.map +0 -1
  82. package/dist/console-prefix.d.ts.map +0 -1
  83. package/dist/console-prefix.js.map +0 -1
  84. package/dist/cube-name.d.ts.map +0 -1
  85. package/dist/cube-name.js.map +0 -1
  86. package/dist/cubes.d.ts.map +0 -1
  87. package/dist/cubes.js.map +0 -1
  88. package/dist/debug.d.ts.map +0 -1
  89. package/dist/debug.js.map +0 -1
  90. package/dist/device-auth.d.ts.map +0 -1
  91. package/dist/device-auth.js.map +0 -1
  92. package/dist/direct-log.d.ts.map +0 -1
  93. package/dist/direct-log.js.map +0 -1
  94. package/dist/health-beat.d.ts.map +0 -1
  95. package/dist/health-beat.js.map +0 -1
  96. package/dist/inbox-monitor.d.ts.map +0 -1
  97. package/dist/inbox-monitor.js.map +0 -1
  98. package/dist/index.d.ts.map +0 -1
  99. package/dist/index.js.map +0 -1
  100. package/dist/lifecycle-log-guard.d.ts.map +0 -1
  101. package/dist/lifecycle-log-guard.js.map +0 -1
  102. package/dist/list-roles-render.d.ts.map +0 -1
  103. package/dist/list-roles-render.js.map +0 -1
  104. package/dist/log-audit.d.ts.map +0 -1
  105. package/dist/log-audit.js.map +0 -1
  106. package/dist/log-stream.d.ts.map +0 -1
  107. package/dist/log-stream.js.map +0 -1
  108. package/dist/name-validator.d.ts.map +0 -1
  109. package/dist/name-validator.js.map +0 -1
  110. package/dist/parse-assimilate-args.d.ts.map +0 -1
  111. package/dist/parse-assimilate-args.js.map +0 -1
  112. package/dist/postinstall.d.ts.map +0 -1
  113. package/dist/postinstall.js.map +0 -1
  114. package/dist/regen-format.d.ts.map +0 -1
  115. package/dist/regen-format.js.map +0 -1
  116. package/dist/regen.d.ts.map +0 -1
  117. package/dist/regen.js.map +0 -1
  118. package/dist/remote-client.d.ts.map +0 -1
  119. package/dist/remote-client.js.map +0 -1
  120. package/dist/role-resolver.d.ts.map +0 -1
  121. package/dist/role-resolver.js.map +0 -1
  122. package/dist/role-section.d.ts.map +0 -1
  123. package/dist/role-section.js.map +0 -1
  124. package/dist/roster-render.d.ts.map +0 -1
  125. package/dist/roster-render.js.map +0 -1
  126. package/dist/setup.d.ts.map +0 -1
  127. package/dist/setup.js.map +0 -1
  128. package/dist/shell-escape.d.ts.map +0 -1
  129. package/dist/shell-escape.js.map +0 -1
  130. package/dist/spawn.d.ts.map +0 -1
  131. package/dist/spawn.js.map +0 -1
  132. package/dist/stale-version-check.d.ts.map +0 -1
  133. package/dist/stale-version-check.js.map +0 -1
  134. package/dist/stream-owner.d.ts.map +0 -1
  135. package/dist/stream-owner.js.map +0 -1
  136. package/dist/stream-status.d.ts.map +0 -1
  137. package/dist/stream-status.js.map +0 -1
  138. package/dist/subscription-retry.d.ts.map +0 -1
  139. package/dist/subscription-retry.js.map +0 -1
  140. package/dist/sync-roles-render.d.ts.map +0 -1
  141. package/dist/sync-roles-render.js.map +0 -1
  142. package/dist/sync.d.ts.map +0 -1
  143. package/dist/sync.js.map +0 -1
  144. package/dist/templates.d.ts.map +0 -1
  145. package/dist/templates.js.map +0 -1
  146. package/dist/terminal-title.d.ts.map +0 -1
  147. package/dist/terminal-title.js.map +0 -1
  148. package/dist/token-crypto.d.ts.map +0 -1
  149. package/dist/token-crypto.js.map +0 -1
  150. package/dist/token-store.d.ts.map +0 -1
  151. package/dist/token-store.js.map +0 -1
  152. package/dist/types.d.ts.map +0 -1
  153. package/dist/types.js.map +0 -1
  154. package/dist/version.d.ts.map +0 -1
  155. package/dist/version.js.map +0 -1
  156. package/dist/worktree-lifecycle.d.ts.map +0 -1
  157. package/dist/worktree-lifecycle.js.map +0 -1
package/dist/auth.js CHANGED
@@ -1,627 +1,38 @@
1
- /**
2
- * Google OAuth 2.0 Authorization Code Flow with PKCE
3
- * For Desktop/CLI applications
4
- *
5
- * Flow:
6
- * 1. Generate PKCE code_verifier and code_challenge
7
- * 2. Start local HTTP server for callback
8
- * 3. Open browser to Google authorization URL
9
- * 4. User authorizes in browser
10
- * 5. Receive authorization code via localhost callback
11
- * 6. Exchange code for tokens
12
- * 7. Store tokens securely in OS keychain
13
- */
14
- import { createServer } from 'http';
15
- import { URL } from 'url';
16
- import crypto from 'crypto';
17
- import open from 'open';
18
- import { storeIdToken, storeRefreshToken, getRefreshToken } from './config.js';
19
- import { cerr } from './console-prefix.js';
20
- import { isNoBrowserEnv } from './auth-env.js';
21
- import { requestDeviceCode, pollForDeviceToken, } from './device-auth.js';
22
- /**
23
- * Refresh-token-revoked / expired — the user must re-run `borg setup`
24
- * to recover. Anchored on Google's canonical signal: HTTP 400 + JSON
25
- * body `{"error": "invalid_grant", ...}` from the OAuth token
26
- * endpoint. Callers should `clearTokens()` on this class only —
27
- * preserving keychain state on the transient class.
28
- *
29
- * Constructor stores parsed `error` + `error_description` fields
30
- * only — never the request body (which contains the refresh_token)
31
- * or the raw response body verbatim (per drone-8 SR axis b on
32
- * token-material non-leakage in error messages).
33
- */
34
- export class RefreshTokenInvalidError extends Error {
35
- errorCode;
36
- errorDescription;
37
- constructor(errorCode, errorDescription) {
38
- super(errorDescription
39
- ? `Refresh token invalid (${errorCode}): ${errorDescription}`
40
- : `Refresh token invalid (${errorCode})`);
41
- this.errorCode = errorCode;
42
- this.errorDescription = errorDescription;
43
- this.name = 'RefreshTokenInvalidError';
44
- }
45
- }
46
- /**
47
- * Transient failure of the refresh path — network failure, Google
48
- * 5xx, malformed response, non-`invalid_grant` 4xx, etc. The
49
- * refresh_token in keychain is presumed still valid; callers MUST
50
- * NOT `clearTokens()` on this class.
51
- *
52
- * The pre-gh#34 implementation classified all refresh failures
53
- * uniformly and called `clearTokens()` unconditionally; a single
54
- * transient blip would destroy the durable session. This typed
55
- * class is the discrimination axis.
56
- */
57
- export class RefreshTransientError extends Error {
58
- constructor(message) {
59
- super(message);
60
- this.name = 'RefreshTransientError';
61
- }
62
- }
63
- // Google OAuth Client credentials for Borg MCP CLI (Desktop app)
64
- // Per Google's documentation: "the client secret is obviously not treated as a secret"
65
- // for installed/desktop applications. This follows industry standard (AWS CLI, gcloud, GitHub CLI)
66
- const GOOGLE_CLIENT_ID = '675073910799-41pbe12rfhqemidh64h09s4q3e0udpgp.apps.googleusercontent.com';
67
- const GOOGLE_CLIENT_SECRET = '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';
68
- // gh#557 ESCALATION-1: the device-grant flow needs a SEPARATE Google OAuth
69
- // client of type "TVs & Limited Input devices" — the Desktop/loopback client
70
- // above rejects POST /device/code with invalid_client. The operator/Queen
71
- // created that client in Cloud Console (project 675073910799) and its
72
- // credentials are baked in below so published clients do headless auth
73
- // out-of-box. For "TVs & Limited Input devices" clients Google designs the
74
- // secret to ship inside distributed apps (RFC 8628 limited-input client) — it
75
- // is NOT a confidential credential, so baking it into the published package is
76
- // the intended pattern, not a leak. GOOGLE_DEVICE_CLIENT_ID /
77
- // GOOGLE_DEVICE_CLIENT_SECRET stay as optional env overrides for operators who
78
- // point the device flow at their own client.
79
- const BAKED_IN_DEVICE_CLIENT_ID = '675073910799-6qmi73v5106dj1v0l22j2qnkh5r3e8fq.apps.googleusercontent.com';
80
- const BAKED_IN_DEVICE_CLIENT_SECRET = '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';
81
- const GOOGLE_AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
82
- const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
83
- const GOOGLE_REVOKE_URL = 'https://oauth2.googleapis.com/revoke';
84
- const SCOPES = ['openid', 'email', 'profile'];
85
- // Port range for dynamic port selection (8000-9000)
86
- const PORT_RANGE_START = 8000;
87
- const PORT_RANGE_END = 9000;
88
- /**
89
- * Generate PKCE code_verifier and code_challenge
90
- * Uses SHA256 hashing per OAuth 2.0 PKCE spec (RFC 7636)
91
- */
92
- function generatePKCE() {
93
- // Generate random code_verifier (43-128 characters)
94
- const verifier = crypto.randomBytes(32).toString('base64url');
95
- // Generate code_challenge = BASE64URL(SHA256(code_verifier))
96
- const challenge = crypto
97
- .createHash('sha256')
98
- .update(verifier)
99
- .digest('base64url');
100
- return { verifier, challenge };
101
- }
102
- /**
103
- * Find an available port in the specified range
104
- */
105
- async function findAvailablePort() {
106
- return new Promise((resolve, reject) => {
107
- // Try to bind to port 0 to let the OS assign a free port
108
- const testServer = createServer();
109
- testServer.listen(0, () => {
110
- const address = testServer.address();
111
- if (address && typeof address === 'object') {
112
- const port = address.port;
113
- testServer.close(() => resolve(port));
114
- }
115
- else {
116
- testServer.close(() => reject(new Error('Failed to get assigned port')));
117
- }
118
- });
119
- testServer.on('error', reject);
120
- });
121
- }
122
- /**
123
- * Start local HTTP server to receive OAuth callback
124
- * Returns { server, port, codePromise }
125
- */
126
- async function startCallbackServer() {
127
- // Find available port first
128
- const port = await findAvailablePort();
129
- const codePromise = new Promise((resolve, reject) => {
130
- const server = createServer((req, res) => {
131
- const url = new URL(req.url, `http://localhost:${port}`);
132
- if (url.pathname === '/callback') {
133
- const code = url.searchParams.get('code');
134
- const error = url.searchParams.get('error');
135
- if (error) {
136
- res.writeHead(400, { 'Content-Type': 'text/html' });
137
- res.end(`
1
+ import{createServer as m}from"http";import{URL as k}from"url";import _ from"crypto";import b from"open";import{storeIdToken as u,storeRefreshToken as d,getRefreshToken as p}from"./config.js";import{cerr as r}from"./console-prefix.js";import{isNoBrowserEnv as R}from"./auth-env.js";import{requestDeviceCode as P,pollForDeviceToken as O}from"./device-auth.js";class x extends Error{errorCode;errorDescription;constructor(t,s){super(s?`Refresh token invalid (${t}): ${s}`:`Refresh token invalid (${t})`),this.errorCode=t,this.errorDescription=s,this.name="RefreshTokenInvalidError"}}class l extends Error{constructor(t){super(t),this.name="RefreshTransientError"}}const w="675073910799-41pbe12rfhqemidh64h09s4q3e0udpgp.apps.googleusercontent.com",g="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",I="675073910799-6qmi73v5106dj1v0l22j2qnkh5r3e8fq.apps.googleusercontent.com",G="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",S="https://accounts.google.com/o/oauth2/v2/auth",E="https://oauth2.googleapis.com/token",A="https://oauth2.googleapis.com/revoke",T=["openid","email","profile"],J=8e3,X=9e3;function L(){const e=_.randomBytes(32).toString("base64url"),t=_.createHash("sha256").update(e).digest("base64url");return{verifier:e,challenge:t}}async function D(){return new Promise((e,t)=>{const s=m();s.listen(0,()=>{const i=s.address();if(i&&typeof i=="object"){const o=i.port;s.close(()=>e(o))}else s.close(()=>t(new Error("Failed to get assigned port")))}),s.on("error",t)})}async function N(){const e=await D(),t=new Promise((s,i)=>{const o=m((n,c)=>{const a=new k(n.url,`http://localhost:${e}`);if(a.pathname==="/callback"){const h=a.searchParams.get("code"),f=a.searchParams.get("error");if(f){c.writeHead(400,{"Content-Type":"text/html"}),c.end(`
138
2
  <html>
139
3
  <body>
140
- <h1>◼ Authentication Failed</h1>
141
- <p>Error: ${error}</p>
4
+ <h1>\u25FC Authentication Failed</h1>
5
+ <p>Error: ${f}</p>
142
6
  <p>You can close this window.</p>
143
7
  </body>
144
8
  </html>
145
- `);
146
- server.close();
147
- reject(new Error(`OAuth error: ${error}`));
148
- return;
149
- }
150
- if (code) {
151
- res.writeHead(200, { 'Content-Type': 'text/html' });
152
- res.end(`
9
+ `),o.close(),i(new Error(`OAuth error: ${f}`));return}if(h){c.writeHead(200,{"Content-Type":"text/html"}),c.end(`
153
10
  <html>
154
11
  <body>
155
- <h1>◼ Authentication Successful!</h1>
12
+ <h1>\u25FC Authentication Successful!</h1>
156
13
  <p>You can close this window and return to your terminal.</p>
157
14
  </body>
158
15
  </html>
159
- `);
160
- server.close();
161
- resolve(code);
162
- return;
163
- }
164
- res.writeHead(400, { 'Content-Type': 'text/html' });
165
- res.end(`
16
+ `),o.close(),s(h);return}c.writeHead(400,{"Content-Type":"text/html"}),c.end(`
166
17
  <html>
167
18
  <body>
168
- <h1>◼ Invalid Request</h1>
19
+ <h1>\u25FC Invalid Request</h1>
169
20
  <p>Missing authorization code.</p>
170
21
  </body>
171
22
  </html>
172
- `);
173
- server.close();
174
- reject(new Error('Missing authorization code'));
175
- }
176
- });
177
- server.listen(port, () => {
178
- cerr(`Callback server listening on http://localhost:${port}`);
179
- });
180
- // Timeout after 5 minutes. .unref() so the timer doesn't keep the
181
- // Node event loop alive after auth succeeds — without it, borg-setup
182
- // appears to hang for 5 minutes after printing "Setup complete!"
183
- // even though all real work is done.
184
- setTimeout(() => {
185
- server.close();
186
- reject(new Error('Authentication timeout - no response received'));
187
- }, 5 * 60 * 1000).unref();
188
- });
189
- return { port, codePromise };
190
- }
191
- /**
192
- * Exchange authorization code for tokens
193
- */
194
- async function exchangeCodeForTokens(code, codeVerifier, port) {
195
- const redirectUri = `http://localhost:${port}/callback`;
196
- const response = await fetch(GOOGLE_TOKEN_URL, {
197
- method: 'POST',
198
- headers: {
199
- 'Content-Type': 'application/x-www-form-urlencoded',
200
- },
201
- body: new URLSearchParams({
202
- client_id: GOOGLE_CLIENT_ID,
203
- client_secret: GOOGLE_CLIENT_SECRET,
204
- code,
205
- code_verifier: codeVerifier,
206
- grant_type: 'authorization_code',
207
- redirect_uri: redirectUri,
208
- }),
209
- });
210
- if (!response.ok) {
211
- const error = await response.text();
212
- throw new Error(`Failed to exchange code for tokens: ${error}`);
213
- }
214
- return (await response.json());
215
- }
216
- /**
217
- * Best-effort revocation of a refresh_token at Google's revocation endpoint
218
- * (RFC 7009). Failures are intentionally swallowed — revocation is opportunistic
219
- * cleanup of Google's session-side state before requesting fresh consent. If the
220
- * network is down or the token is already revoked, the subsequent consent flow
221
- * still runs. The only consequence of skipping revocation is that Google MAY
222
- * dedupe the new consent and decline to return a fresh refresh_token (the bug
223
- * class this revocation step exists to prevent).
224
- */
225
- async function revokeRefreshTokenAtGoogle(refreshToken) {
226
- try {
227
- // gh#55: per RFC 7009 §2.1, the token MUST be sent in the request
228
- // body (application/x-www-form-urlencoded), not the query string.
229
- // Google accepts both shapes in practice, but the prior query-string
230
- // transport contradicted the declared `Content-Type` header and was
231
- // a code-clarity issue drone-2 flagged in PR #38 retrospective.
232
- await fetch(GOOGLE_REVOKE_URL, {
233
- method: 'POST',
234
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
235
- body: `token=${encodeURIComponent(refreshToken)}`,
236
- });
237
- }
238
- catch {
239
- // Intentional swallow — see docstring above. Revocation is opportunistic.
240
- }
241
- }
242
- /**
243
- * The browser/loopback OAuth flow (PKCE authorization-code). The DEFAULT
244
- * path on a desktop with a browser; gh#557 dispatches here from
245
- * `authenticateWithGoogle` unless the environment is browserless.
246
- *
247
- * Opens a browser for user authorization and stores tokens in the selected
248
- * token backend on success.
249
- *
250
- * Force-fresh-consent discipline: before requesting new consent, this function
251
- * revokes any existing refresh_token at Google's revocation endpoint AND uses
252
- * `prompt=consent select_account` (multi-value prompt) to force both the
253
- * consent screen and account picker. Together, these clear Google's
254
- * session-side memory of prior consent, ensuring Google issues a fresh
255
- * `refresh_token` rather than deduping the consent and returning only an
256
- * id_token. (Without this, Google's dedup behavior can leave the user with
257
- * an id_token but no refresh_token, forcing manual re-setup after the ~1h
258
- * id_token TTL expires.)
259
- */
260
- async function authenticateWithBrowser() {
261
- cerr('\n◼ Borg MCP Authentication');
262
- cerr('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
263
- // Step 0: revoke any existing refresh_token to force Google to re-issue
264
- // one in the fresh consent flow below (defeats Google's consent dedup
265
- // behavior that leaves clients in a no-refresh-token state).
266
- const existingRefreshToken = await getRefreshToken();
267
- if (existingRefreshToken) {
268
- cerr('Revoking previous refresh_token to force fresh consent...');
269
- await revokeRefreshTokenAtGoogle(existingRefreshToken);
270
- }
271
- // Step 1: Generate PKCE pair
272
- cerr('Generating PKCE challenge...');
273
- const pkce = generatePKCE();
274
- // Step 2: Start local callback server (gets dynamic port)
275
- cerr('Starting local callback server...');
276
- const { port, codePromise } = await startCallbackServer();
277
- // Step 3: Build authorization URL with dynamic redirect URI
278
- const redirectUri = `http://localhost:${port}/callback`;
279
- const authUrl = new URL(GOOGLE_AUTHORIZE_URL);
280
- authUrl.searchParams.set('client_id', GOOGLE_CLIENT_ID);
281
- authUrl.searchParams.set('redirect_uri', redirectUri);
282
- authUrl.searchParams.set('response_type', 'code');
283
- authUrl.searchParams.set('scope', SCOPES.join(' '));
284
- authUrl.searchParams.set('code_challenge', pkce.challenge);
285
- authUrl.searchParams.set('code_challenge_method', 'S256');
286
- authUrl.searchParams.set('access_type', 'offline'); // Request refresh token
287
- // Multi-value prompt forces both consent screen AND account picker. Combined
288
- // with the pre-revocation step above, this reliably forces Google to issue
289
- // a fresh refresh_token instead of deduping the consent.
290
- authUrl.searchParams.set('prompt', 'consent select_account');
291
- // Step 4: Open browser
292
- cerr('\n📱 Opening browser for authorization...');
293
- cerr('If browser does not open, visit:');
294
- cerr(`${authUrl.toString()}\n`);
295
- await open(authUrl.toString());
296
- // Step 5: Wait for authorization code
297
- cerr('Waiting for authorization...');
298
- const code = await codePromise;
299
- // Step 6: Exchange code for tokens
300
- cerr('Exchanging authorization code for tokens...');
301
- const tokenData = await exchangeCodeForTokens(code, pkce.verifier, port);
302
- // Step 7: Store tokens securely
303
- const expiresAt = Date.now() + tokenData.expires_in * 1000;
304
- await storeIdToken(tokenData.id_token, expiresAt);
305
- if (tokenData.refresh_token) {
306
- await storeRefreshToken(tokenData.refresh_token);
307
- }
308
- else {
309
- // No refresh_token means auto-refresh-on-expiry will fail and force
310
- // the user back through `borg setup` after ~1 hour. Google
311
- // sometimes dedupes consent and skips the refresh_token even when
312
- // prompt=consent is set. Surface this so the user can revoke and
313
- // reconsent if they want durable sessions.
314
- cerr('\n⚠ No refresh_token returned by Google.');
315
- cerr(' Your session will expire after ~1 hour and require');
316
- cerr(' re-running `borg setup`. To enable auto-refresh:');
317
- cerr(' 1. Visit https://myaccount.google.com/permissions');
318
- cerr(' 2. Find "Borg MCP" and click "Remove access"');
319
- cerr(' 3. Re-run `borg setup`');
320
- cerr(' (Google will then issue a fresh refresh_token.)\n');
321
- }
322
- cerr('\n◼ Authentication successful!\n');
323
- }
324
- /**
325
- * Decide whether to use the no-browser device-grant flow. An explicit
326
- * `--no-browser`/`--device` (surfaced as opts.noBrowser) wins; otherwise
327
- * auto-detect via isNoBrowserEnv (SSH session, container, headless Linux).
328
- */
329
- export function shouldUseDeviceFlow(opts) {
330
- return opts?.noBrowser ?? isNoBrowserEnv();
331
- }
332
- /**
333
- * Assemble the device-grant OAuth config from the environment. Enforces the
334
- * gh#557 ESCALATION-1 gate: a "TVs & Limited Input devices" client id must be
335
- * available (baked-in once the operator creates it, or via GOOGLE_DEVICE_CLIENT_ID).
336
- * Without one we fail with an actionable error instead of hitting Google with
337
- * the Desktop client, which rejects /device/code as invalid_client.
338
- */
339
- export function buildDeviceAuthConfig(env = process.env) {
340
- // Pair the secret with the id SOURCE so an env override never inherits the
341
- // baked-in client's secret: an operator pointing GOOGLE_DEVICE_CLIENT_ID at
342
- // their own client supplies their own (optional) secret; the baked-in secret
343
- // applies only to the baked-in id.
344
- const envClientId = env.GOOGLE_DEVICE_CLIENT_ID?.trim();
345
- const envClientSecret = env.GOOGLE_DEVICE_CLIENT_SECRET?.trim() || undefined;
346
- let clientId;
347
- let clientSecret;
348
- if (envClientId) {
349
- clientId = envClientId;
350
- clientSecret = envClientSecret;
351
- }
352
- else {
353
- // Baked-in id ALWAYS pairs with the baked-in secret. A stray
354
- // GOOGLE_DEVICE_CLIENT_SECRET set WITHOUT an id override must NOT re-pair
355
- // the baked id with a foreign secret ({baked id, wrong secret} →
356
- // invalid_client). Override is all-or-nothing with the id.
357
- clientId = BAKED_IN_DEVICE_CLIENT_ID;
358
- clientSecret = BAKED_IN_DEVICE_CLIENT_SECRET || undefined;
359
- }
360
- if (!clientId) {
361
- throw new Error('No-browser (device-grant) auth needs a Google "TVs & Limited Input devices" ' +
362
- 'OAuth client. Set GOOGLE_DEVICE_CLIENT_ID in the environment, or run ' +
363
- '`borg setup` on a machine with a browser. See docs/REMOTE_TERMINAL_AUTH.md.');
364
- }
365
- return { clientId, clientSecret, scopes: SCOPES };
366
- }
367
- /** Real sleep for the production device-poll loop. */
368
- function defaultSleep(ms) {
369
- return new Promise((resolve) => setTimeout(resolve, ms));
370
- }
371
- /**
372
- * The RFC 8628 device-grant flow (no browser). Prints a verification URL +
373
- * user_code for the human to open on ANY device, polls Google until they
374
- * authorize, then stores tokens in the selected backend. Network deps are
375
- * injectable for tests; the device-poll state machine itself lives in
376
- * device-auth.ts (fully unit-tested).
377
- */
378
- export async function authenticateWithDeviceFlow(deps = { fetch, sleep: defaultSleep }, env = process.env) {
379
- cerr('\n◼ Borg MCP Authentication (no-browser mode)');
380
- cerr('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
381
- const config = buildDeviceAuthConfig(env);
382
- // Same force-fresh-consent discipline as the browser flow: revoke any
383
- // existing refresh_token so Google re-issues one in the new grant.
384
- const existingRefreshToken = await getRefreshToken();
385
- if (existingRefreshToken) {
386
- cerr('Revoking previous refresh_token to force fresh consent...');
387
- await revokeRefreshTokenAtGoogle(existingRefreshToken);
388
- }
389
- const deviceCode = await requestDeviceCode(config, deps);
390
- cerr('To authorize Borg MCP on this machine:');
391
- cerr(` 1. On any device with a browser, open: ${deviceCode.verification_url}`);
392
- cerr(` 2. Enter this code: ${deviceCode.user_code}\n`);
393
- cerr('Waiting for authorization (this page can be open on your phone or laptop)...');
394
- const tokens = await pollForDeviceToken(deviceCode, config, deps);
395
- const expiresAt = Date.now() + tokens.expires_in * 1000;
396
- await storeIdToken(tokens.id_token, expiresAt);
397
- if (tokens.refresh_token) {
398
- await storeRefreshToken(tokens.refresh_token);
399
- }
400
- else {
401
- cerr('\n⚠ No refresh_token returned by Google.');
402
- cerr(' Your session will expire after ~1 hour and require re-running');
403
- cerr(' `borg setup`. Re-consent at https://myaccount.google.com/permissions');
404
- cerr(' (remove "Borg MCP") then re-run setup to restore automatic token refresh.\n');
405
- }
406
- cerr('\n◼ Authentication successful!\n');
407
- }
408
- /**
409
- * Perform the complete OAuth flow, choosing the browser/loopback flow or the
410
- * no-browser device-grant flow based on the environment (gh#557). Stores
411
- * tokens in the selected backend on success.
412
- *
413
- * @param opts.noBrowser force the device flow (`--no-browser`/`--device`);
414
- * when omitted, the environment is auto-detected.
415
- */
416
- export async function authenticateWithGoogle(opts) {
417
- if (shouldUseDeviceFlow(opts)) {
418
- return authenticateWithDeviceFlow();
419
- }
420
- return authenticateWithBrowser();
421
- }
422
- /**
423
- * Attempt a single refresh_token → id_token exchange against ONE OAuth
424
- * client (gh#34 hardened). The exported `refreshIdToken` below orchestrates
425
- * the web-then-device cascade over this helper (gh#691); a refresh_token can
426
- * only be redeemed by its issuing client and we don't persist which flow
427
- * minted the stored token.
428
- *
429
- * Returns a RefreshAttemptResult (never throws on a classified failure) so
430
- * the orchestrator can weigh BOTH client attempts before deciding whether
431
- * the token is genuinely revoked (clear keychain) or should be retried.
432
- *
433
- * Three substantive properties carried from the pre-gh#34 shape:
434
- *
435
- * 1. **Typed discrimination.** Classifies as `kind:'invalid'` (carrying
436
- * `RefreshTokenInvalidError`) only when Google's response is the
437
- * canonical revoked/expired signal (HTTP 400 + JSON body
438
- * `{"error": "invalid_grant"}`). Every other failure mode
439
- * (network/DNS/timeout, Google 5xx, malformed response, other
440
- * 4xx error codes like `invalid_request` or `unauthorized_client`,
441
- * non-JSON body) becomes `kind:'transient'`. The orchestrator gates
442
- * `clearTokens()` on BOTH attempts returning Invalid — transient
443
- * (or single-client invalid) outcomes preserve the keychain so a
444
- * network blip or wrong-client rejection doesn't destroy a durable
445
- * session.
446
- *
447
- * 2. **refresh_token rotation handling.** If Google's response
448
- * includes a new `refresh_token` (token rotation feature),
449
- * store the new value before storing the id_token. Ordering
450
- * matters: refresh_token write FIRST so a subsequent id_token
451
- * write failure leaves us with `(new refresh_token, stale
452
- * id_token)` — a recoverable state where the next refresh
453
- * attempt uses the new refresh_token to fetch a fresh id_token.
454
- * The reverse ordering would leave `(new id_token, stale
455
- * refresh_token)`, and Google's rotation eagerly invalidates
456
- * the stale refresh_token server-side, so the next refresh
457
- * fails with `invalid_grant` and locks the user out (drone-8 SR
458
- * axis c, 14:07:28).
459
- *
460
- * 3. **Classification anchored on the parsed body**, not on the
461
- * HTTP status alone or substring-matched against a thrown JS
462
- * error string. Per drone-8 SR axis (a): status-code-alone is
463
- * fragile (Google can return 400 for `invalid_request` /
464
- * `unauthorized_client` which are NOT revocation); substring
465
- * matching is spoofable. We parse the JSON `error` field and
466
- * match on its exact value.
467
- */
468
- async function attemptRefreshWithClient(refreshToken, clientId, clientSecret) {
469
- const refreshParams = {
470
- client_id: clientId,
471
- refresh_token: refreshToken,
472
- grant_type: 'refresh_token',
473
- };
474
- // Device-code clients ("TVs & Limited Input devices") may be configured
475
- // without a secret; only send one when we have it (mirrors the device
476
- // token request in device-auth.ts).
477
- if (clientSecret) {
478
- refreshParams.client_secret = clientSecret;
479
- }
480
- let response;
481
- try {
482
- response = await fetch(GOOGLE_TOKEN_URL, {
483
- method: 'POST',
484
- headers: {
485
- 'Content-Type': 'application/x-www-form-urlencoded',
486
- },
487
- body: new URLSearchParams(refreshParams),
488
- });
489
- }
490
- catch (err) {
491
- // Network-layer failure: DNS, connection refused, TLS handshake
492
- // failure, timeout, etc. None of these signal refresh_token
493
- // revocation — treat as transient.
494
- return {
495
- ok: false,
496
- kind: 'transient',
497
- error: new RefreshTransientError(`Network failure during token refresh: ${err?.message ?? 'unknown'}`),
498
- };
499
- }
500
- if (!response.ok) {
501
- // Parse the response body. Fall back to Transient on non-JSON
502
- // (proxy error page, HTML error from misrouted request, etc.) —
503
- // when we can't tell, preserve tokens (safer default).
504
- let errBody = null;
505
- try {
506
- errBody = (await response.json());
507
- }
508
- catch {
509
- return {
510
- ok: false,
511
- kind: 'transient',
512
- error: new RefreshTransientError(`Token refresh failed with HTTP ${response.status} (non-JSON body)`),
513
- };
514
- }
515
- // Canonical Google OAuth revocation signal: HTTP 400 + parsed
516
- // body `error === 'invalid_grant'`. Everything else (other 4xx
517
- // error codes, 5xx, missing/unexpected error field) is Transient.
518
- // NB: a refresh_token redeemed against the WRONG client can also
519
- // yield invalid_grant — the orchestrator only treats it as terminal
520
- // when BOTH clients reject it (gh#691).
521
- if (response.status === 400 && errBody?.error === 'invalid_grant') {
522
- return {
523
- ok: false,
524
- kind: 'invalid',
525
- error: new RefreshTokenInvalidError('invalid_grant', errBody.error_description),
526
- };
527
- }
528
- return {
529
- ok: false,
530
- kind: 'transient',
531
- error: new RefreshTransientError(`Token refresh failed with HTTP ${response.status}${errBody?.error ? ` (${errBody.error})` : ''}`),
532
- };
533
- }
534
- let data;
535
- try {
536
- data = (await response.json());
537
- }
538
- catch (err) {
539
- return {
540
- ok: false,
541
- kind: 'transient',
542
- error: new RefreshTransientError(`Token refresh response unparseable: ${err?.message ?? 'unknown'}`),
543
- };
544
- }
545
- if (!data.id_token || typeof data.expires_in !== 'number') {
546
- return {
547
- ok: false,
548
- kind: 'transient',
549
- error: new RefreshTransientError('Token refresh response missing id_token or expires_in'),
550
- };
551
- }
552
- const expiresAt = Date.now() + data.expires_in * 1000;
553
- // Rotation case: Google returned a new refresh_token. Store it
554
- // FIRST so a subsequent id_token write failure leaves us in the
555
- // recoverable state (new refresh_token + stale id_token) rather
556
- // than the locked-out state (new id_token + invalidated old
557
- // refresh_token). The previous-token snapshot lets us rollback
558
- // the refresh_token write itself if the id_token write fails —
559
- // keeping the keychain consistent with what the caller perceives.
560
- if (data.refresh_token) {
561
- const previousRefreshToken = await getRefreshToken();
562
- await storeRefreshToken(data.refresh_token);
563
- try {
564
- await storeIdToken(data.id_token, expiresAt);
565
- }
566
- catch (err) {
567
- // Rollback the refresh_token write so we don't end up with a
568
- // half-rotated keychain on a transient keychain-layer failure.
569
- // Best-effort: if the rollback itself fails, the caller still
570
- // sees the original error and the keychain is in the new-
571
- // refresh-token-only state, which is still recoverable on the
572
- // next refresh attempt.
573
- if (previousRefreshToken) {
574
- try {
575
- await storeRefreshToken(previousRefreshToken);
576
- }
577
- catch {
578
- // intentional swallow — original error takes precedence
579
- }
580
- }
581
- throw err;
582
- }
583
- return { ok: true };
584
- }
585
- // No rotation — just refresh the id_token.
586
- await storeIdToken(data.id_token, expiresAt);
587
- return { ok: true };
588
- }
589
- /**
590
- * Refresh the stored id_token, redeeming the refresh_token against the
591
- * OAuth client that ISSUED it (gh#691).
592
- *
593
- * A refresh_token can only be redeemed by its issuing client. The browser
594
- * flow issues tokens under the web client (GOOGLE_CLIENT_ID); the device
595
- * flow (`borg setup --no-browser`, gh#557) issues them under the device
596
- * client (buildDeviceAuthConfig). We don't persist which flow minted the
597
- * stored token, so try the web client first, then the device client.
598
- *
599
- * Before this fix the refresh hard-coded the web client, so every
600
- * device-flow user's silent refresh failed at id_token expiry — forcing a
601
- * full re-auth (which revokes the working refresh_token to force fresh
602
- * consent) on the next command. That was the friction in gh#691.
603
- *
604
- * Keychain safety: clear-on-revocation (RefreshTokenInvalidError, which
605
- * callers gate `clearTokens()` on) is surfaced ONLY when BOTH clients
606
- * reject the token with invalid_grant. Any inconclusive/transient outcome
607
- * on either attempt preserves the keychain for retry — a wrong-client
608
- * invalid_grant must not be mistaken for a genuine revocation.
609
- */
610
- export async function refreshIdToken(refreshToken) {
611
- const web = await attemptRefreshWithClient(refreshToken, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
612
- if (web.ok)
613
- return;
614
- const device = buildDeviceAuthConfig();
615
- const dev = await attemptRefreshWithClient(refreshToken, device.clientId, device.clientSecret);
616
- if (dev.ok)
617
- return;
618
- // Neither client could redeem the token. Surface the canonical
619
- // revocation signal (→ clearTokens) ONLY when BOTH clients rejected it
620
- // as invalid_grant; otherwise surface the transient error so the
621
- // keychain is preserved and the next attempt can retry.
622
- if (web.kind === 'invalid' && dev.kind === 'invalid') {
623
- throw dev.error;
624
- }
625
- throw web.kind === 'transient' ? web.error : dev.error;
626
- }
627
- //# sourceMappingURL=auth.js.map
23
+ `),o.close(),i(new Error("Missing authorization code"))}});o.listen(e,()=>{r(`Callback server listening on http://localhost:${e}`)}),setTimeout(()=>{o.close(),i(new Error("Authentication timeout - no response received"))},300*1e3).unref()});return{port:e,codePromise:t}}async function $(e,t,s){const i=`http://localhost:${s}/callback`,o=await fetch(E,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({client_id:w,client_secret:g,code:e,code_verifier:t,grant_type:"authorization_code",redirect_uri:i})});if(!o.ok){const n=await o.text();throw new Error(`Failed to exchange code for tokens: ${n}`)}return await o.json()}async function v(e){try{await fetch(A,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:`token=${encodeURIComponent(e)}`})}catch{}}async function U(){r(`
24
+ \u25FC Borg MCP Authentication`),r(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
25
+ `);const e=await p();e&&(r("Revoking previous refresh_token to force fresh consent..."),await v(e)),r("Generating PKCE challenge...");const t=L();r("Starting local callback server...");const{port:s,codePromise:i}=await N(),o=`http://localhost:${s}/callback`,n=new k(S);n.searchParams.set("client_id",w),n.searchParams.set("redirect_uri",o),n.searchParams.set("response_type","code"),n.searchParams.set("scope",T.join(" ")),n.searchParams.set("code_challenge",t.challenge),n.searchParams.set("code_challenge_method","S256"),n.searchParams.set("access_type","offline"),n.searchParams.set("prompt","consent select_account"),r(`
26
+ \u{1F4F1} Opening browser for authorization...`),r("If browser does not open, visit:"),r(`${n.toString()}
27
+ `),await b(n.toString()),r("Waiting for authorization...");const c=await i;r("Exchanging authorization code for tokens...");const a=await $(c,t.verifier,s),h=Date.now()+a.expires_in*1e3;await u(a.id_token,h),a.refresh_token?await d(a.refresh_token):(r(`
28
+ \u26A0 No refresh_token returned by Google.`),r(" Your session will expire after ~1 hour and require"),r(" re-running `borg setup`. To enable auto-refresh:"),r(" 1. Visit https://myaccount.google.com/permissions"),r(' 2. Find "Borg MCP" and click "Remove access"'),r(" 3. Re-run `borg setup`"),r(` (Google will then issue a fresh refresh_token.)
29
+ `)),r(`
30
+ \u25FC Authentication successful!
31
+ `)}function B(e){return e?.noBrowser??R()}function y(e=process.env){const t=e.GOOGLE_DEVICE_CLIENT_ID?.trim(),s=e.GOOGLE_DEVICE_CLIENT_SECRET?.trim()||void 0;let i,o;if(t?(i=t,o=s):(i=I,o=G||void 0),!i)throw new Error('No-browser (device-grant) auth needs a Google "TVs & Limited Input devices" OAuth client. Set GOOGLE_DEVICE_CLIENT_ID in the environment, or run `borg setup` on a machine with a browser. See docs/REMOTE_TERMINAL_AUTH.md.');return{clientId:i,clientSecret:o,scopes:T}}function M(e){return new Promise(t=>setTimeout(t,e))}async function q(e={fetch,sleep:M},t=process.env){r(`
32
+ \u25FC Borg MCP Authentication (no-browser mode)`),r(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
33
+ `);const s=y(t),i=await p();i&&(r("Revoking previous refresh_token to force fresh consent..."),await v(i));const o=await P(s,e);r("To authorize Borg MCP on this machine:"),r(` 1. On any device with a browser, open: ${o.verification_url}`),r(` 2. Enter this code: ${o.user_code}
34
+ `),r("Waiting for authorization (this page can be open on your phone or laptop)...");const n=await O(o,s,e),c=Date.now()+n.expires_in*1e3;await u(n.id_token,c),n.refresh_token?await d(n.refresh_token):(r(`
35
+ \u26A0 No refresh_token returned by Google.`),r(" Your session will expire after ~1 hour and require re-running"),r(" `borg setup`. Re-consent at https://myaccount.google.com/permissions"),r(` (remove "Borg MCP") then re-run setup to restore automatic token refresh.
36
+ `)),r(`
37
+ \u25FC Authentication successful!
38
+ `)}async function Q(e){return B(e)?q():U()}async function C(e,t,s){const i={client_id:t,refresh_token:e,grant_type:"refresh_token"};s&&(i.client_secret=s);let o;try{o=await fetch(E,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams(i)})}catch(a){return{ok:!1,kind:"transient",error:new l(`Network failure during token refresh: ${a?.message??"unknown"}`)}}if(!o.ok){let a=null;try{a=await o.json()}catch{return{ok:!1,kind:"transient",error:new l(`Token refresh failed with HTTP ${o.status} (non-JSON body)`)}}return o.status===400&&a?.error==="invalid_grant"?{ok:!1,kind:"invalid",error:new x("invalid_grant",a.error_description)}:{ok:!1,kind:"transient",error:new l(`Token refresh failed with HTTP ${o.status}${a?.error?` (${a.error})`:""}`)}}let n;try{n=await o.json()}catch(a){return{ok:!1,kind:"transient",error:new l(`Token refresh response unparseable: ${a?.message??"unknown"}`)}}if(!n.id_token||typeof n.expires_in!="number")return{ok:!1,kind:"transient",error:new l("Token refresh response missing id_token or expires_in")};const c=Date.now()+n.expires_in*1e3;if(n.refresh_token){const a=await p();await d(n.refresh_token);try{await u(n.id_token,c)}catch(h){if(a)try{await d(a)}catch{}throw h}return{ok:!0}}return await u(n.id_token,c),{ok:!0}}async function Z(e){const t=await C(e,w,g);if(t.ok)return;const s=y(),i=await C(e,s.clientId,s.clientSecret);if(!i.ok)throw t.kind==="invalid"&&i.kind==="invalid"?i.error:t.kind==="transient"?t.error:i.error}export{x as RefreshTokenInvalidError,l as RefreshTransientError,q as authenticateWithDeviceFlow,Q as authenticateWithGoogle,y as buildDeviceAuthConfig,Z as refreshIdToken,B as shouldUseDeviceFlow};