slashvibe-mcp 0.8.2 → 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 +38 -2
- package/incoming.js +13 -0
- package/oauth-callback.js +271 -0
- package/package.json +2 -1
- package/presence.js +7 -4
- package/setup.js +16 -97
- package/tools/init.js +46 -256
- package/tools/start.js +1 -2
- package/tools/token.js +10 -1
- package/tools/weave.js +23 -11
- package/tools/who.js +2 -3
- package/version.json +8 -7
package/auth-store.js
CHANGED
|
@@ -20,6 +20,12 @@ 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)
|
|
@@ -99,9 +105,21 @@ function hydrate() {
|
|
|
99
105
|
* Set auth token (call after OAuth completes)
|
|
100
106
|
* @param {string} token - JWT token
|
|
101
107
|
*/
|
|
102
|
-
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
|
+
|
|
103
120
|
const hadToken = !!_token;
|
|
104
121
|
_token = token;
|
|
122
|
+
_verified = !!token && verified;
|
|
105
123
|
|
|
106
124
|
// Identity travels WITH the credential (see hydrate). A new token can belong to a
|
|
107
125
|
// different person — after a re-auth, an account switch, or a cross-client sync —
|
|
@@ -169,6 +187,21 @@ function isAuthenticated() {
|
|
|
169
187
|
return !!_token;
|
|
170
188
|
}
|
|
171
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
|
+
|
|
172
205
|
/**
|
|
173
206
|
* Clear all auth state (for logout/reset)
|
|
174
207
|
*/
|
|
@@ -176,6 +209,7 @@ function clear() {
|
|
|
176
209
|
_token = null;
|
|
177
210
|
_handle = null;
|
|
178
211
|
_oneLiner = null;
|
|
212
|
+
_verified = false;
|
|
179
213
|
console.error('[auth-store] Cleared');
|
|
180
214
|
}
|
|
181
215
|
|
|
@@ -203,5 +237,7 @@ module.exports = {
|
|
|
203
237
|
getOneLiner,
|
|
204
238
|
isAuthenticated,
|
|
205
239
|
clear,
|
|
206
|
-
getState
|
|
240
|
+
getState,
|
|
241
|
+
isVerified,
|
|
242
|
+
markVerified,
|
|
207
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')
|
|
@@ -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">🟢</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.
|
|
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
|
-
//
|
|
57
|
-
|
|
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
|
-
//
|
|
78
|
-
const handle =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
551
|
-
console.log(`${colors.red} → Authentication timed out (
|
|
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
|
|
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
|
-
|
|
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
|
|
package/tools/init.js
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
* 5. Tool WAITS for callback and returns success
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
const http = require('http');
|
|
13
12
|
const { exec, execSync } = require('child_process');
|
|
14
13
|
const fs = require('fs');
|
|
15
14
|
const path = require('path');
|
|
@@ -17,8 +16,8 @@ const config = require('../config');
|
|
|
17
16
|
const store = require('../store');
|
|
18
17
|
const discord = require('../discord');
|
|
19
18
|
const authStore = require('../auth-store');
|
|
19
|
+
const { beginOAuth } = require('../oauth-callback');
|
|
20
20
|
|
|
21
|
-
const CALLBACK_PORT = 9876;
|
|
22
21
|
const API_BASE = 'https://www.slashvibe.dev';
|
|
23
22
|
|
|
24
23
|
/**
|
|
@@ -241,15 +240,7 @@ async function sendPersonalizedWelcome(handle, oneLiner) {
|
|
|
241
240
|
}
|
|
242
241
|
}
|
|
243
242
|
|
|
244
|
-
const LOGIN_URL = 'https://www.slashvibe.dev/login';
|
|
245
243
|
const API_URL = process.env.VIBE_API_URL || 'https://www.slashvibe.dev';
|
|
246
|
-
// A first sign-in is not two minutes of typing: it can be a GitHub login, 2FA, a
|
|
247
|
-
// password manager, or switching accounts. The old fuse expired mid-flow for people
|
|
248
|
-
// who WROTE this client (issue #108), so it is now generous.
|
|
249
|
-
const AUTH_TIMEOUT_MS = 300000; // 5 minutes to complete the browser flow
|
|
250
|
-
// After we stop waiting, keep the socket up briefly so a late callback still gets a
|
|
251
|
-
// real page instead of the browser's connection-refused error.
|
|
252
|
-
const LATE_CALLBACK_GRACE_MS = 300000; // 5 more minutes to answer honestly
|
|
253
244
|
|
|
254
245
|
/**
|
|
255
246
|
* Send welcome message from @seth (founder)
|
|
@@ -269,13 +260,9 @@ async function sendWelcomeMessage(handle, one_liner) {
|
|
|
269
260
|
}
|
|
270
261
|
}
|
|
271
262
|
|
|
272
|
-
// The exact link a user can click if the browser didn't pop open on its own.
|
|
273
|
-
// Deterministic (fixed callback port), so it's safe to show verbatim anywhere.
|
|
274
|
-
const MANUAL_LOGIN_URL = `https://www.slashvibe.dev/login?redirect=${encodeURIComponent(`http://localhost:${CALLBACK_PORT}/callback`)}`;
|
|
275
|
-
|
|
276
263
|
const definition = {
|
|
277
264
|
name: 'vibe_init',
|
|
278
|
-
description: `Join /vibe social network. Opens the browser for GitHub sign-in — NO INPUT NEEDED; the user's GitHub username becomes their handle automatically. This BLOCKS for up to
|
|
265
|
+
description: `Join /vibe social network. Opens the browser for GitHub sign-in — NO INPUT NEEDED; the user's GitHub username becomes their handle automatically. This BLOCKS for up to 5 minutes waiting for the browser login to finish, so BEFORE it returns the user sees only a spinner. IMPORTANT: right when you call this, tell the user in your own words that their browser is opening to sign in with GitHub, then finish the login there and come back.`,
|
|
279
266
|
inputSchema: {
|
|
280
267
|
type: 'object',
|
|
281
268
|
properties: {
|
|
@@ -314,221 +301,6 @@ function openBrowser(url) {
|
|
|
314
301
|
});
|
|
315
302
|
}
|
|
316
303
|
|
|
317
|
-
/**
|
|
318
|
-
* Wait for OAuth callback - returns Promise that resolves with handle when auth completes
|
|
319
|
-
*/
|
|
320
|
-
function waitForCallback(requestedHandle, one_liner) {
|
|
321
|
-
return new Promise((resolve, reject) => {
|
|
322
|
-
let resolved = false;
|
|
323
|
-
let timedOut = false;
|
|
324
|
-
|
|
325
|
-
const server = http.createServer(async (req, res) => {
|
|
326
|
-
const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
|
|
327
|
-
|
|
328
|
-
// A callback that arrives after we stopped waiting still deserves an answer.
|
|
329
|
-
// Anything is better than the browser's raw connection-refused page (issue #108).
|
|
330
|
-
if (timedOut && url.pathname === '/callback') {
|
|
331
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
332
|
-
res.end(`<!DOCTYPE html>
|
|
333
|
-
<html lang="en"><head><meta charset="UTF-8">
|
|
334
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
335
|
-
<title>sign-in timed out · /vibe</title>
|
|
336
|
-
<link rel="stylesheet" href="https://www.slashvibe.dev/vibe-tokens.css">
|
|
337
|
-
<style>
|
|
338
|
-
*{margin:0;padding:0;box-sizing:border-box}
|
|
339
|
-
body{background:var(--bg,#0A0A0A);color:var(--dim,#9CA3AF);font-family:var(--mono,ui-monospace,Menlo,monospace);
|
|
340
|
-
font-size:var(--t-14,14px);line-height:1.6;min-height:100vh;display:flex;align-items:center;
|
|
341
|
-
justify-content:center;padding:24px}
|
|
342
|
-
.card{border:1px solid var(--line,#1F2937);border-radius:var(--r-lg,10px);background:var(--panel,#111316);
|
|
343
|
-
padding:32px;max-width:420px;width:100%}
|
|
344
|
-
.logo{color:var(--ink,#E0E0E0);font-size:var(--t-16,16px);margin-bottom:24px}
|
|
345
|
-
.logo span{color:var(--blue,#6B8FFF)}
|
|
346
|
-
h1{color:var(--ink,#E0E0E0);font-size:var(--t-20,20px);font-weight:600;margin-bottom:8px}
|
|
347
|
-
code{color:var(--ink,#E0E0E0);background:var(--bg,#0A0A0A);border:1px solid var(--line,#1F2937);
|
|
348
|
-
border-radius:var(--r-sm,6px);padding:1px 6px}
|
|
349
|
-
.next{margin-top:24px;padding-top:16px;border-top:1px solid var(--line,#1F2937);font-size:var(--t-13,13px)}
|
|
350
|
-
</style></head>
|
|
351
|
-
<body><div class="card">
|
|
352
|
-
<div class="logo">/<span>vibe</span></div>
|
|
353
|
-
<h1>sign-in took too long</h1>
|
|
354
|
-
<p>your terminal stopped waiting, so this sign-in didn't finish. nothing is broken and
|
|
355
|
-
nothing was saved.</p>
|
|
356
|
-
<div class="next">back in your terminal, say <code>vibe init</code> and it will open a fresh
|
|
357
|
-
sign-in. this window can be closed.</div>
|
|
358
|
-
</div></body></html>`);
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Handle callback
|
|
363
|
-
if (url.pathname === '/callback') {
|
|
364
|
-
const token = url.searchParams.get('token');
|
|
365
|
-
const callbackHandle = url.searchParams.get('handle');
|
|
366
|
-
|
|
367
|
-
if (token && callbackHandle) {
|
|
368
|
-
// WHO AUTHENTICATED WINS — not who we asked for.
|
|
369
|
-
//
|
|
370
|
-
// This was `requestedHandle || callbackHandle`, i.e. the handle passed into
|
|
371
|
-
// vibe_init overrode the identity the OAuth flow actually returned. Ask to
|
|
372
|
-
// sign in as @a while the browser is signed in as @b and the client stores
|
|
373
|
-
// @b's token under @a's name: the banner says @a, every message goes out as
|
|
374
|
-
// @b. That is exactly the mismatch issue #107 records, reproduced here.
|
|
375
|
-
//
|
|
376
|
-
// A requested handle is a preference for what to CALL a brand-new account,
|
|
377
|
-
// never a claim about whose credential this is.
|
|
378
|
-
const finalHandle = callbackHandle;
|
|
379
|
-
if (requestedHandle && requestedHandle !== callbackHandle) {
|
|
380
|
-
console.error(
|
|
381
|
-
`[vibe_init] Signed in as @${callbackHandle} (you asked for @${requestedHandle}) — using the account that authenticated.`
|
|
382
|
-
);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Save to config (file persistence for restarts)
|
|
386
|
-
config.saveAuthToken(token);
|
|
387
|
-
config.setSessionIdentity(finalHandle, one_liner || '');
|
|
388
|
-
|
|
389
|
-
// PUSH to in-memory auth store (immediate propagation)
|
|
390
|
-
authStore.setToken(token);
|
|
391
|
-
authStore.setHandle(finalHandle);
|
|
392
|
-
authStore.setOneLiner(one_liner || '');
|
|
393
|
-
|
|
394
|
-
// Update shared config
|
|
395
|
-
const cfg = config.load();
|
|
396
|
-
cfg.handle = finalHandle;
|
|
397
|
-
cfg.one_liner = one_liner || '';
|
|
398
|
-
cfg.authMethod = 'browser';
|
|
399
|
-
cfg.pendingAuth = false;
|
|
400
|
-
config.save(cfg);
|
|
401
|
-
|
|
402
|
-
// Register session with API
|
|
403
|
-
const sessionId = config.getSessionId();
|
|
404
|
-
await store.registerSession(sessionId, finalHandle, one_liner);
|
|
405
|
-
|
|
406
|
-
// Send initial heartbeat
|
|
407
|
-
await store.heartbeat(finalHandle, one_liner);
|
|
408
|
-
|
|
409
|
-
// Post to Discord
|
|
410
|
-
discord.postJoin(finalHandle, one_liner);
|
|
411
|
-
|
|
412
|
-
// NOTE: Welcome message is sent in the main return path (awaited)
|
|
413
|
-
// to ensure it arrives before we show the unread count
|
|
414
|
-
|
|
415
|
-
// Send success response to browser - lightweight, no infinite animations
|
|
416
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
417
|
-
res.end(`<!DOCTYPE html>
|
|
418
|
-
<html lang="en">
|
|
419
|
-
<head>
|
|
420
|
-
<meta charset="UTF-8">
|
|
421
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
422
|
-
<title>signed in to /vibe</title>
|
|
423
|
-
<!-- ROOM TONE, from the one source of record. This page is the LAST thing an
|
|
424
|
-
invitee sees before the product, so it must not look like a different one:
|
|
425
|
-
it used neon #00FF88, cyan, glow shadows, CRT scanlines and two arcade fonts
|
|
426
|
-
— every item on the "not this" list. The user just completed a browser OAuth,
|
|
427
|
-
so they are online by definition and the hosted token file is safe to link. -->
|
|
428
|
-
<link rel="stylesheet" href="https://www.slashvibe.dev/vibe-tokens.css">
|
|
429
|
-
<style>
|
|
430
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
431
|
-
body {
|
|
432
|
-
background: var(--bg, #0A0A0A);
|
|
433
|
-
color: var(--dim, #9CA3AF);
|
|
434
|
-
font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
435
|
-
font-size: var(--t-14, 14px);
|
|
436
|
-
line-height: 1.6;
|
|
437
|
-
min-height: 100vh;
|
|
438
|
-
display: flex; align-items: center; justify-content: center;
|
|
439
|
-
padding: var(--s-6, 24px);
|
|
440
|
-
}
|
|
441
|
-
.card {
|
|
442
|
-
border: 1px solid var(--line, #1F2937);
|
|
443
|
-
border-radius: var(--r-lg, 10px);
|
|
444
|
-
background: var(--panel, #111316);
|
|
445
|
-
padding: var(--s-8, 32px);
|
|
446
|
-
max-width: 420px; width: 100%;
|
|
447
|
-
}
|
|
448
|
-
.logo { color: var(--ink, #E0E0E0); font-size: var(--t-16, 16px); margin-bottom: var(--s-6, 24px); }
|
|
449
|
-
.logo span { color: var(--blue, #6B8FFF); }
|
|
450
|
-
.line { color: var(--ink, #E0E0E0); font-size: var(--t-20, 20px); margin-bottom: var(--s-2, 8px); }
|
|
451
|
-
/* green is presence and nothing else — here it states a fact that is now true:
|
|
452
|
-
this handle is on the board. */
|
|
453
|
-
.dot { color: var(--green, #22c55e); }
|
|
454
|
-
.meta { font-size: var(--t-13, 13px); }
|
|
455
|
-
.next {
|
|
456
|
-
margin-top: var(--s-6, 24px); padding-top: var(--s-4, 16px);
|
|
457
|
-
border-top: 1px solid var(--line, #1F2937); font-size: var(--t-13, 13px);
|
|
458
|
-
}
|
|
459
|
-
code {
|
|
460
|
-
color: var(--ink, #E0E0E0);
|
|
461
|
-
background: var(--bg, #0A0A0A);
|
|
462
|
-
border: 1px solid var(--line, #1F2937);
|
|
463
|
-
border-radius: var(--r-sm, 6px);
|
|
464
|
-
padding: 1px 6px;
|
|
465
|
-
}
|
|
466
|
-
.close { color: var(--faint, #6B7280); font-size: var(--t-11, 11px); margin-top: var(--s-6, 24px); }
|
|
467
|
-
</style>
|
|
468
|
-
</head>
|
|
469
|
-
<body>
|
|
470
|
-
<div class="card">
|
|
471
|
-
<div class="logo">/<span>vibe</span></div>
|
|
472
|
-
<p class="line"><span class="dot">🟢</span> you're in, @${finalHandle}</p>
|
|
473
|
-
<p class="meta">your session is signed in and you're on the board.</p>
|
|
474
|
-
<div class="next">
|
|
475
|
-
back in your terminal, say <code>vibe who</code> to see who's around,
|
|
476
|
-
or <code>vibe inbox</code> if someone already wrote to you.
|
|
477
|
-
</div>
|
|
478
|
-
<p class="close">you can close this window.</p>
|
|
479
|
-
</div>
|
|
480
|
-
</body>
|
|
481
|
-
</html>`);
|
|
482
|
-
|
|
483
|
-
// Close server and resolve
|
|
484
|
-
resolved = true;
|
|
485
|
-
setTimeout(() => server.close(), 500);
|
|
486
|
-
resolve({ success: true, handle: finalHandle });
|
|
487
|
-
} else {
|
|
488
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
489
|
-
res.end('Missing token or handle');
|
|
490
|
-
}
|
|
491
|
-
} else {
|
|
492
|
-
res.writeHead(404);
|
|
493
|
-
res.end('Not found');
|
|
494
|
-
}
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
server.on('error', (err) => {
|
|
498
|
-
if (err.code === 'EADDRINUSE') {
|
|
499
|
-
reject(new Error('AUTH_IN_PROGRESS'));
|
|
500
|
-
} else {
|
|
501
|
-
reject(err);
|
|
502
|
-
}
|
|
503
|
-
});
|
|
504
|
-
|
|
505
|
-
// Start server
|
|
506
|
-
server.listen(CALLBACK_PORT, '127.0.0.1', () => {
|
|
507
|
-
console.log(`[vibe_init] Callback server listening on port ${CALLBACK_PORT}`);
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
// Stop WAITING at the timeout, but keep LISTENING for a grace period.
|
|
511
|
-
//
|
|
512
|
-
// These were the same moment before, and it produced the worst screen in the
|
|
513
|
-
// product: sign-in takes longer than the fuse (first-time GitHub, 2FA, a
|
|
514
|
-
// password manager, switching accounts — all normal), the listener closes, and
|
|
515
|
-
// the browser lands on a raw ERR_CONNECTION_REFUSED holding a valid token, with
|
|
516
|
-
// no explanation and nothing to click. It happened twice in one evening to
|
|
517
|
-
// people who built this. An invitee reads that as "broken". (issue #108)
|
|
518
|
-
//
|
|
519
|
-
// So: the promise rejects on time (the caller must not hang), and the socket
|
|
520
|
-
// stays up long enough to answer a late callback with a page that says what
|
|
521
|
-
// happened and how to retry.
|
|
522
|
-
setTimeout(() => {
|
|
523
|
-
if (!resolved) {
|
|
524
|
-
timedOut = true;
|
|
525
|
-
reject(new Error('AUTH_TIMEOUT'));
|
|
526
|
-
setTimeout(() => { if (!resolved) server.close(); }, LATE_CALLBACK_GRACE_MS);
|
|
527
|
-
}
|
|
528
|
-
}, AUTH_TIMEOUT_MS);
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
|
-
|
|
532
304
|
async function handler(args) {
|
|
533
305
|
const { handle, one_liner, auth_method } = args;
|
|
534
306
|
|
|
@@ -621,6 +393,8 @@ To check messages: \`vibe inbox\`${emailNudge}`
|
|
|
621
393
|
// BROWSER AUTH (Default): GitHub OAuth
|
|
622
394
|
// ===========================================
|
|
623
395
|
if (auth_method === 'browser' || !auth_method) {
|
|
396
|
+
let oauth;
|
|
397
|
+
|
|
624
398
|
// Save one_liner for callback handler
|
|
625
399
|
const cfg = config.load();
|
|
626
400
|
if (h) cfg.handle = h;
|
|
@@ -628,18 +402,44 @@ To check messages: \`vibe inbox\`${emailNudge}`
|
|
|
628
402
|
cfg.pendingAuth = true;
|
|
629
403
|
config.save(cfg);
|
|
630
404
|
|
|
631
|
-
// Build login URL with redirect to our local callback
|
|
632
|
-
const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
|
|
633
|
-
const loginUrl = h
|
|
634
|
-
? `${LOGIN_URL}?redirect=${encodeURIComponent(callbackUrl)}&handle=${encodeURIComponent(h)}`
|
|
635
|
-
: `${LOGIN_URL}?redirect=${encodeURIComponent(callbackUrl)}`;
|
|
636
|
-
|
|
637
|
-
// Open browser BEFORE starting to wait
|
|
638
|
-
openBrowser(loginUrl);
|
|
639
|
-
|
|
640
405
|
try {
|
|
406
|
+
// The callback listener is bound before this resolves. Only then is it
|
|
407
|
+
// safe to hand the attempt-correlated URL to the browser.
|
|
408
|
+
oauth = await beginOAuth({ requestedHandle: h });
|
|
409
|
+
openBrowser(oauth.loginUrl);
|
|
410
|
+
|
|
641
411
|
// Wait for callback (blocks until auth completes or times out)
|
|
642
|
-
const
|
|
412
|
+
const { token, handle: callbackHandle } = await oauth.waitForCallback();
|
|
413
|
+
const finalHandle = callbackHandle;
|
|
414
|
+
|
|
415
|
+
// Save to config (file persistence for restarts)
|
|
416
|
+
config.saveAuthToken(token);
|
|
417
|
+
config.setSessionIdentity(finalHandle, one_liner || '');
|
|
418
|
+
|
|
419
|
+
// PUSH to in-memory auth store (immediate propagation)
|
|
420
|
+
authStore.setToken(token);
|
|
421
|
+
authStore.setHandle(finalHandle);
|
|
422
|
+
authStore.setOneLiner(one_liner || '');
|
|
423
|
+
|
|
424
|
+
// Update shared config
|
|
425
|
+
const authConfig = config.load();
|
|
426
|
+
authConfig.handle = finalHandle;
|
|
427
|
+
authConfig.one_liner = one_liner || '';
|
|
428
|
+
authConfig.authMethod = 'browser';
|
|
429
|
+
authConfig.pendingAuth = false;
|
|
430
|
+
config.save(authConfig);
|
|
431
|
+
|
|
432
|
+
// Register session with API
|
|
433
|
+
const sessionId = config.getSessionId();
|
|
434
|
+
await store.registerSession(sessionId, finalHandle, one_liner);
|
|
435
|
+
|
|
436
|
+
// Send initial heartbeat
|
|
437
|
+
await store.heartbeat(finalHandle, one_liner);
|
|
438
|
+
|
|
439
|
+
// Post to Discord
|
|
440
|
+
discord.postJoin(finalHandle, one_liner);
|
|
441
|
+
|
|
442
|
+
const result = { success: true, handle: finalHandle };
|
|
643
443
|
|
|
644
444
|
// Send personalized welcome and wait for it (2.5s timeout)
|
|
645
445
|
let welcomeResult = null;
|
|
@@ -747,16 +547,8 @@ To check messages: \`vibe inbox\`${emailNudge}`
|
|
|
747
547
|
return {
|
|
748
548
|
display: `## A login is already running
|
|
749
549
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
2. **Open it yourself** if the tab didn't pop up:
|
|
753
|
-
${MANUAL_LOGIN_URL}
|
|
754
|
-
|
|
755
|
-
Still stuck? Clear it and start over:
|
|
756
|
-
\`\`\`
|
|
757
|
-
lsof -ti:9876 | xargs kill
|
|
758
|
-
\`\`\`
|
|
759
|
-
Then say **"let's vibe"** again.`
|
|
550
|
+
Another sign-in owns the callback listener. Finish that browser sign-in, or say
|
|
551
|
+
**"let's vibe"** again to start a fresh attempt.`
|
|
760
552
|
};
|
|
761
553
|
}
|
|
762
554
|
|
|
@@ -764,26 +556,24 @@ Then say **"let's vibe"** again.`
|
|
|
764
556
|
return {
|
|
765
557
|
display: `## The sign-in timed out
|
|
766
558
|
|
|
767
|
-
The browser login wasn't finished within
|
|
559
|
+
The browser login wasn't finished within 5 minutes — no problem, just start it again.
|
|
768
560
|
|
|
769
561
|
**1. Say "let's vibe"** to reopen the login.
|
|
770
|
-
**2.
|
|
771
|
-
${MANUAL_LOGIN_URL}
|
|
772
|
-
**3. Sign in with GitHub** in that tab, then come back here.
|
|
562
|
+
**2. Sign in with GitHub** in that tab, then come back here.
|
|
773
563
|
|
|
774
564
|
_Tip: keep this window and the browser both visible so you can see when it finishes._`
|
|
775
565
|
};
|
|
776
566
|
}
|
|
777
567
|
|
|
568
|
+
if (oauth) await oauth.cancel();
|
|
778
569
|
return {
|
|
779
570
|
display: `## Couldn't finish sign-in
|
|
780
571
|
|
|
781
572
|
**What happened:** ${err.message}
|
|
782
573
|
|
|
783
574
|
**Try this:**
|
|
784
|
-
1. **
|
|
575
|
+
1. Say **"let's vibe"** to open a fresh sign-in
|
|
785
576
|
2. Finish the GitHub sign-in in that tab, then come back
|
|
786
|
-
3. Or say **"let's vibe"** to try the whole thing again
|
|
787
577
|
|
|
788
578
|
**Still stuck?** Email seth@slashvibe.dev — happy to get you in.`
|
|
789
579
|
};
|
package/tools/start.js
CHANGED
|
@@ -368,8 +368,7 @@ async function handler(args) {
|
|
|
368
368
|
display += `\n\n**🎤 ${guestMessages.length} guest message${guestMessages.length > 1 ? 's' : ''} in your session:**`;
|
|
369
369
|
guestMessages.forEach(m => {
|
|
370
370
|
const time = new Date(m.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
371
|
-
|
|
372
|
-
display += `\n• [${time}] @${m.from}: ${preview}`;
|
|
371
|
+
display += `\n• [${time}] @${inertField(m.from, 40)}: ${inertField(m.message, 80)}`;
|
|
373
372
|
});
|
|
374
373
|
display += `\n_Use vibe_guest with action "ack" to clear after reading._`;
|
|
375
374
|
}
|
package/tools/token.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
const config = require('../config');
|
|
12
12
|
const store = require('../store');
|
|
13
|
+
const authStore = require('../auth-store');
|
|
13
14
|
|
|
14
15
|
const definition = {
|
|
15
16
|
name: 'vibe_token',
|
|
@@ -59,8 +60,16 @@ The token may be expired or invalid. Try authenticating again:
|
|
|
59
60
|
};
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
// Save token
|
|
63
|
+
// Save token — to BOTH authorities, in one step.
|
|
64
|
+
//
|
|
65
|
+
// This wrote config and session identity but never touched the in-memory store,
|
|
66
|
+
// which is what every outbound Authorization header actually reads. The result:
|
|
67
|
+
// display and routing moved to the new account while requests kept going out as
|
|
68
|
+
// the old one (codex #3). The store is updated first, marked verified because the
|
|
69
|
+
// server just confirmed it above.
|
|
63
70
|
config.saveAuthToken(token.trim());
|
|
71
|
+
authStore.setToken(token.trim(), { verified: true });
|
|
72
|
+
authStore.markVerified(verification.handle);
|
|
64
73
|
|
|
65
74
|
// Update session identity with verified handle
|
|
66
75
|
const handle = verification.handle;
|
package/tools/weave.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
const config = require('../config');
|
|
22
22
|
const store = require('../store');
|
|
23
|
+
const { renderIncoming } = require('../incoming');
|
|
23
24
|
const { requireInit, normalizeHandle, formatTimeAgo, truncate } = require('./_shared');
|
|
24
25
|
|
|
25
26
|
const definition = {
|
|
@@ -183,17 +184,28 @@ async function findHeldHalf(myHandle) {
|
|
|
183
184
|
async function weaveMoment(myHandle) {
|
|
184
185
|
const held = await findHeldHalf(myHandle);
|
|
185
186
|
if (!held) return '';
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
187
|
+
// This block renders ANOTHER PERSON'S WORDS into a model's context, and it used to
|
|
188
|
+
// do it as a bare markdown blockquote followed by trusted copy saying "Draft it
|
|
189
|
+
// now" and "deliver with vibe_dm". That is untrusted text sitting adjacent to an
|
|
190
|
+
// instruction to compose and send — the strongest injection shape in this codebase,
|
|
191
|
+
// and it fired from DEFAULT vibe_start, not from an opt-in tool.
|
|
192
|
+
//
|
|
193
|
+
// Two changes, both required:
|
|
194
|
+
// 1. the body goes through the shared envelope (framing BEFORE content, markers
|
|
195
|
+
// neutralized) like every other inbound message;
|
|
196
|
+
// 2. nothing here tells the model to draft or send. Drafting is a thing the USER
|
|
197
|
+
// asks for — `vibe weave` still does it on request. A notification may say
|
|
198
|
+
// someone is waiting; it may not act on what they wrote.
|
|
199
|
+
const block =
|
|
200
|
+
renderIncoming([{ from: held.handle, text: held.inbound }], {
|
|
201
|
+
replyTo: held.handle,
|
|
202
|
+
threadHint: true,
|
|
203
|
+
}) +
|
|
204
|
+
`\n_@${held.handle} is waiting on your reply (${held.inboundAgo}). Say \`vibe weave\` if you want help drafting one._`;
|
|
205
|
+
const more = held.otherWaiting > 0
|
|
206
|
+
? `\n_(+${held.otherWaiting} more thread${held.otherWaiting > 1 ? 's' : ''} waiting on you.)_`
|
|
207
|
+
: '';
|
|
208
|
+
return block + more;
|
|
197
209
|
}
|
|
198
210
|
|
|
199
211
|
async function handler(args = {}) {
|
package/tools/who.js
CHANGED
|
@@ -228,8 +228,7 @@ say hi to **@vibe** (the platform account, always around), or send
|
|
|
228
228
|
for (const req of helpRequests.slice(0, 3)) {
|
|
229
229
|
// urgency is a word, not a traffic light
|
|
230
230
|
const urgencyWord = req.urgency === 'high' ? 'urgent' : req.urgency === 'low' ? 'whenever' : 'soon';
|
|
231
|
-
|
|
232
|
-
display += ` **@${req.handle}** (${urgencyWord}): ${shortProblem}\n`;
|
|
231
|
+
display += ` **@${inertField(req.handle, 40)}** (${urgencyWord}): ${inertField(req.problem, 50)}\n`;
|
|
233
232
|
}
|
|
234
233
|
display += `→ \`vibe stuck\` to help or ask\n\n---\n\n`;
|
|
235
234
|
}
|
|
@@ -296,7 +295,7 @@ say hi to **@vibe** (the platform account, always around), or send
|
|
|
296
295
|
const tag = isMe ? ' _(you)_' : '';
|
|
297
296
|
const timeAgo = formatTimeAgo(u.lastSeen);
|
|
298
297
|
|
|
299
|
-
display += `💤 **@${u.handle}**${tag} — _"${u.awayMessage}"_\n`;
|
|
298
|
+
display += `💤 **@${u.handle}**${tag} — _"${inertField(u.awayMessage)}"_\n`;
|
|
300
299
|
display += ` _${timeAgo}_\n\n`;
|
|
301
300
|
});
|
|
302
301
|
|
package/version.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.8.
|
|
2
|
+
"version": "0.8.3",
|
|
3
3
|
"updated": "2026-07-31",
|
|
4
|
-
"changelog": "
|
|
4
|
+
"changelog": "Security release, and the one that reaches the door people actually use. A clean install runs `npx slashvibe-mcp`, which had its own separate sign-in code \u2014 so the fixes shipped in 0.8.2 were never on that path. There is now one sign-in implementation: the listener binds before the browser opens, each attempt is tagged so another process's login can never land in yours, two sign-ins at once both work, and a slow sign-in gets a page explaining itself instead of a browser error. Identity is one thing everywhere \u2014 pasting a token no longer leaves your requests going out as the previous account, and presence announces the account your credential names rather than one read from a file. A message someone sends you is never placed next to an instruction telling your agent to reply.",
|
|
5
5
|
"features": [
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
6
|
+
"One sign-in path for terminal setup and vibe_init \u2014 the invite path has the fixes",
|
|
7
|
+
"Each sign-in attempt is tagged; a concurrent login cannot land in your session",
|
|
8
|
+
"Two sign-ins at once both work (port falls back instead of colliding)",
|
|
9
|
+
"vibe_token switches the whole session, not half of it",
|
|
10
|
+
"Presence names the account your credential names",
|
|
11
|
+
"Inbound messages are never seated beside an instruction to act on them"
|
|
11
12
|
],
|
|
12
13
|
"deprecated": [],
|
|
13
14
|
"breaking": false,
|