From d522256370ced5e4422cf6303302cb748ee3f445 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 23 Oct 2026 12:05:27 +0530 Subject: [PATCH] feat: authenticate with OAuth DCR and PKCE --- README.md | 10 +- src/cli.ts | 135 +++++-- src/config.ts | 108 +++++- src/index.ts | 10 +- src/services/auth.ts | 739 ++++++++++++++++++++++++++++-------- src/services/client.ts | 32 +- src/services/memory-tool.ts | 2 +- src/services/v5-client.ts | 2 +- src/v2/runtime.ts | 32 +- 9 files changed, 833 insertions(+), 237 deletions(-) diff --git a/README.md b/README.md index 19da222..f25982f 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,11 @@ is safe to re-run; if anything is missing, add it manually: bunx opencode-supermemory@latest login ``` +`login` signs in with OAuth (browser consent, callback on `127.0.0.1:19880`) and +saves refreshable credentials to `~/.supermemory-opencode/credentials.json`. Running +OpenCode sessions pick up a login or logout without a restart. `logout` revokes the +saved refresh token. An existing saved API key keeps working until you run `login`. + For headless environments, set `SUPERMEMORY_API_KEY` manually, or create `~/.config/opencode/supermemory.jsonc`: @@ -299,8 +304,9 @@ does not require a migration. | `SUPERMEMORY_API_KEY` | Your Supermemory API key (takes precedence over the config file). | | `SUPERMEMORY_API_URL` / `SUPERMEMORY_BASE_URL` | Override the Supermemory API base URL. | | `SUPERMEMORY_API_VERSION` | Select `legacy` or `v5`, overriding `apiVersion` in the config file. | -| `SUPERMEMORY_AUTH_URL` | Override the browser-auth base URL. | -| `SUPERMEMORY_AUTH_TIMEOUT` | Browser-auth timeout in milliseconds (default 5 minutes). | +| `SUPERMEMORY_MCP_URL` | OAuth resource / MCP URL (default `https://mcp.supermemory.ai/mcp`; required for OAuth against a custom API URL that is not `api.*`). | +| `SUPERMEMORY_OAUTH_ISSUER` | Override the OAuth issuer (default `/api/auth`). | +| `SUPERMEMORY_AUTH_TIMEOUT` | Browser-login timeout in milliseconds (default 5 minutes). | | `SUPERMEMORY_REPO_TAG` | Explicit project-container override, checked before the config value. | | `SUPERMEMORY_ISOLATE_WORKTREES` | Set to `true` to key the project container on the worktree path instead of the Git remote. | | `SUPERMEMORY_DEBUG` | Set to show `[recall-decision]` lines and enable debug logging. | diff --git a/src/cli.ts b/src/cli.ts index d63c00d..769c022 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,8 +3,24 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import * as readline from "node:readline"; -import { startAuthFlow, clearCredentials, loadCredentials, CREDENTIALS_FILE } from "./services/auth.js"; -import { CONFIG, CONFIG_FILE, SUPERMEMORY_API_KEY, getApiBaseUrl, isConfigured, writeInstallDefaults } from "./config.js"; +import { + startAuthFlow, + clearCredentials, + loadCredentials, + loadCredentialData, + settleAuth, + CREDENTIALS_FILE, +} from "./services/auth.js"; +import { + CONFIG, + CONFIG_FILE, + getApiBaseUrl, + getApiKey, + getAuthToken, + getOAuthConfig, + isConfigured, + writeInstallDefaults, +} from "./config.js"; import { SupermemoryClient } from "./services/client.js"; import { getTags } from "./services/tags.js"; import { @@ -209,9 +225,11 @@ bunx opencode-supermemory@latest login \`\`\` This will: -1. Start a local server on port 19877 -2. Open the browser to Supermemory's authentication page -3. After the user logs in, save credentials to ~/.supermemory-opencode/credentials.json +1. Start a local callback server on 127.0.0.1:19880 +2. Open the browser to Supermemory's OAuth consent page +3. After the user approves, save OAuth credentials to ~/.supermemory-opencode/credentials.json + +OpenCode picks up the new credentials without a restart. Wait for the command to complete, then inform the user whether authentication succeeded or failed. @@ -230,7 +248,7 @@ Run this command to log out and clear Supermemory credentials: bunx opencode-supermemory@latest logout \`\`\` -This will remove the saved credentials from ~/.supermemory-opencode/credentials.json. +This will revoke the saved OAuth refresh token and remove the saved credentials from ~/.supermemory-opencode/credentials.json. Inform the user whether logout succeeded and that they'll need to run /supermemory-login to re-authenticate. `; @@ -509,22 +527,28 @@ async function install(options: InstallOptions): Promise { } async function login(): Promise { - const existing = loadCredentials(); - if (existing) { + const existing = loadCredentialData(); + if (existing?.type === "oauth" && !getApiKey() && isConfigured()) { console.log("Already authenticated. Use 'logout' first to re-authenticate."); return 0; } + if (existing && existing.type !== "oauth") { + console.log("Replacing the saved API key with OAuth sign-in."); + } - const result = await startAuthFlow(); - - if (result.success) { - console.log("\n✓ Successfully authenticated with Supermemory!"); - console.log("Restart OpenCode to activate.\n"); - return 0; - } else { - console.error(`\n✗ Authentication failed: ${result.error}`); + try { + await startAuthFlow(getOAuthConfig()); + } catch (error) { + console.error(`\n✗ Authentication failed: ${error instanceof Error ? error.message : String(error)}`); return 1; } + + console.log("\n✓ Successfully authenticated with Supermemory!"); + if (process.env.SUPERMEMORY_API_KEY || getConfiguredApiKeyFromFile()) { + console.log("An API key from SUPERMEMORY_API_KEY or the config file still takes precedence. Remove it to use OAuth."); + } + console.log("Running OpenCode sessions pick this up automatically.\n"); + return 0; } function maskKey(key: string | undefined): string { @@ -550,6 +574,11 @@ function getKeySource(): string { return "not configured"; } +function getAuthType(): "oauth" | "api_key" | "none" { + if (getApiKey()) return "api_key"; + return loadCredentialData()?.type === "oauth" ? "oauth" : "none"; +} + function getDevTlsHint(apiUrl: string): string | null { if (!apiUrl.includes(".dev.supermemory.ai")) return null; if (process.env.NODE_EXTRA_CA_CERTS) return null; @@ -557,11 +586,11 @@ function getDevTlsHint(apiUrl: string): string | null { } async function fetchJson(apiUrl: string, path: string): Promise { - if (!SUPERMEMORY_API_KEY) return null; try { + const token = await getAuthToken(undefined, apiUrl); const response = await fetch(`${apiUrl}${path}`, { headers: { - Authorization: `Bearer ${SUPERMEMORY_API_KEY}`, + Authorization: `Bearer ${token}`, "x-sm-source": "opencode", }, }); @@ -630,7 +659,14 @@ async function status(): Promise { lines.push("supermemory status"); lines.push(""); lines.push(`Connected: ${isConfigured() ? "checking..." : "no"}`); - lines.push(`API key: ${maskKey(SUPERMEMORY_API_KEY)} (${getKeySource()})`); + const authType = getAuthType(); + const saved = loadCredentialData(); + lines.push(`Auth type: ${authType}`); + if (authType === "oauth" && saved?.type === "oauth") { + lines.push(`OAuth client: ${saved.clientId}`); + } else { + lines.push(`API key: ${maskKey(getApiKey())} (${getKeySource()})`); + } lines.push(`API URL: ${apiUrl}`); lines.push("Memory scope: unified project container with personal/project metadata"); lines.push(`Recall mode: ${CONFIG.recallMode}`); @@ -643,7 +679,11 @@ async function status(): Promise { if (!isConfigured()) { lines.push(""); - lines.push("Run /supermemory-login to connect, or set SUPERMEMORY_API_KEY."); + lines.push( + authType === "oauth" + ? "Saved sign-in is expired or for a different API URL. Run /supermemory-login to reconnect, or set SUPERMEMORY_API_KEY." + : "Run /supermemory-login to connect, or set SUPERMEMORY_API_KEY.", + ); console.log(lines.join("\n")); return 0; } @@ -669,7 +709,7 @@ async function status(): 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 (!profileResult.success) { @@ -683,21 +723,29 @@ async function status(): Promise { return 0; } -function logout(): number { - if (clearCredentials()) { +async function logout(): Promise { + let cleared; + try { + cleared = await clearCredentials(); + } catch (error) { + console.error(`✗ Logout failed: ${error instanceof Error ? error.message : String(error)}`); + console.error("Saved credentials were kept. Try again when the network is available."); + return 1; + } + if (cleared) { console.log("✓ Logged out. Credentials cleared."); - console.log("This only logs out this local OpenCode install. To revoke the account-level OpenCode integration key, disconnect it from the Supermemory integrations page."); - if (process.env.SUPERMEMORY_API_KEY) { - console.log("SUPERMEMORY_API_KEY is still set in this shell, so memory may remain active until you unset it or restart OpenCode."); + if (cleared.type === "oauth") { + console.log("The OAuth refresh token was revoked. Running OpenCode sessions stop using it automatically."); + } else { + console.log("This only logs out this local OpenCode install. To revoke the account-level OpenCode integration key, disconnect it from the Supermemory integrations page."); } - return 0; } else { console.log("No credentials found."); - if (process.env.SUPERMEMORY_API_KEY) { - console.log("SUPERMEMORY_API_KEY is still set in this shell."); - } - return 0; } + if (process.env.SUPERMEMORY_API_KEY) { + console.log("SUPERMEMORY_API_KEY is still set in this shell, so memory may remain active until you unset it or restart OpenCode."); + } + return 0; } function printHelp(): void { @@ -709,7 +757,7 @@ Commands: --no-tui Non-interactive mode (for LLM agents) --disable-context-recovery Disable Oh My OpenCode's context hook login Authenticate with Supermemory (opens browser) - logout Clear stored credentials + logout Revoke and clear stored credentials status Show Supermemory connection status Examples: @@ -720,30 +768,33 @@ Examples: `); } +// Let a token refresh that is still rotating credentials save them before exiting. +function exit(code: number): void { + void settleAuth().finally(() => process.exit(code)); +} + const args = process.argv.slice(2); if (args.length === 0 || args[0] === "help" || args[0] === "--help" || args[0] === "-h") { printHelp(); - process.exit(0); -} - -if (args[0] === "install") { + exit(0); +} else if (args[0] === "install") { const noTui = args.includes("--no-tui"); const disableAutoCompact = args.includes("--disable-context-recovery"); - install({ tui: !noTui, disableAutoCompact }).then((code) => process.exit(code)); + install({ tui: !noTui, disableAutoCompact }).then(exit); } else if (args[0] === "setup") { console.log("Note: 'setup' is deprecated. Use 'install' instead.\n"); const noTui = args.includes("--no-tui"); const disableAutoCompact = args.includes("--disable-context-recovery"); - install({ tui: !noTui, disableAutoCompact }).then((code) => process.exit(code)); + install({ tui: !noTui, disableAutoCompact }).then(exit); } else if (args[0] === "login") { - login().then((code) => process.exit(code)); + login().then(exit); } else if (args[0] === "logout") { - process.exit(logout()); + logout().then(exit); } else if (args[0] === "status") { - status().then((code) => process.exit(code)); + status().then(exit); } else { console.error(`Unknown command: ${args[0]}`); printHelp(); - process.exit(1); + exit(1); } diff --git a/src/config.ts b/src/config.ts index 2946693..94a2218 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,15 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { stripJsoncComments } from "./services/jsonc.js"; -import { loadCredentials } from "./services/auth.js"; +import { + authRequired, + getAccessToken, + getOAuthConfig as buildOAuthConfig, + hasOAuthCredentials, + loadCredentialData, + loadCredentials, + type OAuthConfig, +} from "./services/auth.js"; const CONFIG_DIR = join(homedir(), ".config", "opencode"); export { PLUGIN_VERSION } from "./version.js"; @@ -136,14 +144,17 @@ function loadRawConfig(): { config: SupermemoryConfig; existed: boolean } { const { config: fileConfig, existed: configExisted } = loadRawConfig(); -function getApiKey(): string | undefined { +/** + * Static API key, resolved on every call: `SUPERMEMORY_API_KEY`, then the config + * file, then a legacy saved `{ apiKey }` credentials file. These take precedence + * over OAuth. Never returns an OAuth token. + */ +export function getApiKey(): string | undefined { if (process.env.SUPERMEMORY_API_KEY) return process.env.SUPERMEMORY_API_KEY; if (fileConfig.apiKey) return fileConfig.apiKey; - return loadCredentials()?.apiKey; + return loadCredentials(); } -export const SUPERMEMORY_API_KEY = getApiKey(); - function normalizeBaseUrl(baseUrl: unknown): string | null { if (typeof baseUrl !== "string" || !baseUrl.trim()) return null; @@ -164,7 +175,7 @@ export function getApiBaseUrl(): string { process.env.SUPERMEMORY_API_URL || process.env.SUPERMEMORY_BASE_URL || fileConfig.baseUrl || - loadCredentials()?.apiBaseUrl || + loadCredentialData()?.apiBaseUrl || DEFAULT_BASE_URL; const normalized = normalizeBaseUrl(configured); if (!normalized) { @@ -173,6 +184,83 @@ export function getApiBaseUrl(): string { return normalized; } +function savedOAuthFor(apiBaseUrl: string) { + const saved = loadCredentialData(); + return saved?.type === "oauth" && saved.apiBaseUrl === apiBaseUrl ? saved : undefined; +} + +/** + * OAuth resource (the MCP URL). `SUPERMEMORY_MCP_URL` wins; otherwise the + * resource saved by a login against this API URL, so the OpenCode process does + * not need the CLI's environment; otherwise `api.*` maps to `mcp.*` + `/mcp`. + */ +export function getMcpUrl(apiBaseUrl = getApiBaseUrl()): 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 saved = savedOAuthFor(apiBaseUrl); + if (saved?.resource) return saved.resource; + const api = new URL(apiBaseUrl); + 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 on a custom Supermemory backend."); +} + +/** OAuth issuer/resource for an API URL (HTTPS required except loopback). */ +export function getOAuthConfig(apiBaseUrl = getApiBaseUrl()): OAuthConfig { + const issuer = + process.env.SUPERMEMORY_OAUTH_ISSUER || + savedOAuthFor(apiBaseUrl)?.issuer || + `${apiBaseUrl}/api/auth`; + return buildOAuthConfig(apiBaseUrl, issuer, getMcpUrl(apiBaseUrl)); +} + +/** + * Bearer for REST/SDK calls, resolved at request time: a static API key when one + * is configured, otherwise the saved OAuth access token (refreshed when needed). + */ +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 ?? getApiBaseUrl()), timeoutMs); +} + +/** + * The legacy SDK only accepts a static API key. Resolve the bearer at send time + * so cached SDK clients and retries observe login, rotated tokens and logout. + */ +export function createAuthenticatedFetch(apiBaseUrl: string): typeof fetch { + const base = new URL(apiBaseUrl); + const basePath = base.pathname.replace(/\/+$/, ""); + const authenticatedFetch = async ( + input: Parameters[0], + init?: Parameters[1], + ): Promise => { + const target = new URL(input instanceof Request ? input.url : input.toString()); + 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, apiBaseUrl); + signal?.throwIfAborted(); + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ); + headers.set("Authorization", `Bearer ${token}`); + return fetch(input, { ...init, headers }); + }; + return authenticatedFetch as typeof fetch; +} + export function getApiVersion(): "legacy" | "v5" { const version = process.env.SUPERMEMORY_API_VERSION ?? fileConfig.apiVersion; if (version !== undefined) { @@ -215,8 +303,14 @@ export const CONFIG = { recallDirective: fileConfig.recallDirective ?? null, }; +/** Resolved on every call so login and logout apply without restarting OpenCode. */ export function isConfigured(): boolean { - return !!SUPERMEMORY_API_KEY; + if (getApiKey()) return true; + try { + return hasOAuthCredentials(getOAuthConfig()); + } catch { + return false; + } } export function getRecallConfig(): { diff --git a/src/index.ts b/src/index.ts index b062239..68efa05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,7 +86,8 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { log("Plugin init", { directory, tags, configured: isConfigured() }); if (!isConfigured()) { - log("Plugin disabled - SUPERMEMORY_API_KEY not set"); + // Hooks stay registered and idle; a later login applies without a restart. + log("Plugin not authenticated yet - memory hooks idle until login or SUPERMEMORY_API_KEY"); } // Fetch model limits once at plugin init @@ -116,13 +117,15 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { return modelLimits.get(`${providerID}/${modelID}`); }; - const compactionHook = isConfigured() && ctx.client + // Created regardless of auth state; each event checks isConfigured() so a + // login or logout after startup takes effect without restarting OpenCode. + const compactionHook = ctx.client ? createCompactionHook(ctx as CompactionContext, tags, { threshold: CONFIG.compactionThreshold, getModelLimit, }) : null; - const captureHook = isConfigured() && ctx.client + const captureHook = ctx.client ? createCaptureHook(ctx, tags, { onSaved: () => activity.saved() }) : null; @@ -369,6 +372,7 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { recallSessions.clear(); } + if (!isConfigured()) return; if (compactionHook) { await compactionHook.event(input); } diff --git a/src/services/auth.ts b/src/services/auth.ts index c6ef20e..6dee50b 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,190 +1,607 @@ -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import * as fs from "node:fs"; import { join } from "node:path"; -import { arch, homedir, hostname, platform } 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 { openUrl } from "./openUrl.js"; import { PLUGIN_VERSION } from "../version.js"; const CREDENTIALS_DIR = join(homedir(), ".supermemory-opencode"); export const CREDENTIALS_FILE = join(CREDENTIALS_DIR, "credentials.json"); -const AUTH_BASE_URL = process.env.SUPERMEMORY_AUTH_URL || "https://console.supermemory.ai/auth/connect"; -const AUTH_TIMEOUT = Number(process.env.SUPERMEMORY_AUTH_TIMEOUT) || 5 * 60_000; -const CLIENT_NAME = "opencode"; +export const CLIENT_FILE = join(CREDENTIALS_DIR, "oauth-client.json"); +const LOCK_FILE = join(CREDENTIALS_DIR, "auth.lock"); +const AUTH_PORT = 19880; +const REDIRECT_URI = `http://127.0.0.1:${AUTH_PORT}/callback`; +const OAUTH_SCOPE = "openid profile email offline_access"; +const REFRESH_TIMEOUT = 3000; +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; -export interface Credentials { +function callbackPage(title: string, color: string, heading: string, message: string): string { + return ` +${title} - Supermemory + +

