From 7f4700c13a2eaa636986d47c868cb7949d14e011 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 23 Oct 2026 12:06:53 +0530 Subject: [PATCH 1/3] feat: authenticate with OAuth DCR and PKCE --- CHANGELOG.md | 4 + README.md | 8 +- commands/supermemory-logout.md | 2 +- commands/supermemory-setup.md | 2 +- dist/capture.js | 376 +++++++++++++--- dist/cli.js | 758 +++++++++++++++++++++++++-------- dist/prompt-recall.js | 383 ++++++++++++++--- dist/session-start.js | 396 ++++++++++++++--- src/auth.test.ts | 13 +- src/auth.ts | 660 ++++++++++++++++++++++++---- src/cli.ts | 161 ++++--- src/config.ts | 70 ++- src/hook-api.ts | 45 +- src/hooks/capture.ts | 8 +- src/hooks/prompt-recall.ts | 8 +- src/hooks/session-start.ts | 27 +- src/mcp-proxy.ts | 52 ++- 17 files changed, 2438 insertions(+), 535 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d593213..12479b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Sign in with OAuth 2.1 (dynamic client registration and PKCE) instead of a browser-issued API key. Tokens refresh automatically across hooks and the MCP proxy; `logout` revokes them. API keys from the environment, config or an existing credentials file keep working and take precedence. + ## 1.2.3 - Use the bundled official Supermemory 5.0.1 SDK for hosted REST profile, search and document capture, preserving existing namespace strings, metadata and incremental generation IDs. diff --git a/README.md b/README.md index 9b4ae07..a1da1e0 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,11 @@ Every host names the install path after itself — Cursor sets `CURSOR_PLUGIN_RO Claude Code and Grok set `CLAUDE_PLUGIN_ROOT`, Grok also sets `GROK_PLUGIN_ROOT` — so the command above finds the install itself and works from any directory. +`login` signs in through OAuth in your browser (dynamic client registration with PKCE, callback on +`127.0.0.1:19879`) and stores rotating tokens in `~/.supermemory-cursor/credentials.json`. An API key +from `SUPERMEMORY_API_KEY` or config still takes precedence, and a previously saved API key keeps +working until you run `login` again. `logout` revokes the refresh token and removes the credentials. + ### Upgrade channels This repository's **1.2.3 plugin-panel/Git distribution** uses the hosted MCP proxy and SDK-backed v5 REST hooks. Existing plugin-panel installations can update in Cursor and reload the window; an isolated Git/local installation can replace its plugin directory with this version. Keep your existing global/project config, credentials and hook-state files. The update does not rewrite them or upload historical transcripts. Existing container-tag strings are used unchanged as v5 namespaces, with canonical and legacy reads retained. @@ -85,7 +90,8 @@ run the `/supermemory-config` command. | `SUPERMEMORY_API_URL` | Override the Supermemory API base URL | | `SUPERMEMORY_BASE_URL` | API base URL fallback when `SUPERMEMORY_API_URL` is unset | | `SUPERMEMORY_API_VERSION` | Explicit `v5` or `legacy` REST contract; overrides config `apiVersion` | -| `SUPERMEMORY_MCP_URL` | Independent hosted MCP proxy endpoint; REST version selection does not change it | +| `SUPERMEMORY_MCP_URL` | Independent hosted MCP proxy endpoint and OAuth resource; REST version selection does not change it. Required for OAuth on a custom API URL that is not an `api.*` host | +| `SUPERMEMORY_OAUTH_ISSUER` | OAuth issuer (default `/api/auth`) | | `SUPERMEMORY_REPO_TAG` | Override the unified repository container tag | | `SUPERMEMORY_USER_TAG` | Legacy Cursor personal container to continue reading | | `SUPERMEMORY_PROJECT_TAG` | Legacy Cursor project container to continue reading | diff --git a/commands/supermemory-logout.md b/commands/supermemory-logout.md index ac973dd..2466cd1 100644 --- a/commands/supermemory-logout.md +++ b/commands/supermemory-logout.md @@ -9,4 +9,4 @@ Run the following command to remove your Supermemory credentials: node "$(ls -d ~/.cursor/plugins/local/cursor-supermemory ~/.cursor/plugins/cache/*/cursor-supermemory/*/ 2>/dev/null | head -1)/dist/cli.js" logout ``` -Your credentials will be removed from ~/.supermemory-cursor/credentials.json. Your memories in Supermemory are not deleted. +Your OAuth refresh token is revoked and your credentials are removed from ~/.supermemory-cursor/credentials.json. Your memories in Supermemory are not deleted. diff --git a/commands/supermemory-setup.md b/commands/supermemory-setup.md index 87094bf..9449353 100644 --- a/commands/supermemory-setup.md +++ b/commands/supermemory-setup.md @@ -12,4 +12,4 @@ node "$(ls -d ~/.cursor/plugins/local/cursor-supermemory ~/.cursor/plugins/cache This opens your browser to connect your Supermemory account to Cursor. Once connected, the AI will have persistent memory across all your coding sessions. -If the browser doesn't open automatically, visit: https://app.supermemory.ai/auth/connect?client=cursor +If the browser doesn't open automatically, open the authorization URL the command prints. diff --git a/dist/capture.js b/dist/capture.js index 94c03cc..ea53578 100644 --- a/dist/capture.js +++ b/dist/capture.js @@ -16,7 +16,7 @@ var __export = (target, all) => { var __require = /* @__PURE__ */ createRequire(import.meta.url); // src/hooks/capture.ts -import { createHash as createHash4 } from "node:crypto"; +import { createHash as createHash5 } from "node:crypto"; // src/config.ts import path2 from "node:path"; @@ -27,23 +27,253 @@ import fs2 from "node:fs"; import path from "node:path"; import os from "node:os"; import fs from "node:fs"; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; var CREDENTIALS_DIR = path.join(os.homedir(), ".supermemory-cursor"); var CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json"); -var AUTH_URL = process.env.SUPERMEMORY_AUTH_URL || "https://console.supermemory.ai/auth/connect"; -function loadCredentials() { +var CLIENT_FILE = path.join(CREDENTIALS_DIR, "oauth-client.json"); +var LOCK_FILE = path.join(CREDENTIALS_DIR, "auth.lock"); +var AUTH_PORT = 19879; +var REDIRECT_URI = `http://127.0.0.1:${AUTH_PORT}/callback`; +var REFRESH_TIMEOUT = 3000; +var REFRESH_REQUEST_TIMEOUT = 1e4; +var REFRESH_SKEW = 60000; +var configuredAuthTimeout = Number(process.env.SUPERMEMORY_AUTH_TIMEOUT); +var AUTH_TIMEOUT = Number.isFinite(configuredAuthTimeout) && configuredAuthTimeout > 0 ? configuredAuthTimeout : 5 * 60000; +function page(title, message) { + return ` +${title} - Supermemory

${title}

${message}

