From c7fc6b0941369ea463a4c49610f0773cd880e557 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Thu, 29 Oct 2026 14:27:01 +0530 Subject: [PATCH 1/2] fix(auth): keep the browser login listening for 5 minutes --- hooks/auth-server.js | 12 +++++ hooks/lib/auth.js | 82 +++++++++++++++++++++++++++--- hooks/session-start.js | 10 ++-- test/unit.mjs | 111 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 hooks/auth-server.js diff --git a/hooks/auth-server.js b/hooks/auth-server.js new file mode 100644 index 0000000..dfe6ff8 --- /dev/null +++ b/hooks/auth-server.js @@ -0,0 +1,12 @@ +// Detached OAuth login started by the SessionStart hook; see startBackgroundAuth. +const { AUTH_WAIT_MS, getOAuthConfig, startAuthFlow } = require('./lib/auth'); + +const [baseUrl, mcpUrl] = process.argv.slice(2); + +startAuthFlow(getOAuthConfig(baseUrl, mcpUrl), { + timeoutMs: AUTH_WAIT_MS, + background: true, +}).then( + () => process.exit(0), + () => process.exit(1), +); diff --git a/hooks/lib/auth.js b/hooks/lib/auth.js index 9809eeb..bc9d8a1 100644 --- a/hooks/lib/auth.js +++ b/hooks/lib/auth.js @@ -1,4 +1,4 @@ -const { execFile } = require('node:child_process'); +const { execFile, spawn } = require('node:child_process'); const crypto = require('node:crypto'); const fs = require('node:fs'); const http = require('node:http'); @@ -34,7 +34,9 @@ function renderAuthError(error) { } const AUTH_PORT = 19881; -const AUTH_TIMEOUT = 25000; +const AUTH_WAIT_MS = 5 * 60 * 1000; +const HOOK_WAIT_MS = 20000; +const POLL_MS = 500; const REFRESH_TIMEOUT = 3000; const REFRESH_REQUEST_TIMEOUT = 10000; const REFRESH_SKEW = 60000; @@ -151,7 +153,7 @@ function recoverAbandonedLock() { abandoned = error.code === 'ESRCH'; } } else { - abandoned = Date.now() - snapshot.mtimeMs > AUTH_TIMEOUT * 2; + abandoned = Date.now() - snapshot.mtimeMs > AUTH_WAIT_MS * 2; } if (abandoned) { const current = fs.statSync(lockFile); @@ -166,16 +168,36 @@ function recoverAbandonedLock() { } } -async function withAuthLock(action, timeoutMs) { +// A login started by a hook waits in the background, so a newer login stops it +// instead of waiting up to five minutes for the lock. +function stopBackgroundLogin() { + const owner = readJson(authLockFile()); + if (!owner?.background || !Number.isInteger(owner.pid) || owner.pid <= 0) + return; + try { + process.kill(owner.pid); + } catch {} +} + +async function withAuthLock( + action, + timeoutMs, + { background = false, preempt = false } = {}, +) { ensureDir(); const lockFile = authLockFile(); const deadline = Date.now() + timeoutMs; let descriptor; + let stopped = false; while (descriptor === undefined) { try { descriptor = fs.openSync(lockFile, 'wx', 0o600); } catch (error) { if (error.code !== 'EEXIST') throw error; + if (preempt && !stopped) { + stopBackgroundLogin(); + stopped = true; + } recoverAbandonedLock(); if (Date.now() >= deadline) throw new Error('Supermemory authentication is busy. Try again.'); @@ -183,7 +205,10 @@ async function withAuthLock(action, timeoutMs) { } } try { - fs.writeFileSync(descriptor, JSON.stringify({ pid: process.pid })); + fs.writeFileSync( + descriptor, + JSON.stringify({ pid: process.pid, ...(background && { background }) }), + ); return await action(Math.max(1, deadline - Date.now())); } finally { fs.closeSync(descriptor); @@ -480,7 +505,7 @@ async function clearCredentials() { async function startAuthFlow( config, - { force = false, timeoutMs = AUTH_TIMEOUT } = {}, + { force = false, timeoutMs = AUTH_WAIT_MS, background = false } = {}, ) { return withAuthLock(async (remaining) => { const current = ownOAuthCredentials(config); @@ -607,7 +632,48 @@ async function startAuthFlow( server?.close(); server?.closeAllConnections(); } - }, timeoutMs); + }, timeoutMs, { background, preempt: true }); +} + +// The SessionStart hook is killed at 30s, so the login runs detached for five +// minutes and the hook only waits briefly for it. +function startBackgroundAuth(config, waitMs = HOOK_WAIT_MS) { + const child = spawn( + process.execPath, + [ + path.join(__dirname, '../auth-server.js'), + config.apiBaseUrl, + config.resource, + ], + { detached: true, stdio: 'ignore', windowsHide: true }, + ); + child.unref(); + + return new Promise((resolve, reject) => { + const startedAt = Date.now(); + const done = (error, token) => { + clearInterval(timer); + child.removeAllListeners(); + if (error) reject(error); + else resolve(token); + }; + child.on('error', () => done(new Error('AUTH_FAILED'))); + // A newer login stopping this one (a signal) is not a failure. + child.on('exit', (code, signal) => { + if (code !== 0 && !signal) done(new Error('AUTH_FAILED')); + }); + const timer = setInterval(() => { + const current = ownOAuthCredentials(config); + if ( + current?.accessToken && + current.expiresAt > Date.now() + REFRESH_SKEW + ) { + done(null, current.accessToken); + } else if (Date.now() - startedAt > waitMs) { + done(new Error('AUTH_PENDING')); + } + }, POLL_MS); + }); } module.exports = { @@ -620,6 +686,8 @@ module.exports = { getAccessToken, settleAuth, startAuthFlow, + startBackgroundAuth, + AUTH_WAIT_MS, openUrl, }; diff --git a/hooks/session-start.js b/hooks/session-start.js index 49213d8..a1ec611 100644 --- a/hooks/session-start.js +++ b/hooks/session-start.js @@ -15,7 +15,7 @@ const { credentialsFile, getOAuthConfig, settleAuth, - startAuthFlow, + startBackgroundAuth, } = require('./lib/auth'); const { getUserFriendlyError } = require('./lib/error-helpers'); const { lastSessionFile } = require('./lib/last-session'); @@ -90,18 +90,18 @@ async function main() { } catch (error) { if (error.code !== 'AUTH_REQUIRED') throw error; try { - apiKey = await startAuthFlow( + apiKey = await startBackgroundAuth( getOAuthConfig( getBaseUrl(cwd, projectConfig), getMcpUrl(cwd, projectConfig), ), - { timeoutMs: Math.max(1, 25000 - (Date.now() - authStartedAt)) }, + Math.max(1, 20000 - (Date.now() - authStartedAt)), ); } catch (authErr) { output( ` -${authErr.message === 'AUTH_TIMEOUT' ? 'Authentication timed out. Start a new session to retry login.' : 'Authentication failed. Start a new session to retry login.'} -For an explicit login, run: ${LOGIN_COMMAND} +${authErr.message === 'AUTH_PENDING' ? 'Finish connecting Supermemory in the browser window (you have 5 minutes). Memory turns on from your next message.' : 'Authentication failed. Start a new session to retry login.'} +If the browser did not open, run: ${LOGIN_COMMAND} Or write an API key to ${credentialsFile()} as {"apiKey":"sm_..."}. `, [], diff --git a/test/unit.mjs b/test/unit.mjs index 821841c..19dbf11 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -1,6 +1,14 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { describe, test } from 'node:test'; @@ -141,3 +149,104 @@ describe('mcp proxy', () => { assert.equal(message.params.arguments.containerTag, 'other'); }); }); + +describe('background login', () => { + const { getAccessToken, getOAuthConfig, startBackgroundAuth } = require( + join(root, 'hooks/lib/auth.js'), + ); + + // A fake OAuth server plus a browser opener that records the consent URL. + async function setup(t) { + const dir = mkdtempSync(join(tmpdir(), 'muse-sm-')); + const binDir = join(dir, 'bin'); + const urlFile = join(dir, 'authorize-url'); + mkdirSync(binDir); + for (const opener of ['open', 'xdg-open']) { + writeFileSync( + join(binDir, opener), + `#!/bin/sh\nprintf '%s' "$1" > "${urlFile}"\n`, + ); + chmodSync(join(binDir, opener), 0o755); + } + const server = createServer((req, res) => { + const url = new URL(req.url, base); + const json = (body) => + res + .writeHead(200, { 'Content-Type': 'application/json' }) + .end(JSON.stringify(body)); + if (url.pathname === '/.well-known/oauth-authorization-server/api/auth') { + json({ + issuer: `${base}/api/auth`, + authorization_endpoint: `${base}/api/auth/authorize`, + token_endpoint: `${base}/api/auth/token`, + registration_endpoint: `${base}/api/auth/register`, + revocation_endpoint: `${base}/api/auth/revoke`, + }); + } else if (url.pathname === '/api/auth/register') { + json({ client_id: 'client-1', token_endpoint_auth_method: 'none' }); + } else if (url.pathname === '/api/auth/authorize') { + const callback = new URL(url.searchParams.get('redirect_uri')); + callback.search = new URLSearchParams({ + code: 'code-1', + state: url.searchParams.get('state'), + }).toString(); + res.writeHead(302, { Location: callback.toString() }).end(); + } else if (url.pathname === '/api/auth/token') { + json({ + access_token: 'access-1', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh-1', + }); + } else { + res.writeHead(404).end(); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + const env = { + SUPERMEMORY_MUSE_HOME: process.env.SUPERMEMORY_MUSE_HOME, + PATH: process.env.PATH, + }; + process.env.SUPERMEMORY_MUSE_HOME = join(dir, 'home'); + process.env.PATH = `${binDir}:${process.env.PATH}`; + t.after(() => { + try { + process.kill( + JSON.parse( + readFileSync(join(dir, 'home', 'auth.lock'), 'utf-8'), + ).pid, + ); + } catch {} + Object.assign(process.env, env); + server.close(); + rmSync(dir, { recursive: true, force: true }); + }); + return { base, urlFile }; + } + + test('keeps listening after the hook stops waiting', async (t) => { + const { base, urlFile } = await setup(t); + const config = getOAuthConfig(base, `${base}/mcp`); + + await assert.rejects(startBackgroundAuth(config, 1000), { + message: 'AUTH_PENDING', + }); + await assert.rejects(getAccessToken(config), { code: 'AUTH_REQUIRED' }); + + const authorize = await fetch(readFileSync(urlFile, 'utf-8'), { + redirect: 'manual', + }); + const callback = await fetch(authorize.headers.get('location')); + assert.equal(callback.status, 200); + assert.equal(await getAccessToken(config), 'access-1'); + }); + + test('reports AUTH_FAILED when the login cannot start', async (t) => { + await setup(t); + const config = getOAuthConfig('http://127.0.0.1:9', 'http://127.0.0.1:9/mcp'); + await assert.rejects(startBackgroundAuth(config, 10000), { + message: 'AUTH_FAILED', + }); + }); +}); From aecceb1e6de7615ff6ffd6c633c310f633b2a431 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Thu, 29 Oct 2026 14:27:01 +0530 Subject: [PATCH 2/2] chore(release): 0.3.0 --- .muse-plugin/plugin.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.muse-plugin/plugin.json b/.muse-plugin/plugin.json index 13bab52..26d651a 100644 --- a/.muse-plugin/plugin.json +++ b/.muse-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "supermemory", "displayName": "Supermemory", - "version": "0.2.0", + "version": "0.3.0", "description": "Persistent memory across Muse Code sessions using Supermemory.", "compat": { "source": "native", diff --git a/package.json b/package.json index b8de3fe..31d041a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "muse-supermemory", - "version": "0.2.0", + "version": "0.3.0", "description": "Muse Code plugin by Supermemory AI", "private": true, "type": "commonjs",