${heading}

${message}

+`; +} + +const AUTH_SUCCESS_HTML = callbackPage( + "Connected", + "#22c55e", + "Connected!", + "You can close this window and return to OpenCode.", +); +const AUTH_DENIED_HTML = callbackPage( + "Authorization denied", + "#ef4444", + "Authorization denied", + "Supermemory was not connected. Return to OpenCode and run login again to retry.", +); +const AUTH_FAILED_HTML = callbackPage( + "Connection failed", + "#ef4444", + "Connection failed", + "Return to OpenCode and try 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 interface ApiKeyCredentials { + type?: undefined; apiKey: string; apiBaseUrl?: string; - createdAt: string; + createdAt?: string; +} + +export type Credentials = OAuthCredentials | ApiKeyCredentials; + +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 loadCredentials(): Credentials | null { - if (!existsSync(CREDENTIALS_FILE)) return null; +export function authRequired(): AuthError { + return new AuthError( + "Supermemory authentication required. Run `bunx opencode-supermemory@latest login` (or /supermemory-login), or set SUPERMEMORY_API_KEY.", + "AUTH_REQUIRED", + ); +} + +function readJson(file: string): T | null { try { - const content = readFileSync(CREDENTIALS_FILE, "utf-8"); - return JSON.parse(content) as Credentials; + const value = JSON.parse(fs.readFileSync(file, "utf-8")); + return value && typeof value === "object" && !Array.isArray(value) ? (value as T) : null; } catch { return null; } } -function normalizeApiBaseUrl(apiBaseUrl: string | null | undefined): string | undefined { - if (!apiBaseUrl) return undefined; +// OpenCode checks auth on every plugin event; skip re-parsing an unchanged file. +// Writes replace the file by rename, so the inode/size/mtime key changes. +let credentialCache: { key: string; data: Credentials | null } | undefined; + +export function loadCredentialData(): Credentials | null { + let key: string; 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(/\/$/, ""); + const stat = fs.statSync(CREDENTIALS_FILE); + key = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`; } catch { - return undefined; + credentialCache = undefined; + return null; + } + if (credentialCache?.key !== key) { + const data = readJson(CREDENTIALS_FILE); + credentialCache = { + key, + data: + data?.type === "oauth" || (data && typeof data.apiKey === "string" && data.apiKey) + ? data + : null, + }; } + return credentialCache.data ? { ...credentialCache.data } : null; } -export function saveCredentials(apiKey: string, apiBaseUrl?: string): void { - mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 }); - const credentials: Credentials = { - apiKey, - createdAt: new Date().toISOString(), - }; - const normalizedApiBaseUrl = normalizeApiBaseUrl(apiBaseUrl); - if (normalizedApiBaseUrl) credentials.apiBaseUrl = normalizedApiBaseUrl; - writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 0o600 }); -} - -export function clearCredentials(): boolean { - if (!existsSync(CREDENTIALS_FILE)) return false; - rmSync(CREDENTIALS_FILE); - return true; -} - -export interface AuthResult { - success: boolean; - apiKey?: string; - error?: string; -} - -export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { - return new Promise((resolve) => { - let resolved = false; - const stateToken = randomBytes(16).toString("hex"); - - const server = createServer((req: IncomingMessage, res: ServerResponse) => { - if (resolved) return; - - const url = new URL(req.url || "/", "http://127.0.0.1"); - - if (url.pathname === "/callback") { - const callbackState = url.searchParams.get("state"); - if (callbackState !== stateToken) { - res.writeHead(403, { "Content-Type": "text/html" }); - res.end(` - - - Error - -
-

Connection Failed

-

Invalid auth state. Please try again.

-
- - - `); - resolved = true; - clearTimeout(timer); - server.close(); - resolve({ success: false, error: "Invalid auth state" }); - 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(` - - - Success - -
-

Connected!

-

You can close this window and return to your terminal.

-
- - - `); - resolved = true; - clearTimeout(timer); - server.close(); - resolve({ success: true, apiKey }); - } else { - res.writeHead(400, { "Content-Type": "text/html" }); - res.end(` - - - Error - -
-

Connection Failed

-

No API key received. Please try again.

-
- - - `); - resolved = true; - clearTimeout(timer); - server.close(); - resolve({ success: false, error: "No API key received" }); - } - } else { - res.writeHead(404); - res.end("Not Found"); - } - }); +// Legacy saved keys stay available; never return 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 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 }); + } +} - server.on("error", (err: NodeJS.ErrnoException) => { - if (!resolved) { - resolved = true; - clearTimeout(timer); - resolve({ success: false, error: err.message }); +// The OpenCode plugin process and CLI commands share these files. 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"; } - }); - - 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: CLIENT_NAME, - hostname: `opencode - ${hostname()}`, - os: `${platform()}-${arch()}`, - cwd: process.cwd(), - cli_version: PLUGIN_VERSION, - }); - const authUrl = `${AUTH_BASE_URL}?${params.toString()}`; - - console.log("Opening browser for authentication..."); - console.log(`If it doesn't open, visit: ${authUrl}`); - openUrl(authUrl).catch((error) => { - if (!resolved) { - resolved = true; - clearTimeout(timer); - server.close(); - resolve({ success: false, error: `Failed to open browser: ${error.message}` }); - } - }); - }); + } 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(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 }); + } +} + +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, issuer: string, mcpUrl: string): OAuthConfig { + return { + apiBaseUrl: secureUrl(baseUrl), + issuer: secureUrl(issuer), + 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 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 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 OpenCode", + client_uri: "https://github.com/supermemoryai/opencode-supermemory", + software_id: "opencode-supermemory", + software_version: PLUGIN_VERSION, + redirect_uris: [REDIRECT_URI], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + scope: OAUTH_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; +} - const timer = setTimeout(() => { - if (!resolved) { - resolved = true; - server.close(); - resolve({ success: false, error: "Authentication timed out" }); +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, + ); + } 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(() => { + 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 a saved OAuth refresh token and removes saved credentials. */ +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", + }), + }); + } + fs.rmSync(CREDENTIALS_FILE, { force: true }); + return credentials; + }, REFRESH_REQUEST_TIMEOUT); +} + +/** + * Runs the browser OAuth 2.1 authorization-code flow (dynamic client + * registration + S256 PKCE) and saves the tokens. Resolves with the access token. + */ +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 { + 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: OAUTH_SCOPE, + 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 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(AUTH_SUCCESS_HTML, () => resolve(token)); + } catch (error) { + const denied = error instanceof AuthError && error.code === "access_denied"; + res + .writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) + .end(denied ? AUTH_DENIED_HTML : AUTH_FAILED_HTML); + 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 ${AUTH_PORT}. Retry after other logins finish.`, + ), + ), + ); + server.listen(AUTH_PORT, "127.0.0.1", () => { + console.log("Opening browser for authentication..."); + console.log(`If it doesn't open, visit: ${authorizationUrl.toString()}`); + 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 0604f7a..4fbc1b6 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -3,8 +3,10 @@ import { V5Client } from "./v5-client.js"; import { CONFIG, PLUGIN_VERSION, - SUPERMEMORY_API_KEY, + createAuthenticatedFetch, getApiBaseUrl, + getApiKey, + getAuthToken, getApiVersion, isConfigured, } from "../config.js"; @@ -128,6 +130,7 @@ function withTimeout(promise: Promise, ms: number): Promise { export class SupermemoryClient { private client: Supermemory | V5Client | null = null; + private clientKey: string | null = null; private formatConversationMessage(message: ConversationMessage): string { const content = @@ -160,20 +163,31 @@ export class SupermemoryClient { } private getClient(): Supermemory | V5Client { + // OpenCode keeps this process alive across login/logout; rebuild when the + // effective endpoint changes. Bearers are resolved per request, never cached, + // so a cached client stops authenticating as soon as credentials are removed. + const baseUrl = getApiBaseUrl(); + const apiVersion = getApiVersion(); + const clientKey = `${apiVersion} ${baseUrl}`; + if (this.client && this.clientKey && this.clientKey !== clientKey) this.client = null; + if ((!this.client || this.clientKey) && !isConfigured()) { + throw new Error( + "Supermemory authentication required. Run `bunx opencode-supermemory@latest login`, or set SUPERMEMORY_API_KEY.", + ); + } if (!this.client) { - if (!isConfigured()) { - throw new Error("SUPERMEMORY_API_KEY not set"); - } + this.clientKey = clientKey; const legacyClient = new Supermemory({ - apiKey: SUPERMEMORY_API_KEY, - baseURL: getApiBaseUrl(), + apiKey: getApiKey() || "oauth", + baseURL: baseUrl, + fetch: createAuthenticatedFetch(baseUrl), defaultHeaders: { "x-sm-source": OPENCODE_SOURCE }, }); this.client = - getApiVersion() === "v5" + apiVersion === "v5" ? new V5Client( - SUPERMEMORY_API_KEY!, - getApiBaseUrl(), + () => getAuthToken(undefined, baseUrl), + baseUrl, legacyClient.settings, TIMEOUT_MS - TIMEOUT_BACKSTOP_GRACE_MS, ) diff --git a/src/services/memory-tool.ts b/src/services/memory-tool.ts index 53793f9..1a94c1d 100644 --- a/src/services/memory-tool.ts +++ b/src/services/memory-tool.ts @@ -75,7 +75,7 @@ export async function executeSupermemoryTool( return JSON.stringify({ success: false, error: - "SUPERMEMORY_API_KEY not set. Set it in your environment to use Supermemory.", + "Supermemory is not authenticated. Run `bunx opencode-supermemory@latest login` (or /supermemory-login), or set SUPERMEMORY_API_KEY.", }); } diff --git a/src/services/v5-client.ts b/src/services/v5-client.ts index 1c8b54a..e7be229 100644 --- a/src/services/v5-client.ts +++ b/src/services/v5-client.ts @@ -63,7 +63,7 @@ export class V5Client { private sdk: Supermemory; constructor( - apiKey: string, + apiKey: string | (() => Promise), baseUrl: string, readonly settings: { update( diff --git a/src/v2/runtime.ts b/src/v2/runtime.ts index 2fee56b..922a41a 100644 --- a/src/v2/runtime.ts +++ b/src/v2/runtime.ts @@ -125,7 +125,8 @@ type RuntimeConfig = Pick< >; export interface V2RuntimeDependencies { - configured: boolean; + /** Auth state; a function is re-checked per hook so login/logout apply live. */ + configured: boolean | (() => boolean); config: RuntimeConfig; memoryClient: RuntimeMemoryClient; executeTool: typeof executeSupermemoryTool; @@ -135,7 +136,7 @@ export interface V2RuntimeDependencies { } const DEFAULT_DEPENDENCIES: V2RuntimeDependencies = { - configured: isConfigured(), + configured: isConfigured, config: CONFIG, memoryClient: supermemoryClient, executeTool: executeSupermemoryTool, @@ -466,6 +467,11 @@ export class V2Runtime { return this.#active && this.#isOwner(); } + get #configured(): boolean { + const { configured } = this.#deps; + return typeof configured === "function" ? configured() : configured; + } + get trackedSessionCount(): number { return this.#states.size; } @@ -510,9 +516,9 @@ export class V2Runtime { ); if (!this.active) return; - if (!this.#deps.configured) { - logger("v2 plugin disabled - SUPERMEMORY_API_KEY not set (tools registered, hooks skipped)"); - return; + if (!this.#configured) { + // Hooks still register and stay idle until login, so no restart is needed. + logger("v2 plugin not authenticated yet - hooks idle until login or SUPERMEMORY_API_KEY"); } await this.#track( @@ -534,7 +540,7 @@ export class V2Runtime { // Mirror the V1 `permission.ask` auto-allow: recall is read-only. await this.#track( this.#ctx.permission.hook("evaluate", (event) => { - if (!this.active) return; + if (!this.active || !this.#configured) return; if (event.action === RECALL_PERMISSION.action && event.effect === "ask") { event.effect = "allow"; } @@ -595,7 +601,7 @@ export class V2Runtime { const tags = await this.#resolveSession(sessionID); return await this.#deps.executeTool(args, tags, { memoryClient: this.#deps.memoryClient, - configured: this.#deps.configured, + configured: this.#configured, onSaved: () => this.#activity.saved(), }); } catch (error) { @@ -655,7 +661,7 @@ export class V2Runtime { sessionID: string; messages: RequestMessage[]; }): Promise { - if (!this.#deps.configured) return; + if (!this.#configured) return; const latestUser = event.messages.findLast((message) => message.role === "user"); if (!latestUser) return; const userText = extractMessageText(latestUser); @@ -689,7 +695,7 @@ export class V2Runtime { sessionID: string; system: Array<{ type: "text"; text: string }>; }): Promise { - if (!this.#deps.configured || !this.#deps.config.compactionEnabled) return; + if (!this.#configured || !this.#deps.config.compactionEnabled) return; const memories = await this.#projectMemories(event.sessionID); event.system.push({ type: "text", text: createCompactionPrompt(memories) }); this.#deps.logger("v2 compaction context injected", { @@ -699,7 +705,7 @@ export class V2Runtime { } async handleEvent(event: V2Event): Promise { - if (!this.active || this.#deduper.hasSeen(event.id)) return; + if (!this.active || !this.#configured || this.#deduper.hasSeen(event.id)) return; const sessionID = this.#eventSessionID(event); if (sessionID && event.type !== "session.compaction.ended") { @@ -1294,7 +1300,11 @@ export async function setupV2( log("v2 plugin init", { generation: owner.generation, directory: ctx.location?.directory, - configured: runtime["active"] && (options?.configured ?? isConfigured()), + configured: + runtime["active"] && + (typeof options?.configured === "function" + ? options.configured() + : options?.configured ?? isConfigured()), }); try {