From f994c91b89ecb2b70abafce98e97ea61c83193d3 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 16 Oct 2026 19:01:46 +0530 Subject: [PATCH 1/6] feat: authenticate Codex plugin with OAuth DCR and PKCE --- src/cli.ts | 48 ++- src/config.ts | 53 ++- src/hooks/mcp-proxy.ts | 25 +- src/hooks/recall.ts | 4 +- src/hooks/session-start.ts | 16 +- src/services/api.ts | 7 +- src/services/auth.ts | 610 ++++++++++++++++++++++++------- src/services/client.ts | 13 +- src/services/hookRecallClient.ts | 7 +- src/skills/status.ts | 33 +- 10 files changed, 609 insertions(+), 207 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 31c0891..7fb34dc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,8 +6,8 @@ import { copyFileSync, rmSync, } from "node:fs"; -import { loadCredentials } from "./services/auth.js"; -import { writeInstallDefaults, CONFIG_FILE, getRecallModeSummary } from "./config.js"; +import { loadCredentialData, startAuthFlow, clearCredentials } from "./services/auth.js"; +import { writeInstallDefaults, CONFIG_FILE, getRecallModeSummary, getApiKeyValue, isConfigured } from "./config.js"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; @@ -578,14 +578,9 @@ function uninstall() { } function status() { - const envApiKey = process.env.SUPERMEMORY_CODEX_API_KEY; - const credentialsApiKey = !envApiKey ? loadCredentials() : undefined; - const apiKey = envApiKey || credentialsApiKey; - const apiKeySource = envApiKey - ? "SUPERMEMORY_CODEX_API_KEY env var" - : credentialsApiKey - ? "credentials file (~/.codex/supermemory/credentials.json)" - : null; + const apiKey = getApiKeyValue(); + const credentials = loadCredentialData(); + const configured = isConfigured(); const hooksInstalled = existsSync(RECALL_SCRIPT) && @@ -646,7 +641,11 @@ function status() { } console.log("codex-supermemory status:\n"); - console.log(` API key: ${apiKey ? `✓ set (${apiKeySource})` : "✗ not set"}`); + console.log(` Authentication: ${apiKey ? "API key" : credentials?.type === "oauth" ? "OAuth" : "not configured"}`); + if (!apiKey && credentials?.type === "oauth") { + console.log(` OAuth client: ${credentials.clientId}`); + console.log(` Login needed: ${configured ? "no" : "yes"}`); + } console.log(` Recall mode: ${getRecallModeSummary()}`); console.log(` Hook scripts: ${hooksInstalled ? `✓ installed at ${SUPERMEMORY_HOOKS_DIR}` : "✗ not installed"}`); console.log(` hooks.json: ${hooksEnabled ? "✓ registered (implicit memory)" : "✗ not registered"}`); @@ -655,15 +654,16 @@ function status() { console.log(` Persistent mark: ${persistentIndicatorEnabled ? "✓ enabled" : ownsSupermemoryPet() ? "○ installed, another pet selection is active" : "✗ not installed"}`); console.log(` config.toml: ${configTomlExists ? "✓ exists" : "✗ not found"}`); - if (!apiKey || !hooksInstalled || !hooksEnabled || !mcpInstalled || !statusSkillInstalled) { + if (!configured || !hooksInstalled || !hooksEnabled || !mcpInstalled || !statusSkillInstalled) { console.log("\nRun `npx codex-supermemory install` to set up."); + if (!configured) console.log("Run `npx codex-supermemory login` to authenticate."); } else { console.log("\nAll good! Memory is active."); } } -const command = process.argv[2]; -try { +async function main(): Promise { + const command = process.argv[2]; switch (command) { case "install": install(); @@ -674,11 +674,23 @@ try { case "status": status(); break; + case "login": + await startAuthFlow(); + console.log("Supermemory connected through OAuth."); + if (getApiKeyValue()) console.log("An environment/config API key still takes precedence. Remove it to use OAuth."); + break; + case "logout": + await clearCredentials(); + console.log("Supermemory saved credentials cleared; automatic login is paused until you run login."); + if (getApiKeyValue()) console.log("An environment/config API key is still configured and has not been removed."); + break; default: - console.log("Usage: codex-supermemory "); + console.log("Usage: codex-supermemory "); process.exit(1); } -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); } + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/src/config.ts b/src/config.ts index f1d3395..8c17ddd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; -import { loadCredentialData, loadCredentials } from "./services/auth.js"; +import { loadCredentialData, loadCredentials, getAccessToken, getOAuthConfig, hasOAuthCredentials, authRequired } from "./services/auth.js"; export { PLUGIN_VERSION } from "./version.js"; @@ -160,13 +160,43 @@ export const CONFIG = { }; export function isConfigured(): boolean { - return !!SUPERMEMORY_API_KEY; + if (getApiKey()) return true; + try { + return hasOAuthCredentials(getOAuthConfig(getBaseUrl(), getMcpUrl())); + } catch { + return false; + } } export function getApiKeyValue(): string | undefined { - return SUPERMEMORY_API_KEY; + return getApiKey(); } +export async function getAuthToken(timeoutMs?: number, apiBaseUrl?: string): Promise { + const apiKey = getApiKey(); + if (apiKey) return apiKey; + if (loadCredentialData()?.type !== "oauth") throw authRequired(); + return getAccessToken(getOAuthConfig(apiBaseUrl ?? getBaseUrl(), getMcpUrl()), timeoutMs); +} + +// The legacy SDK only accepts a static API key. Resolve the bearer at send time +// so cached SDK clients and retries observe rotated tokens and logout. +export const authenticatedFetch: typeof fetch = async (input, init) => { + const target = new URL(input instanceof Request ? input.url : input.toString()); + const base = new URL(getBaseUrl()); + const basePath = base.pathname.replace(/\/+$/, ""); + if (target.origin !== base.origin || !target.pathname.startsWith(`${basePath}/`)) { + throw authRequired(); + } + const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined); + signal?.throwIfAborted(); + const token = await getAuthToken(undefined, base.toString()); + signal?.throwIfAborted(); + const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); + headers.set("Authorization", `Bearer ${token}`); + return fetch(input, { ...init, headers }); +}; + function normalizeBaseUrl(baseUrl: unknown): string | null { if (typeof baseUrl !== "string" || !baseUrl.trim()) return null; @@ -194,6 +224,23 @@ export function getBaseUrl(): string { return normalized; } +export function getMcpUrl(): string { + if (process.env.SUPERMEMORY_MCP_URL) { + const configured = normalizeBaseUrl(process.env.SUPERMEMORY_MCP_URL); + if (!configured) throw new Error("Invalid SUPERMEMORY_MCP_URL"); + return configured; + } + const api = new URL(getBaseUrl()); + 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 getApiVersion(): "v5" | "legacy" { const configured = process.env.SUPERMEMORY_API_VERSION ?? fileConfig.apiVersion; if (configured !== undefined) { diff --git a/src/hooks/mcp-proxy.ts b/src/hooks/mcp-proxy.ts index 57e2fa1..f1e4451 100644 --- a/src/hooks/mcp-proxy.ts +++ b/src/hooks/mcp-proxy.ts @@ -1,8 +1,6 @@ import { createInterface } from "node:readline"; -import { getApiKeyValue } from "../config.js"; - -const MCP_URL = - process.env.SUPERMEMORY_MCP_URL || "https://mcp.supermemory.ai/mcp"; +import { getAuthToken, getMcpUrl } from "../config.js"; +import { isAuthRequired } from "../services/auth.js"; const REQUEST_TIMEOUT_MS = 30_000; let sessionId: string | null = null; @@ -43,7 +41,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(getMcpUrl(), { method: "POST", headers, body: JSON.stringify(message), @@ -73,7 +71,6 @@ async function forward(message: JsonRpcMessage, apiKey: string): Promise { } function main(): void { - const apiKey = getApiKeyValue(); let queue = Promise.resolve(); const lines = createInterface({ input: process.stdin }); @@ -88,18 +85,16 @@ function main(): void { } queue = queue.then(async () => { - if (!apiKey) { - sendError( - message.id, - -32001, - "Supermemory is not authenticated. Start a new Codex task to log in, or set SUPERMEMORY_CODEX_API_KEY.", - ); - return; - } - try { + // SessionStart may authenticate after the proxy has already started. + const apiKey = await getAuthToken(); await forward(message, apiKey); } catch (error) { + if (isAuthRequired(error)) { + sessionId = null; + sendError(message.id, -32001, "Supermemory is not authenticated. Start a new Codex task to log in, run `npx codex-supermemory login`, or set SUPERMEMORY_CODEX_API_KEY."); + return; + } const detail = error instanceof Error ? error.message : String(error); sendError(message.id, -32000, `Supermemory MCP proxy error: ${detail}`); } diff --git a/src/hooks/recall.ts b/src/hooks/recall.ts index b53e1f6..a606990 100644 --- a/src/hooks/recall.ts +++ b/src/hooks/recall.ts @@ -72,8 +72,8 @@ async function main() { // UserPromptSubmit has a 5s backstop and must never launch browser auth. // SessionStart owns authentication. exitWithContext( - "[SUPERMEMORY] Memory is installed but NOT active — missing API key.\n" + - "Start a new Codex task to authenticate, or set SUPERMEMORY_CODEX_API_KEY in your shell profile." + "[SUPERMEMORY] Memory is installed but NOT active — authentication required.\n" + + "Run `npx codex-supermemory login`, start a new Codex task to authenticate, or set SUPERMEMORY_CODEX_API_KEY in your shell profile." ); } diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index 68a35c6..8b25c1d 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -1,12 +1,12 @@ import { readFileSync, existsSync, writeFileSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; -import { isConfigured, CONFIG, PLUGIN_VERSION, reloadApiKey } from "../config.js"; +import { getAuthToken, CONFIG, PLUGIN_VERSION } from "../config.js"; import { HOOK_API_TIMEOUT_MS, SupermemoryClient } from "../services/client.js"; import { getTags } from "../services/tags.js"; import { formatCombinedContext } from "../services/context.js"; import { log } from "../services/logger.js"; -import { startAuthFlow, AUTH_BASE_URL } from "../services/auth.js"; +import { startAuthFlow, isAuthRequired, isLoggedOut } from "../services/auth.js"; import { getSeenFacts, addSeenFacts } from "../services/factCache.js"; import { checkNpmUpdate, formatUpdateNotice } from "../services/version-check.js"; @@ -72,15 +72,17 @@ async function main() { process.exit(0); } - if (!isConfigured()) { + try { + await getAuthToken(1000); + } catch (error) { try { + // Transient refresh failures must not launch another browser login. + if (!isAuthRequired(error) || isLoggedOut()) throw error; await startAuthFlow(getSessionStartAuthTimeoutMs()); - reloadApiKey(); } catch { exitWithContext( - "[SUPERMEMORY] Memory is installed but NOT active — missing API key.\n" + - `Visit: ${AUTH_BASE_URL}\n` + - "A new Codex task will try browser authentication again, or set SUPERMEMORY_CODEX_API_KEY." + "[SUPERMEMORY] Memory is installed but NOT active — authentication unavailable.\n" + + "Run `npx codex-supermemory login` to connect, or set SUPERMEMORY_CODEX_API_KEY." ); } } diff --git a/src/services/api.ts b/src/services/api.ts index d0600ca..9240551 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,12 +1,13 @@ import Supermemory, { SupermemoryError, type FilterExpression, type RequestOptions } from "supermemory"; -import { getApiKeyValue, getBaseUrl } from "../config.js"; +import { getAuthToken, getBaseUrl } from "../config.js"; import type { ProfileWithSearchResult, SearchResultItem, MemoryScope } from "./client.js"; import { memoryText, recallProvenance } from "./resultText.js"; export function createV5Client(fetchImpl?: typeof fetch): Supermemory { + const baseUrl = getBaseUrl(); return new Supermemory({ - apiKey: getApiKeyValue(), - baseUrl: getBaseUrl(), + apiKey: () => getAuthToken(undefined, baseUrl), + baseUrl, headers: { "x-sm-source": "codex" }, timeoutInSeconds: 60, maxRetries: 2, diff --git a/src/services/auth.ts b/src/services/auth.ts index 295decd..362a365 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,28 +1,20 @@ -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import * as fs from "node:fs"; import { join } from "node:path"; -import { homedir, hostname, platform, arch } from "node:os"; -import { randomBytes } from "node:crypto"; -import type { AddressInfo } from "node:net"; +import { homedir } from "node:os"; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import { createServer, type Server } from "node:http"; import { PLUGIN_VERSION } from "../version.js"; import { openUrl } from "./openUrl.js"; const SUPERMEMORY_DIR = join(homedir(), ".codex", "supermemory"); -const CREDENTIALS_FILE = join(SUPERMEMORY_DIR, "credentials.json"); -export interface Credentials { - apiKey?: string; - apiBaseUrl?: string; - savedAt?: string; -} - -const AUTH_BASE_URL = - process.env.SUPERMEMORY_AUTH_URL || "https://console.supermemory.ai/auth/connect"; -const configuredAuthTimeout = Number(process.env.SUPERMEMORY_AUTH_TIMEOUT); -const AUTH_TIMEOUT = - Number.isFinite(configuredAuthTimeout) && configuredAuthTimeout > 0 - ? configuredAuthTimeout - : 5 * 60_000; - +export const CREDENTIALS_FILE = join(SUPERMEMORY_DIR, "credentials.json"); +const CLIENT_FILE = join(SUPERMEMORY_DIR, "oauth-client.json"); +const LOCK_FILE = join(SUPERMEMORY_DIR, "auth.lock"); +const LOGGED_OUT_FILE = join(SUPERMEMORY_DIR, ".logged-out"); +const AUTH_PORT = 19877; +const REDIRECT_URI = `http://127.0.0.1:${AUTH_PORT}/callback`; +const REFRESH_TIMEOUT = 3000; +const REFRESH_SKEW = 60_000; const AUTH_SUCCESS_HTML = ` Connected - Supermemory -
Error
-

Connection Failed

-

Invalid API key received. Please try again.

-`; +export interface OAuthConfig { + apiBaseUrl: string; + issuer: string; + resource: string; +} -export function loadCredentialData(): Credentials | null { +interface OAuthClient extends OAuthConfig { + clientId: string; + redirectUri: string; + authorizationEndpoint: string; + tokenEndpoint: string; + revocationEndpoint: string; +} + +interface OAuthCredentials extends OAuthClient { + type: "oauth"; + accessToken?: string; + refreshToken?: string; + expiresAt: number; + savedAt?: string; +} + +export type Credentials = + | OAuthCredentials + | { + type?: undefined; + apiKey: string; + apiBaseUrl?: string; + savedAt?: string; + }; + +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 authRequired(): AuthError { + return new AuthError( + "Supermemory authentication required. Run `npx codex-supermemory login`.", + "AUTH_REQUIRED", + ); +} + +function readJson(file: string): T | null { try { - if (existsSync(CREDENTIALS_FILE)) { - return JSON.parse(readFileSync(CREDENTIALS_FILE, "utf-8")) as Credentials; - } - } catch {} - 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 loadCredentialData(): Credentials | null { + const data = readJson(CREDENTIALS_FILE); + return data?.type === "oauth" || data?.apiKey ? data : null; } +// Legacy keys stay available; never return an OAuth token to a synchronous caller. export function loadCredentials(): string | undefined { const data = loadCredentialData(); - if (data?.apiKey) return data.apiKey; - return undefined; + return data?.type === "oauth" ? undefined : data?.apiKey; } -function normalizeApiBaseUrl(apiBaseUrl: string | null | undefined): string | undefined { - if (!apiBaseUrl) return undefined; +function writeJson(file: string, data: unknown): void { + fs.mkdirSync(SUPERMEMORY_DIR, { recursive: true, mode: 0o700 }); + const temporary = `${file}.${randomUUID()}.tmp`; try { - const url = new URL(apiBaseUrl); - if (url.protocol !== "https:" && url.protocol !== "http:") return undefined; - url.pathname = url.pathname.replace(/\/+$/, ""); - url.search = ""; - url.hash = ""; - return url.toString().replace(/\/$/, ""); - } catch { - return undefined; + fs.writeFileSync(temporary, JSON.stringify(data, null, 2), { mode: 0o600, flag: "wx" }); + fs.renameSync(temporary, file); + } finally { + fs.rmSync(temporary, { force: true }); } } -function saveCredentials(apiKey: string, apiBaseUrl?: string): void { - mkdirSync(SUPERMEMORY_DIR, { recursive: true, mode: 0o700 }); - const credentials: Credentials = { apiKey, savedAt: new Date().toISOString() }; - const normalizedApiBaseUrl = normalizeApiBaseUrl(apiBaseUrl); - if (normalizedApiBaseUrl) credentials.apiBaseUrl = normalizedApiBaseUrl; - writeFileSync( - CREDENTIALS_FILE, - JSON.stringify(credentials, null, 2), - { mode: 0o600 } +// 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 { + 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; + } + 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 }); + } +} + +async function withAuthLock( + action: (remaining: number) => Promise, + timeoutMs: number, +): Promise { + fs.mkdirSync(SUPERMEMORY_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 }); + } +} + +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(/\/+$/, ""); +} + +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), + }; +} + +function sameConfig(credentials: Partial | null, config: OAuthConfig): boolean { + return ( + credentials?.apiBaseUrl === config.apiBaseUrl && + credentials?.issuer === config.issuer && + credentials?.resource === config.resource ); } -export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { - return new Promise((resolve, reject) => { - let resolved = false; - const stateToken = randomBytes(16).toString("hex"); - - const server = createServer((req: IncomingMessage, res: ServerResponse) => { - const url = new URL(req.url || "/", "http://localhost"); - - if (url.pathname === "/callback") { - const callbackState = url.searchParams.get("state"); - if (callbackState !== stateToken) { - res.writeHead(403, { "Content-Type": "text/html" }); - res.end(AUTH_ERROR_HTML); - return; - } - - const apiKey = - url.searchParams.get("apikey") || url.searchParams.get("api_key"); - const apiBaseUrl = - url.searchParams.get("api_url") || url.searchParams.get("api_base_url"); - - if (apiKey?.startsWith("sm_")) { - saveCredentials(apiKey, apiBaseUrl ?? undefined); - res.writeHead(200, { "Content-Type": "text/html" }); - res.end(AUTH_SUCCESS_HTML); - resolved = true; - clearTimeout(timer); - server.close(); - resolve(apiKey); - } else { - res.writeHead(400, { "Content-Type": "text/html" }); - res.end(AUTH_ERROR_HTML); - } - } else { - res.writeHead(404); - res.end("Not found"); - } - }); - - // Listen on an ephemeral port to avoid conflicts; embed the state token in - // the callback URL so the console redirects it back through the redirect. - server.listen(0, "127.0.0.1", () => { - const { port } = server.address() as AddressInfo; - const callbackUrl = `http://127.0.0.1:${port}/callback?state=${stateToken}`; - const params = new URLSearchParams({ - callback: callbackUrl, - client: "codex", - hostname: `codex - ${hostname()}`, - os: `${platform()}-${arch()}`, - cwd: process.cwd(), - cli_version: PLUGIN_VERSION, - }); - const authUrl = `${AUTH_BASE_URL}?${params.toString()}`; - openUrl(authUrl).catch((error) => { - if (!resolved) { - clearTimeout(timer); - server.close(); - reject(new Error(`Failed to open browser: ${error.message}`)); - } - }); - }); +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)) + ); +} - server.on("error", (err) => { - if (!resolved) { - clearTimeout(timer); - reject(new Error(`Failed to start auth server: ${err.message}`)); - } - }); +export function isLoggedOut(): boolean { + return fs.existsSync(LOGGED_OUT_FILE); +} - const timer = setTimeout(() => { - if (!resolved) { - server.close(); - reject(new Error("AUTH_TIMEOUT")); - } - }, timeoutMs); +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 Codex", + client_uri: "https://github.com/supermemoryai/codex-supermemory", + software_id: "codex-supermemory", + software_version: PLUGIN_VERSION, + redirect_uris: [REDIRECT_URI], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + scope: "openid profile email offline_access", + }), + }); + 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(); + writeJson(CREDENTIALS_FILE, { + ...client, + type: "oauth", + accessToken: data.access_token, + refreshToken, + expiresAt: Date.now() + data.expires_in * 1000, + savedAt: new Date().toISOString(), }); + fs.rmSync(LOGGED_OUT_FILE, { force: true }); + return data.access_token; } -export { AUTH_BASE_URL, CREDENTIALS_FILE }; +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 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; + } + }, timeoutMs); +} + +export async function clearCredentials(): Promise { + return withAuthLock(async (remaining) => { + const credentials = loadCredentialData(); + 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", + }), + }); + } + writeJson(LOGGED_OUT_FILE, { loggedOutAt: new Date().toISOString() }); + fs.rmSync(CREDENTIALS_FILE, { force: true }); + }, REFRESH_TIMEOUT); +} + +export async function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { + // Resolve configuration lazily: config.ts reads legacy credentials at startup. + const { getBaseUrl, getMcpUrl } = await import("../config.js"); + const config = getOAuthConfig(getBaseUrl(), getMcpUrl()); + return withAuthLock(async (remaining) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error("AUTH_TIMEOUT")), remaining); + let server: Server | undefined; + try { + const client = await registerClient(config, controller.signal); + const state = randomBytes(32).toString("base64url"); + const verifier = randomBytes(32).toString("base64url"); + const authorizationUrl = new URL(issuerEndpoint(client.authorizationEndpoint, client.issuer)); + authorizationUrl.search = new URLSearchParams({ + client_id: client.clientId, + redirect_uri: REDIRECT_URI, + response_type: "code", + scope: "openid profile email offline_access", + resource: client.resource, + code_challenge: createHash("sha256").update(verifier).digest("base64url"), + code_challenge_method: "S256", + state, + prompt: "consent", + }).toString(); + return await new Promise((resolve, reject) => { + let exchanging = false; + 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 ( + 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 Error("Authorization was 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(AUTH_SUCCESS_HTML, () => resolve(token)); + } catch (error) { + res.writeHead(400).end("Connection failed. Return to Codex and try login again."); + if ( + error instanceof AuthError && + error.code === "invalid_client" && + !controller.signal.aborted + ) { + fs.rmSync(CLIENT_FILE, { force: true }); + } + reject(error); + } + }); + server.on("error", () => + reject( + new Error( + "Cannot open the authentication callback port. Retry after other logins finish.", + ), + ), + ); + server.listen(AUTH_PORT, "127.0.0.1", () => { + openUrl(authorizationUrl).catch(() => + reject( + new Error("Failed to open the browser. Retry login from an interactive terminal."), + ), + ); + }); + }); + } finally { + clearTimeout(timer); + controller.abort(); + server?.close(); + server?.closeAllConnections(); + } + }, timeoutMs); +} diff --git a/src/services/client.ts b/src/services/client.ts index 7cb5ea9..80b2e66 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -1,6 +1,6 @@ import Supermemory from "supermemory"; import LegacySupermemory from "supermemory-legacy"; -import { CONFIG, isConfigured, getApiKeyValue, getBaseUrl, getApiVersion, PLUGIN_VERSION } from "../config.js"; +import { CONFIG, isConfigured, getApiKeyValue, getAuthToken, authenticatedFetch, getBaseUrl, getApiVersion, PLUGIN_VERSION } from "../config.js"; import { log } from "./logger.js"; import type { MemoryType } from "../types/index.js"; import { mergeProfileResults, mergeSearchResponses } from "./resultMerge.js"; @@ -137,7 +137,7 @@ export class SupermemoryClient { private getClient(): Supermemory { if (!this.client) { if (!isConfigured()) { - throw new Error("SUPERMEMORY_API_KEY not set"); + throw new Error("Supermemory authentication required"); } this.client = createV5Client(); } @@ -146,9 +146,10 @@ export class SupermemoryClient { private getLegacyClient(): LegacySupermemory { if (!this.legacyClient) { - if (!isConfigured()) throw new Error("SUPERMEMORY_API_KEY not set"); + if (!isConfigured()) throw new Error("Supermemory authentication required"); this.legacyClient = new LegacySupermemory({ - apiKey: getApiKeyValue(), + apiKey: getApiKeyValue() || "oauth", + fetch: authenticatedFetch, baseURL: getBaseUrl(), defaultHeaders: { "x-sm-source": CODEX_SOURCE }, timeout: 60000, @@ -490,7 +491,7 @@ export class SupermemoryClient { const currentResponse = await withTimeout( fetch(`${baseUrl}/v3/container-tags/${encodeURIComponent(containerTag)}`, { headers: { - Authorization: `Bearer ${getApiKeyValue()}`, + Authorization: `Bearer ${await getAuthToken(undefined, baseUrl)}`, }, }), SPACE_NAME_TIMEOUT_MS @@ -524,7 +525,7 @@ export class SupermemoryClient { fetch(`${baseUrl}/v3/container-tags/${encodeURIComponent(containerTag)}`, { method: "PATCH", headers: { - Authorization: `Bearer ${getApiKeyValue()}`, + Authorization: `Bearer ${await getAuthToken(undefined, baseUrl)}`, "Content-Type": "application/json", }, body: JSON.stringify({ name }), diff --git a/src/services/hookRecallClient.ts b/src/services/hookRecallClient.ts index 555cf8a..0c9bda2 100644 --- a/src/services/hookRecallClient.ts +++ b/src/services/hookRecallClient.ts @@ -1,4 +1,4 @@ -import { CONFIG, getApiKeyValue, getBaseUrl, getApiVersion } from "../config.js"; +import { CONFIG, getAuthToken, getBaseUrl, getApiVersion } from "../config.js"; import type { ProfileWithSearchResult, SearchResultItem } from "./client.js"; import { mergeProfileResults } from "./resultMerge.js"; import { memoryText, recallProvenance } from "./resultText.js"; @@ -80,12 +80,11 @@ async function fetchProfile( query: string, options: Required, ): Promise { - const apiKey = getApiKeyValue(); - if (!apiKey) return { success: false, error: "Missing API key", profile: null }; - const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), options.timeoutMs); try { + const apiKey = await getAuthToken(Math.min(options.timeoutMs, 3000)); + controller.signal.throwIfAborted(); if (getApiVersion() === "v5") { return await readV5Profile(createV5Client(options.fetchImpl), containerTag, query, undefined, { timeoutInSeconds: options.timeoutMs / 1000, diff --git a/src/skills/status.ts b/src/skills/status.ts index a1b4faa..55b625c 100644 --- a/src/skills/status.ts +++ b/src/skills/status.ts @@ -1,8 +1,8 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; -import { CONFIG, getApiBaseUrl, getApiKeyValue, getApiVersion, isConfigured } from "../config.js"; -import { loadCredentials } from "../services/auth.js"; +import { CONFIG, getApiBaseUrl, getApiKeyValue, getAuthToken, getApiVersion, isConfigured } from "../config.js"; +import { loadCredentials, loadCredentialData } from "../services/auth.js"; import { getTags } from "../services/tags.js"; import { createV5Client } from "../services/api.js"; @@ -45,10 +45,8 @@ function getAutoRecallStatus(): string { } async function fetchJson(path: string): Promise { - const apiKey = getApiKeyValue(); - if (!apiKey) return null; - try { + const apiKey = await getAuthToken(undefined, API_URL); const response = await fetch(`${API_URL.replace(/\/+$/, "")}${path}`, { headers: { Authorization: `Bearer ${apiKey}`, @@ -84,17 +82,15 @@ async function probeApi(containerTag: string): Promise<{ status?: number; detail: string; }> { - const apiKey = getApiKeyValue(); - if (!apiKey) return { ok: false, detail: "not checked (missing API key)" }; - try { + const apiKey = await getAuthToken(undefined, API_URL); if (getApiVersion() === "v5") { await createV5Client().profile(containerTag, {}, { timeoutInSeconds: 8, maxRetries: 0, abortSignal: AbortSignal.timeout(8_000), }); - return { ok: true, status: 200, detail: "reachable, key valid" }; + return { ok: true, status: 200, detail: "reachable, credentials valid" }; } const response = await fetch(`${API_URL.replace(/\/+$/, "")}/v4/profile`, { method: "POST", @@ -107,10 +103,10 @@ async function probeApi(containerTag: string): Promise<{ signal: AbortSignal.timeout(8_000), }); if (response.status === 200) { - return { ok: true, status: 200, detail: "reachable, key valid" }; + return { ok: true, status: 200, detail: "reachable, credentials valid" }; } if (response.status === 401 || response.status === 403) { - return { ok: false, status: response.status, detail: "reachable, key invalid or revoked" }; + return { ok: false, status: response.status, detail: "reachable, credentials invalid or revoked" }; } return { ok: false, status: response.status, detail: "API returned an error" }; } catch (error) { @@ -120,7 +116,7 @@ async function probeApi(containerTag: string): Promise<{ ok: false, status: typeof status === "number" ? status : undefined, detail: status === 401 || status === 403 - ? "reachable, key invalid or revoked" + ? "reachable, credentials invalid or revoked" : "API request failed", }; } @@ -130,13 +126,18 @@ async function main(): Promise { const cwd = process.cwd(); const tags = getTags(cwd); const apiKey = getApiKeyValue(); + const credentials = loadCredentialData(); const lines: string[] = []; lines.push("supermemory status"); lines.push(""); lines.push(`Authenticated: ${isConfigured() ? "yes" : "no"}`); lines.push(`Connected: ${isConfigured() ? "checking..." : "no"}`); - lines.push(`API key: ${maskKey(apiKey)} (${getKeySource()})`); + if (!apiKey && credentials?.type === "oauth") { + lines.push(`Authentication: OAuth (client ${credentials.clientId})`); + } else { + lines.push(`API key: ${maskKey(apiKey)} (${getKeySource()})`); + } const displayUrl = new URL(API_URL); lines.push(`API URL: ${displayUrl.origin}${displayUrl.pathname}`); lines.push(`Memory scope: one project container with metadata scopes`); @@ -146,9 +147,9 @@ async function main(): Promise { lines.push(`Reads (including legacy): ${tags.allReads.join(", ")}`); if (!isConfigured()) { - lines[2] = "Connected: no"; + lines[3] = "Connected: no"; lines.push(""); - lines.push("Start a new Codex task to connect automatically, or set SUPERMEMORY_CODEX_API_KEY."); + lines.push("Start a new Codex task to connect automatically, run `npx codex-supermemory login`, or set SUPERMEMORY_CODEX_API_KEY."); console.log(lines.join("\n")); process.exit(0); } @@ -170,7 +171,7 @@ async function main(): Promise { if (accountInfo.orgName) lines.push(`Organization: ${accountInfo.orgName}`); } else { lines.push(""); - lines.push("Account: authenticated API key (account details unavailable from API key)"); + lines.push("Account: details unavailable for these credentials"); } if (!probe.ok) { From eff49680aa8b5a26ba3c3a52c0c4b87f2b6b0773 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 16 Oct 2026 21:05:16 +0530 Subject: [PATCH 2/6] fix: show denied message after OAuth consent deny --- src/services/auth.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/services/auth.ts b/src/services/auth.ts index 362a365..6a2282a 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -462,7 +462,9 @@ export async function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { } exchanging = true; try { - if (url.searchParams.has("error")) throw new Error("Authorization was denied."); + 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( @@ -479,7 +481,11 @@ export async function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { .writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) .end(AUTH_SUCCESS_HTML, () => resolve(token)); } catch (error) { - res.writeHead(400).end("Connection failed. Return to Codex and try login again."); + res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }).end( + error instanceof AuthError && error.code === "access_denied" + ? "Authorization denied. Supermemory was not connected. Return to Codex and run login again to retry." + : "Connection failed. Return to Codex and try login again.", + ); if ( error instanceof AuthError && error.code === "invalid_client" && From 5d17324ba8ffc4f74bbd1b42beeebf521ad4921c Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Mon, 19 Oct 2026 13:35:18 +0530 Subject: [PATCH 3/6] fix: finish token refresh after the hook stops waiting --- build.mjs | 1 + src/hooks/flush.ts | 4 +- src/hooks/mcp-proxy.ts | 5 +- src/hooks/recall.ts | 15 ++++-- src/hooks/session-start.ts | 32 +++++++++---- src/services/auth.ts | 77 +++++++++++++++++++++++++++---- test/unit.mjs | 93 +++++++++++++++++++++++++++++++++++++- 7 files changed, 198 insertions(+), 29 deletions(-) diff --git a/build.mjs b/build.mjs index 3bf67c3..0cc63bc 100644 --- a/build.mjs +++ b/build.mjs @@ -59,6 +59,7 @@ const libraryEntries = [ { in: "src/services/capture.ts", out: "dist/services/capture.js" }, { in: "src/services/context.ts", out: "dist/services/context.js" }, { in: "src/services/tracker.ts", out: "dist/services/tracker.js" }, + { in: "src/services/auth.ts", out: "dist/services/auth.js" }, ]; await Promise.all( diff --git a/src/hooks/flush.ts b/src/hooks/flush.ts index f5fdbb1..32e8bf3 100644 --- a/src/hooks/flush.ts +++ b/src/hooks/flush.ts @@ -1,3 +1,4 @@ +import { settleAuth } from "../services/auth.js"; import { readFileSync } from "node:fs"; import { isConfigured } from "../config.js"; import { SupermemoryClient } from "../services/client.js"; @@ -51,6 +52,7 @@ async function main() { } } +// Let a refresh that is still rotating tokens save them before exiting. main() .catch(() => {}) - .finally(() => process.exit(0)); + .finally(() => settleAuth().finally(() => process.exit(0))); diff --git a/src/hooks/mcp-proxy.ts b/src/hooks/mcp-proxy.ts index f1e4451..bc93277 100644 --- a/src/hooks/mcp-proxy.ts +++ b/src/hooks/mcp-proxy.ts @@ -1,6 +1,6 @@ import { createInterface } from "node:readline"; import { getAuthToken, getMcpUrl } from "../config.js"; -import { isAuthRequired } from "../services/auth.js"; +import { isAuthRequired , settleAuth } from "../services/auth.js"; const REQUEST_TIMEOUT_MS = 30_000; let sessionId: string | null = null; @@ -102,7 +102,8 @@ function main(): 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))); }); } diff --git a/src/hooks/recall.ts b/src/hooks/recall.ts index a606990..2c345c8 100644 --- a/src/hooks/recall.ts +++ b/src/hooks/recall.ts @@ -1,3 +1,4 @@ +import { exitHook, isHookExit } from "../services/auth.js"; import { readFileSync } from "node:fs"; import { isConfigured, CONFIG } from "../config.js"; import { getTags } from "../services/tags.js"; @@ -34,7 +35,7 @@ function exitWithContext(additionalContext: string, systemMessage?: string): nev }) ); } - process.exit(0); + exitHook(0); } interface RecallItem { @@ -65,7 +66,7 @@ async function main() { try { rawInput = readFileSync(0, "utf-8"); } catch { - process.exit(0); + exitHook(0); } if (!isConfigured()) { @@ -148,11 +149,15 @@ async function main() { exitWithContext(""); } catch (error) { + if (isHookExit(error)) throw error; log("recall: error", { error: String(error) }); exitWithContext("", "◪ supermemory · recall unavailable; continuing without recalled context"); } } -main().catch(() => { - exitWithContext(""); -}); +main() + .catch((error) => { + if (isHookExit(error)) return; + exitWithContext(""); + }) + .catch(() => {}); diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index 8b25c1d..3e25eae 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -6,7 +6,7 @@ import { HOOK_API_TIMEOUT_MS, SupermemoryClient } from "../services/client.js"; import { getTags } from "../services/tags.js"; import { formatCombinedContext } from "../services/context.js"; import { log } from "../services/logger.js"; -import { startAuthFlow, isAuthRequired, isLoggedOut } from "../services/auth.js"; +import { startAuthFlow, isAuthRequired, isLoggedOut, exitHook, isHookExit, AuthError } from "../services/auth.js"; import { getSeenFacts, addSeenFacts } from "../services/factCache.js"; import { checkNpmUpdate, formatUpdateNotice } from "../services/version-check.js"; @@ -46,7 +46,7 @@ function exitWithContext(additionalContext: string, systemMessage?: string): nev }) ); } - process.exit(0); + exitHook(0); } function combineContextParts(parts: Array): string { @@ -69,7 +69,7 @@ async function main() { try { rawInput = readFileSync(0, "utf-8"); } catch { - process.exit(0); + exitHook(0); } try { @@ -79,7 +79,15 @@ async function main() { // Transient refresh failures must not launch another browser login. if (!isAuthRequired(error) || isLoggedOut()) throw error; await startAuthFlow(getSessionStartAuthTimeoutMs()); - } catch { + } catch (authError) { + // A refresh that outlives the hook's wait still completes and saves in + // the background; the next hook will find the rotated token. + if (authError instanceof AuthError && authError.code === "AUTH_PENDING") { + exitWithContext( + "[SUPERMEMORY] Memory is installed; the Supermemory sign-in is still refreshing. " + + "Memories will load on the next prompt.", + ); + } exitWithContext( "[SUPERMEMORY] Memory is installed but NOT active — authentication unavailable.\n" + "Run `npx codex-supermemory login` to connect, or set SUPERMEMORY_CODEX_API_KEY." @@ -159,6 +167,7 @@ ${text} markTip(), ])); } catch (error) { + if (isHookExit(error)) throw error; log("session-start: error", { error: String(error) }); exitWithContext( "", @@ -170,9 +179,12 @@ ${text} } } -main().catch(() => { - exitWithContext( - "", - "◪ supermemory · profile unavailable; continuing without recalled context", - ); -}); +main() + .catch((error) => { + if (isHookExit(error)) return; + exitWithContext( + "", + "◪ supermemory · profile unavailable; continuing without recalled context", + ); + }) + .catch(() => {}); diff --git a/src/services/auth.ts b/src/services/auth.ts index 6a2282a..f9f2d1c 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -14,6 +14,7 @@ const LOGGED_OUT_FILE = join(SUPERMEMORY_DIR, ".logged-out"); const AUTH_PORT = 19877; const REDIRECT_URI = `http://127.0.0.1:${AUTH_PORT}/callback`; const REFRESH_TIMEOUT = 3000; +const REFRESH_REQUEST_TIMEOUT = 10_000; const REFRESH_SKEW = 60_000; const AUTH_SUCCESS_HTML = ` Connected - Supermemory