Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .muse-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions hooks/auth-server.js
Original file line number Diff line number Diff line change
@@ -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),
);
82 changes: 75 additions & 7 deletions hooks/lib/auth.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -166,24 +168,47 @@ 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.');
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 = {
Expand All @@ -620,6 +686,8 @@ module.exports = {
getAccessToken,
settleAuth,
startAuthFlow,
startBackgroundAuth,
AUTH_WAIT_MS,
openUrl,
};

Expand Down
10 changes: 5 additions & 5 deletions hooks/session-start.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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(
`<supermemory-status>
${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_..."}.
</supermemory-status>`,
[],
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
111 changes: 110 additions & 1 deletion test/unit.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
});
});
});
Loading