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 +92 -7
- package/incoming.js +13 -0
- package/index.js +8 -2
- package/oauth-callback.js +271 -0
- package/package.json +2 -1
- package/presence.js +7 -4
- package/setup.js +16 -97
- package/tools/inbox.js +11 -1
- package/tools/init.js +59 -366
- 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 -5
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
669
|
-
|
|
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">🟢</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
|
|