// agentmemory-refresh.ts — keeps the agentmemory MCP OAuth token alive so a // session NEVER dies mid-run. // // THE BUG THIS EXISTS FOR (docs: PLAN-agentmemory-oauth-resilience.md): // The access token expires (was 60 min). opencode's MCP client does not // reliably refresh it (upstream #34582 "refresh token stored but not used"). // When it dies mid-session the MCP server is DROPPED FROM THE TOOLSET — the // agent doesn't get a retryable error, the tool simply ceases to exist: // "Model tried to call unavailable tool 'agentmemory_memory_recall'" // Re-running `opencode mcp auth` does NOT fix the RUNNING process (#21702): it // keeps the dead Mcp-Session-Id, the gateway rejects it, and only a full // restart recovers. That is the loop users hit roughly hourly. // // WHAT THIS DOES // At session start (and on a light timer while a session is alive) it reads // opencode's own mcp-auth.json, and when the agentmemory token is expired or // expiring soon it refreshes it IN PLACE using the stored refresh token, via // the token endpoint discovered from the server's OAuth metadata. opencode // picks the refreshed token up from disk for the next connection. // // WHAT IT DELIBERATELY DOES NOT DO // • Never spawns `opencode` (starting the CLI from inside a plugin can // recursively load plugins and crash the process). // • Never opens a browser. If there is no refresh token it logs ONE clear // line telling you to run `opencode mcp auth agentmemory`, and stops — // no retry loop, no noise. // • Never throws into the session: every failure path is a log line. import { Plugin } from "@opencode/plugin"; import { promises as fs } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; const SERVER = process.env.AGENTMEMORY_MCP_NAME || "agentmemory"; // Refresh this far BEFORE expiry so a long tool call can't straddle the boundary. const SKEW_SECONDS = Number(process.env.AGENTMEMORY_REFRESH_SKEW || 600); // 10 min // Re-check while a session is alive (tokens can outlive a single prompt). const CHECK_INTERVAL_MS = Number(process.env.AGENTMEMORY_REFRESH_INTERVAL || 15 * 60 * 1000); function authPath(): string { const dataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"); return path.join(dataHome, "opencode", "mcp-auth.json"); } function log(msg: string) { console.error(`[agentmemory-refresh] ${msg}`); } async function readAuth(): Promise | null> { try { return JSON.parse(await fs.readFile(authPath(), "utf8")); } catch { return null; // not installed / never authed — nothing to do } } // Atomic + 0600, matching how opencode writes this file. async function writeAuth(all: Record): Promise { const p = authPath(); const tmp = `${p}.refresh.tmp`; await fs.writeFile(tmp, JSON.stringify(all, null, 2), { mode: 0o600 }); await fs.rename(tmp, p); } // RFC 9728 → RFC 8414: protected-resource metadata names the authorization // server; the AS metadata names the token endpoint. Our gateway serves both // (the AS doc is a facade in front of Pocket ID). async function discoverTokenEndpoint(serverUrl: string): Promise { const u = new URL(serverUrl); const candidates = [ `${u.origin}/.well-known/oauth-authorization-server${u.pathname}`, `${u.origin}/.well-known/oauth-authorization-server`, ]; for (const c of candidates) { try { const r = await fetch(c, { signal: AbortSignal.timeout(8000) }); if (!r.ok) continue; const meta: any = await r.json(); if (meta?.token_endpoint) return meta.token_endpoint as string; } catch { /* try the next candidate */ } } return null; } async function refreshEntry(entry: any): Promise { const tokens = entry?.tokens; const refreshToken = tokens?.refreshToken; const clientId = entry?.clientInfo?.clientId; const serverUrl = entry?.serverUrl; if (!serverUrl) return false; if (!tokens?.accessToken) { log( `no stored token for "${SERVER}" — the OAuth flow never completed. Run: opencode mcp auth ${SERVER}`, ); return false; } if (!refreshToken || !clientId) { log( `token for "${SERVER}" cannot be auto-renewed (no refresh token). Run: opencode mcp auth ${SERVER}`, ); return false; } const tokenEndpoint = await discoverTokenEndpoint(serverUrl); if (!tokenEndpoint) { log(`could not discover the token endpoint for ${serverUrl} — leaving the token alone`); return false; } const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: clientId, }); // Audience-bound (RFC 8707): the gateway validates aud == the /mcp URL, so // the refreshed token must be minted for the same resource. body.set("resource", serverUrl); let res: Response; try { res = await fetch(tokenEndpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, signal: AbortSignal.timeout(15000), }); } catch (e: any) { log(`refresh request failed (${e?.message ?? e}) — keeping the existing token`); return false; } if (!res.ok) { const text = await res.text().catch(() => ""); log( `refresh rejected (${res.status}) ${text.slice(0, 160)} — run: opencode mcp auth ${SERVER}`, ); return false; } const t: any = await res.json().catch(() => null); if (!t?.access_token) { log("refresh returned no access_token — keeping the existing token"); return false; } entry.tokens = { ...tokens, accessToken: t.access_token, // A rotated refresh token MUST replace the old one (Pocket ID rotates). refreshToken: t.refresh_token ?? refreshToken, expiresAt: t.expires_in ? Math.floor(Date.now() / 1000) + Number(t.expires_in) : tokens.expiresAt, scope: t.scope ?? tokens.scope, }; const mins = t.expires_in ? Math.round(Number(t.expires_in) / 60) : "?"; log(`refreshed the "${SERVER}" token (valid ~${mins}m)`); return true; } // Returns true when it wrote a new token. async function checkOnce(): Promise { const all = await readAuth(); const entry = all?.[SERVER]; if (!all || !entry) return false; const expiresAt = entry?.tokens?.expiresAt; // No expiry recorded → match opencode's behaviour and leave it alone. if (entry?.tokens?.accessToken && !expiresAt) return false; const secondsLeft = expiresAt ? expiresAt - Math.floor(Date.now() / 1000) : -1; if (entry?.tokens?.accessToken && secondsLeft > SKEW_SECONDS) return false; const changed = await refreshEntry(entry); if (changed) { // Re-read before writing: opencode may have rewritten the file meanwhile. const fresh = (await readAuth()) ?? all; fresh[SERVER] = entry; await writeAuth(fresh); } return changed; } // PORTED TO THE OPENCODE 2.x PLUGIN API (design D1/D2). // // V1 exported a bare async function returning a hook map, which V2 rejects outright // ("Plugin must export a default definition with an id and an effect or setup // function"). The module loaded, looked healthy, and never ran. // // Two shape changes beyond the entrypoint: // * `event` hook map -> ctx.event.subscribe(), an async iterable. // * module-level timer -> owned by setup() and cleared by the returned cleanup // function, together with an AbortController for the event stream. V1 parked the // interval in module scope with an `unref()`, so an unload left it running; V2 // gives a real unload hook, so the plugin disposes what it created. export default Plugin.define({ id: "ai-stack.agentmemory-refresh", async setup(ctx) { // Startup pass — the common case is "laptop slept, token died". await checkOnce().catch((e) => log(`startup check failed: ${e?.message ?? e}`)); const timer = setInterval(() => { checkOnce().catch((e) => log(`periodic check failed: ${e?.message ?? e}`)); }, CHECK_INTERVAL_MS); (timer as any)?.unref?.(); // A new session is the moment a stale token hurts most — check again. const controller = new AbortController(); void (async () => { try { for await (const event of ctx.event.subscribe({ signal: controller.signal })) { if (event.type === "session.created") await checkOnce().catch(() => {}); } } catch { // Aborted on unload, or the stream ended. Never throw into the session. } })(); return () => { clearInterval(timer); controller.abort(); }; }, });