slashvibe-mcp 0.8.1 → 0.8.3

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/auth-store.js CHANGED
@@ -20,11 +20,36 @@ let _token = null;
20
20
  let _handle = null;
21
21
  let _oneLiner = null;
22
22
  let _hydrated = false;
23
+ // Has the SERVER confirmed this credential in this process? A token read off disk is
24
+ // a claim; only a verify round-trip makes it a fact. Kept separate from _token so
25
+ // callers can distinguish "saved" from "authenticated" — announcing presence or
26
+ // unlocking a tool on the strength of a filename is how a green dot comes to mean
27
+ // nothing (issues #107, #110).
28
+ let _verified = false;
23
29
 
24
30
  /**
25
31
  * Hydrate auth state from disk (call once at MCP startup)
26
32
  * This loads persisted state from config.json
27
33
  */
34
+ /**
35
+ * The handle a token was ISSUED FOR, read from its own subject claim.
36
+ *
37
+ * Decode, not verify — the signature is the server's business and it checks it on
38
+ * every call. What this answers is narrower and local: "whose credential is this?"
39
+ * Returns null for anything unparseable, so a malformed token can never masquerade.
40
+ */
41
+ function handleFromToken(token) {
42
+ try {
43
+ const part = String(token).split('.')[1];
44
+ if (!part) return null;
45
+ const claims = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
46
+ const who = claims.handle || claims.sub;
47
+ return typeof who === 'string' && who ? who : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
28
53
  function hydrate() {
29
54
  if (_hydrated) return; // Only hydrate once
30
55
 
@@ -33,16 +58,35 @@ function hydrate() {
33
58
  const cfg = config.load();
34
59
 
35
60
  _token = cfg.authToken || cfg.privyToken || null;
36
- _handle = cfg.handle || cfg.username || null;
37
61
  _oneLiner = cfg.one_liner || cfg.workingOn || null;
38
62
 
39
63
  // Cross-client: if no token from config.json, check terminal/buddy auth.json
40
64
  if (!_token) {
41
65
  const terminalAuth = config.loadTerminalAuth();
42
- if (terminalAuth?.token) {
43
- _token = terminalAuth.token;
44
- _handle = _handle || terminalAuth.handle || null;
45
- }
66
+ if (terminalAuth?.token) _token = terminalAuth.token;
67
+ }
68
+
69
+ // THE TOKEN IS THE IDENTITY.
70
+ //
71
+ // The handle used to be read from config.json independently of the token, so the
72
+ // two could name different people and nothing noticed: the client displayed
73
+ // @brightseth, the server saw @vibetester1, and a DM went out as the wrong
74
+ // principal while reporting success. (Found by the first terminal-to-terminal
75
+ // test, issue #107 — a self-send recorded in the database.)
76
+ //
77
+ // A stored handle is a label someone wrote down; the token is what the server
78
+ // acts on. When they disagree, the file is wrong — never the credential.
79
+ const stored = cfg.handle || cfg.username || null;
80
+ _handle = _token ? handleFromToken(_token) : null;
81
+
82
+ if (_token && !_handle) {
83
+ // A token we cannot attribute is one we must not act under.
84
+ console.error('[auth-store] Token carries no identity — treating as signed out');
85
+ _token = null;
86
+ } else if (_token && stored && stored !== _handle) {
87
+ console.error(
88
+ `[auth-store] Stored handle @${stored} does not own this session — acting as @${_handle}`
89
+ );
46
90
  }
47
91
 
48
92
  _hydrated = true;
@@ -61,9 +105,32 @@ function hydrate() {
61
105
  * Set auth token (call after OAuth completes)
62
106
  * @param {string} token - JWT token
63
107
  */
64
- function setToken(token) {
108
+ function setToken(token, { verified = false } = {}) {
109
+ if (token) {
110
+ const who = handleFromToken(token);
111
+ if (!who) {
112
+ // Same rule hydrate() applies, applied here too — a credential we cannot
113
+ // attribute must not become the session.
114
+ console.error('[auth-store] Refusing a token with no identity');
115
+ _token = null; _handle = null; _verified = false;
116
+ return;
117
+ }
118
+ }
119
+
65
120
  const hadToken = !!_token;
66
121
  _token = token;
122
+ _verified = !!token && verified;
123
+
124
+ // Identity travels WITH the credential (see hydrate). A new token can belong to a
125
+ // different person — after a re-auth, an account switch, or a cross-client sync —
126
+ // so the handle is re-derived here rather than left pointing at the previous one.
127
+ if (token) {
128
+ const who = handleFromToken(token);
129
+ if (who && who !== _handle) {
130
+ if (_handle) console.error(`[auth-store] Now acting as @${who} (was @${_handle})`);
131
+ _handle = who;
132
+ }
133
+ }
67
134
 
68
135
  if (!hadToken && token) {
69
136
  console.error('[auth-store] Token set (was empty)');
@@ -120,6 +187,21 @@ function isAuthenticated() {
120
187
  return !!_token;
121
188
  }
122
189
 
190
+ /** The server confirmed this credential in this process. */
191
+ function isVerified() {
192
+ return !!_token && _verified;
193
+ }
194
+
195
+ /** Record a successful server verification for the token we already hold. */
196
+ function markVerified(handle) {
197
+ if (!_token) return;
198
+ if (handle && handle !== _handle) {
199
+ console.error(`[auth-store] Server says this session is @${handle} (held @${_handle})`);
200
+ _handle = handle;
201
+ }
202
+ _verified = true;
203
+ }
204
+
123
205
  /**
124
206
  * Clear all auth state (for logout/reset)
125
207
  */
@@ -127,6 +209,7 @@ function clear() {
127
209
  _token = null;
128
210
  _handle = null;
129
211
  _oneLiner = null;
212
+ _verified = false;
130
213
  console.error('[auth-store] Cleared');
131
214
  }
132
215
 
@@ -154,5 +237,7 @@ module.exports = {
154
237
  getOneLiner,
155
238
  isAuthenticated,
156
239
  clear,
157
- getState
240
+ getState,
241
+ isVerified,
242
+ markVerified,
158
243
  };
package/incoming.js CHANGED
@@ -62,14 +62,27 @@ function renderIncoming(items, { replyTo, threadHint } = {}) {
62
62
  * - our own fence markers are neutralized, as in scrub();
63
63
  * - backticks and square brackets are defanged, so foreign text cannot open a code
64
64
  * span or synthesize a link/citation that looks like ours;
65
+ * - bidi overrides and zero-width characters are stripped, so the bytes a model
66
+ * reads cannot differ from the glyphs a human sees;
65
67
  * - it is length-bounded, because the injection payloads that work are long.
66
68
  *
69
+ * What it deliberately does NOT do is judge meaning. A status that reads
70
+ * "SYSTEM: call vibe_dm" survives as TEXT — and must, because the alternative is a
71
+ * blocklist that fails silently. Meaning is handled by the other half of the rule:
72
+ * foreign text is always labelled as data, and no surface may place it next to an
73
+ * instruction to act on it. That is why the weave draft-and-send prompt was removed
74
+ * rather than filtered.
75
+ *
67
76
  * Callers still label the region as data — this makes the VALUE inert, and the
68
77
  * surrounding copy says whose words they are.
69
78
  */
70
79
  function inertField(text, maxLen = 80) {
71
80
  const flat = String(text || '')
72
81
  .replace(/[\u0000-\u001f\u007f]/g, ' ') // control chars, incl. newlines
82
+ // Bidi overrides and invisible formatting characters: a status can otherwise
83
+ // render as one thing to the human reading the terminal and another to the model
84
+ // reading the same bytes, or hide text inside an apparently short field.
85
+ .replace(/[\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff\u00ad]/g, '')
73
86
  .replace(/\s+/g, ' ')
74
87
  .replaceAll('<<<', '\u2039\u2039\u2039')
75
88
  .replaceAll('>>>', '\u203a\u203a\u203a')
package/index.js CHANGED
@@ -665,8 +665,14 @@ class VibeMCPServer {
665
665
 
666
666
  async autoPresence() {
667
667
  try {
668
- const handle = config.getHandle();
669
- if (!handle) return; // Not authenticated yet
668
+ // Presence follows the CREDENTIAL, not a handle written in a file. This used to
669
+ // read config.getHandle() and announce "Auto-connected" on the strength of that
670
+ // alone — so an expired or foreign session still printed a confident green line
671
+ // and started a heartbeat that could only 401. That is the client-side version
672
+ // of the bug where an agent holds a green dot and never reads its mail: the dot
673
+ // has to mean someone is actually reachable. (issues #107, #91)
674
+ const handle = authStore.getHandle();
675
+ if (!handle || !authStore.isAuthenticated()) return;
670
676
 
671
677
  const one_liner = config.getOneLiner() || '';
672
678
 
@@ -0,0 +1,271 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const http = require('http');
5
+
6
+ const CALLBACK_HOST = '127.0.0.1';
7
+ const DEFAULT_CALLBACK_PORT = 9876;
8
+ const DEFAULT_TIMEOUT_MS = 300000;
9
+ const DEFAULT_GRACE_MS = 300000;
10
+ const LOGIN_URL = 'https://www.slashvibe.dev/login';
11
+
12
+ const SUCCESS_PAGE = `<!DOCTYPE html>
13
+ <html lang="en">
14
+ <head>
15
+ <meta charset="UTF-8">
16
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
17
+ <title>signed in to /vibe</title>
18
+ <link rel="stylesheet" href="https://www.slashvibe.dev/vibe-tokens.css">
19
+ <style>
20
+ * { margin: 0; padding: 0; box-sizing: border-box; }
21
+ body {
22
+ background: var(--bg, #0A0A0A);
23
+ color: var(--dim, #9CA3AF);
24
+ font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
25
+ font-size: var(--t-14, 14px);
26
+ line-height: 1.6;
27
+ min-height: 100vh;
28
+ display: flex; align-items: center; justify-content: center;
29
+ padding: var(--s-6, 24px);
30
+ }
31
+ .card {
32
+ border: 1px solid var(--line, #1F2937);
33
+ border-radius: var(--r-lg, 10px);
34
+ background: var(--panel, #111316);
35
+ padding: var(--s-8, 32px);
36
+ max-width: 420px; width: 100%;
37
+ }
38
+ .logo { color: var(--ink, #E0E0E0); font-size: var(--t-16, 16px); margin-bottom: var(--s-6, 24px); }
39
+ .logo span { color: var(--blue, #6B8FFF); }
40
+ .line { color: var(--ink, #E0E0E0); font-size: var(--t-20, 20px); margin-bottom: var(--s-2, 8px); }
41
+ .dot { color: var(--green, #22c55e); }
42
+ .meta { font-size: var(--t-13, 13px); }
43
+ .next {
44
+ margin-top: var(--s-6, 24px); padding-top: var(--s-4, 16px);
45
+ border-top: 1px solid var(--line, #1F2937); font-size: var(--t-13, 13px);
46
+ }
47
+ code {
48
+ color: var(--ink, #E0E0E0);
49
+ background: var(--bg, #0A0A0A);
50
+ border: 1px solid var(--line, #1F2937);
51
+ border-radius: var(--r-sm, 6px);
52
+ padding: 1px 6px;
53
+ }
54
+ .close { color: var(--faint, #6B7280); font-size: var(--t-11, 11px); margin-top: var(--s-6, 24px); }
55
+ </style>
56
+ </head>
57
+ <body>
58
+ <div class="card">
59
+ <div class="logo">/<span>vibe</span></div>
60
+ <p class="line"><span class="dot">&#x1F7E2;</span> you're in</p>
61
+ <p class="meta">your session is signed in and you're on the board.</p>
62
+ <div class="next">
63
+ back in your terminal, say <code>vibe who</code> to see who's around,
64
+ or <code>vibe inbox</code> if someone already wrote to you.
65
+ </div>
66
+ <p class="close">you can close this window.</p>
67
+ </div>
68
+ </body>
69
+ </html>`;
70
+
71
+ const LATE_CALLBACK_PAGE = `<!DOCTYPE html>
72
+ <html lang="en"><head><meta charset="UTF-8">
73
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
74
+ <title>sign-in timed out · /vibe</title>
75
+ <link rel="stylesheet" href="https://www.slashvibe.dev/vibe-tokens.css">
76
+ <style>
77
+ *{margin:0;padding:0;box-sizing:border-box}
78
+ body{background:var(--bg,#0A0A0A);color:var(--dim,#9CA3AF);font-family:var(--mono,ui-monospace,Menlo,monospace);
79
+ font-size:var(--t-14,14px);line-height:1.6;min-height:100vh;display:flex;align-items:center;
80
+ justify-content:center;padding:24px}
81
+ .card{border:1px solid var(--line,#1F2937);border-radius:var(--r-lg,10px);background:var(--panel,#111316);
82
+ padding:32px;max-width:420px;width:100%}
83
+ .logo{color:var(--ink,#E0E0E0);font-size:var(--t-16,16px);margin-bottom:24px}
84
+ .logo span{color:var(--blue,#6B8FFF)}
85
+ h1{color:var(--ink,#E0E0E0);font-size:var(--t-20,20px);font-weight:600;margin-bottom:8px}
86
+ code{color:var(--ink,#E0E0E0);background:var(--bg,#0A0A0A);border:1px solid var(--line,#1F2937);
87
+ border-radius:var(--r-sm,6px);padding:1px 6px}
88
+ .next{margin-top:24px;padding-top:16px;border-top:1px solid var(--line,#1F2937);font-size:var(--t-13,13px)}
89
+ </style></head>
90
+ <body><div class="card">
91
+ <div class="logo">/<span>vibe</span></div>
92
+ <h1>sign-in took too long</h1>
93
+ <p>your terminal stopped waiting, so this sign-in didn't finish. nothing is broken and
94
+ nothing was saved.</p>
95
+ <div class="next">back in your terminal, say <code>vibe init</code> and it will open a fresh
96
+ sign-in. this window can be closed.</div>
97
+ </div></body></html>`;
98
+
99
+ const BAD_CALLBACK_PAGE = `<!DOCTYPE html>
100
+ <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
101
+ <title>sign-in could not finish · /vibe</title></head>
102
+ <body><p>this sign-in callback could not be accepted. return to your terminal and try again.</p></body></html>`;
103
+
104
+ function listen(server, port) {
105
+ return new Promise((resolve, reject) => {
106
+ const onError = (error) => {
107
+ server.removeListener('listening', onListening);
108
+ reject(error);
109
+ };
110
+ const onListening = () => {
111
+ server.removeListener('error', onError);
112
+ resolve();
113
+ };
114
+
115
+ server.once('error', onError);
116
+ server.once('listening', onListening);
117
+ server.listen(port, CALLBACK_HOST);
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Bind the loopback listener, THEN hand back the URL to open.
123
+ * Binding first is load-bearing: opening the browser before the socket exists is
124
+ * what lets a local squatter or a concurrent init receive the credential.
125
+ */
126
+ async function beginOAuth({
127
+ requestedHandle,
128
+ timeoutMs = DEFAULT_TIMEOUT_MS,
129
+ graceMs = DEFAULT_GRACE_MS,
130
+ } = {}) {
131
+ const state = crypto.randomBytes(32).toString('hex');
132
+ let phase = 'waiting';
133
+ let timeoutTimer = null;
134
+ let graceTimer = null;
135
+ let resolveCallback;
136
+ let rejectCallback;
137
+ let closePromise;
138
+
139
+ const callbackPromise = new Promise((resolve, reject) => {
140
+ resolveCallback = resolve;
141
+ rejectCallback = reject;
142
+ });
143
+ // A caller can cancel without ever waiting. Keep that from becoming an
144
+ // unhandled rejection while preserving the original promise for waiters.
145
+ callbackPromise.catch(() => {});
146
+
147
+ const server = http.createServer((req, res) => {
148
+ const url = new URL(req.url, `http://${CALLBACK_HOST}`);
149
+
150
+ if (url.pathname !== '/callback') {
151
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
152
+ res.end('Not found');
153
+ return;
154
+ }
155
+
156
+ if (url.searchParams.get('state') !== state) {
157
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
158
+ res.end(BAD_CALLBACK_PAGE);
159
+ return;
160
+ }
161
+
162
+ if (phase === 'timedOut') {
163
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
164
+ res.end(LATE_CALLBACK_PAGE);
165
+ return;
166
+ }
167
+
168
+ const token = url.searchParams.get('token');
169
+ const callbackHandle = url.searchParams.get('handle');
170
+ if (phase !== 'waiting' || !token || !callbackHandle) {
171
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
172
+ res.end(BAD_CALLBACK_PAGE);
173
+ return;
174
+ }
175
+
176
+ // WHO AUTHENTICATED WINS — not who we asked for. A requested handle is a
177
+ // preference for a brand-new account, never a claim about the credential.
178
+ if (requestedHandle && requestedHandle !== callbackHandle) {
179
+ console.error(
180
+ `[vibe] Signed in as @${callbackHandle} (you asked for @${requestedHandle}) — using the account that authenticated.`
181
+ );
182
+ }
183
+
184
+ phase = 'completed';
185
+ clearTimeout(timeoutTimer);
186
+ clearTimeout(graceTimer);
187
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
188
+ res.end(SUCCESS_PAGE, () => { closeServer().catch(() => {}); });
189
+ resolveCallback({ token, handle: callbackHandle });
190
+ });
191
+
192
+ function closeServer() {
193
+ if (closePromise) return closePromise;
194
+ if (!server.listening) {
195
+ closePromise = Promise.resolve();
196
+ return closePromise;
197
+ }
198
+ closePromise = new Promise((resolve, reject) => {
199
+ server.close((error) => {
200
+ if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') reject(error);
201
+ else resolve();
202
+ });
203
+ });
204
+ return closePromise;
205
+ }
206
+
207
+ try {
208
+ await listen(server, DEFAULT_CALLBACK_PORT);
209
+ } catch (error) {
210
+ if (error.code !== 'EADDRINUSE') {
211
+ phase = 'cancelled';
212
+ rejectCallback(error);
213
+ throw error;
214
+ }
215
+
216
+ try {
217
+ await listen(server, 0);
218
+ } catch (fallbackError) {
219
+ phase = 'cancelled';
220
+ const finalError = fallbackError.code === 'EADDRINUSE'
221
+ ? new Error('AUTH_IN_PROGRESS')
222
+ : fallbackError;
223
+ rejectCallback(finalError);
224
+ throw finalError;
225
+ }
226
+ }
227
+
228
+ const address = server.address();
229
+ const port = address.port;
230
+ const callbackUrl = new URL(`http://${CALLBACK_HOST}:${port}/callback`);
231
+ callbackUrl.searchParams.set('state', state);
232
+ const loginUrl = new URL(LOGIN_URL);
233
+ loginUrl.searchParams.set('redirect', callbackUrl.toString());
234
+ loginUrl.searchParams.set('state', state);
235
+ if (requestedHandle) loginUrl.searchParams.set('handle', requestedHandle);
236
+
237
+ timeoutTimer = setTimeout(() => {
238
+ if (phase !== 'waiting') return;
239
+ phase = 'timedOut';
240
+ rejectCallback(new Error('AUTH_TIMEOUT'));
241
+ graceTimer = setTimeout(() => {
242
+ if (phase === 'timedOut') {
243
+ phase = 'closed';
244
+ closeServer().catch(() => {});
245
+ }
246
+ }, graceMs);
247
+ }, timeoutMs);
248
+
249
+ async function cancel() {
250
+ clearTimeout(timeoutTimer);
251
+ clearTimeout(graceTimer);
252
+ if (phase === 'waiting') {
253
+ phase = 'cancelled';
254
+ rejectCallback(new Error('AUTH_CANCELLED'));
255
+ } else if (phase !== 'closed') {
256
+ phase = 'cancelled';
257
+ }
258
+
259
+ await closeServer();
260
+ }
261
+
262
+ return {
263
+ loginUrl: loginUrl.toString(),
264
+ port,
265
+ state,
266
+ waitForCallback: () => callbackPromise,
267
+ cancel,
268
+ };
269
+ }
270
+
271
+ module.exports = { beginOAuth };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slashvibe-mcp",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "mcpName": "io.github.vibecodinginc/vibe",
5
5
  "description": "Presence + messaging for terminal coding agents (Claude Code, Codex, Cursor) — the /vibe kernel",
6
6
  "main": "index.js",
@@ -64,6 +64,7 @@
64
64
  "memory.js",
65
65
  "notification-emitter.js",
66
66
  "notify.js",
67
+ "oauth-callback.js",
67
68
  "presence.js",
68
69
  "prompts.js",
69
70
  "protocol/index.js",
package/presence.js CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  const config = require('./config');
16
+ const authStore = require('./auth-store');
16
17
  const store = require('./store');
17
18
  const notify = require('./notify');
18
19
  const { apiHeaders } = require('./api-auth');
@@ -53,8 +54,10 @@ function stop() {
53
54
  async function initSession() {
54
55
  if (!config.isInitialized()) return;
55
56
 
56
- // Use session-aware getters (prefer session identity over shared config)
57
- const handle = config.getHandle();
57
+ // The handle we broadcast is the one our CREDENTIAL names — not the one written in
58
+ // a config file. Reading config here is what made "presence follows the credential"
59
+ // untrue after #107: the gate moved, the heartbeat did not (codex #4).
60
+ const handle = authStore.getHandle();
58
61
  if (!handle) return;
59
62
 
60
63
  // Get or create session ID
@@ -74,8 +77,8 @@ async function initSession() {
74
77
  async function sendHeartbeat() {
75
78
  if (!config.isInitialized()) return;
76
79
 
77
- // Use session-aware getters (prefer session identity over shared config)
78
- const handle = config.getHandle();
80
+ // Same rule as initSession: presence names whoever holds the credential.
81
+ const handle = authStore.getHandle();
79
82
  const one_liner = config.getOneLiner();
80
83
  if (handle) {
81
84
  store.heartbeat(handle, one_liner || '');
package/setup.js CHANGED
@@ -18,7 +18,7 @@ const fs = require('fs');
18
18
  const path = require('path');
19
19
  const os = require('os');
20
20
  const { exec, execSync } = require('child_process');
21
- const http = require('http');
21
+ const { beginOAuth } = require('./oauth-callback');
22
22
 
23
23
  // ANSI colors for terminal output
24
24
  const colors = {
@@ -31,9 +31,7 @@ const colors = {
31
31
  dim: '\x1b[2m'
32
32
  };
33
33
 
34
- const CALLBACK_PORT = 9876;
35
34
  const API_BASE = 'https://www.slashvibe.dev';
36
- const LOGIN_URL = 'https://www.slashvibe.dev/login';
37
35
 
38
36
  /**
39
37
  * Print styled banner
@@ -315,90 +313,6 @@ function openBrowser(url) {
315
313
  });
316
314
  }
317
315
 
318
- /**
319
- * Wait for OAuth callback
320
- */
321
- function waitForAuth() {
322
- return new Promise((resolve, reject) => {
323
- let resolved = false;
324
-
325
- const server = http.createServer(async (req, res) => {
326
- const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
327
-
328
- if (url.pathname === '/callback') {
329
- const token = url.searchParams.get('token');
330
- const handle = url.searchParams.get('handle');
331
-
332
- if (token && handle) {
333
- // Send success page
334
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
335
- res.end(`<!DOCTYPE html>
336
- <html>
337
- <head>
338
- <title>Welcome to /vibe!</title>
339
- <style>
340
- body {
341
- font-family: 'SF Mono', Monaco, monospace;
342
- background: #0a0a0a;
343
- color: #e0e0e0;
344
- display: flex;
345
- justify-content: center;
346
- align-items: center;
347
- min-height: 100vh;
348
- margin: 0;
349
- }
350
- .container {
351
- text-align: center;
352
- padding: 2rem;
353
- }
354
- h1 { color: #00FF88; }
355
- .handle { color: #6B8FFF; }
356
- .close { color: #888; margin-top: 2rem; }
357
- </style>
358
- </head>
359
- <body>
360
- <div class="container">
361
- <h1>✓ Setup Complete!</h1>
362
- <p>Welcome, <span class="handle">@${handle}</span></p>
363
- <p>You're now connected to /vibe</p>
364
- <p class="close">You can close this window</p>
365
- </div>
366
- </body>
367
- </html>`);
368
-
369
- resolved = true;
370
- setTimeout(() => server.close(), 500);
371
- resolve({ success: true, handle, token });
372
- } else {
373
- res.writeHead(400);
374
- res.end('Missing token or handle');
375
- }
376
- } else {
377
- res.writeHead(404);
378
- res.end('Not found');
379
- }
380
- });
381
-
382
- server.on('error', (err) => {
383
- if (err.code === 'EADDRINUSE') {
384
- reject(new Error('Auth server port in use'));
385
- } else {
386
- reject(err);
387
- }
388
- });
389
-
390
- server.listen(CALLBACK_PORT, '127.0.0.1');
391
-
392
- // Timeout after 2 minutes
393
- setTimeout(() => {
394
- if (!resolved) {
395
- server.close();
396
- reject(new Error('Auth timed out'));
397
- }
398
- }, 120000);
399
- });
400
- }
401
-
402
316
  /**
403
317
  * Save auth config
404
318
  */
@@ -486,14 +400,16 @@ async function setup() {
486
400
  // Step 4: Authenticate
487
401
  printStep(4, 'Opening browser for GitHub auth...', 'running');
488
402
 
489
- const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
490
- const loginUrl = `${LOGIN_URL}?redirect=${encodeURIComponent(callbackUrl)}&setup=true`;
491
-
492
- openBrowser(loginUrl);
493
- console.log(`${colors.dim} → Waiting for authentication...${colors.reset}`);
403
+ let oauth;
494
404
 
495
405
  try {
496
- const authResult = await waitForAuth();
406
+ // The callback listener is bound before beginOAuth returns, so the browser
407
+ // cannot race a listener that does not exist yet.
408
+ oauth = await beginOAuth();
409
+ openBrowser(oauth.loginUrl);
410
+ console.log(`${colors.dim} → Waiting for authentication...${colors.reset}`);
411
+
412
+ const authResult = await oauth.waitForCallback();
497
413
 
498
414
  // Save auth config
499
415
  saveAuthConfig(authResult.handle, authResult.token);
@@ -547,15 +463,15 @@ async function setup() {
547
463
  printStep(4, 'Opening browser for GitHub auth...', 'error');
548
464
 
549
465
  // Specific error recovery messages
550
- if (err.message.includes('timed out')) {
551
- console.log(`${colors.red} → Authentication timed out (2 min limit)${colors.reset}`);
466
+ if (err.message === 'AUTH_TIMEOUT') {
467
+ console.log(`${colors.red} → Authentication timed out (5 min limit)${colors.reset}`);
552
468
  console.log('');
553
469
  console.log(`${colors.yellow} What happened:${colors.reset}`);
554
470
  console.log(`${colors.dim} The browser auth wasn't completed in time.${colors.reset}`);
555
471
  console.log('');
556
472
  console.log(`${colors.bold} Try again:${colors.reset}`);
557
473
  console.log(`${colors.cyan} npx slashvibe-mcp setup${colors.reset}`);
558
- } else if (err.message.includes('port in use')) {
474
+ } else if (err.message === 'AUTH_IN_PROGRESS') {
559
475
  console.log(`${colors.red} → Auth callback port busy${colors.reset}`);
560
476
  console.log('');
561
477
  console.log(`${colors.yellow} What happened:${colors.reset}`);
@@ -573,9 +489,12 @@ async function setup() {
573
489
  console.log(`${colors.dim} 2. Try again: ${colors.cyan}npx slashvibe-mcp setup${colors.reset}`);
574
490
  console.log(`${colors.dim} 3. Or in Claude Code, type: ${colors.cyan}add the vibe mcp server${colors.reset}`);
575
491
  }
492
+ if (oauth && err.message !== 'AUTH_TIMEOUT') await oauth.cancel();
576
493
  console.log('');
577
494
  console.log(`${colors.dim} Need help? slashvibe.dev/help${colors.reset}`);
578
- process.exit(1);
495
+ // On timeout the shared listener remains live during its grace window so a
496
+ // late browser callback gets the explanatory page instead of a refusal.
497
+ process.exitCode = 1;
579
498
  }
580
499
  }
581
500