`; +} +var SUCCESS_HTML = page("Connected to Cursor!", "You can close this tab and return to Cursor."); +var DENIED_HTML = page("Authorization denied", "Supermemory was not connected. Return to Cursor and run login again to retry."); +var FAILED_HTML = page("Connection failed", "Return to Cursor and run login again."); + +class AuthError extends Error { + code; + constructor(message, code) { + super(message); + this.code = code; + } +} +function authRequired() { + return new AuthError("Supermemory authentication required. Run `cursor-supermemory login`, or set SUPERMEMORY_API_KEY.", "AUTH_REQUIRED"); +} +function readJson(file) { try { - if (!fs.existsSync(CREDENTIALS_FILE)) - return null; - const data = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, "utf-8")); - if (data.apiKey) - return data; - return null; + const value = JSON.parse(fs.readFileSync(file, "utf-8")); + return value && typeof value === "object" && !Array.isArray(value) ? value : null; } catch { return null; } } +function loadCredentialData() { + const data = readJson(CREDENTIALS_FILE); + return data?.type === "oauth" || data?.apiKey ? data : null; +} +function loadCredentials() { + const data = loadCredentialData(); + return data?.type === "oauth" ? undefined : data?.apiKey; +} +function writeJson(file, data) { + fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 448 }); + fs.chmodSync(CREDENTIALS_DIR, 448); + const temporary = `${file}.${randomUUID()}.tmp`; + try { + fs.writeFileSync(temporary, JSON.stringify(data, null, 2), { mode: 384, flag: "wx" }); + fs.renameSync(temporary, file); + } finally { + fs.rmSync(temporary, { force: true }); + } +} +function recoverAbandonedLock() { + let recovery; + let linked = false; + try { + const original = fs.statSync(LOCK_FILE); + recovery = `${LOCK_FILE}.${original.dev}.${original.ino}.recovery`; + fs.linkSync(LOCK_FILE, recovery); + linked = true; + const snapshot = fs.statSync(recovery); + if (snapshot.ino !== original.ino || snapshot.dev !== original.dev) + return; + const owner = readJson(recovery); + let abandoned = false; + if (owner && Number.isInteger(owner.pid) && owner.pid > 0) { + try { + process.kill(owner.pid, 0); + } catch (error) { + abandoned = error.code === "ESRCH"; + } + } else { + abandoned = Date.now() - snapshot.mtimeMs > 10 * 60000; + } + if (abandoned) { + const current = fs.statSync(LOCK_FILE); + if (current.ino === original.ino && current.dev === original.dev) + fs.unlinkSync(LOCK_FILE); + } + } catch (error) { + if (!["ENOENT", "EEXIST"].includes(error.code ?? "")) + throw error; + } finally { + if (linked && recovery) + fs.rmSync(recovery, { force: true }); + } +} +async function withAuthLock(action, timeoutMs) { + fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 448 }); + const deadline = Date.now() + timeoutMs; + let descriptor; + while (descriptor === undefined) { + try { + descriptor = fs.openSync(LOCK_FILE, "wx", 384); + } catch (error) { + if (error.code !== "EEXIST") + throw error; + 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 })); + return await action(Math.max(1, deadline - Date.now())); + } finally { + fs.closeSync(descriptor); + fs.rmSync(LOCK_FILE, { force: true }); + } +} +function secureUrl(value) { + const url = new URL(value); + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if (url.username || url.password || url.hash || url.search || url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) { + throw new Error("OAuth requires HTTPS (HTTP is allowed only for loopback development)."); + } + return url.toString().replace(/\/+$/, ""); +} +function getOAuthConfig(baseUrl, mcpUrl) { + const apiBaseUrl = secureUrl(baseUrl); + return { + apiBaseUrl, + issuer: secureUrl(process.env.SUPERMEMORY_OAUTH_ISSUER || `${apiBaseUrl}/api/auth`), + resource: secureUrl(mcpUrl) + }; +} +function sameConfig(credentials, config) { + return credentials?.apiBaseUrl === config.apiBaseUrl && credentials?.issuer === config.issuer && credentials?.resource === config.resource; +} +function hasOAuthCredentials(config) { + const credentials = loadCredentialData(); + return credentials?.type === "oauth" && sameConfig(credentials, config) && (!!credentials.refreshToken || !!credentials.accessToken && credentials.expiresAt > Date.now() + REFRESH_SKEW); +} +function issuerEndpoint(value, issuer) { + const endpoint = secureUrl(value); + if (new URL(endpoint).origin !== new URL(issuer).origin) { + throw new Error("OAuth endpoint does not belong to the configured issuer."); + } + return endpoint; +} +async function oauthRequest(url, options) { + const response = await fetch(url, { ...options, redirect: "error" }); + const body = await response.json().catch(() => null); + const data = body && typeof body === "object" ? body : {}; + if (!response.ok) { + const code = ["invalid_grant", "invalid_client", "access_denied"].includes(String(data.error)) ? String(data.error) : "oauth_request_failed"; + throw new AuthError(`Supermemory authorization failed (${code}, HTTP ${response.status}).`, code); + } + return data; +} +async function exchangeToken(client, parameters, signal, previous) { + const data = await oauthRequest(issuerEndpoint(client.tokenEndpoint, client.issuer), { + method: "POST", + signal, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: client.clientId, + resource: client.resource, + ...parameters + }) + }); + const refreshToken = data.refresh_token ?? previous?.refreshToken; + if (typeof data.access_token !== "string" || !data.access_token || typeof data.token_type !== "string" || data.token_type.toLowerCase() !== "bearer" || typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0 || typeof refreshToken !== "string" || !refreshToken) { + throw new Error("OAuth returned an invalid token response."); + } + signal.throwIfAborted(); + const credentials = { + type: "oauth", + apiBaseUrl: client.apiBaseUrl, + issuer: client.issuer, + resource: client.resource, + clientId: client.clientId, + redirectUri: client.redirectUri, + authorizationEndpoint: client.authorizationEndpoint, + tokenEndpoint: client.tokenEndpoint, + revocationEndpoint: client.revocationEndpoint, + accessToken: data.access_token, + refreshToken, + expiresAt: Date.now() + data.expires_in * 1000, + savedAt: new Date().toISOString() + }; + writeJson(CREDENTIALS_FILE, credentials); + return data.access_token; +} +var pendingRefresh = null; +function refreshCredentials(config) { + if (pendingRefresh) + return pendingRefresh; + const refresh = withAuthLock(async (remaining) => { + const credentials = loadCredentialData(); + if (credentials?.type !== "oauth" || !sameConfig(credentials, config)) + throw authRequired(); + if (credentials.accessToken && credentials.expiresAt > Date.now() + REFRESH_SKEW) + return credentials.accessToken; + if (!credentials.refreshToken) + throw authRequired(); + try { + return await exchangeToken(credentials, { grant_type: "refresh_token", refresh_token: credentials.refreshToken }, AbortSignal.timeout(remaining), credentials); + } catch (error) { + if (error instanceof AuthError && ["invalid_grant", "invalid_client"].includes(error.code)) { + const { accessToken: _access, refreshToken: _refresh, ...registration } = credentials; + writeJson(CREDENTIALS_FILE, registration); + if (error.code === "invalid_client") + fs.rmSync(CLIENT_FILE, { force: true }); + throw authRequired(); + } + throw error; + } + }, REFRESH_REQUEST_TIMEOUT).finally(() => { + pendingRefresh = null; + }); + refresh.catch(() => {}); + pendingRefresh = refresh; + return refresh; +} +function waitFor(promise, timeoutMs) { + let timer; + const expired = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new AuthError("Supermemory token refresh is still in progress.", "AUTH_PENDING"); + error.name = "TimeoutError"; + reject(error); + }, timeoutMs); + }); + return Promise.race([promise, expired]).finally(() => clearTimeout(timer)); +} +async function getAccessToken(config, timeoutMs = REFRESH_TIMEOUT) { + const saved = loadCredentialData(); + if (saved?.type !== "oauth" || !sameConfig(saved, config)) + throw authRequired(); + if (saved.accessToken && saved.expiresAt > Date.now() + REFRESH_SKEW) + return saved.accessToken; + return waitFor(refreshCredentials(config), timeoutMs); +} // src/config.ts +var DEFAULT_API_URL = "https://api.supermemory.ai"; +var DEFAULT_MCP_URL = "https://mcp.supermemory.ai/mcp"; var GLOBAL_CONFIG_PATH = path2.join(os2.homedir(), ".config", "cursor", "supermemory.json"); var DEFAULTS = { baseUrl: null, @@ -58,7 +288,7 @@ var DEFAULTS = { userContainerTag: null, projectContainerTag: null }; -function readJson(filePath) { +function readJson2(filePath) { try { if (!fs2.existsSync(filePath)) return null; @@ -71,7 +301,7 @@ function findProjectConfig(cwd) { let dir = cwd; while (true) { const configPath = path2.join(dir, ".cursor", ".supermemory", "config.json"); - const data = readJson(configPath); + const data = readJson2(configPath); if (data) return data; const parent = path2.dirname(dir); @@ -83,7 +313,7 @@ function findProjectConfig(cwd) { } function loadConfig(cwd) { const projectConfig = findProjectConfig(cwd || process.cwd()); - const globalConfig = readJson(GLOBAL_CONFIG_PATH); + const globalConfig = readJson2(GLOBAL_CONFIG_PATH); const merged = { ...DEFAULTS, ...globalConfig, ...projectConfig }; return { apiKey: process.env.SUPERMEMORY_API_KEY ?? merged.apiKey ?? null, @@ -104,12 +334,58 @@ function loadConfig(cwd) { function getApiKey(config) { if (config.apiKey) return config.apiKey; - const creds = loadCredentials(); - return creds?.apiKey ?? null; + return loadCredentials() ?? null; +} +function getApiBaseUrl(config) { + return config.baseUrl || DEFAULT_API_URL; +} +function normalizeUrl(value) { + try { + const url = new URL(value.trim()); + return ["http:", "https:"].includes(url.protocol) ? url.toString() : null; + } catch { + return null; + } +} +function getMcpUrl(config) { + if (process.env.SUPERMEMORY_MCP_URL) { + const configured = normalizeUrl(process.env.SUPERMEMORY_MCP_URL); + if (!configured) + throw new Error("Invalid SUPERMEMORY_MCP_URL"); + return configured; + } + if (!config.baseUrl) + return DEFAULT_MCP_URL; + const api = new URL(config.baseUrl); + if (api.hostname.startsWith("api.")) { + api.hostname = api.hostname.replace(/^api\./, "mcp."); + api.pathname = "/mcp"; + api.search = ""; + api.hash = ""; + return api.toString(); + } + throw new Error("Set SUPERMEMORY_MCP_URL for OAuth or MCP on a custom Supermemory backend."); +} +function isConfigured(config) { + if (getApiKey(config)) + return true; + try { + return hasOAuthCredentials(getOAuthConfig(getApiBaseUrl(config), getMcpUrl(config))); + } catch { + return false; + } +} +async function getAuthToken(config, timeoutMs) { + const apiKey = getApiKey(config); + if (apiKey) + return apiKey; + if (loadCredentialData()?.type !== "oauth") + throw authRequired(); + return getAccessToken(getOAuthConfig(getApiBaseUrl(config), getMcpUrl(config)), timeoutMs); } // src/hook-api.ts -import { createHash, createHmac } from "node:crypto"; +import { createHash as createHash2, createHmac } from "node:crypto"; // node_modules/supermemory/dist/esm/generated/core/auth/AuthProvider.js function isAuthProvider(value) { @@ -1904,10 +2180,10 @@ class ConnectorsClient { return HttpResponsePromise.fromPromise(this.__listAll(request, requestOptions)); } async __listAll(request = {}, requestOptions) { - const { provider, page, limit } = request; + const { provider, page: page2, limit } = request; const _queryParams = { provider: provider != null ? provider : undefined, - page, + page: page2, limit }; const _authRequest = await this._options.authProvider.getAuthRequest(); @@ -1953,10 +2229,10 @@ class ConnectorsClient { return HttpResponsePromise.fromPromise(this.__list(namespace, request, requestOptions)); } async __list(namespace, request = {}, requestOptions) { - const { provider, page, limit } = request; + const { provider, page: page2, limit } = request; const _queryParams = { provider: provider != null ? provider : undefined, - page, + page: page2, limit }; const _authRequest = await this._options.authProvider.getAuthRequest(); @@ -2816,9 +3092,9 @@ class NamespacesClient { return HttpResponsePromise.fromPromise(this.__list(request, requestOptions)); } async __list(request = {}, requestOptions) { - const { page, limit } = request; + const { page: page2, limit } = request; const _queryParams = { - page, + page: page2, limit }; const _authRequest = await this._options.authProvider.getAuthRequest(); @@ -3404,9 +3680,9 @@ class SupermemoryClient { return HttpResponsePromise.fromPromise(this.__list(namespace, type, request, requestOptions)); } async __list(namespace, type, request = {}, requestOptions) { - const { page, limit, sort, order, ..._body } = request; + const { page: page2, limit, sort, order, ..._body } = request; const _queryParams = { - page, + page: page2, limit, sort: sort != null ? sort : undefined, order: order != null ? order : undefined @@ -3500,7 +3776,6 @@ class Supermemory extends SupermemoryClient { } // src/hook-api.ts -var DEFAULT_BASE_URL = "https://api.supermemory.ai"; var REQUEST_TIMEOUT_MS = 3000; var INTEGRITY_VERSION = 1; var SEED = "7f2a9c4b8e1d6f3a5c0b9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a"; @@ -3512,16 +3787,20 @@ function resolveApiVersion(baseUrl, apiVersion) { } let url; try { - url = new URL(baseUrl || DEFAULT_BASE_URL); + url = new URL(baseUrl || DEFAULT_API_URL); } catch { throw new Error("Supermemory API base URL is invalid"); } - return url.origin === DEFAULT_BASE_URL && /^\/*$/.test(url.pathname) && !url.username && !url.password && !url.search && !url.hash ? "v5" : "legacy"; + return url.origin === DEFAULT_API_URL && /^\/*$/.test(url.pathname) && !url.username && !url.password && !url.search && !url.hash ? "v5" : "legacy"; +} +function resolveAuth(auth2) { + return typeof auth2 === "function" ? auth2() : Promise.resolve(auth2); } -function sdk(baseUrl, apiKey, tag) { +async function sdk(baseUrl, auth2, tag) { + const apiKey = await resolveAuth(auth2); return new Supermemory({ apiKey, - baseUrl: (baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ""), + baseUrl: (baseUrl || DEFAULT_API_URL).replace(/\/+$/, ""), headers: headers(apiKey, tag), timeoutInSeconds: REQUEST_TIMEOUT_MS / 1000, maxRetries: 0 @@ -3551,7 +3830,7 @@ SKIP: - Transient command output and low-value implementation chatter - Granular details that do not help future work`; function sha256(input) { - return createHash("sha256").update(input).digest("hex"); + return createHash2("sha256").update(input).digest("hex"); } function headers(apiKey, containerTag) { const contentHash = sha256(containerTag); @@ -3565,8 +3844,9 @@ function headers(apiKey, containerTag) { "x-sm-source": "cursor" }; } -async function post(baseUrl, apiKey, path3, containerTag, body) { - const response = await fetch(`${(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "")}${path3}`, { +async function post(baseUrl, auth2, path3, containerTag, body) { + const apiKey = await resolveAuth(auth2); + const response = await fetch(`${(baseUrl || DEFAULT_API_URL).replace(/\/+$/, "")}${path3}`, { method: "POST", headers: headers(apiKey, containerTag), body: JSON.stringify(body), @@ -3581,9 +3861,9 @@ async function post(baseUrl, apiKey, path3, containerTag, body) { throw new Error("Supermemory returned an invalid response"); }); } -async function addMemory(baseUrl, apiKey, content, containerTag, metadata, options = {}, apiVersion) { - const version = resolveApiVersion(baseUrl, apiVersion); - const result = version === "v5" ? await sdkRequest(sdk(baseUrl, apiKey, containerTag).add(containerTag, { +async function addMemory(baseUrl, auth2, content, containerTag, metadata, options = {}, apiVersion) { + const version2 = resolveApiVersion(baseUrl, apiVersion); + const result = version2 === "v5" ? await sdkRequest((await sdk(baseUrl, auth2, containerTag)).add(containerTag, { content, id: options.customId, supportingContext: options.entityContext, @@ -3596,14 +3876,14 @@ async function addMemory(baseUrl, apiKey, content, containerTag, metadata, optio }, { abortSignal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), maxRetries: 0 - })) : await post(baseUrl, apiKey, "/v3/documents", containerTag, { + })) : await post(baseUrl, auth2, "/v3/documents", containerTag, { content, containerTag, metadata: { sm_source: "cursor", ...metadata }, customId: options.customId, entityContext: options.entityContext }); - if (typeof result?.id !== "string" || !result.id.trim() || version === "v5" && ![ + if (typeof result?.id !== "string" || !result.id.trim() || version2 === "v5" && ![ "unknown", "queued", "extracting", @@ -3618,7 +3898,7 @@ async function addMemory(baseUrl, apiKey, content, containerTag, metadata, optio } // src/hook-state.ts -import { createHash as createHash2 } from "node:crypto"; +import { createHash as createHash3 } from "node:crypto"; import { mkdirSync, readFileSync, @@ -3629,7 +3909,7 @@ import { import { homedir } from "node:os"; import { join as join2 } from "node:path"; function statePath(conversationId) { - const id = createHash2("sha256").update(conversationId || "unknown").digest("hex").slice(0, 32); + const id = createHash3("sha256").update(conversationId || "unknown").digest("hex").slice(0, 32); return join2(homedir(), ".supermemory-cursor", "hook-state", `${id}.json`); } function readHookState(conversationId) { @@ -3656,7 +3936,7 @@ function deleteHookState(conversationId) { // src/tags.ts import { execSync } from "node:child_process"; -import { createHash as createHash3 } from "node:crypto"; +import { createHash as createHash4 } from "node:crypto"; import { existsSync, readFileSync as readFileSync2, @@ -3671,7 +3951,7 @@ import { sep } from "node:path"; function sha2562(input) { - return createHash3("sha256").update(input).digest("hex").slice(0, 16); + return createHash4("sha256").update(input).digest("hex").slice(0, 16); } function getGitRoot(directory) { const isolateWorktrees = process.env.SUPERMEMORY_ISOLATE_WORKTREES === "true"; @@ -3769,7 +4049,7 @@ function getGitRepoInfo(directory) { return result; } } -function readJson2(filePath) { +function readJson3(filePath) { try { if (!existsSync(filePath)) return null; @@ -3902,7 +4182,7 @@ function getLegacyCursorProjectTags(directory, config) { function getLegacyClaudeTags(directory) { const basePath = getProjectBasePath(directory); const projectHash = sha2562(basePath); - const config = readJson2(join3(basePath, ".claude", ".supermemory-claude", "config.json")); + const config = readJson3(join3(basePath, ".claude", ".supermemory-claude", "config.json")); return { personal: uniqueTags([ stringValue(config?.personalContainerTag), @@ -3917,7 +4197,7 @@ function getLegacyClaudeTags(directory) { }; } function getLegacyCodexTags(directory) { - const config = readJson2(join3(homedir2(), ".codex", "supermemory.json")); + const config = readJson3(join3(homedir2(), ".codex", "supermemory.json")); const prefix = stringValue(config?.containerTagPrefix) || "codex"; const userIdentity = getGitEmail(directory) || process.env.USER || process.env.USERNAME || hostname(); const userHash = sha2562(userIdentity); @@ -4138,9 +4418,9 @@ async function capture(input) { try { const root = workspaceRoot(input); const config = loadConfig(root); - const apiKey = getApiKey(config); - if (!apiKey) + if (!isConfigured(config)) return; + const auth2 = () => getAuthToken(config); const id = conversationId(input); const state = readHookState(id); const entries = parseTranscript(await readTextFile(transcriptPath)); @@ -4151,8 +4431,8 @@ async function capture(input) { if (content.length < 100) return; const tags = getResolvedTags(root, config); - const generation = input.generation_id || createHash4("sha256").update(content).digest("hex").slice(0, 32); - await addMemory(config.baseUrl, apiKey, content, tags.canonical, { + const generation = input.generation_id || createHash5("sha256").update(content).digest("hex").slice(0, 32); + await addMemory(config.baseUrl, auth2, content, tags.canonical, { type: "conversation", project: tags.projectName, sm_project_id: tags.projectId, @@ -4161,7 +4441,7 @@ async function capture(input) { sessionId: id, timestamp: new Date().toISOString() }, { - customId: `cursor:capture:${createHash4("sha256").update(`${id}:${generation}`).digest("hex")}`, + customId: `cursor:capture:${createHash5("sha256").update(`${id}:${generation}`).digest("hex")}`, entityContext: AGENT_ENTITY_CONTEXT }, config.apiVersion); writeHookState(id, { diff --git a/dist/cli.js b/dist/cli.js index a7591dc..aff9264 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -30,105 +30,449 @@ import fs2 from "node:fs"; import path from "node:path"; import os from "node:os"; import fs from "node:fs"; -import http from "node:http"; -import { spawn } from "node:child_process"; +import childProcess from "node:child_process"; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import { createServer } from "node:http"; +// package.json +var version = "1.2.3"; + +// src/auth.ts var CREDENTIALS_DIR = path.join(os.homedir(), ".supermemory-cursor"); var CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json"); -var AUTH_PORT = 19878; -var AUTH_URL = process.env.SUPERMEMORY_AUTH_URL || "https://console.supermemory.ai/auth/connect"; -var SUCCESS_HTML = ` -

Connected to Cursor!

`; -function loadCredentials() { +var CLIENT_FILE = path.join(CREDENTIALS_DIR, "oauth-client.json"); +var LOCK_FILE = path.join(CREDENTIALS_DIR, "auth.lock"); +var AUTH_PORT = 19879; +var REDIRECT_URI = `http://127.0.0.1:${AUTH_PORT}/callback`; +var SCOPE = "openid profile email offline_access"; +var REFRESH_TIMEOUT = 3000; +var REFRESH_REQUEST_TIMEOUT = 1e4; +var REFRESH_SKEW = 60000; +var configuredAuthTimeout = Number(process.env.SUPERMEMORY_AUTH_TIMEOUT); +var AUTH_TIMEOUT = Number.isFinite(configuredAuthTimeout) && configuredAuthTimeout > 0 ? configuredAuthTimeout : 5 * 60000; +function page(title, message) { + return ` +${title} - Supermemory

${title}

${message}

`; +} +var SUCCESS_HTML = page("Connected to Cursor!", "You can close this tab and return to Cursor."); +var DENIED_HTML = page("Authorization denied", "Supermemory was not connected. Return to Cursor and run login again to retry."); +var FAILED_HTML = page("Connection failed", "Return to Cursor and run login again."); + +class AuthError extends Error { + code; + constructor(message, code) { + super(message); + this.code = code; + } +} +function isAuthRequired(error) { + return error instanceof AuthError && error.code === "AUTH_REQUIRED"; +} +function authRequired() { + return new AuthError("Supermemory authentication required. Run `cursor-supermemory login`, or set SUPERMEMORY_API_KEY.", "AUTH_REQUIRED"); +} +function readJson(file) { try { - if (!fs.existsSync(CREDENTIALS_FILE)) - return null; - const data = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, "utf-8")); - if (data.apiKey) - return data; - return null; + const value = JSON.parse(fs.readFileSync(file, "utf-8")); + return value && typeof value === "object" && !Array.isArray(value) ? value : null; } catch { return null; } } -function saveCredentials(apiKey) { +function loadCredentialData() { + const data = readJson(CREDENTIALS_FILE); + return data?.type === "oauth" || data?.apiKey ? data : null; +} +function loadCredentials() { + const data = loadCredentialData(); + return data?.type === "oauth" ? undefined : data?.apiKey; +} +function writeJson(file, data) { fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 448 }); - const data = { apiKey, createdAt: new Date().toISOString() }; - fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(data, null, 2), { mode: 384 }); + fs.chmodSync(CREDENTIALS_DIR, 448); + const temporary = `${file}.${randomUUID()}.tmp`; + try { + fs.writeFileSync(temporary, JSON.stringify(data, null, 2), { mode: 384, flag: "wx" }); + fs.renameSync(temporary, file); + } finally { + fs.rmSync(temporary, { force: true }); + } } -function clearCredentials() { +function recoverAbandonedLock() { + let recovery; + let linked = false; try { - if (fs.existsSync(CREDENTIALS_FILE)) { - fs.unlinkSync(CREDENTIALS_FILE); - return true; + const original = fs.statSync(LOCK_FILE); + recovery = `${LOCK_FILE}.${original.dev}.${original.ino}.recovery`; + fs.linkSync(LOCK_FILE, recovery); + linked = true; + const snapshot = fs.statSync(recovery); + if (snapshot.ino !== original.ino || snapshot.dev !== original.dev) + return; + const owner = readJson(recovery); + let abandoned = false; + if (owner && Number.isInteger(owner.pid) && owner.pid > 0) { + try { + process.kill(owner.pid, 0); + } catch (error) { + abandoned = error.code === "ESRCH"; + } + } else { + abandoned = Date.now() - snapshot.mtimeMs > 10 * 60000; } - return false; - } catch { - return false; + if (abandoned) { + const current = fs.statSync(LOCK_FILE); + if (current.ino === original.ino && current.dev === original.dev) + fs.unlinkSync(LOCK_FILE); + } + } catch (error) { + if (!["ENOENT", "EEXIST"].includes(error.code ?? "")) + throw error; + } finally { + if (linked && recovery) + fs.rmSync(recovery, { force: true }); } } -function openBrowser(url) { - const platform = process.platform; - const command = platform === "win32" ? "cmd" : platform === "darwin" ? "open" : "xdg-open"; - const args = platform === "win32" ? ["/c", "start", "", url] : [url]; - spawn(command, args, { stdio: "ignore", detached: true }).unref(); -} -async function startAuthFlow(timeoutMs = 120000) { - return new Promise((resolve) => { - let settled = false; - let timer; - const finish = (result) => { - if (settled) - return; - settled = true; - if (timer) - clearTimeout(timer); - server.close(); - resolve(result); - }; - const server = http.createServer((req, res) => { - const url = new URL(req.url ?? "/", `http://127.0.0.1:${AUTH_PORT}`); - if (url.pathname !== "/callback") { - res.writeHead(404); - res.end("Not found"); - return; - } - const apiKey = url.searchParams.get("apikey") || url.searchParams.get("api_key"); - if (!apiKey?.startsWith("sm_")) { - res.writeHead(400); - res.end("Invalid API key"); - return; +async function withAuthLock(action, timeoutMs) { + fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 448 }); + const deadline = Date.now() + timeoutMs; + let descriptor; + while (descriptor === undefined) { + try { + descriptor = fs.openSync(LOCK_FILE, "wx", 384); + } catch (error) { + if (error.code !== "EEXIST") + throw error; + 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 })); + return await action(Math.max(1, deadline - Date.now())); + } finally { + fs.closeSync(descriptor); + fs.rmSync(LOCK_FILE, { force: true }); + } +} +function secureUrl(value) { + const url = new URL(value); + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if (url.username || url.password || url.hash || url.search || url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) { + throw new Error("OAuth requires HTTPS (HTTP is allowed only for loopback development)."); + } + return url.toString().replace(/\/+$/, ""); +} +function getOAuthConfig(baseUrl, mcpUrl) { + const apiBaseUrl = secureUrl(baseUrl); + return { + apiBaseUrl, + issuer: secureUrl(process.env.SUPERMEMORY_OAUTH_ISSUER || `${apiBaseUrl}/api/auth`), + resource: secureUrl(mcpUrl) + }; +} +function sameConfig(credentials, config) { + return credentials?.apiBaseUrl === config.apiBaseUrl && credentials?.issuer === config.issuer && credentials?.resource === config.resource; +} +function hasOAuthCredentials(config) { + const credentials = loadCredentialData(); + return credentials?.type === "oauth" && sameConfig(credentials, config) && (!!credentials.refreshToken || !!credentials.accessToken && credentials.expiresAt > Date.now() + REFRESH_SKEW); +} +function issuerEndpoint(value, issuer) { + const endpoint = secureUrl(value); + if (new URL(endpoint).origin !== new URL(issuer).origin) { + throw new Error("OAuth endpoint does not belong to the configured issuer."); + } + return endpoint; +} +async function oauthRequest(url, options) { + const response = await fetch(url, { ...options, redirect: "error" }); + const body = await response.json().catch(() => null); + const data = body && typeof body === "object" ? body : {}; + if (!response.ok) { + const code = ["invalid_grant", "invalid_client", "access_denied"].includes(String(data.error)) ? String(data.error) : "oauth_request_failed"; + throw new AuthError(`Supermemory authorization failed (${code}, HTTP ${response.status}).`, code); + } + return data; +} +async function registerClient(config, signal) { + const saved = readJson(CLIENT_FILE); + if (sameConfig(saved, config) && saved?.clientId && saved.redirectUri === REDIRECT_URI) + return saved; + const issuer = new URL(config.issuer); + const metadata = await oauthRequest(new URL(`/.well-known/oauth-authorization-server${issuer.pathname.replace(/\/$/, "")}`, issuer), { signal }); + if (metadata.issuer !== config.issuer) + throw new Error("OAuth discovery returned an unexpected issuer."); + const authorizationEndpoint = issuerEndpoint(String(metadata.authorization_endpoint), config.issuer); + const tokenEndpoint = issuerEndpoint(String(metadata.token_endpoint), config.issuer); + const registrationEndpoint = issuerEndpoint(String(metadata.registration_endpoint), config.issuer); + const revocationEndpoint = issuerEndpoint(String(metadata.revocation_endpoint), config.issuer); + const registration = await oauthRequest(registrationEndpoint, { + method: "POST", + signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Supermemory for Cursor", + client_uri: "https://github.com/supermemoryai/cursor-supermemory", + software_id: "cursor-supermemory", + software_version: version, + redirect_uris: [REDIRECT_URI], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + scope: SCOPE + }) + }); + if (typeof registration.client_id !== "string" || !registration.client_id || registration.token_endpoint_auth_method !== "none") { + throw new Error("OAuth registration did not return a public client."); + } + const client = { + ...config, + clientId: registration.client_id, + redirectUri: REDIRECT_URI, + authorizationEndpoint, + tokenEndpoint, + revocationEndpoint + }; + signal.throwIfAborted(); + writeJson(CLIENT_FILE, client); + return client; +} +async function exchangeToken(client, parameters, signal, previous) { + const data = await oauthRequest(issuerEndpoint(client.tokenEndpoint, client.issuer), { + method: "POST", + signal, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: client.clientId, + resource: client.resource, + ...parameters + }) + }); + const refreshToken = data.refresh_token ?? previous?.refreshToken; + if (typeof data.access_token !== "string" || !data.access_token || typeof data.token_type !== "string" || data.token_type.toLowerCase() !== "bearer" || typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0 || typeof refreshToken !== "string" || !refreshToken) { + throw new Error("OAuth returned an invalid token response."); + } + signal.throwIfAborted(); + const credentials = { + type: "oauth", + apiBaseUrl: client.apiBaseUrl, + issuer: client.issuer, + resource: client.resource, + clientId: client.clientId, + redirectUri: client.redirectUri, + authorizationEndpoint: client.authorizationEndpoint, + tokenEndpoint: client.tokenEndpoint, + revocationEndpoint: client.revocationEndpoint, + accessToken: data.access_token, + refreshToken, + expiresAt: Date.now() + data.expires_in * 1000, + savedAt: new Date().toISOString() + }; + writeJson(CREDENTIALS_FILE, credentials); + return data.access_token; +} +var pendingRefresh = null; +function refreshCredentials(config) { + if (pendingRefresh) + return pendingRefresh; + const refresh = withAuthLock(async (remaining) => { + const credentials = loadCredentialData(); + if (credentials?.type !== "oauth" || !sameConfig(credentials, config)) + throw authRequired(); + if (credentials.accessToken && credentials.expiresAt > Date.now() + REFRESH_SKEW) + return credentials.accessToken; + if (!credentials.refreshToken) + throw authRequired(); + try { + return await exchangeToken(credentials, { grant_type: "refresh_token", refresh_token: credentials.refreshToken }, AbortSignal.timeout(remaining), credentials); + } catch (error) { + if (error instanceof AuthError && ["invalid_grant", "invalid_client"].includes(error.code)) { + const { accessToken: _access, refreshToken: _refresh, ...registration } = credentials; + writeJson(CREDENTIALS_FILE, registration); + if (error.code === "invalid_client") + fs.rmSync(CLIENT_FILE, { force: true }); + throw authRequired(); } - saveCredentials(apiKey); - res.writeHead(200, { "Content-Type": "text/html" }); - res.end(SUCCESS_HTML); - finish({ success: true, apiKey }); - }); - server.on("error", (error) => { - const detail = error.code === "EADDRINUSE" ? `Port ${AUTH_PORT} is already in use` : error.message; - finish({ success: false, error: detail }); - }); - server.listen(AUTH_PORT, "127.0.0.1", () => { - const callbackUrl = `http://localhost:${AUTH_PORT}/callback`; - const authUrl = `${AUTH_URL}?callback=${encodeURIComponent(callbackUrl)}&client=cursor`; - process.stderr.write(` + throw error; + } + }, REFRESH_REQUEST_TIMEOUT).finally(() => { + pendingRefresh = null; + }); + refresh.catch(() => {}); + pendingRefresh = refresh; + return refresh; +} +function waitFor(promise, timeoutMs) { + let timer; + const expired = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new AuthError("Supermemory token refresh is still in progress.", "AUTH_PENDING"); + error.name = "TimeoutError"; + reject(error); + }, timeoutMs); + }); + return Promise.race([promise, expired]).finally(() => clearTimeout(timer)); +} +async function getAccessToken(config, timeoutMs = REFRESH_TIMEOUT) { + const saved = loadCredentialData(); + if (saved?.type !== "oauth" || !sameConfig(saved, config)) + throw authRequired(); + if (saved.accessToken && saved.expiresAt > Date.now() + REFRESH_SKEW) + return saved.accessToken; + return waitFor(refreshCredentials(config), timeoutMs); +} +async function settleAuth() { + while (pendingRefresh) { + await pendingRefresh.catch(() => {}); + } +} +async function clearCredentials() { + return withAuthLock(async (remaining) => { + const credentials = readJson(CREDENTIALS_FILE); + if (credentials?.type === "oauth" && credentials.refreshToken) { + await oauthRequest(issuerEndpoint(credentials.revocationEndpoint, credentials.issuer), { + method: "POST", + signal: AbortSignal.timeout(remaining), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: credentials.clientId, + token: credentials.refreshToken, + token_type_hint: "refresh_token" + }) + }); + } + const existed = fs.existsSync(CREDENTIALS_FILE); + fs.rmSync(CREDENTIALS_FILE, { force: true }); + return existed; + }, REFRESH_TIMEOUT); +} +function run(command, args) { + return new Promise((resolve, reject) => { + childProcess.execFile(command, args, { windowsHide: true }, (error) => error ? reject(error) : resolve()); + }); +} +async function openUrl(url) { + const href = url.toString(); + if (!/^https?:\/\//i.test(href)) + throw new Error("Refusing to open non-http URL"); + if (process.platform === "win32") { + try { + await run("rundll32.exe", ["url.dll,FileProtocolHandler", href]); + return; + } catch {} + await run("cmd.exe", ["/c", "start", '""', href]); + return; + } + await run(process.platform === "darwin" ? "open" : "xdg-open", [href]); +} +async function startAuthFlow(config, timeoutMs = AUTH_TIMEOUT) { + return withAuthLock(async (remaining) => { + const controller = new AbortController; + const timer = setTimeout(() => controller.abort(new Error("Authentication timed out")), remaining); + let server; + try { + return await new Promise((resolve, reject) => { + let client; + let exchanging = false; + const state = randomBytes(32).toString("base64url"); + const verifier = randomBytes(32).toString("base64url"); + controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { + once: true + }); + server = createServer(async (req, res) => { + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Referrer-Policy", "no-referrer"); + let url; + try { + url = new URL(req.url || "/", REDIRECT_URI); + } catch { + res.writeHead(400).end(); + return; + } + if (req.method !== "GET" || url.pathname !== "/callback") { + res.writeHead(404).end(); + return; + } + const receivedState = Buffer.from(url.searchParams.get("state") || ""); + if (!client || receivedState.length !== state.length || !timingSafeEqual(receivedState, Buffer.from(state)) || url.searchParams.has("iss") && url.searchParams.get("iss") !== client.issuer) { + res.writeHead(400).end("Invalid authorization state."); + return; + } + if (exchanging) { + res.writeHead(409).end(); + return; + } + exchanging = true; + try { + if (url.searchParams.has("error")) { + throw new AuthError("Authorization was denied.", "access_denied"); + } + const code = url.searchParams.get("code"); + if (!code) + throw new Error("Authorization code missing."); + const token = await exchangeToken(client, { + grant_type: "authorization_code", + code, + code_verifier: verifier, + redirect_uri: REDIRECT_URI + }, controller.signal); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(SUCCESS_HTML, () => resolve(token)); + } catch (error) { + const denied = error instanceof AuthError && error.code === "access_denied"; + if (error instanceof AuthError && error.code === "invalid_client" && !controller.signal.aborted) { + fs.rmSync(CLIENT_FILE, { force: true }); + } + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }).end(denied ? DENIED_HTML : FAILED_HTML, () => reject(error)); + } + }); + server.on("error", () => reject(new Error(`Cannot open the authentication callback port ${AUTH_PORT}. Retry after other logins finish.`))); + server.listen(AUTH_PORT, "127.0.0.1", async () => { + try { + const registered = await registerClient(config, controller.signal); + const authorizationUrl = new URL(issuerEndpoint(registered.authorizationEndpoint, registered.issuer)); + authorizationUrl.search = new URLSearchParams({ + client_id: registered.clientId, + redirect_uri: REDIRECT_URI, + response_type: "code", + scope: SCOPE, + resource: registered.resource, + code_challenge: createHash("sha256").update(verifier).digest("base64url"), + code_challenge_method: "S256", + state, + prompt: "consent" + }).toString(); + client = registered; + process.stderr.write(` Open this URL to connect Supermemory to Cursor: - ${authUrl} + ${authorizationUrl} Waiting... `); - openBrowser(authUrl); - }); - timer = setTimeout(() => { - finish({ success: false, error: "Authentication timed out" }); - }, timeoutMs); - }); + openUrl(authorizationUrl).catch(() => process.stderr.write(`Could not open a browser. Open the URL above to continue. +`)); + } catch (error) { + reject(error); + } + }); + }); + } finally { + clearTimeout(timer); + controller.abort(); + server?.close(); + server?.closeAllConnections(); + } + }, timeoutMs); } // src/config.ts +var DEFAULT_API_URL = "https://api.supermemory.ai"; +var DEFAULT_MCP_URL = "https://mcp.supermemory.ai/mcp"; var GLOBAL_CONFIG_PATH = path2.join(os2.homedir(), ".config", "cursor", "supermemory.json"); var DEFAULTS = { baseUrl: null, @@ -143,7 +487,7 @@ var DEFAULTS = { userContainerTag: null, projectContainerTag: null }; -function readJson(filePath) { +function readJson2(filePath) { try { if (!fs2.existsSync(filePath)) return null; @@ -156,7 +500,7 @@ function findProjectConfig(cwd) { let dir = cwd; while (true) { const configPath = path2.join(dir, ".cursor", ".supermemory", "config.json"); - const data = readJson(configPath); + const data = readJson2(configPath); if (data) return data; const parent = path2.dirname(dir); @@ -168,7 +512,7 @@ function findProjectConfig(cwd) { } function loadConfig(cwd) { const projectConfig = findProjectConfig(cwd || process.cwd()); - const globalConfig = readJson(GLOBAL_CONFIG_PATH); + const globalConfig = readJson2(GLOBAL_CONFIG_PATH); const merged = { ...DEFAULTS, ...globalConfig, ...projectConfig }; return { apiKey: process.env.SUPERMEMORY_API_KEY ?? merged.apiKey ?? null, @@ -189,12 +533,62 @@ function loadConfig(cwd) { function getApiKey(config) { if (config.apiKey) return config.apiKey; - const creds = loadCredentials(); - return creds?.apiKey ?? null; + return loadCredentials() ?? null; +} +function getApiBaseUrl(config) { + return config.baseUrl || DEFAULT_API_URL; +} +function normalizeUrl(value) { + try { + const url = new URL(value.trim()); + return ["http:", "https:"].includes(url.protocol) ? url.toString() : null; + } catch { + return null; + } +} +function getMcpUrl(config) { + if (process.env.SUPERMEMORY_MCP_URL) { + const configured = normalizeUrl(process.env.SUPERMEMORY_MCP_URL); + if (!configured) + throw new Error("Invalid SUPERMEMORY_MCP_URL"); + return configured; + } + if (!config.baseUrl) + return DEFAULT_MCP_URL; + const api = new URL(config.baseUrl); + if (api.hostname.startsWith("api.")) { + api.hostname = api.hostname.replace(/^api\./, "mcp."); + api.pathname = "/mcp"; + api.search = ""; + api.hash = ""; + return api.toString(); + } + throw new Error("Set SUPERMEMORY_MCP_URL for OAuth or MCP on a custom Supermemory backend."); +} +function getAuthType(config) { + if (getApiKey(config)) + return "api_key"; + return loadCredentialData()?.type === "oauth" ? "oauth" : "none"; +} +function isConfigured(config) { + if (getApiKey(config)) + return true; + try { + return hasOAuthCredentials(getOAuthConfig(getApiBaseUrl(config), getMcpUrl(config))); + } catch { + return false; + } +} +async function getAuthToken(config, timeoutMs) { + const apiKey = getApiKey(config); + if (apiKey) + return apiKey; + if (loadCredentialData()?.type !== "oauth") + throw authRequired(); + return getAccessToken(getOAuthConfig(getApiBaseUrl(config), getMcpUrl(config)), timeoutMs); } // src/mcp-proxy.ts -var MCP_URL = process.env.SUPERMEMORY_MCP_URL || "https://mcp.supermemory.ai/mcp"; var REQUEST_TIMEOUT_MS = 30000; var sessionId = null; function send(message) { @@ -221,7 +615,7 @@ function emitSseData(body, write) { } } } -async function forward(message, apiKey) { +async function forward(message, apiKey, mcpUrl) { const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", @@ -229,7 +623,7 @@ async function forward(message, apiKey) { }; if (sessionId) headers["Mcp-Session-Id"] = sessionId; - const response = await fetch(MCP_URL, { + const response = await fetch(mcpUrl, { method: "POST", headers, body: JSON.stringify(message), @@ -255,8 +649,12 @@ async function forward(message, apiKey) { `); } } +function proxyTarget(config) { + if (getApiKey(config)) + return process.env.SUPERMEMORY_MCP_URL || DEFAULT_MCP_URL; + return getMcpUrl(config); +} function startMcpProxy() { - const apiKey = getApiKey(loadConfig()); let queue = Promise.resolve(); const lines = createInterface({ input: process.stdin }); lines.on("line", (line) => { @@ -269,25 +667,28 @@ function startMcpProxy() { return; } queue = queue.then(async () => { - if (!apiKey) { - sendError(message.id, -32001, "Supermemory is not authenticated. Run `cursor-supermemory login`, or set SUPERMEMORY_API_KEY."); - return; - } try { - await forward(message, apiKey); + const config = loadConfig(); + const apiKey = await getAuthToken(config); + await forward(message, apiKey, proxyTarget(config)); } catch (error) { + if (isAuthRequired(error)) { + sessionId = null; + sendError(message.id, -32001, "Supermemory is not authenticated. Run `cursor-supermemory login`, or set SUPERMEMORY_API_KEY."); + return; + } const detail = error instanceof Error ? error.message : String(error); sendError(message.id, -32000, `Supermemory MCP proxy error: ${detail}`); } }); }); lines.on("close", () => { - queue.finally(() => process.exit(0)); + queue.finally(() => settleAuth().finally(() => process.exit(0))); }); } // src/hook-api.ts -import { createHash, createHmac } from "node:crypto"; +import { createHash as createHash2, createHmac } from "node:crypto"; // node_modules/supermemory/dist/esm/generated/core/auth/AuthProvider.js function isAuthProvider(value) { @@ -2082,10 +2483,10 @@ class ConnectorsClient { return HttpResponsePromise.fromPromise(this.__listAll(request, requestOptions)); } async __listAll(request = {}, requestOptions) { - const { provider, page, limit } = request; + const { provider, page: page2, limit } = request; const _queryParams = { provider: provider != null ? provider : undefined, - page, + page: page2, limit }; const _authRequest = await this._options.authProvider.getAuthRequest(); @@ -2131,10 +2532,10 @@ class ConnectorsClient { return HttpResponsePromise.fromPromise(this.__list(namespace, request, requestOptions)); } async __list(namespace, request = {}, requestOptions) { - const { provider, page, limit } = request; + const { provider, page: page2, limit } = request; const _queryParams = { provider: provider != null ? provider : undefined, - page, + page: page2, limit }; const _authRequest = await this._options.authProvider.getAuthRequest(); @@ -2994,9 +3395,9 @@ class NamespacesClient { return HttpResponsePromise.fromPromise(this.__list(request, requestOptions)); } async __list(request = {}, requestOptions) { - const { page, limit } = request; + const { page: page2, limit } = request; const _queryParams = { - page, + page: page2, limit }; const _authRequest = await this._options.authProvider.getAuthRequest(); @@ -3582,9 +3983,9 @@ class SupermemoryClient { return HttpResponsePromise.fromPromise(this.__list(namespace, type, request, requestOptions)); } async __list(namespace, type, request = {}, requestOptions) { - const { page, limit, sort, order, ..._body } = request; + const { page: page2, limit, sort, order, ..._body } = request; const _queryParams = { - page, + page: page2, limit, sort: sort != null ? sort : undefined, order: order != null ? order : undefined @@ -3678,7 +4079,6 @@ class Supermemory extends SupermemoryClient { } // src/hook-api.ts -var DEFAULT_BASE_URL = "https://api.supermemory.ai"; var REQUEST_TIMEOUT_MS2 = 3000; var INTEGRITY_VERSION = 1; var SEED = "7f2a9c4b8e1d6f3a5c0b9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a"; @@ -3690,16 +4090,20 @@ function resolveApiVersion(baseUrl, apiVersion) { } let url; try { - url = new URL(baseUrl || DEFAULT_BASE_URL); + url = new URL(baseUrl || DEFAULT_API_URL); } catch { throw new Error("Supermemory API base URL is invalid"); } - return url.origin === DEFAULT_BASE_URL && /^\/*$/.test(url.pathname) && !url.username && !url.password && !url.search && !url.hash ? "v5" : "legacy"; + return url.origin === DEFAULT_API_URL && /^\/*$/.test(url.pathname) && !url.username && !url.password && !url.search && !url.hash ? "v5" : "legacy"; } -function sdk(baseUrl, apiKey, tag) { +function resolveAuth(auth2) { + return typeof auth2 === "function" ? auth2() : Promise.resolve(auth2); +} +async function sdk(baseUrl, auth2, tag) { + const apiKey = await resolveAuth(auth2); return new Supermemory({ apiKey, - baseUrl: (baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ""), + baseUrl: (baseUrl || DEFAULT_API_URL).replace(/\/+$/, ""), headers: headers(apiKey, tag), timeoutInSeconds: REQUEST_TIMEOUT_MS2 / 1000, maxRetries: 0 @@ -3714,7 +4118,7 @@ async function sdkRequest(request) { } } function sha256(input) { - return createHash("sha256").update(input).digest("hex"); + return createHash2("sha256").update(input).digest("hex"); } function headers(apiKey, containerTag) { const contentHash = sha256(containerTag); @@ -3728,8 +4132,9 @@ function headers(apiKey, containerTag) { "x-sm-source": "cursor" }; } -async function post(baseUrl, apiKey, path3, containerTag, body) { - const response = await fetch(`${(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "")}${path3}`, { +async function post(baseUrl, auth2, path3, containerTag, body) { + const apiKey = await resolveAuth(auth2); + const response = await fetch(`${(baseUrl || DEFAULT_API_URL).replace(/\/+$/, "")}${path3}`, { method: "POST", headers: headers(apiKey, containerTag), body: JSON.stringify(body), @@ -3744,9 +4149,9 @@ async function post(baseUrl, apiKey, path3, containerTag, body) { throw new Error("Supermemory returned an invalid response"); }); } -async function getProfile(baseUrl, apiKey, containerTag, query, scope, apiVersion) { +async function getProfile(baseUrl, auth2, containerTag, query, scope, apiVersion) { if (resolveApiVersion(baseUrl, apiVersion) === "v5") { - const client = sdk(baseUrl, apiKey, containerTag); + const client = await sdk(baseUrl, auth2, containerTag); const options = { abortSignal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2), maxRetries: 0 @@ -3754,11 +4159,11 @@ async function getProfile(baseUrl, apiKey, containerTag, query, scope, apiVersio const body = scope ? { filter: { field: "sm_scope", operator: "eq", value: scope } } : {}; const [profile, searchResults] = await Promise.all([ sdkRequest(client.profile(containerTag, body, options)), - query ? searchMemories(baseUrl, apiKey, containerTag, query, scope, "v5", options.abortSignal) : undefined + query ? searchMemories(baseUrl, auth2, containerTag, query, scope, "v5", options.abortSignal) : undefined ]); return { ...profile, ...searchResults ? { searchResults } : {} }; } - return post(baseUrl, apiKey, "/v4/profile", containerTag, { + return post(baseUrl, auth2, "/v4/profile", containerTag, { containerTag, ...query ? { q: query } : {}, ...scope ? { @@ -3768,11 +4173,12 @@ async function getProfile(baseUrl, apiKey, containerTag, query, scope, apiVersio } : {} }); } -async function searchMemories(baseUrl, apiKey, containerTag, query, scope, apiVersion, abortSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS2)) { +async function searchMemories(baseUrl, auth2, containerTag, query, scope, apiVersion, abortSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS2)) { if (resolveApiVersion(baseUrl, apiVersion) === "legacy") { - return getProfile(baseUrl, apiKey, containerTag, query, scope, "legacy"); + return getProfile(baseUrl, auth2, containerTag, query, scope, "legacy"); } - return sdkRequest(sdk(baseUrl, apiKey, containerTag).search(containerTag, { + const client = await sdk(baseUrl, auth2, containerTag); + return sdkRequest(client.search(containerTag, { query, searchMode: "memories", threshold: 0.55, @@ -3791,7 +4197,7 @@ async function searchMemories(baseUrl, apiKey, containerTag, query, scope, apiVe // src/tags.ts import { execSync } from "node:child_process"; -import { createHash as createHash2 } from "node:crypto"; +import { createHash as createHash3 } from "node:crypto"; import { existsSync, readFileSync, @@ -3806,7 +4212,7 @@ import { sep } from "node:path"; function sha2562(input) { - return createHash2("sha256").update(input).digest("hex").slice(0, 16); + return createHash3("sha256").update(input).digest("hex").slice(0, 16); } function getGitRoot(directory) { const isolateWorktrees = process.env.SUPERMEMORY_ISOLATE_WORKTREES === "true"; @@ -3904,7 +4310,7 @@ function getGitRepoInfo(directory) { return result; } } -function readJson2(filePath) { +function readJson3(filePath) { try { if (!existsSync(filePath)) return null; @@ -4037,7 +4443,7 @@ function getLegacyCursorProjectTags(directory, config) { function getLegacyClaudeTags(directory) { const basePath = getProjectBasePath(directory); const projectHash = sha2562(basePath); - const config = readJson2(join2(basePath, ".claude", ".supermemory-claude", "config.json")); + const config = readJson3(join2(basePath, ".claude", ".supermemory-claude", "config.json")); return { personal: uniqueTags([ stringValue(config?.personalContainerTag), @@ -4052,7 +4458,7 @@ function getLegacyClaudeTags(directory) { }; } function getLegacyCodexTags(directory) { - const config = readJson2(join2(homedir(), ".codex", "supermemory.json")); + const config = readJson3(join2(homedir(), ".codex", "supermemory.json")); const prefix = stringValue(config?.containerTagPrefix) || "codex"; const userIdentity = getGitEmail(directory) || process.env.USER || process.env.USERNAME || hostname(); const userHash = sha2562(userIdentity); @@ -4161,68 +4567,72 @@ function writeGlobalMcpEntry(cliPath, configPath = GLOBAL_MCP_PATH) { // src/cli.ts var command = process.argv[2]; -switch (command) { - case "mcp": - startMcpProxy(); - break; - case "mcp-install": { - const configPath = writeGlobalMcpEntry(fileURLToPath(import.meta.url)); - console.log(`Registered the supermemory MCP server in ${configPath}.`); - console.log("Restart Cursor (or the cloud agent) to pick it up."); - break; - } - case "login": { - const existing = loadCredentials(); - if (existing) { - console.log("Already authenticated. Use `logout` first to re-authenticate."); - process.exit(0); - } - console.log("Opening browser to authenticate..."); - const result = await startAuthFlow(); - if (result.success) { - console.log("Authenticated successfully."); - } else { - console.error(`Authentication failed: ${result.error}`); - process.exit(1); +async function main() { + switch (command) { + case "mcp": + startMcpProxy(); + break; + case "mcp-install": { + const configPath = writeGlobalMcpEntry(fileURLToPath(import.meta.url)); + console.log(`Registered the supermemory MCP server in ${configPath}.`); + console.log("Restart Cursor (or the cloud agent) to pick it up."); + break; } - break; - } - case "logout": { - const removed = clearCredentials(); - console.log(removed ? "Logged out." : "No credentials found."); - break; - } - case "status": { - const config = loadConfig(); - const apiKey = getApiKey(config); - if (!apiKey) { - console.log("Not authenticated. Run `cursor-supermemory login` to connect."); + case "login": { + const config = loadConfig(); + console.log("Opening browser to authenticate..."); + await startAuthFlow(getOAuthConfig(getApiBaseUrl(config), getMcpUrl(config))); + console.log("Authenticated successfully."); + if (getApiKey(config)) { + console.log("An environment/config API key still takes precedence. Remove it to use OAuth."); + } break; } - const credentials = loadCredentials(); - if (credentials?.createdAt) { - console.log(`Authenticated since ${credentials.createdAt}`); + case "logout": { + const removed = await clearCredentials(); + console.log(removed ? "Logged out." : "No credentials found."); + if (getApiKey(loadConfig())) { + console.log("An environment/config API key is still configured and has not been removed."); + } + break; } - console.log("API key is configured."); - try { - const tags = getResolvedTags(process.cwd(), config); - await getProfile(config.baseUrl, apiKey, tags.canonical, resolveApiVersion(config.baseUrl, config.apiVersion) === "legacy" ? "connectivity probe" : undefined, undefined, config.apiVersion); - console.log(`Connected to Supermemory (${tags.canonical}).`); - } catch (error) { - console.error(`Supermemory is unreachable: ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; + case "status": { + const config = loadConfig(); + const authType = getAuthType(config); + console.log(`Authentication: ${authType}`); + const credentials = loadCredentialData(); + if (authType === "oauth" && credentials?.type === "oauth") { + console.log(`OAuth client: ${credentials.clientId}`); + } + if (!isConfigured(config)) { + console.log("Not authenticated. Run `cursor-supermemory login` to connect."); + break; + } + try { + const tags = getResolvedTags(process.cwd(), config); + await getProfile(config.baseUrl, () => getAuthToken(config), tags.canonical, resolveApiVersion(config.baseUrl, config.apiVersion) === "legacy" ? "connectivity probe" : undefined, undefined, config.apiVersion); + console.log(`Connected to Supermemory (${tags.canonical}).`); + } catch (error) { + console.error(`Supermemory is unreachable: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } + break; } - break; - } - default: - console.log(`cursor-supermemory — Persistent AI memory for Cursor + default: + console.log(`cursor-supermemory — Persistent AI memory for Cursor Commands: mcp Proxy the hosted Supermemory MCP server over stdio mcp-install Register the MCP server in ~/.cursor/mcp.json with an absolute path login Authenticate with Supermemory - logout Remove stored credentials + logout Revoke and remove stored credentials status Show authentication status`); - if (command) - process.exit(1); + if (command) + process.exitCode = 1; + } } +main().catch(async (error) => { + console.error(error instanceof Error ? error.message : String(error)); + await settleAuth(); + process.exit(1); +}); diff --git a/dist/prompt-recall.js b/dist/prompt-recall.js index e746eab..b95adc6 100644 --- a/dist/prompt-recall.js +++ b/dist/prompt-recall.js @@ -16,7 +16,7 @@ var __export = (target, all) => { var __require = /* @__PURE__ */ createRequire(import.meta.url); // src/hooks/prompt-recall.ts -import { createHash as createHash4 } from "node:crypto"; +import { createHash as createHash5 } from "node:crypto"; // src/config.ts import path2 from "node:path"; @@ -27,23 +27,253 @@ import fs2 from "node:fs"; import path from "node:path"; import os from "node:os"; import fs from "node:fs"; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; var CREDENTIALS_DIR = path.join(os.homedir(), ".supermemory-cursor"); var CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json"); -var AUTH_URL = process.env.SUPERMEMORY_AUTH_URL || "https://console.supermemory.ai/auth/connect"; -function loadCredentials() { +var CLIENT_FILE = path.join(CREDENTIALS_DIR, "oauth-client.json"); +var LOCK_FILE = path.join(CREDENTIALS_DIR, "auth.lock"); +var AUTH_PORT = 19879; +var REDIRECT_URI = `http://127.0.0.1:${AUTH_PORT}/callback`; +var REFRESH_TIMEOUT = 3000; +var REFRESH_REQUEST_TIMEOUT = 1e4; +var REFRESH_SKEW = 60000; +var configuredAuthTimeout = Number(process.env.SUPERMEMORY_AUTH_TIMEOUT); +var AUTH_TIMEOUT = Number.isFinite(configuredAuthTimeout) && configuredAuthTimeout > 0 ? configuredAuthTimeout : 5 * 60000; +function page(title, message) { + return ` +${title} - Supermemory

${title}

${message}

`; +} +var SUCCESS_HTML = page("Connected to Cursor!", "You can close this tab and return to Cursor."); +var DENIED_HTML = page("Authorization denied", "Supermemory was not connected. Return to Cursor and run login again to retry."); +var FAILED_HTML = page("Connection failed", "Return to Cursor and run login again."); + +class AuthError extends Error { + code; + constructor(message, code) { + super(message); + this.code = code; + } +} +function authRequired() { + return new AuthError("Supermemory authentication required. Run `cursor-supermemory login`, or set SUPERMEMORY_API_KEY.", "AUTH_REQUIRED"); +} +function readJson(file) { try { - if (!fs.existsSync(CREDENTIALS_FILE)) - return null; - const data = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, "utf-8")); - if (data.apiKey) - return data; - return null; + const value = JSON.parse(fs.readFileSync(file, "utf-8")); + return value && typeof value === "object" && !Array.isArray(value) ? value : null; } catch { return null; } } +function loadCredentialData() { + const data = readJson(CREDENTIALS_FILE); + return data?.type === "oauth" || data?.apiKey ? data : null; +} +function loadCredentials() { + const data = loadCredentialData(); + return data?.type === "oauth" ? undefined : data?.apiKey; +} +function writeJson(file, data) { + fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 448 }); + fs.chmodSync(CREDENTIALS_DIR, 448); + const temporary = `${file}.${randomUUID()}.tmp`; + try { + fs.writeFileSync(temporary, JSON.stringify(data, null, 2), { mode: 384, flag: "wx" }); + fs.renameSync(temporary, file); + } finally { + fs.rmSync(temporary, { force: true }); + } +} +function recoverAbandonedLock() { + let recovery; + let linked = false; + try { + const original = fs.statSync(LOCK_FILE); + recovery = `${LOCK_FILE}.${original.dev}.${original.ino}.recovery`; + fs.linkSync(LOCK_FILE, recovery); + linked = true; + const snapshot = fs.statSync(recovery); + if (snapshot.ino !== original.ino || snapshot.dev !== original.dev) + return; + const owner = readJson(recovery); + let abandoned = false; + if (owner && Number.isInteger(owner.pid) && owner.pid > 0) { + try { + process.kill(owner.pid, 0); + } catch (error) { + abandoned = error.code === "ESRCH"; + } + } else { + abandoned = Date.now() - snapshot.mtimeMs > 10 * 60000; + } + if (abandoned) { + const current = fs.statSync(LOCK_FILE); + if (current.ino === original.ino && current.dev === original.dev) + fs.unlinkSync(LOCK_FILE); + } + } catch (error) { + if (!["ENOENT", "EEXIST"].includes(error.code ?? "")) + throw error; + } finally { + if (linked && recovery) + fs.rmSync(recovery, { force: true }); + } +} +async function withAuthLock(action, timeoutMs) { + fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 448 }); + const deadline = Date.now() + timeoutMs; + let descriptor; + while (descriptor === undefined) { + try { + descriptor = fs.openSync(LOCK_FILE, "wx", 384); + } catch (error) { + if (error.code !== "EEXIST") + throw error; + 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 })); + return await action(Math.max(1, deadline - Date.now())); + } finally { + fs.closeSync(descriptor); + fs.rmSync(LOCK_FILE, { force: true }); + } +} +function secureUrl(value) { + const url = new URL(value); + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if (url.username || url.password || url.hash || url.search || url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) { + throw new Error("OAuth requires HTTPS (HTTP is allowed only for loopback development)."); + } + return url.toString().replace(/\/+$/, ""); +} +function getOAuthConfig(baseUrl, mcpUrl) { + const apiBaseUrl = secureUrl(baseUrl); + return { + apiBaseUrl, + issuer: secureUrl(process.env.SUPERMEMORY_OAUTH_ISSUER || `${apiBaseUrl}/api/auth`), + resource: secureUrl(mcpUrl) + }; +} +function sameConfig(credentials, config) { + return credentials?.apiBaseUrl === config.apiBaseUrl && credentials?.issuer === config.issuer && credentials?.resource === config.resource; +} +function hasOAuthCredentials(config) { + const credentials = loadCredentialData(); + return credentials?.type === "oauth" && sameConfig(credentials, config) && (!!credentials.refreshToken || !!credentials.accessToken && credentials.expiresAt > Date.now() + REFRESH_SKEW); +} +function issuerEndpoint(value, issuer) { + const endpoint = secureUrl(value); + if (new URL(endpoint).origin !== new URL(issuer).origin) { + throw new Error("OAuth endpoint does not belong to the configured issuer."); + } + return endpoint; +} +async function oauthRequest(url, options) { + const response = await fetch(url, { ...options, redirect: "error" }); + const body = await response.json().catch(() => null); + const data = body && typeof body === "object" ? body : {}; + if (!response.ok) { + const code = ["invalid_grant", "invalid_client", "access_denied"].includes(String(data.error)) ? String(data.error) : "oauth_request_failed"; + throw new AuthError(`Supermemory authorization failed (${code}, HTTP ${response.status}).`, code); + } + return data; +} +async function exchangeToken(client, parameters, signal, previous) { + const data = await oauthRequest(issuerEndpoint(client.tokenEndpoint, client.issuer), { + method: "POST", + signal, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: client.clientId, + resource: client.resource, + ...parameters + }) + }); + const refreshToken = data.refresh_token ?? previous?.refreshToken; + if (typeof data.access_token !== "string" || !data.access_token || typeof data.token_type !== "string" || data.token_type.toLowerCase() !== "bearer" || typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0 || typeof refreshToken !== "string" || !refreshToken) { + throw new Error("OAuth returned an invalid token response."); + } + signal.throwIfAborted(); + const credentials = { + type: "oauth", + apiBaseUrl: client.apiBaseUrl, + issuer: client.issuer, + resource: client.resource, + clientId: client.clientId, + redirectUri: client.redirectUri, + authorizationEndpoint: client.authorizationEndpoint, + tokenEndpoint: client.tokenEndpoint, + revocationEndpoint: client.revocationEndpoint, + accessToken: data.access_token, + refreshToken, + expiresAt: Date.now() + data.expires_in * 1000, + savedAt: new Date().toISOString() + }; + writeJson(CREDENTIALS_FILE, credentials); + return data.access_token; +} +var pendingRefresh = null; +function refreshCredentials(config) { + if (pendingRefresh) + return pendingRefresh; + const refresh = withAuthLock(async (remaining) => { + const credentials = loadCredentialData(); + if (credentials?.type !== "oauth" || !sameConfig(credentials, config)) + throw authRequired(); + if (credentials.accessToken && credentials.expiresAt > Date.now() + REFRESH_SKEW) + return credentials.accessToken; + if (!credentials.refreshToken) + throw authRequired(); + try { + return await exchangeToken(credentials, { grant_type: "refresh_token", refresh_token: credentials.refreshToken }, AbortSignal.timeout(remaining), credentials); + } catch (error) { + if (error instanceof AuthError && ["invalid_grant", "invalid_client"].includes(error.code)) { + const { accessToken: _access, refreshToken: _refresh, ...registration } = credentials; + writeJson(CREDENTIALS_FILE, registration); + if (error.code === "invalid_client") + fs.rmSync(CLIENT_FILE, { force: true }); + throw authRequired(); + } + throw error; + } + }, REFRESH_REQUEST_TIMEOUT).finally(() => { + pendingRefresh = null; + }); + refresh.catch(() => {}); + pendingRefresh = refresh; + return refresh; +} +function waitFor(promise, timeoutMs) { + let timer; + const expired = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new AuthError("Supermemory token refresh is still in progress.", "AUTH_PENDING"); + error.name = "TimeoutError"; + reject(error); + }, timeoutMs); + }); + return Promise.race([promise, expired]).finally(() => clearTimeout(timer)); +} +async function getAccessToken(config, timeoutMs = REFRESH_TIMEOUT) { + const saved = loadCredentialData(); + if (saved?.type !== "oauth" || !sameConfig(saved, config)) + throw authRequired(); + if (saved.accessToken && saved.expiresAt > Date.now() + REFRESH_SKEW) + return saved.accessToken; + return waitFor(refreshCredentials(config), timeoutMs); +} // src/config.ts +var DEFAULT_API_URL = "https://api.supermemory.ai"; +var DEFAULT_MCP_URL = "https://mcp.supermemory.ai/mcp"; var GLOBAL_CONFIG_PATH = path2.join(os2.homedir(), ".config", "cursor", "supermemory.json"); var DEFAULTS = { baseUrl: null, @@ -58,7 +288,7 @@ var DEFAULTS = { userContainerTag: null, projectContainerTag: null }; -function readJson(filePath) { +function readJson2(filePath) { try { if (!fs2.existsSync(filePath)) return null; @@ -71,7 +301,7 @@ function findProjectConfig(cwd) { let dir = cwd; while (true) { const configPath = path2.join(dir, ".cursor", ".supermemory", "config.json"); - const data = readJson(configPath); + const data = readJson2(configPath); if (data) return data; const parent = path2.dirname(dir); @@ -83,7 +313,7 @@ function findProjectConfig(cwd) { } function loadConfig(cwd) { const projectConfig = findProjectConfig(cwd || process.cwd()); - const globalConfig = readJson(GLOBAL_CONFIG_PATH); + const globalConfig = readJson2(GLOBAL_CONFIG_PATH); const merged = { ...DEFAULTS, ...globalConfig, ...projectConfig }; return { apiKey: process.env.SUPERMEMORY_API_KEY ?? merged.apiKey ?? null, @@ -104,12 +334,58 @@ function loadConfig(cwd) { function getApiKey(config) { if (config.apiKey) return config.apiKey; - const creds = loadCredentials(); - return creds?.apiKey ?? null; + return loadCredentials() ?? null; +} +function getApiBaseUrl(config) { + return config.baseUrl || DEFAULT_API_URL; +} +function normalizeUrl(value) { + try { + const url = new URL(value.trim()); + return ["http:", "https:"].includes(url.protocol) ? url.toString() : null; + } catch { + return null; + } +} +function getMcpUrl(config) { + if (process.env.SUPERMEMORY_MCP_URL) { + const configured = normalizeUrl(process.env.SUPERMEMORY_MCP_URL); + if (!configured) + throw new Error("Invalid SUPERMEMORY_MCP_URL"); + return configured; + } + if (!config.baseUrl) + return DEFAULT_MCP_URL; + const api = new URL(config.baseUrl); + if (api.hostname.startsWith("api.")) { + api.hostname = api.hostname.replace(/^api\./, "mcp."); + api.pathname = "/mcp"; + api.search = ""; + api.hash = ""; + return api.toString(); + } + throw new Error("Set SUPERMEMORY_MCP_URL for OAuth or MCP on a custom Supermemory backend."); +} +function isConfigured(config) { + if (getApiKey(config)) + return true; + try { + return hasOAuthCredentials(getOAuthConfig(getApiBaseUrl(config), getMcpUrl(config))); + } catch { + return false; + } +} +async function getAuthToken(config, timeoutMs) { + const apiKey = getApiKey(config); + if (apiKey) + return apiKey; + if (loadCredentialData()?.type !== "oauth") + throw authRequired(); + return getAccessToken(getOAuthConfig(getApiBaseUrl(config), getMcpUrl(config)), timeoutMs); } // src/hook-api.ts -import { createHash, createHmac } from "node:crypto"; +import { createHash as createHash2, createHmac } from "node:crypto"; // node_modules/supermemory/dist/esm/generated/core/auth/AuthProvider.js function isAuthProvider(value) { @@ -1904,10 +2180,10 @@ class ConnectorsClient { return HttpResponsePromise.fromPromise(this.__listAll(request, requestOptions)); } async __listAll(request = {}, requestOptions) { - const { provider, page, limit } = request; + const { provider, page: page2, limit } = request; const _queryParams = { provider: provider != null ? provider : undefined, - page, + page: page2, limit }; const _authRequest = await this._options.authProvider.getAuthRequest(); @@ -1953,10 +2229,10 @@ class ConnectorsClient { return HttpResponsePromise.fromPromise(this.__list(namespace, request, requestOptions)); } async __list(namespace, request = {}, requestOptions) { - const { provider, page, limit } = request; + const { provider, page: page2, limit } = request; const _queryParams = { provider: provider != null ? provider : undefined, - page, + page: page2, limit }; const _authRequest = await this._options.authProvider.getAuthRequest(); @@ -2816,9 +3092,9 @@ class NamespacesClient { return HttpResponsePromise.fromPromise(this.__list(request, requestOptions)); } async __list(request = {}, requestOptions) { - const { page, limit } = request; + const { page: page2, limit } = request; const _queryParams = { - page, + page: page2, limit }; const _authRequest = await this._options.authProvider.getAuthRequest(); @@ -3404,9 +3680,9 @@ class SupermemoryClient { return HttpResponsePromise.fromPromise(this.__list(namespace, type, request, requestOptions)); } async __list(namespace, type, request = {}, requestOptions) { - const { page, limit, sort, order, ..._body } = request; + const { page: page2, limit, sort, order, ..._body } = request; const _queryParams = { - page, + page: page2, limit, sort: sort != null ? sort : undefined, order: order != null ? order : undefined @@ -3500,7 +3776,6 @@ class Supermemory extends SupermemoryClient { } // src/hook-api.ts -var DEFAULT_BASE_URL = "https://api.supermemory.ai"; var REQUEST_TIMEOUT_MS = 3000; var INTEGRITY_VERSION = 1; var SEED = "7f2a9c4b8e1d6f3a5c0b9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a"; @@ -3512,16 +3787,20 @@ function resolveApiVersion(baseUrl, apiVersion) { } let url; try { - url = new URL(baseUrl || DEFAULT_BASE_URL); + url = new URL(baseUrl || DEFAULT_API_URL); } catch { throw new Error("Supermemory API base URL is invalid"); } - return url.origin === DEFAULT_BASE_URL && /^\/*$/.test(url.pathname) && !url.username && !url.password && !url.search && !url.hash ? "v5" : "legacy"; + return url.origin === DEFAULT_API_URL && /^\/*$/.test(url.pathname) && !url.username && !url.password && !url.search && !url.hash ? "v5" : "legacy"; } -function sdk(baseUrl, apiKey, tag) { +function resolveAuth(auth2) { + return typeof auth2 === "function" ? auth2() : Promise.resolve(auth2); +} +async function sdk(baseUrl, auth2, tag) { + const apiKey = await resolveAuth(auth2); return new Supermemory({ apiKey, - baseUrl: (baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ""), + baseUrl: (baseUrl || DEFAULT_API_URL).replace(/\/+$/, ""), headers: headers(apiKey, tag), timeoutInSeconds: REQUEST_TIMEOUT_MS / 1000, maxRetries: 0 @@ -3536,7 +3815,7 @@ async function sdkRequest(request) { } } function sha256(input) { - return createHash("sha256").update(input).digest("hex"); + return createHash2("sha256").update(input).digest("hex"); } function headers(apiKey, containerTag) { const contentHash = sha256(containerTag); @@ -3550,8 +3829,9 @@ function headers(apiKey, containerTag) { "x-sm-source": "cursor" }; } -async function post(baseUrl, apiKey, path3, containerTag, body) { - const response = await fetch(`${(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "")}${path3}`, { +async function post(baseUrl, auth2, path3, containerTag, body) { + const apiKey = await resolveAuth(auth2); + const response = await fetch(`${(baseUrl || DEFAULT_API_URL).replace(/\/+$/, "")}${path3}`, { method: "POST", headers: headers(apiKey, containerTag), body: JSON.stringify(body), @@ -3566,9 +3846,9 @@ async function post(baseUrl, apiKey, path3, containerTag, body) { throw new Error("Supermemory returned an invalid response"); }); } -async function getProfile(baseUrl, apiKey, containerTag, query, scope, apiVersion) { +async function getProfile(baseUrl, auth2, containerTag, query, scope, apiVersion) { if (resolveApiVersion(baseUrl, apiVersion) === "v5") { - const client = sdk(baseUrl, apiKey, containerTag); + const client = await sdk(baseUrl, auth2, containerTag); const options = { abortSignal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), maxRetries: 0 @@ -3576,11 +3856,11 @@ async function getProfile(baseUrl, apiKey, containerTag, query, scope, apiVersio const body = scope ? { filter: { field: "sm_scope", operator: "eq", value: scope } } : {}; const [profile, searchResults] = await Promise.all([ sdkRequest(client.profile(containerTag, body, options)), - query ? searchMemories(baseUrl, apiKey, containerTag, query, scope, "v5", options.abortSignal) : undefined + query ? searchMemories(baseUrl, auth2, containerTag, query, scope, "v5", options.abortSignal) : undefined ]); return { ...profile, ...searchResults ? { searchResults } : {} }; } - return post(baseUrl, apiKey, "/v4/profile", containerTag, { + return post(baseUrl, auth2, "/v4/profile", containerTag, { containerTag, ...query ? { q: query } : {}, ...scope ? { @@ -3590,11 +3870,12 @@ async function getProfile(baseUrl, apiKey, containerTag, query, scope, apiVersio } : {} }); } -async function searchMemories(baseUrl, apiKey, containerTag, query, scope, apiVersion, abortSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS)) { +async function searchMemories(baseUrl, auth2, containerTag, query, scope, apiVersion, abortSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS)) { if (resolveApiVersion(baseUrl, apiVersion) === "legacy") { - return getProfile(baseUrl, apiKey, containerTag, query, scope, "legacy"); + return getProfile(baseUrl, auth2, containerTag, query, scope, "legacy"); } - return sdkRequest(sdk(baseUrl, apiKey, containerTag).search(containerTag, { + const client = await sdk(baseUrl, auth2, containerTag); + return sdkRequest(client.search(containerTag, { query, searchMode: "memories", threshold: 0.55, @@ -3610,8 +3891,8 @@ async function searchMemories(baseUrl, apiKey, containerTag, query, scope, apiVe } : {} }, { abortSignal, maxRetries: 0 })); } -function getSearches(baseUrl, apiKey, tags, query, apiVersion) { - return readNamespaces(tags, (tag) => searchMemories(baseUrl, apiKey, tag, query, undefined, apiVersion)); +function getSearches(baseUrl, auth2, tags, query, apiVersion) { + return readNamespaces(tags, (tag) => searchMemories(baseUrl, auth2, tag, query, undefined, apiVersion)); } async function readNamespaces(tags, read) { const results = await Promise.allSettled([...new Set(tags.filter(Boolean))].map(read)); @@ -3624,7 +3905,7 @@ async function readNamespaces(tags, read) { } // src/hook-state.ts -import { createHash as createHash2 } from "node:crypto"; +import { createHash as createHash3 } from "node:crypto"; import { mkdirSync, readFileSync, @@ -3635,7 +3916,7 @@ import { import { homedir } from "node:os"; import { join as join2 } from "node:path"; function statePath(conversationId) { - const id = createHash2("sha256").update(conversationId || "unknown").digest("hex").slice(0, 32); + const id = createHash3("sha256").update(conversationId || "unknown").digest("hex").slice(0, 32); return join2(homedir(), ".supermemory-cursor", "hook-state", `${id}.json`); } function readHookState(conversationId) { @@ -3659,7 +3940,7 @@ function writeHookState(conversationId, updates) { // src/tags.ts import { execSync } from "node:child_process"; -import { createHash as createHash3 } from "node:crypto"; +import { createHash as createHash4 } from "node:crypto"; import { existsSync, readFileSync as readFileSync2, @@ -3674,7 +3955,7 @@ import { sep } from "node:path"; function sha2562(input) { - return createHash3("sha256").update(input).digest("hex").slice(0, 16); + return createHash4("sha256").update(input).digest("hex").slice(0, 16); } function getGitRoot(directory) { const isolateWorktrees = process.env.SUPERMEMORY_ISOLATE_WORKTREES === "true"; @@ -3772,7 +4053,7 @@ function getGitRepoInfo(directory) { return result; } } -function readJson2(filePath) { +function readJson3(filePath) { try { if (!existsSync(filePath)) return null; @@ -3905,7 +4186,7 @@ function getLegacyCursorProjectTags(directory, config) { function getLegacyClaudeTags(directory) { const basePath = getProjectBasePath(directory); const projectHash = sha2562(basePath); - const config = readJson2(join3(basePath, ".claude", ".supermemory-claude", "config.json")); + const config = readJson3(join3(basePath, ".claude", ".supermemory-claude", "config.json")); return { personal: uniqueTags([ stringValue(config?.personalContainerTag), @@ -3920,7 +4201,7 @@ function getLegacyClaudeTags(directory) { }; } function getLegacyCodexTags(directory) { - const config = readJson2(join3(homedir2(), ".codex", "supermemory.json")); + const config = readJson3(join3(homedir2(), ".codex", "supermemory.json")); const prefix = stringValue(config?.containerTagPrefix) || "codex"; const userIdentity = getGitEmail(directory) || process.env.USER || process.env.USERNAME || hostname(); const userHash = sha2562(userIdentity); @@ -4070,7 +4351,7 @@ function resultText(result) { return String(result.memory ?? result.chunk ?? result.content ?? result.title ?? "").trim(); } function hashText(text) { - return createHash4("sha256").update(text.toLowerCase()).digest("hex"); + return createHash5("sha256").update(text.toLowerCase()).digest("hex"); } function selectRecallResults(profiles, seenHashes, threshold = MIN_SIMILARITY) { const seen = new Set(seenHashes); @@ -4121,15 +4402,15 @@ async function runPromptRecall(input) { try { const root = workspaceRoot(input); const config = loadConfig(root); - const apiKey = getApiKey(config); - if (!apiKey) { + if (!isConfigured(config)) { process.stdout.write(JSON.stringify({ continue: true })); return; } + const auth2 = () => getAuthToken(config); const id = conversationId(input); const state = readHookState(id); const tags = getResolvedTags(root, config); - const profiles = await getSearches(config.baseUrl, apiKey, tags.allReads, prompt.slice(0, MAX_QUERY_LENGTH), config.apiVersion); + const profiles = await getSearches(config.baseUrl, auth2, tags.allReads, prompt.slice(0, MAX_QUERY_LENGTH), config.apiVersion); const { fresh, hashes } = selectRecallResults(profiles, state.seenHashes ?? [], Math.max(MIN_SIMILARITY, config.similarityThreshold)); writeHookState(id, fresh.length > 0 ? { pendingContext: formatRecall(fresh, tags.canonical), diff --git a/dist/session-start.js b/dist/session-start.js index da64155..8917468 100644 --- a/dist/session-start.js +++ b/dist/session-start.js @@ -24,23 +24,259 @@ import fs2 from "node:fs"; import path from "node:path"; import os from "node:os"; import fs from "node:fs"; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; var CREDENTIALS_DIR = path.join(os.homedir(), ".supermemory-cursor"); var CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json"); -var AUTH_URL = process.env.SUPERMEMORY_AUTH_URL || "https://console.supermemory.ai/auth/connect"; -function loadCredentials() { +var CLIENT_FILE = path.join(CREDENTIALS_DIR, "oauth-client.json"); +var LOCK_FILE = path.join(CREDENTIALS_DIR, "auth.lock"); +var AUTH_PORT = 19879; +var REDIRECT_URI = `http://127.0.0.1:${AUTH_PORT}/callback`; +var REFRESH_TIMEOUT = 3000; +var REFRESH_REQUEST_TIMEOUT = 1e4; +var REFRESH_SKEW = 60000; +var configuredAuthTimeout = Number(process.env.SUPERMEMORY_AUTH_TIMEOUT); +var AUTH_TIMEOUT = Number.isFinite(configuredAuthTimeout) && configuredAuthTimeout > 0 ? configuredAuthTimeout : 5 * 60000; +function page(title, message) { + return ` +${title} - Supermemory

${title}

${message}

`; +} +var SUCCESS_HTML = page("Connected to Cursor!", "You can close this tab and return to Cursor."); +var DENIED_HTML = page("Authorization denied", "Supermemory was not connected. Return to Cursor and run login again to retry."); +var FAILED_HTML = page("Connection failed", "Return to Cursor and run login again."); + +class AuthError extends Error { + code; + constructor(message, code) { + super(message); + this.code = code; + } +} +function isAuthRequired(error) { + return error instanceof AuthError && error.code === "AUTH_REQUIRED"; +} +function isAuthPending(error) { + return error instanceof AuthError && error.code === "AUTH_PENDING"; +} +function authRequired() { + return new AuthError("Supermemory authentication required. Run `cursor-supermemory login`, or set SUPERMEMORY_API_KEY.", "AUTH_REQUIRED"); +} +function readJson(file) { try { - if (!fs.existsSync(CREDENTIALS_FILE)) - return null; - const data = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, "utf-8")); - if (data.apiKey) - return data; - return null; + const value = JSON.parse(fs.readFileSync(file, "utf-8")); + return value && typeof value === "object" && !Array.isArray(value) ? value : null; } catch { return null; } } +function loadCredentialData() { + const data = readJson(CREDENTIALS_FILE); + return data?.type === "oauth" || data?.apiKey ? data : null; +} +function loadCredentials() { + const data = loadCredentialData(); + return data?.type === "oauth" ? undefined : data?.apiKey; +} +function writeJson(file, data) { + fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 448 }); + fs.chmodSync(CREDENTIALS_DIR, 448); + const temporary = `${file}.${randomUUID()}.tmp`; + try { + fs.writeFileSync(temporary, JSON.stringify(data, null, 2), { mode: 384, flag: "wx" }); + fs.renameSync(temporary, file); + } finally { + fs.rmSync(temporary, { force: true }); + } +} +function recoverAbandonedLock() { + let recovery; + let linked = false; + try { + const original = fs.statSync(LOCK_FILE); + recovery = `${LOCK_FILE}.${original.dev}.${original.ino}.recovery`; + fs.linkSync(LOCK_FILE, recovery); + linked = true; + const snapshot = fs.statSync(recovery); + if (snapshot.ino !== original.ino || snapshot.dev !== original.dev) + return; + const owner = readJson(recovery); + let abandoned = false; + if (owner && Number.isInteger(owner.pid) && owner.pid > 0) { + try { + process.kill(owner.pid, 0); + } catch (error) { + abandoned = error.code === "ESRCH"; + } + } else { + abandoned = Date.now() - snapshot.mtimeMs > 10 * 60000; + } + if (abandoned) { + const current = fs.statSync(LOCK_FILE); + if (current.ino === original.ino && current.dev === original.dev) + fs.unlinkSync(LOCK_FILE); + } + } catch (error) { + if (!["ENOENT", "EEXIST"].includes(error.code ?? "")) + throw error; + } finally { + if (linked && recovery) + fs.rmSync(recovery, { force: true }); + } +} +async function withAuthLock(action, timeoutMs) { + fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 448 }); + const deadline = Date.now() + timeoutMs; + let descriptor; + while (descriptor === undefined) { + try { + descriptor = fs.openSync(LOCK_FILE, "wx", 384); + } catch (error) { + if (error.code !== "EEXIST") + throw error; + 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 })); + return await action(Math.max(1, deadline - Date.now())); + } finally { + fs.closeSync(descriptor); + fs.rmSync(LOCK_FILE, { force: true }); + } +} +function secureUrl(value) { + const url = new URL(value); + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if (url.username || url.password || url.hash || url.search || url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) { + throw new Error("OAuth requires HTTPS (HTTP is allowed only for loopback development)."); + } + return url.toString().replace(/\/+$/, ""); +} +function getOAuthConfig(baseUrl, mcpUrl) { + const apiBaseUrl = secureUrl(baseUrl); + return { + apiBaseUrl, + issuer: secureUrl(process.env.SUPERMEMORY_OAUTH_ISSUER || `${apiBaseUrl}/api/auth`), + resource: secureUrl(mcpUrl) + }; +} +function sameConfig(credentials, config) { + return credentials?.apiBaseUrl === config.apiBaseUrl && credentials?.issuer === config.issuer && credentials?.resource === config.resource; +} +function hasOAuthCredentials(config) { + const credentials = loadCredentialData(); + return credentials?.type === "oauth" && sameConfig(credentials, config) && (!!credentials.refreshToken || !!credentials.accessToken && credentials.expiresAt > Date.now() + REFRESH_SKEW); +} +function issuerEndpoint(value, issuer) { + const endpoint = secureUrl(value); + if (new URL(endpoint).origin !== new URL(issuer).origin) { + throw new Error("OAuth endpoint does not belong to the configured issuer."); + } + return endpoint; +} +async function oauthRequest(url, options) { + const response = await fetch(url, { ...options, redirect: "error" }); + const body = await response.json().catch(() => null); + const data = body && typeof body === "object" ? body : {}; + if (!response.ok) { + const code = ["invalid_grant", "invalid_client", "access_denied"].includes(String(data.error)) ? String(data.error) : "oauth_request_failed"; + throw new AuthError(`Supermemory authorization failed (${code}, HTTP ${response.status}).`, code); + } + return data; +} +async function exchangeToken(client, parameters, signal, previous) { + const data = await oauthRequest(issuerEndpoint(client.tokenEndpoint, client.issuer), { + method: "POST", + signal, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: client.clientId, + resource: client.resource, + ...parameters + }) + }); + const refreshToken = data.refresh_token ?? previous?.refreshToken; + if (typeof data.access_token !== "string" || !data.access_token || typeof data.token_type !== "string" || data.token_type.toLowerCase() !== "bearer" || typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0 || typeof refreshToken !== "string" || !refreshToken) { + throw new Error("OAuth returned an invalid token response."); + } + signal.throwIfAborted(); + const credentials = { + type: "oauth", + apiBaseUrl: client.apiBaseUrl, + issuer: client.issuer, + resource: client.resource, + clientId: client.clientId, + redirectUri: client.redirectUri, + authorizationEndpoint: client.authorizationEndpoint, + tokenEndpoint: client.tokenEndpoint, + revocationEndpoint: client.revocationEndpoint, + accessToken: data.access_token, + refreshToken, + expiresAt: Date.now() + data.expires_in * 1000, + savedAt: new Date().toISOString() + }; + writeJson(CREDENTIALS_FILE, credentials); + return data.access_token; +} +var pendingRefresh = null; +function refreshCredentials(config) { + if (pendingRefresh) + return pendingRefresh; + const refresh = withAuthLock(async (remaining) => { + const credentials = loadCredentialData(); + if (credentials?.type !== "oauth" || !sameConfig(credentials, config)) + throw authRequired(); + if (credentials.accessToken && credentials.expiresAt > Date.now() + REFRESH_SKEW) + return credentials.accessToken; + if (!credentials.refreshToken) + throw authRequired(); + try { + return await exchangeToken(credentials, { grant_type: "refresh_token", refresh_token: credentials.refreshToken }, AbortSignal.timeout(remaining), credentials); + } catch (error) { + if (error instanceof AuthError && ["invalid_grant", "invalid_client"].includes(error.code)) { + const { accessToken: _access, refreshToken: _refresh, ...registration } = credentials; + writeJson(CREDENTIALS_FILE, registration); + if (error.code === "invalid_client") + fs.rmSync(CLIENT_FILE, { force: true }); + throw authRequired(); + } + throw error; + } + }, REFRESH_REQUEST_TIMEOUT).finally(() => { + pendingRefresh = null; + }); + refresh.catch(() => {}); + pendingRefresh = refresh; + return refresh; +} +function waitFor(promise, timeoutMs) { + let timer; + const expired = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new AuthError("Supermemory token refresh is still in progress.", "AUTH_PENDING"); + error.name = "TimeoutError"; + reject(error); + }, timeoutMs); + }); + return Promise.race([promise, expired]).finally(() => clearTimeout(timer)); +} +async function getAccessToken(config, timeoutMs = REFRESH_TIMEOUT) { + const saved = loadCredentialData(); + if (saved?.type !== "oauth" || !sameConfig(saved, config)) + throw authRequired(); + if (saved.accessToken && saved.expiresAt > Date.now() + REFRESH_SKEW) + return saved.accessToken; + return waitFor(refreshCredentials(config), timeoutMs); +} // src/config.ts +var DEFAULT_API_URL = "https://api.supermemory.ai"; +var DEFAULT_MCP_URL = "https://mcp.supermemory.ai/mcp"; var GLOBAL_CONFIG_PATH = path2.join(os2.homedir(), ".config", "cursor", "supermemory.json"); var DEFAULTS = { baseUrl: null, @@ -55,7 +291,7 @@ var DEFAULTS = { userContainerTag: null, projectContainerTag: null }; -function readJson(filePath) { +function readJson2(filePath) { try { if (!fs2.existsSync(filePath)) return null; @@ -68,7 +304,7 @@ function findProjectConfig(cwd) { let dir = cwd; while (true) { const configPath = path2.join(dir, ".cursor", ".supermemory", "config.json"); - const data = readJson(configPath); + const data = readJson2(configPath); if (data) return data; const parent = path2.dirname(dir); @@ -80,7 +316,7 @@ function findProjectConfig(cwd) { } function loadConfig(cwd) { const projectConfig = findProjectConfig(cwd || process.cwd()); - const globalConfig = readJson(GLOBAL_CONFIG_PATH); + const globalConfig = readJson2(GLOBAL_CONFIG_PATH); const merged = { ...DEFAULTS, ...globalConfig, ...projectConfig }; return { apiKey: process.env.SUPERMEMORY_API_KEY ?? merged.apiKey ?? null, @@ -101,13 +337,59 @@ function loadConfig(cwd) { function getApiKey(config) { if (config.apiKey) return config.apiKey; - const creds = loadCredentials(); - return creds?.apiKey ?? null; + return loadCredentials() ?? null; +} +function getApiBaseUrl(config) { + return config.baseUrl || DEFAULT_API_URL; +} +function normalizeUrl(value) { + try { + const url = new URL(value.trim()); + return ["http:", "https:"].includes(url.protocol) ? url.toString() : null; + } catch { + return null; + } +} +function getMcpUrl(config) { + if (process.env.SUPERMEMORY_MCP_URL) { + const configured = normalizeUrl(process.env.SUPERMEMORY_MCP_URL); + if (!configured) + throw new Error("Invalid SUPERMEMORY_MCP_URL"); + return configured; + } + if (!config.baseUrl) + return DEFAULT_MCP_URL; + const api = new URL(config.baseUrl); + if (api.hostname.startsWith("api.")) { + api.hostname = api.hostname.replace(/^api\./, "mcp."); + api.pathname = "/mcp"; + api.search = ""; + api.hash = ""; + return api.toString(); + } + throw new Error("Set SUPERMEMORY_MCP_URL for OAuth or MCP on a custom Supermemory backend."); +} +function isConfigured(config) { + if (getApiKey(config)) + return true; + try { + return hasOAuthCredentials(getOAuthConfig(getApiBaseUrl(config), getMcpUrl(config))); + } catch { + return false; + } +} +async function getAuthToken(config, timeoutMs) { + const apiKey = getApiKey(config); + if (apiKey) + return apiKey; + if (loadCredentialData()?.type !== "oauth") + throw authRequired(); + return getAccessToken(getOAuthConfig(getApiBaseUrl(config), getMcpUrl(config)), timeoutMs); } // src/tags.ts import { execSync } from "node:child_process"; -import { createHash } from "node:crypto"; +import { createHash as createHash2 } from "node:crypto"; import { existsSync, readFileSync, @@ -122,7 +404,7 @@ import { sep } from "node:path"; function sha256(input) { - return createHash("sha256").update(input).digest("hex").slice(0, 16); + return createHash2("sha256").update(input).digest("hex").slice(0, 16); } function getGitRoot(directory) { const isolateWorktrees = process.env.SUPERMEMORY_ISOLATE_WORKTREES === "true"; @@ -220,7 +502,7 @@ function getGitRepoInfo(directory) { return result; } } -function readJson2(filePath) { +function readJson3(filePath) { try { if (!existsSync(filePath)) return null; @@ -353,7 +635,7 @@ function getLegacyCursorProjectTags(directory, config) { function getLegacyClaudeTags(directory) { const basePath = getProjectBasePath(directory); const projectHash = sha256(basePath); - const config = readJson2(join(basePath, ".claude", ".supermemory-claude", "config.json")); + const config = readJson3(join(basePath, ".claude", ".supermemory-claude", "config.json")); return { personal: uniqueTags([ stringValue(config?.personalContainerTag), @@ -368,7 +650,7 @@ function getLegacyClaudeTags(directory) { }; } function getLegacyCodexTags(directory) { - const config = readJson2(join(homedir(), ".codex", "supermemory.json")); + const config = readJson3(join(homedir(), ".codex", "supermemory.json")); const prefix = stringValue(config?.containerTagPrefix) || "codex"; const userIdentity = getGitEmail(directory) || process.env.USER || process.env.USERNAME || hostname(); const userHash = sha256(userIdentity); @@ -450,7 +732,7 @@ function getResolvedTags(directory, config) { } // src/hook-api.ts -import { createHash as createHash2, createHmac } from "node:crypto"; +import { createHash as createHash3, createHmac } from "node:crypto"; // node_modules/supermemory/dist/esm/generated/core/auth/AuthProvider.js function isAuthProvider(value) { @@ -2245,10 +2527,10 @@ class ConnectorsClient { return HttpResponsePromise.fromPromise(this.__listAll(request, requestOptions)); } async __listAll(request = {}, requestOptions) { - const { provider, page, limit } = request; + const { provider, page: page2, limit } = request; const _queryParams = { provider: provider != null ? provider : undefined, - page, + page: page2, limit }; const _authRequest = await this._options.authProvider.getAuthRequest(); @@ -2294,10 +2576,10 @@ class ConnectorsClient { return HttpResponsePromise.fromPromise(this.__list(namespace, request, requestOptions)); } async __list(namespace, request = {}, requestOptions) { - const { provider, page, limit } = request; + const { provider, page: page2, limit } = request; const _queryParams = { provider: provider != null ? provider : undefined, - page, + page: page2, limit }; const _authRequest = await this._options.authProvider.getAuthRequest(); @@ -3157,9 +3439,9 @@ class NamespacesClient { return HttpResponsePromise.fromPromise(this.__list(request, requestOptions)); } async __list(request = {}, requestOptions) { - const { page, limit } = request; + const { page: page2, limit } = request; const _queryParams = { - page, + page: page2, limit }; const _authRequest = await this._options.authProvider.getAuthRequest(); @@ -3745,9 +4027,9 @@ class SupermemoryClient { return HttpResponsePromise.fromPromise(this.__list(namespace, type, request, requestOptions)); } async __list(namespace, type, request = {}, requestOptions) { - const { page, limit, sort, order, ..._body } = request; + const { page: page2, limit, sort, order, ..._body } = request; const _queryParams = { - page, + page: page2, limit, sort: sort != null ? sort : undefined, order: order != null ? order : undefined @@ -3841,7 +4123,6 @@ class Supermemory extends SupermemoryClient { } // src/hook-api.ts -var DEFAULT_BASE_URL = "https://api.supermemory.ai"; var REQUEST_TIMEOUT_MS = 3000; var INTEGRITY_VERSION = 1; var SEED = "7f2a9c4b8e1d6f3a5c0b9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a"; @@ -3853,16 +4134,20 @@ function resolveApiVersion(baseUrl, apiVersion) { } let url; try { - url = new URL(baseUrl || DEFAULT_BASE_URL); + url = new URL(baseUrl || DEFAULT_API_URL); } catch { throw new Error("Supermemory API base URL is invalid"); } - return url.origin === DEFAULT_BASE_URL && /^\/*$/.test(url.pathname) && !url.username && !url.password && !url.search && !url.hash ? "v5" : "legacy"; + return url.origin === DEFAULT_API_URL && /^\/*$/.test(url.pathname) && !url.username && !url.password && !url.search && !url.hash ? "v5" : "legacy"; +} +function resolveAuth(auth2) { + return typeof auth2 === "function" ? auth2() : Promise.resolve(auth2); } -function sdk(baseUrl, apiKey, tag) { +async function sdk(baseUrl, auth2, tag) { + const apiKey = await resolveAuth(auth2); return new Supermemory({ apiKey, - baseUrl: (baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ""), + baseUrl: (baseUrl || DEFAULT_API_URL).replace(/\/+$/, ""), headers: headers(apiKey, tag), timeoutInSeconds: REQUEST_TIMEOUT_MS / 1000, maxRetries: 0 @@ -3877,7 +4162,7 @@ async function sdkRequest(request) { } } function sha2562(input) { - return createHash2("sha256").update(input).digest("hex"); + return createHash3("sha256").update(input).digest("hex"); } function headers(apiKey, containerTag) { const contentHash = sha2562(containerTag); @@ -3891,8 +4176,9 @@ function headers(apiKey, containerTag) { "x-sm-source": "cursor" }; } -async function post(baseUrl, apiKey, path3, containerTag, body) { - const response = await fetch(`${(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "")}${path3}`, { +async function post(baseUrl, auth2, path3, containerTag, body) { + const apiKey = await resolveAuth(auth2); + const response = await fetch(`${(baseUrl || DEFAULT_API_URL).replace(/\/+$/, "")}${path3}`, { method: "POST", headers: headers(apiKey, containerTag), body: JSON.stringify(body), @@ -3907,9 +4193,9 @@ async function post(baseUrl, apiKey, path3, containerTag, body) { throw new Error("Supermemory returned an invalid response"); }); } -async function getProfile(baseUrl, apiKey, containerTag, query, scope, apiVersion) { +async function getProfile(baseUrl, auth2, containerTag, query, scope, apiVersion) { if (resolveApiVersion(baseUrl, apiVersion) === "v5") { - const client = sdk(baseUrl, apiKey, containerTag); + const client = await sdk(baseUrl, auth2, containerTag); const options = { abortSignal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), maxRetries: 0 @@ -3917,11 +4203,11 @@ async function getProfile(baseUrl, apiKey, containerTag, query, scope, apiVersio const body = scope ? { filter: { field: "sm_scope", operator: "eq", value: scope } } : {}; const [profile, searchResults] = await Promise.all([ sdkRequest(client.profile(containerTag, body, options)), - query ? searchMemories(baseUrl, apiKey, containerTag, query, scope, "v5", options.abortSignal) : undefined + query ? searchMemories(baseUrl, auth2, containerTag, query, scope, "v5", options.abortSignal) : undefined ]); return { ...profile, ...searchResults ? { searchResults } : {} }; } - return post(baseUrl, apiKey, "/v4/profile", containerTag, { + return post(baseUrl, auth2, "/v4/profile", containerTag, { containerTag, ...query ? { q: query } : {}, ...scope ? { @@ -3931,11 +4217,12 @@ async function getProfile(baseUrl, apiKey, containerTag, query, scope, apiVersio } : {} }); } -async function searchMemories(baseUrl, apiKey, containerTag, query, scope, apiVersion, abortSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS)) { +async function searchMemories(baseUrl, auth2, containerTag, query, scope, apiVersion, abortSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS)) { if (resolveApiVersion(baseUrl, apiVersion) === "legacy") { - return getProfile(baseUrl, apiKey, containerTag, query, scope, "legacy"); + return getProfile(baseUrl, auth2, containerTag, query, scope, "legacy"); } - return sdkRequest(sdk(baseUrl, apiKey, containerTag).search(containerTag, { + const client = await sdk(baseUrl, auth2, containerTag); + return sdkRequest(client.search(containerTag, { query, searchMode: "memories", threshold: 0.55, @@ -3951,8 +4238,8 @@ async function searchMemories(baseUrl, apiKey, containerTag, query, scope, apiVe } : {} }, { abortSignal, maxRetries: 0 })); } -function getProfiles(baseUrl, apiKey, tags, query, canonicalScope, apiVersion) { - return readNamespaces(tags, (tag, index) => getProfile(baseUrl, apiKey, tag, query, index === 0 ? canonicalScope : undefined, apiVersion)); +function getProfiles(baseUrl, auth2, tags, query, canonicalScope, apiVersion) { + return readNamespaces(tags, (tag, index) => getProfile(baseUrl, auth2, tag, query, index === 0 ? canonicalScope : undefined, apiVersion)); } async function readNamespaces(tags, read) { const results = await Promise.allSettled([...new Set(tags.filter(Boolean))].map(read)); @@ -4021,23 +4308,36 @@ async function main() { const input = JSON.parse(raw); const workspaceRoot = input.workspace_roots?.[0] || process.cwd(); const config = loadConfig(workspaceRoot); - const apiKey = getApiKey(config); - if (!apiKey) { + const notConnected = () => { const pluginRoot = pluginRootFromEnv(); const loginCmd = pluginRoot ? `node "${pluginRoot}/dist/cli.js" login` : "node dist/cli.js login"; process.stdout.write(JSON.stringify({ additional_context: `Supermemory is not connected. Ask the user to run \`${loginCmd}\` before relying on persistent memory.` })); + }; + if (!isConfigured(config)) { + notConnected(); return; } + const auth2 = () => getAuthToken(config); if (input.user_email && !process.env.CURSOR_USER_EMAIL) { process.env.CURSOR_USER_EMAIL = input.user_email; } const tags = getResolvedTags(workspaceRoot, config); let profiles = []; try { - profiles = config.injectProfile ? await getProfiles(config.baseUrl, apiKey, tags.allReads, resolveApiVersion(config.baseUrl, config.apiVersion) === "legacy" ? tags.projectName : undefined, undefined, config.apiVersion) : []; - } catch { + profiles = config.injectProfile ? await getProfiles(config.baseUrl, auth2, tags.allReads, resolveApiVersion(config.baseUrl, config.apiVersion) === "legacy" ? tags.projectName : undefined, undefined, config.apiVersion) : []; + } catch (error) { + if (isAuthRequired(error)) { + notConnected(); + return; + } + if (isAuthPending(error)) { + process.stdout.write(JSON.stringify({ + additional_context: "The Supermemory sign-in is still refreshing. Continue without memory for now, and do not assume this project has no saved memories." + })); + return; + } process.stdout.write(JSON.stringify({ additional_context: "Supermemory could not be reached. Continue without memory, and do not assume this project has no saved memories." })); diff --git a/src/auth.test.ts b/src/auth.test.ts index 383b0d1..8e2977a 100644 --- a/src/auth.test.ts +++ b/src/auth.test.ts @@ -1,17 +1,20 @@ import { expect, test } from "bun:test"; import { createServer } from "node:http"; -import { startAuthFlow } from "./auth.ts"; +import { AUTH_PORT, getOAuthConfig, startAuthFlow } from "./auth.ts"; test("login fails fast when the callback port is taken", async () => { const blocker = createServer(); await new Promise((resolve, reject) => { blocker.once("error", reject); - blocker.listen(19878, "127.0.0.1", resolve); + blocker.listen(AUTH_PORT, "127.0.0.1", resolve); }); try { - const result = await startAuthFlow(2_000); - expect(result.success).toBe(false); - expect(result.error).toContain("19878"); + await expect( + startAuthFlow( + getOAuthConfig("https://api.supermemory.ai", "https://mcp.supermemory.ai/mcp"), + 2_000, + ), + ).rejects.toThrow("19879"); } finally { await new Promise((resolve, reject) => { blocker.close((error) => (error ? reject(error) : resolve())); diff --git a/src/auth.ts b/src/auth.ts index 5f9142a..aaa5c54 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,114 +1,604 @@ import path from "node:path"; import os from "node:os"; import fs from "node:fs"; -import http from "node:http"; -import { spawn } from "node:child_process"; +import childProcess from "node:child_process"; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import { version as PLUGIN_VERSION } from "../package.json"; const CREDENTIALS_DIR = path.join(os.homedir(), ".supermemory-cursor"); -const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json"); -const AUTH_PORT = 19878; -const AUTH_URL = - process.env.SUPERMEMORY_AUTH_URL || - "https://console.supermemory.ai/auth/connect"; - -const SUCCESS_HTML = ` -

Connected to Cursor!

`; - -export function loadCredentials(): { apiKey: string; createdAt: string } | null { +export const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json"); +const CLIENT_FILE = path.join(CREDENTIALS_DIR, "oauth-client.json"); +const LOCK_FILE = path.join(CREDENTIALS_DIR, "auth.lock"); +export const AUTH_PORT = 19879; +const REDIRECT_URI = `http://127.0.0.1:${AUTH_PORT}/callback`; +const SCOPE = "openid profile email offline_access"; +const REFRESH_TIMEOUT = 3_000; +const REFRESH_REQUEST_TIMEOUT = 10_000; +const REFRESH_SKEW = 60_000; +const configuredAuthTimeout = Number(process.env.SUPERMEMORY_AUTH_TIMEOUT); +const AUTH_TIMEOUT = + Number.isFinite(configuredAuthTimeout) && configuredAuthTimeout > 0 + ? configuredAuthTimeout + : 5 * 60_000; + +// The browser only ever sees these fixed pages; error details stay in the terminal. +function page(title: string, message: string): string { + return ` +${title} - Supermemory

${title}

${message}

`; +} + +const SUCCESS_HTML = page("Connected to Cursor!", "You can close this tab and return to Cursor."); +const DENIED_HTML = page( + "Authorization denied", + "Supermemory was not connected. Return to Cursor and run login again to retry.", +); +const FAILED_HTML = page("Connection failed", "Return to Cursor and run login again."); + +export interface OAuthConfig { + apiBaseUrl: string; + issuer: string; + resource: string; +} + +interface OAuthClient extends OAuthConfig { + clientId: string; + redirectUri: string; + authorizationEndpoint: string; + tokenEndpoint: string; + revocationEndpoint: string; +} + +export interface OAuthCredentials extends OAuthClient { + type: "oauth"; + accessToken?: string; + refreshToken?: string; + expiresAt: number; + savedAt?: string; +} + +export type Credentials = + | OAuthCredentials + | { type?: undefined; apiKey: string; createdAt?: string }; + +export class AuthError extends Error { + constructor( + message: string, + readonly code: string, + ) { + super(message); + } +} + +export function isAuthRequired(error: unknown): boolean { + return error instanceof AuthError && error.code === "AUTH_REQUIRED"; +} + +export function isAuthPending(error: unknown): boolean { + return error instanceof AuthError && error.code === "AUTH_PENDING"; +} + +export function authRequired(): AuthError { + return new AuthError( + "Supermemory authentication required. Run `cursor-supermemory login`, or set SUPERMEMORY_API_KEY.", + "AUTH_REQUIRED", + ); +} + +function readJson(file: string): T | null { try { - if (!fs.existsSync(CREDENTIALS_FILE)) return null; - const data = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, "utf-8")); - if (data.apiKey) return data; - return null; + const value = JSON.parse(fs.readFileSync(file, "utf-8")); + return value && typeof value === "object" && !Array.isArray(value) ? (value as T) : null; } catch { return null; } } -export function saveCredentials(apiKey: string): void { +export function loadCredentialData(): Credentials | null { + const data = readJson(CREDENTIALS_FILE); + return data?.type === "oauth" || (data as { apiKey?: unknown } | null)?.apiKey ? data : null; +} + +// A saved `{apiKey}` file keeps working; never hand an OAuth token to a synchronous caller. +export function loadCredentials(): string | undefined { + const data = loadCredentialData(); + return data?.type === "oauth" ? undefined : data?.apiKey; +} + +function writeJson(file: string, data: unknown): void { fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 }); - const data = { apiKey, createdAt: new Date().toISOString() }; - fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(data, null, 2), { mode: 0o600 }); + // Older releases created the directory with default permissions. + fs.chmodSync(CREDENTIALS_DIR, 0o700); + const temporary = `${file}.${randomUUID()}.tmp`; + try { + fs.writeFileSync(temporary, JSON.stringify(data, null, 2), { mode: 0o600, flag: "wx" }); + fs.renameSync(temporary, file); + } finally { + fs.rmSync(temporary, { force: true }); + } } -export function clearCredentials(): boolean { +// Hooks and the MCP proxy run in separate processes. Reclaim only a lock whose +// owner is gone, and use a hard link to serialize reclamation of that inode. +function recoverAbandonedLock(): void { + let recovery: string | undefined; + let linked = false; try { - if (fs.existsSync(CREDENTIALS_FILE)) { - fs.unlinkSync(CREDENTIALS_FILE); - return true; + const original = fs.statSync(LOCK_FILE); + recovery = `${LOCK_FILE}.${original.dev}.${original.ino}.recovery`; + fs.linkSync(LOCK_FILE, recovery); + linked = true; + const snapshot = fs.statSync(recovery); + if (snapshot.ino !== original.ino || snapshot.dev !== original.dev) return; + const owner = readJson<{ pid: number }>(recovery); + let abandoned = false; + if (owner && Number.isInteger(owner.pid) && owner.pid > 0) { + try { + process.kill(owner.pid, 0); + } catch (error) { + abandoned = (error as NodeJS.ErrnoException).code === "ESRCH"; + } + } else { + abandoned = Date.now() - snapshot.mtimeMs > 10 * 60_000; } - return false; - } catch { - return false; + if (abandoned) { + const current = fs.statSync(LOCK_FILE); + if (current.ino === original.ino && current.dev === original.dev) fs.unlinkSync(LOCK_FILE); + } + } catch (error) { + if (!["ENOENT", "EEXIST"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; + } finally { + if (linked && recovery) fs.rmSync(recovery, { force: true }); } } -function openBrowser(url: string): void { - const platform = process.platform; - const command = - platform === "win32" ? "cmd" : platform === "darwin" ? "open" : "xdg-open"; - const args = platform === "win32" ? ["/c", "start", "", url] : [url]; - spawn(command, args, { stdio: "ignore", detached: true }).unref(); +async function withAuthLock( + action: (remaining: number) => Promise, + timeoutMs: number, +): Promise { + fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 }); + const deadline = Date.now() + timeoutMs; + let descriptor: number | undefined; + while (descriptor === undefined) { + try { + descriptor = fs.openSync(LOCK_FILE, "wx", 0o600); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + 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 })); + return await action(Math.max(1, deadline - Date.now())); + } finally { + fs.closeSync(descriptor); + fs.rmSync(LOCK_FILE, { force: true }); + } } -export async function startAuthFlow( - timeoutMs = 120_000, -): Promise<{ success: boolean; apiKey?: string; error?: string }> { - return new Promise((resolve) => { - let settled = false; - let timer: ReturnType | undefined; - - const finish = (result: { success: boolean; apiKey?: string; error?: string }) => { - if (settled) return; - settled = true; - if (timer) clearTimeout(timer); - server.close(); - resolve(result); - }; - - const server = http.createServer((req, res) => { - const url = new URL(req.url ?? "/", `http://127.0.0.1:${AUTH_PORT}`); - if (url.pathname !== "/callback") { - res.writeHead(404); - res.end("Not found"); - return; - } +function secureUrl(value: string): string { + const url = new URL(value); + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if ( + url.username || + url.password || + url.hash || + url.search || + (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) + ) { + throw new Error("OAuth requires HTTPS (HTTP is allowed only for loopback development)."); + } + return url.toString().replace(/\/+$/, ""); +} - const apiKey = url.searchParams.get("apikey") || url.searchParams.get("api_key"); - if (!apiKey?.startsWith("sm_")) { - res.writeHead(400); - res.end("Invalid API key"); - return; - } +export function getOAuthConfig(baseUrl: string, mcpUrl: string): OAuthConfig { + const apiBaseUrl = secureUrl(baseUrl); + return { + apiBaseUrl, + issuer: secureUrl(process.env.SUPERMEMORY_OAUTH_ISSUER || `${apiBaseUrl}/api/auth`), + resource: secureUrl(mcpUrl), + }; +} - saveCredentials(apiKey); - res.writeHead(200, { "Content-Type": "text/html" }); - res.end(SUCCESS_HTML); - finish({ success: true, apiKey }); - }); - - server.on("error", (error: NodeJS.ErrnoException) => { - const detail = - error.code === "EADDRINUSE" - ? `Port ${AUTH_PORT} is already in use` - : error.message; - finish({ success: false, error: detail }); - }); - - server.listen(AUTH_PORT, "127.0.0.1", () => { - const callbackUrl = `http://localhost:${AUTH_PORT}/callback`; - const authUrl = `${AUTH_URL}?callback=${encodeURIComponent(callbackUrl)}&client=cursor`; - process.stderr.write( - `\nOpen this URL to connect Supermemory to Cursor:\n\n ${authUrl}\n\nWaiting...\n`, +function sameConfig(credentials: Partial | null, config: OAuthConfig): boolean { + return ( + credentials?.apiBaseUrl === config.apiBaseUrl && + credentials?.issuer === config.issuer && + credentials?.resource === config.resource + ); +} + +export function hasOAuthCredentials(config: OAuthConfig): boolean { + const credentials = loadCredentialData(); + return ( + credentials?.type === "oauth" && + sameConfig(credentials, config) && + (!!credentials.refreshToken || + (!!credentials.accessToken && credentials.expiresAt > Date.now() + REFRESH_SKEW)) + ); +} + +function issuerEndpoint(value: string, issuer: string): string { + const endpoint = secureUrl(value); + if (new URL(endpoint).origin !== new URL(issuer).origin) { + throw new Error("OAuth endpoint does not belong to the configured issuer."); + } + return endpoint; +} + +async function oauthRequest( + url: string | URL, + options: RequestInit, +): Promise> { + const response = await fetch(url, { ...options, redirect: "error" }); + const body: unknown = await response.json().catch(() => null); + const data = body && typeof body === "object" ? (body as Record) : {}; + if (!response.ok) { + // Do not leak tokens or server response bodies through hook logs. + const code = ["invalid_grant", "invalid_client", "access_denied"].includes(String(data.error)) + ? String(data.error) + : "oauth_request_failed"; + throw new AuthError( + `Supermemory authorization failed (${code}, HTTP ${response.status}).`, + code, + ); + } + return data; +} + +async function registerClient(config: OAuthConfig, signal: AbortSignal): Promise { + const saved = readJson(CLIENT_FILE); + if (sameConfig(saved, config) && saved?.clientId && saved.redirectUri === REDIRECT_URI) + return saved; + const issuer = new URL(config.issuer); + const metadata = await oauthRequest( + new URL(`/.well-known/oauth-authorization-server${issuer.pathname.replace(/\/$/, "")}`, issuer), + { signal }, + ); + if (metadata.issuer !== config.issuer) + throw new Error("OAuth discovery returned an unexpected issuer."); + const authorizationEndpoint = issuerEndpoint( + String(metadata.authorization_endpoint), + config.issuer, + ); + const tokenEndpoint = issuerEndpoint(String(metadata.token_endpoint), config.issuer); + const registrationEndpoint = issuerEndpoint( + String(metadata.registration_endpoint), + config.issuer, + ); + const revocationEndpoint = issuerEndpoint(String(metadata.revocation_endpoint), config.issuer); + const registration = await oauthRequest(registrationEndpoint, { + method: "POST", + signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Supermemory for Cursor", + client_uri: "https://github.com/supermemoryai/cursor-supermemory", + software_id: "cursor-supermemory", + software_version: PLUGIN_VERSION, + redirect_uris: [REDIRECT_URI], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + scope: SCOPE, + }), + }); + if ( + typeof registration.client_id !== "string" || + !registration.client_id || + registration.token_endpoint_auth_method !== "none" + ) { + throw new Error("OAuth registration did not return a public client."); + } + const client: OAuthClient = { + ...config, + clientId: registration.client_id, + redirectUri: REDIRECT_URI, + authorizationEndpoint, + tokenEndpoint, + revocationEndpoint, + }; + signal.throwIfAborted(); + writeJson(CLIENT_FILE, client); + return client; +} + +async function exchangeToken( + client: OAuthClient, + parameters: Record, + signal: AbortSignal, + previous?: OAuthCredentials, +): Promise { + const data = await oauthRequest(issuerEndpoint(client.tokenEndpoint, client.issuer), { + method: "POST", + signal, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: client.clientId, + resource: client.resource, + ...parameters, + }), + }); + const refreshToken = data.refresh_token ?? previous?.refreshToken; + if ( + typeof data.access_token !== "string" || + !data.access_token || + typeof data.token_type !== "string" || + data.token_type.toLowerCase() !== "bearer" || + typeof data.expires_in !== "number" || + !Number.isFinite(data.expires_in) || + data.expires_in <= 0 || + typeof refreshToken !== "string" || + !refreshToken + ) { + throw new Error("OAuth returned an invalid token response."); + } + signal.throwIfAborted(); + const credentials: OAuthCredentials = { + type: "oauth", + apiBaseUrl: client.apiBaseUrl, + issuer: client.issuer, + resource: client.resource, + clientId: client.clientId, + redirectUri: client.redirectUri, + authorizationEndpoint: client.authorizationEndpoint, + tokenEndpoint: client.tokenEndpoint, + revocationEndpoint: client.revocationEndpoint, + accessToken: data.access_token, + refreshToken, + expiresAt: Date.now() + data.expires_in * 1000, + savedAt: new Date().toISOString(), + }; + writeJson(CREDENTIALS_FILE, credentials); + return data.access_token; +} + +// Rotates the refresh token. The request always runs to completion and saves +// the replacement, whatever the caller's deadline: aborting it mid-flight would +// leave the server holding a rotated refresh token we never stored, and the next +// refresh would fail with invalid_grant and sign the user out. +let pendingRefresh: Promise | null = null; + +function refreshCredentials(config: OAuthConfig): Promise { + if (pendingRefresh) return pendingRefresh; + const refresh = withAuthLock(async (remaining) => { + const credentials = loadCredentialData(); + if (credentials?.type !== "oauth" || !sameConfig(credentials, config)) throw authRequired(); + if (credentials.accessToken && credentials.expiresAt > Date.now() + REFRESH_SKEW) + return credentials.accessToken; + if (!credentials.refreshToken) throw authRequired(); + try { + return await exchangeToken( + credentials, + { grant_type: "refresh_token", refresh_token: credentials.refreshToken }, + AbortSignal.timeout(remaining), + credentials, ); - openBrowser(authUrl); - }); + } catch (error) { + if (error instanceof AuthError && ["invalid_grant", "invalid_client"].includes(error.code)) { + const { accessToken: _access, refreshToken: _refresh, ...registration } = credentials; + writeJson(CREDENTIALS_FILE, registration); + if (error.code === "invalid_client") fs.rmSync(CLIENT_FILE, { force: true }); + throw authRequired(); + } + throw error; + } + }, REFRESH_REQUEST_TIMEOUT).finally(() => { + pendingRefresh = null; + }); + // Callers may stop waiting before the refresh settles. + refresh.catch(() => {}); + pendingRefresh = refresh; + return refresh; +} +// Bounds only how long the caller waits; the refresh itself keeps running. +function waitFor(promise: Promise, timeoutMs: number): Promise { + let timer: ReturnType; + const expired = new Promise((_, reject) => { timer = setTimeout(() => { - finish({ success: false, error: "Authentication timed out" }); + const error = new AuthError("Supermemory token refresh is still in progress.", "AUTH_PENDING"); + error.name = "TimeoutError"; + reject(error); }, timeoutMs); }); + return Promise.race([promise, expired]).finally(() => clearTimeout(timer)); +} + +export async function getAccessToken( + config: OAuthConfig, + timeoutMs = REFRESH_TIMEOUT, +): Promise { + const saved = loadCredentialData(); + if (saved?.type !== "oauth" || !sameConfig(saved, config)) throw authRequired(); + if (saved.accessToken && saved.expiresAt > Date.now() + REFRESH_SKEW) return saved.accessToken; + return waitFor(refreshCredentials(config), timeoutMs); +} + +// Resolves once any in-flight refresh has saved or failed, so a process that +// exits explicitly can let the rotated tokens land first. +export async function settleAuth(): Promise { + while (pendingRefresh) { + await pendingRefresh.catch(() => {}); + } +} + +// Revokes an OAuth refresh token, then removes the saved credentials. Returns +// whether any credentials were stored. +export async function clearCredentials(): Promise { + return withAuthLock(async (remaining) => { + const credentials = readJson(CREDENTIALS_FILE); + if (credentials?.type === "oauth" && credentials.refreshToken) { + await oauthRequest(issuerEndpoint(credentials.revocationEndpoint, credentials.issuer), { + method: "POST", + signal: AbortSignal.timeout(remaining), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: credentials.clientId, + token: credentials.refreshToken, + token_type_hint: "refresh_token", + }), + }); + } + const existed = fs.existsSync(CREDENTIALS_FILE); + fs.rmSync(CREDENTIALS_FILE, { force: true }); + return existed; + }, REFRESH_TIMEOUT); +} + +function run(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + // Called through the module object so tests can stub child_process.execFile. + childProcess.execFile(command, args, { windowsHide: true }, (error) => + error ? reject(error) : resolve(), + ); + }); +} + +export async function openUrl(url: string | URL): Promise { + const href = url.toString(); + if (!/^https?:\/\//i.test(href)) throw new Error("Refusing to open non-http URL"); + if (process.platform === "win32") { + try { + await run("rundll32.exe", ["url.dll,FileProtocolHandler", href]); + return; + } catch {} + await run("cmd.exe", ["/c", "start", '""', href]); + return; + } + await run(process.platform === "darwin" ? "open" : "xdg-open", [href]); +} + +export async function startAuthFlow( + config: OAuthConfig, + timeoutMs = AUTH_TIMEOUT, +): Promise { + return withAuthLock(async (remaining) => { + const controller = new AbortController(); + const timer = setTimeout( + () => controller.abort(new Error("Authentication timed out")), + remaining, + ); + let server: Server | undefined; + try { + return await new Promise((resolve, reject) => { + let client: OAuthClient | undefined; + let exchanging = false; + const state = randomBytes(32).toString("base64url"); + const verifier = randomBytes(32).toString("base64url"); + controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { + once: true, + }); + server = createServer(async (req, res) => { + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Referrer-Policy", "no-referrer"); + let url: URL; + try { + url = new URL(req.url || "/", REDIRECT_URI); + } catch { + res.writeHead(400).end(); + return; + } + if (req.method !== "GET" || url.pathname !== "/callback") { + res.writeHead(404).end(); + return; + } + const receivedState = Buffer.from(url.searchParams.get("state") || ""); + if ( + !client || + receivedState.length !== state.length || + !timingSafeEqual(receivedState, Buffer.from(state)) || + (url.searchParams.has("iss") && url.searchParams.get("iss") !== client.issuer) + ) { + res.writeHead(400).end("Invalid authorization state."); + return; + } + if (exchanging) { + res.writeHead(409).end(); + return; + } + exchanging = true; + try { + if (url.searchParams.has("error")) { + throw new AuthError("Authorization was denied.", "access_denied"); + } + const code = url.searchParams.get("code"); + if (!code) throw new Error("Authorization code missing."); + const token = await exchangeToken( + client, + { + grant_type: "authorization_code", + code, + code_verifier: verifier, + redirect_uri: REDIRECT_URI, + }, + controller.signal, + ); + res + .writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) + .end(SUCCESS_HTML, () => resolve(token)); + } catch (error) { + const denied = error instanceof AuthError && error.code === "access_denied"; + if ( + error instanceof AuthError && + error.code === "invalid_client" && + !controller.signal.aborted + ) { + fs.rmSync(CLIENT_FILE, { force: true }); + } + // Reject once the page is flushed; closing the server drops open connections. + res + .writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) + .end(denied ? DENIED_HTML : FAILED_HTML, () => reject(error)); + } + }); + server.on("error", () => + reject( + new Error( + `Cannot open the authentication callback port ${AUTH_PORT}. Retry after other logins finish.`, + ), + ), + ); + // Claim the callback port before registering, so a busy port fails fast. + server.listen(AUTH_PORT, "127.0.0.1", async () => { + try { + const registered = await registerClient(config, controller.signal); + const authorizationUrl = new URL( + issuerEndpoint(registered.authorizationEndpoint, registered.issuer), + ); + authorizationUrl.search = new URLSearchParams({ + client_id: registered.clientId, + redirect_uri: REDIRECT_URI, + response_type: "code", + scope: SCOPE, + resource: registered.resource, + code_challenge: createHash("sha256").update(verifier).digest("base64url"), + code_challenge_method: "S256", + state, + prompt: "consent", + }).toString(); + client = registered; + process.stderr.write( + `\nOpen this URL to connect Supermemory to Cursor:\n\n ${authorizationUrl}\n\nWaiting...\n`, + ); + openUrl(authorizationUrl).catch(() => + process.stderr.write("Could not open a browser. Open the URL above to continue.\n"), + ); + } catch (error) { + reject(error); + } + }); + }); + } finally { + clearTimeout(timer); + controller.abort(); + server?.close(); + server?.closeAllConnections(); + } + }, timeoutMs); } diff --git a/src/cli.ts b/src/cli.ts index 2131b48..68cf814 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,94 +1,115 @@ import { fileURLToPath } from "node:url"; import { startMcpProxy } from "./mcp-proxy.ts"; -import { loadCredentials, startAuthFlow, clearCredentials } from "./auth.ts"; -import { loadConfig, getApiKey } from "./config.ts"; +import { + clearCredentials, + getOAuthConfig, + loadCredentialData, + settleAuth, + startAuthFlow, +} from "./auth.ts"; +import { + getApiBaseUrl, + getApiKey, + getAuthToken, + getAuthType, + getMcpUrl, + isConfigured, + loadConfig, +} from "./config.ts"; import { getProfile, resolveApiVersion } from "./hook-api.ts"; import { getResolvedTags } from "./tags.ts"; import { writeGlobalMcpEntry } from "./mcp-install.ts"; const command = process.argv[2]; -switch (command) { - case "mcp": - startMcpProxy(); - break; - - case "mcp-install": { - const configPath = writeGlobalMcpEntry(fileURLToPath(import.meta.url)); - console.log(`Registered the supermemory MCP server in ${configPath}.`); - console.log("Restart Cursor (or the cloud agent) to pick it up."); - break; - } +async function main(): Promise { + switch (command) { + case "mcp": + startMcpProxy(); + break; - case "login": { - const existing = loadCredentials(); - if (existing) { - console.log( - "Already authenticated. Use `logout` first to re-authenticate.", - ); - process.exit(0); + case "mcp-install": { + const configPath = writeGlobalMcpEntry(fileURLToPath(import.meta.url)); + console.log(`Registered the supermemory MCP server in ${configPath}.`); + console.log("Restart Cursor (or the cloud agent) to pick it up."); + break; } - console.log("Opening browser to authenticate..."); - const result = await startAuthFlow(); - if (result.success) { + + case "login": { + const config = loadConfig(); + console.log("Opening browser to authenticate..."); + // Signing in replaces any saved `{apiKey}` credentials with OAuth tokens. + await startAuthFlow(getOAuthConfig(getApiBaseUrl(config), getMcpUrl(config))); console.log("Authenticated successfully."); - } else { - console.error(`Authentication failed: ${result.error}`); - process.exit(1); + if (getApiKey(config)) { + console.log( + "An environment/config API key still takes precedence. Remove it to use OAuth.", + ); + } + break; } - break; - } - case "logout": { - const removed = clearCredentials(); - console.log(removed ? "Logged out." : "No credentials found."); - break; - } - - case "status": { - const config = loadConfig(); - const apiKey = getApiKey(config); - if (!apiKey) { - console.log( - "Not authenticated. Run `cursor-supermemory login` to connect.", - ); + case "logout": { + const removed = await clearCredentials(); + console.log(removed ? "Logged out." : "No credentials found."); + if (getApiKey(loadConfig())) { + console.log("An environment/config API key is still configured and has not been removed."); + } break; } - const credentials = loadCredentials(); - if (credentials?.createdAt) { - console.log(`Authenticated since ${credentials.createdAt}`); - } - console.log("API key is configured."); - try { - const tags = getResolvedTags(process.cwd(), config); - await getProfile( - config.baseUrl, - apiKey, - tags.canonical, - resolveApiVersion(config.baseUrl, config.apiVersion) === "legacy" - ? "connectivity probe" - : undefined, - undefined, - config.apiVersion, - ); - console.log(`Connected to Supermemory (${tags.canonical}).`); - } catch (error) { - console.error( - `Supermemory is unreachable: ${error instanceof Error ? error.message : String(error)}`, - ); - process.exitCode = 1; + + case "status": { + const config = loadConfig(); + const authType = getAuthType(config); + console.log(`Authentication: ${authType}`); + const credentials = loadCredentialData(); + if (authType === "oauth" && credentials?.type === "oauth") { + console.log(`OAuth client: ${credentials.clientId}`); + } + if (!isConfigured(config)) { + console.log( + "Not authenticated. Run `cursor-supermemory login` to connect.", + ); + break; + } + try { + const tags = getResolvedTags(process.cwd(), config); + await getProfile( + config.baseUrl, + () => getAuthToken(config), + tags.canonical, + resolveApiVersion(config.baseUrl, config.apiVersion) === "legacy" + ? "connectivity probe" + : undefined, + undefined, + config.apiVersion, + ); + console.log(`Connected to Supermemory (${tags.canonical}).`); + } catch (error) { + console.error( + `Supermemory is unreachable: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + } + break; } - break; - } - default: - console.log(`cursor-supermemory — Persistent AI memory for Cursor + default: + console.log(`cursor-supermemory — Persistent AI memory for Cursor Commands: mcp Proxy the hosted Supermemory MCP server over stdio mcp-install Register the MCP server in ~/.cursor/mcp.json with an absolute path login Authenticate with Supermemory - logout Remove stored credentials + logout Revoke and remove stored credentials status Show authentication status`); - if (command) process.exit(1); + if (command) process.exitCode = 1; + } } + +main().catch(async (error) => { + console.error(error instanceof Error ? error.message : String(error)); + // Let a refresh that is still rotating tokens save them before exiting. + await settleAuth(); + process.exit(1); +}); diff --git a/src/config.ts b/src/config.ts index b51f315..ada1cdf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,17 @@ import path from "node:path"; import os from "node:os"; import fs from "node:fs"; -import { loadCredentials } from "./auth.ts"; +import { + authRequired, + getAccessToken, + getOAuthConfig, + hasOAuthCredentials, + loadCredentialData, + loadCredentials, +} from "./auth.ts"; + +export const DEFAULT_API_URL = "https://api.supermemory.ai"; +export const DEFAULT_MCP_URL = "https://mcp.supermemory.ai/mcp"; export const GLOBAL_CONFIG_PATH = path.join(os.homedir(), ".config", "cursor", "supermemory.json"); @@ -100,9 +110,63 @@ export function loadConfig(cwd?: string): Config { }; } +// API keys (env, config, or a saved `{apiKey}` file) take precedence over OAuth. export function getApiKey(config: Config): string | null { if (config.apiKey) return config.apiKey; + return loadCredentials() ?? null; +} + +export function getApiBaseUrl(config: Config): string { + return config.baseUrl || DEFAULT_API_URL; +} + +function normalizeUrl(value: string): string | null { + try { + const url = new URL(value.trim()); + return ["http:", "https:"].includes(url.protocol) ? url.toString() : null; + } catch { + return null; + } +} + +// The OAuth resource. Hosted `api.*` backends map to their `mcp.*` sibling; any +// other custom backend must name its MCP server explicitly. +export function getMcpUrl(config: Config): string { + if (process.env.SUPERMEMORY_MCP_URL) { + const configured = normalizeUrl(process.env.SUPERMEMORY_MCP_URL); + if (!configured) throw new Error("Invalid SUPERMEMORY_MCP_URL"); + return configured; + } + if (!config.baseUrl) return DEFAULT_MCP_URL; + const api = new URL(config.baseUrl); + if (api.hostname.startsWith("api.")) { + api.hostname = api.hostname.replace(/^api\./, "mcp."); + api.pathname = "/mcp"; + api.search = ""; + api.hash = ""; + return api.toString(); + } + throw new Error("Set SUPERMEMORY_MCP_URL for OAuth or MCP on a custom Supermemory backend."); +} + +export function getAuthType(config: Config): "api_key" | "oauth" | "none" { + if (getApiKey(config)) return "api_key"; + return loadCredentialData()?.type === "oauth" ? "oauth" : "none"; +} + +export function isConfigured(config: Config): boolean { + if (getApiKey(config)) return true; + try { + return hasOAuthCredentials(getOAuthConfig(getApiBaseUrl(config), getMcpUrl(config))); + } catch { + return false; + } +} - const creds = loadCredentials(); - return creds?.apiKey ?? null; +// Resolve the bearer for each request so refreshes and logins in other processes are seen. +export async function getAuthToken(config: Config, timeoutMs?: number): Promise { + const apiKey = getApiKey(config); + if (apiKey) return apiKey; + if (loadCredentialData()?.type !== "oauth") throw authRequired(); + return getAccessToken(getOAuthConfig(getApiBaseUrl(config), getMcpUrl(config)), timeoutMs); } diff --git a/src/hook-api.ts b/src/hook-api.ts index 0dbd4f4..5b0e82f 100644 --- a/src/hook-api.ts +++ b/src/hook-api.ts @@ -1,7 +1,6 @@ import { createHash, createHmac } from "node:crypto"; import { Supermemory, type AddRequest, type ProfileRequest } from "supermemory"; - -const DEFAULT_BASE_URL = "https://api.supermemory.ai"; +import { DEFAULT_API_URL as DEFAULT_BASE_URL } from "./config.ts"; const REQUEST_TIMEOUT_MS = 3_000; const INTEGRITY_VERSION = 1; const SEED = "7f2a9c4b8e1d6f3a5c0b9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a"; @@ -30,7 +29,15 @@ export function resolveApiVersion( : "legacy"; } -function sdk(baseUrl: string | null, apiKey: string, tag: string): Supermemory { +// A static API key, or a resolver called before every request so OAuth tokens stay current. +export type AuthSource = string | (() => Promise); + +function resolveAuth(auth: AuthSource): Promise { + return typeof auth === "function" ? auth() : Promise.resolve(auth); +} + +async function sdk(baseUrl: string | null, auth: AuthSource, tag: string): Promise { + const apiKey = await resolveAuth(auth); return new Supermemory({ apiKey, baseUrl: (baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ""), @@ -90,11 +97,12 @@ function headers(apiKey: string, containerTag: string): Record { async function post( baseUrl: string | null, - apiKey: string, + auth: AuthSource, path: string, containerTag: string, body: Record, ): Promise { + const apiKey = await resolveAuth(auth); const response = await fetch( `${(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "")}${path}`, { @@ -116,14 +124,14 @@ async function post( export async function getProfile( baseUrl: string | null, - apiKey: string, + auth: AuthSource, containerTag: string, query?: string, scope?: "personal" | "project", apiVersion?: string, ): Promise { if (resolveApiVersion(baseUrl, apiVersion) === "v5") { - const client = sdk(baseUrl, apiKey, containerTag); + const client = await sdk(baseUrl, auth, containerTag); const options = { abortSignal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), maxRetries: 0, @@ -136,7 +144,7 @@ export async function getProfile( query ? searchMemories( baseUrl, - apiKey, + auth, containerTag, query, scope, @@ -147,7 +155,7 @@ export async function getProfile( ]); return { ...profile, ...(searchResults ? { searchResults } : {}) }; } - return post(baseUrl, apiKey, "/v4/profile", containerTag, { + return post(baseUrl, auth, "/v4/profile", containerTag, { containerTag, ...(query ? { q: query } : {}), ...(scope @@ -162,7 +170,7 @@ export async function getProfile( export async function addMemory( baseUrl: string | null, - apiKey: string, + auth: AuthSource, content: string, containerTag: string, metadata: Record, @@ -173,7 +181,7 @@ export async function addMemory( const result = version === "v5" ? await sdkRequest( - sdk(baseUrl, apiKey, containerTag).add( + (await sdk(baseUrl, auth, containerTag)).add( containerTag, { content, @@ -192,7 +200,7 @@ export async function addMemory( }, ), ) - : await post(baseUrl, apiKey, "/v3/documents", containerTag, { + : await post(baseUrl, auth, "/v3/documents", containerTag, { content, containerTag, metadata: { sm_source: "cursor", ...metadata }, @@ -222,7 +230,7 @@ export async function addMemory( export async function searchMemories( baseUrl: string | null, - apiKey: string, + auth: AuthSource, containerTag: string, query: string, scope?: "personal" | "project", @@ -230,10 +238,11 @@ export async function searchMemories( abortSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS), ): Promise { if (resolveApiVersion(baseUrl, apiVersion) === "legacy") { - return getProfile(baseUrl, apiKey, containerTag, query, scope, "legacy"); + return getProfile(baseUrl, auth, containerTag, query, scope, "legacy"); } + const client = await sdk(baseUrl, auth, containerTag); return sdkRequest( - sdk(baseUrl, apiKey, containerTag).search( + client.search( containerTag, { query, @@ -259,7 +268,7 @@ export async function searchMemories( export function getProfiles( baseUrl: string | null, - apiKey: string, + auth: AuthSource, tags: string[], query?: string, canonicalScope?: "personal" | "project", @@ -268,7 +277,7 @@ export function getProfiles( return readNamespaces(tags, (tag, index) => getProfile( baseUrl, - apiKey, + auth, tag, query, index === 0 ? canonicalScope : undefined, @@ -279,13 +288,13 @@ export function getProfiles( export function getSearches( baseUrl: string | null, - apiKey: string, + auth: AuthSource, tags: string[], query: string, apiVersion?: string, ): Promise { return readNamespaces(tags, (tag) => - searchMemories(baseUrl, apiKey, tag, query, undefined, apiVersion), + searchMemories(baseUrl, auth, tag, query, undefined, apiVersion), ); } diff --git a/src/hooks/capture.ts b/src/hooks/capture.ts index fdbca91..bb2ee97 100644 --- a/src/hooks/capture.ts +++ b/src/hooks/capture.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { loadConfig, getApiKey } from "../config.ts"; +import { loadConfig, getAuthToken, isConfigured } from "../config.ts"; import { addMemory, AGENT_ENTITY_CONTEXT } from "../hook-api.ts"; import { deleteHookState, @@ -132,8 +132,8 @@ async function capture(input: CursorHookInput): Promise { try { const root = workspaceRoot(input); const config = loadConfig(root); - const apiKey = getApiKey(config); - if (!apiKey) return; + if (!isConfigured(config)) return; + const auth = () => getAuthToken(config); const id = conversationId(input); const state = readHookState(id); @@ -159,7 +159,7 @@ async function capture(input: CursorHookInput): Promise { createHash("sha256").update(content).digest("hex").slice(0, 32); await addMemory( config.baseUrl, - apiKey, + auth, content, tags.canonical, { diff --git a/src/hooks/prompt-recall.ts b/src/hooks/prompt-recall.ts index ff72be4..cbdf31a 100644 --- a/src/hooks/prompt-recall.ts +++ b/src/hooks/prompt-recall.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { loadConfig, getApiKey } from "../config.ts"; +import { loadConfig, getAuthToken, isConfigured } from "../config.ts"; import { getSearches } from "../hook-api.ts"; import { readHookState, writeHookState } from "../hook-state.ts"; import { getResolvedTags } from "../tags.ts"; @@ -110,18 +110,18 @@ export async function runPromptRecall(input: CursorHookInput): Promise { try { const root = workspaceRoot(input); const config = loadConfig(root); - const apiKey = getApiKey(config); - if (!apiKey) { + if (!isConfigured(config)) { process.stdout.write(JSON.stringify({ continue: true })); return; } + const auth = () => getAuthToken(config); const id = conversationId(input); const state = readHookState(id); const tags = getResolvedTags(root, config); const profiles = await getSearches( config.baseUrl, - apiKey, + auth, tags.allReads, prompt!.slice(0, MAX_QUERY_LENGTH), config.apiVersion, diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index 84afe7f..72d8076 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -1,4 +1,5 @@ -import { loadConfig, getApiKey } from "../config.ts"; +import { loadConfig, getAuthToken, isConfigured } from "../config.ts"; +import { isAuthPending, isAuthRequired } from "../auth.ts"; import { getResolvedTags } from "../tags.ts"; import { getProfiles, resolveApiVersion } from "../hook-api.ts"; import { formatContainerDirective, formatSessionContext } from "../context.ts"; @@ -18,8 +19,7 @@ async function main() { const workspaceRoot = input.workspace_roots?.[0] || process.cwd(); const config = loadConfig(workspaceRoot); - const apiKey = getApiKey(config); - if (!apiKey) { + const notConnected = () => { const pluginRoot = pluginRootFromEnv(); const loginCmd = pluginRoot ? `node "${pluginRoot}/dist/cli.js" login` @@ -29,8 +29,12 @@ async function main() { additional_context: `Supermemory is not connected. Ask the user to run \`${loginCmd}\` before relying on persistent memory.`, }), ); + }; + if (!isConfigured(config)) { + notConnected(); return; } + const auth = () => getAuthToken(config); // Inject user email from input for tag resolution if (input.user_email && !process.env.CURSOR_USER_EMAIL) { @@ -43,7 +47,7 @@ async function main() { profiles = config.injectProfile ? await getProfiles( config.baseUrl, - apiKey, + auth, tags.allReads, resolveApiVersion(config.baseUrl, config.apiVersion) === "legacy" ? tags.projectName @@ -52,7 +56,20 @@ async function main() { config.apiVersion, ) : []; - } catch { + } catch (error) { + if (isAuthRequired(error)) { + notConnected(); + return; + } + if (isAuthPending(error)) { + process.stdout.write( + JSON.stringify({ + additional_context: + "The Supermemory sign-in is still refreshing. Continue without memory for now, and do not assume this project has no saved memories.", + }), + ); + return; + } process.stdout.write( JSON.stringify({ additional_context: diff --git a/src/mcp-proxy.ts b/src/mcp-proxy.ts index 295ad80..ae23f91 100644 --- a/src/mcp-proxy.ts +++ b/src/mcp-proxy.ts @@ -1,8 +1,13 @@ import { createInterface } from "node:readline"; -import { getApiKey, loadConfig } from "./config.ts"; +import { + DEFAULT_MCP_URL, + getApiKey, + getAuthToken, + getMcpUrl, + loadConfig, +} from "./config.ts"; +import { isAuthRequired, settleAuth } from "./auth.ts"; -const MCP_URL = - process.env.SUPERMEMORY_MCP_URL || "https://mcp.supermemory.ai/mcp"; const REQUEST_TIMEOUT_MS = 30_000; let sessionId: string | null = null; @@ -38,7 +43,11 @@ export function emitSseData( } } -async function forward(message: JsonRpcMessage, apiKey: string): Promise { +async function forward( + message: JsonRpcMessage, + apiKey: string, + mcpUrl: string, +): Promise { const headers: Record = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", @@ -46,7 +55,7 @@ async function forward(message: JsonRpcMessage, apiKey: string): Promise { }; if (sessionId) headers["Mcp-Session-Id"] = sessionId; - const response = await fetch(MCP_URL, { + const response = await fetch(mcpUrl, { method: "POST", headers, body: JSON.stringify(message), @@ -77,8 +86,13 @@ async function forward(message: JsonRpcMessage, apiKey: string): Promise { } } +// API keys keep the hosted MCP default; an OAuth token is only valid for its resource. +function proxyTarget(config: ReturnType): string { + if (getApiKey(config)) return process.env.SUPERMEMORY_MCP_URL || DEFAULT_MCP_URL; + return getMcpUrl(config); +} + export function startMcpProxy(): void { - const apiKey = getApiKey(loadConfig()); let queue = Promise.resolve(); const lines = createInterface({ input: process.stdin }); @@ -93,18 +107,21 @@ export function startMcpProxy(): void { } queue = queue.then(async () => { - if (!apiKey) { - sendError( - message.id, - -32001, - "Supermemory is not authenticated. Run `cursor-supermemory login`, or set SUPERMEMORY_API_KEY.", - ); - return; - } - try { - await forward(message, apiKey); + // Resolve per message: login or a token refresh may land after the proxy started. + const config = loadConfig(); + const apiKey = await getAuthToken(config); + await forward(message, apiKey, proxyTarget(config)); } catch (error) { + if (isAuthRequired(error)) { + sessionId = null; + sendError( + message.id, + -32001, + "Supermemory is not authenticated. Run `cursor-supermemory login`, or set SUPERMEMORY_API_KEY.", + ); + return; + } const detail = error instanceof Error ? error.message : String(error); sendError(message.id, -32000, `Supermemory MCP proxy error: ${detail}`); } @@ -112,6 +129,7 @@ export function startMcpProxy(): void { }); lines.on("close", () => { - queue.finally(() => process.exit(0)); + // Let a refresh that is still rotating tokens save them before exiting. + queue.finally(() => settleAuth().finally(() => process.exit(0))); }); } From 4558a02bd74821c236d7aa26b5b1df560c863343 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Mon, 26 Oct 2026 21:28:08 +0530 Subject: [PATCH 2/3] chore: trim comments --- src/auth.ts | 13 ++++--------- src/cli.ts | 1 - src/config.ts | 3 +-- src/hook-api.ts | 1 - 4 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index aaa5c54..c60d28c 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -102,7 +102,7 @@ export function loadCredentialData(): Credentials | null { return data?.type === "oauth" || (data as { apiKey?: unknown } | null)?.apiKey ? data : null; } -// A saved `{apiKey}` file keeps working; never hand an OAuth token to a synchronous caller. +// Never hand an OAuth token to a synchronous caller: it may need a refresh first. export function loadCredentials(): string | undefined { const data = loadCredentialData(); return data?.type === "oauth" ? undefined : data?.apiKey; @@ -358,10 +358,8 @@ async function exchangeToken( return data.access_token; } -// Rotates the refresh token. The request always runs to completion and saves -// the replacement, whatever the caller's deadline: aborting it mid-flight would -// leave the server holding a rotated refresh token we never stored, and the next -// refresh would fail with invalid_grant and sign the user out. +// Never abort a refresh mid-flight: the server would rotate a refresh token we never +// stored, and the next refresh would fail with invalid_grant and sign the user out. let pendingRefresh: Promise | null = null; function refreshCredentials(config: OAuthConfig): Promise { @@ -420,16 +418,13 @@ export async function getAccessToken( return waitFor(refreshCredentials(config), timeoutMs); } -// Resolves once any in-flight refresh has saved or failed, so a process that -// exits explicitly can let the rotated tokens land first. +// Await before an explicit exit so rotated tokens from an in-flight refresh are saved. export async function settleAuth(): Promise { while (pendingRefresh) { await pendingRefresh.catch(() => {}); } } -// Revokes an OAuth refresh token, then removes the saved credentials. Returns -// whether any credentials were stored. export async function clearCredentials(): Promise { return withAuthLock(async (remaining) => { const credentials = readJson(CREDENTIALS_FILE); diff --git a/src/cli.ts b/src/cli.ts index 68cf814..5908492 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -38,7 +38,6 @@ async function main(): Promise { case "login": { const config = loadConfig(); console.log("Opening browser to authenticate..."); - // Signing in replaces any saved `{apiKey}` credentials with OAuth tokens. await startAuthFlow(getOAuthConfig(getApiBaseUrl(config), getMcpUrl(config))); console.log("Authenticated successfully."); if (getApiKey(config)) { diff --git a/src/config.ts b/src/config.ts index ada1cdf..71e7cae 100644 --- a/src/config.ts +++ b/src/config.ts @@ -129,8 +129,7 @@ function normalizeUrl(value: string): string | null { } } -// The OAuth resource. Hosted `api.*` backends map to their `mcp.*` sibling; any -// other custom backend must name its MCP server explicitly. +// The OAuth resource (the MCP server URL tokens are issued for). export function getMcpUrl(config: Config): string { if (process.env.SUPERMEMORY_MCP_URL) { const configured = normalizeUrl(process.env.SUPERMEMORY_MCP_URL); diff --git a/src/hook-api.ts b/src/hook-api.ts index 5b0e82f..f407ac6 100644 --- a/src/hook-api.ts +++ b/src/hook-api.ts @@ -29,7 +29,6 @@ export function resolveApiVersion( : "legacy"; } -// A static API key, or a resolver called before every request so OAuth tokens stay current. export type AuthSource = string | (() => Promise); function resolveAuth(auth: AuthSource): Promise { From c0a22e76b4e27b88ad01dbb01d36e8207e1eb578 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Mon, 26 Oct 2026 21:34:16 +0530 Subject: [PATCH 3/3] feat: print the login link for the Supermemory CLI --- dist/cli.js | 15 ++++++++++++--- src/auth.ts | 6 ++++++ src/cli.ts | 7 +++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/dist/cli.js b/dist/cli.js index aff9264..bc7f74d 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -370,7 +370,7 @@ async function openUrl(url) { } await run(process.platform === "darwin" ? "open" : "xdg-open", [href]); } -async function startAuthFlow(config, timeoutMs = AUTH_TIMEOUT) { +async function startAuthFlow(config, timeoutMs = AUTH_TIMEOUT, { printUrl = false } = {}) { return withAuthLock(async (remaining) => { const controller = new AbortController; const timer = setTimeout(() => controller.abort(new Error("Authentication timed out")), remaining); @@ -447,6 +447,11 @@ async function startAuthFlow(config, timeoutMs = AUTH_TIMEOUT) { prompt: "consent" }).toString(); client = registered; + if (printUrl) { + process.stdout.write(`SUPERMEMORY_AUTHORIZE_URL=${authorizationUrl} +`); + return; + } process.stderr.write(` Open this URL to connect Supermemory to Cursor: @@ -4580,8 +4585,12 @@ async function main() { } case "login": { const config = loadConfig(); - console.log("Opening browser to authenticate..."); - await startAuthFlow(getOAuthConfig(getApiBaseUrl(config), getMcpUrl(config))); + const printUrl = process.argv.includes("--print-url"); + if (!printUrl) + console.log("Opening browser to authenticate..."); + await startAuthFlow(getOAuthConfig(getApiBaseUrl(config), getMcpUrl(config)), undefined, { + printUrl + }); console.log("Authenticated successfully."); if (getApiKey(config)) { console.log("An environment/config API key still takes precedence. Remove it to use OAuth."); diff --git a/src/auth.ts b/src/auth.ts index c60d28c..1f14372 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -472,6 +472,7 @@ export async function openUrl(url: string | URL): Promise { export async function startAuthFlow( config: OAuthConfig, timeoutMs = AUTH_TIMEOUT, + { printUrl = false }: { printUrl?: boolean } = {}, ): Promise { return withAuthLock(async (remaining) => { const controller = new AbortController(); @@ -578,6 +579,11 @@ export async function startAuthFlow( prompt: "consent", }).toString(); client = registered; + // The Supermemory CLI collects these links to approve several plugins on one consent screen. + if (printUrl) { + process.stdout.write(`SUPERMEMORY_AUTHORIZE_URL=${authorizationUrl}\n`); + return; + } process.stderr.write( `\nOpen this URL to connect Supermemory to Cursor:\n\n ${authorizationUrl}\n\nWaiting...\n`, ); diff --git a/src/cli.ts b/src/cli.ts index 5908492..5c7f07b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -37,8 +37,11 @@ async function main(): Promise { case "login": { const config = loadConfig(); - console.log("Opening browser to authenticate..."); - await startAuthFlow(getOAuthConfig(getApiBaseUrl(config), getMcpUrl(config))); + const printUrl = process.argv.includes("--print-url"); + if (!printUrl) console.log("Opening browser to authenticate..."); + await startAuthFlow(getOAuthConfig(getApiBaseUrl(config), getMcpUrl(config)), undefined, { + printUrl, + }); console.log("Authenticated successfully."); if (getApiKey(config)) { console.log(