diff --git a/README.md b/README.md index bf5ae1a..90a2cb0 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,38 @@ API key precedence is `SUPERMEMORY_CC_API_KEY`, project `apiKey`, then | `namespace` | Canonical project scope, preserving the exact old container-tag value. | | `repoContainerTag` | Override the unified project container tag. Checked before `SUPERMEMORY_REPO_TAG`. | +### Several organizations + +Each OAuth login is for one organization, picked on the consent screen. Log in +again to add another; the first one stays the default. + +```bash +node plugin/hooks/lib/auth.js login # add an organization +node plugin/hooks/lib/auth.js status # list them (no secrets) +node plugin/hooks/lib/auth.js use # change the default +node plugin/hooks/lib/auth.js logout --org # forget one organization +``` + +Which one a directory uses comes from the rules every Supermemory agent shares: +`SUPERMEMORY_ORG`, `projects` rules in `~/.supermemory/config.json`, the +repository's committed `.supermemory/config.json`, then the default. For +example, to send every repo of a GitHub org to your team: + +```json +{ + "projects": [ + { "match": { "repo": "github.com/acme/*" }, "organization": "" } + ] +} +``` + +If a directory belongs to an organization this plugin isn't logged in to, or +whose login the API rejected, memory is paused there: nothing is saved or +recalled, and no other organization is used instead. Session start opens the +consent screen so you can add it, at most every 12 hours per organization. +Logging in from inside a repository that has no rule yet binds it to the +organization you picked. + ### Advanced self-hosted compatibility Hosted Supermemory uses v5 without a version choice. Existing custom REST URLs diff --git a/package.json b/package.json index 2fdc7cf..3c2fd6c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "test": "node --test test/unit.mjs", "lint": "biome check .", "lint:fix": "biome check --write .", - "format": "biome format --write ." + "format": "biome format --write .", + "build:agent-config": "node scripts/build-agent-config.mjs" }, "engines": { "node": ">=18.0.0" diff --git a/plugin/hooks/lib/api.js b/plugin/hooks/lib/api.js index 84cf2a4..f02a1d2 100644 --- a/plugin/hooks/lib/api.js +++ b/plugin/hooks/lib/api.js @@ -1,3 +1,4 @@ +const { reportUnauthorized } = require('./auth'); const { resolveApiVersion } = require('./settings'); const AGENT_SUPPORTING_CONTEXT = `Shared coding-agent memory for one software repository. @@ -42,6 +43,7 @@ async function post( signal: AbortSignal.timeout(timeoutMs), }); + if (response.status === 401) await reportUnauthorized(apiKey); if (!response.ok) { const text = await response.text().catch(() => ''); throw Object.assign( @@ -69,10 +71,21 @@ function useV5(baseUrl, options, namespace) { return true; } +// A 401 tells the auth layer which OAuth org refused us, so routing can pause +// that org instead of retrying it forever. +function reportingFetch(apiKey) { + return async (...args) => { + const response = await fetch(...args); + if (response.status === 401) await reportUnauthorized(apiKey); + return response; + }; +} + function sdkClient(baseUrl, apiKey, options) { const { Supermemory } = require('../vendor/supermemory.cjs'); return new Supermemory({ apiKey, + fetch: reportingFetch(apiKey), baseUrl: baseUrl.replace(/\/+$/, ''), headers: { 'x-sm-source': 'claude-code' }, timeoutInSeconds: (options.timeoutMs ?? REQUEST_TIMEOUT_MS) / 1000, diff --git a/plugin/hooks/lib/auth.js b/plugin/hooks/lib/auth.js index 9ad6565..06be596 100644 --- a/plugin/hooks/lib/auth.js +++ b/plugin/hooks/lib/auth.js @@ -26,6 +26,19 @@ function renderAuthError(error) { : 'Supermemory could not finish signing you in. Return to Claude Code and run the login again.', ); } +const { + listOAuthOrgs, + newOAuthStore, + oauthTokensFor, + orgIdFromAccessToken, + upgradeOAuthStore, + withDefaultOAuthOrg, + withOAuthTokens, + withoutAnyOAuthTokens, + withoutOAuthOrg, + withRejectedOAuthOrg, +} = require('../vendor/agent-config.cjs'); + const SETTINGS_DIR = path.join(os.homedir(), '.supermemory-claude'); const CREDENTIALS_FILE = path.join(SETTINGS_DIR, 'credentials.json'); const CLIENT_FILE = path.join(SETTINGS_DIR, 'oauth-client.json'); @@ -35,6 +48,9 @@ const AUTH_TIMEOUT = 25000; const REFRESH_TIMEOUT = 3000; const REFRESH_REQUEST_TIMEOUT = 10000; const REFRESH_SKEW = 60000; +// A 401 on a token this young means the org itself refused us (removed from +// the org, grant revoked), not an expired token. +const FRESH_TOKEN_MS = 2 * 60 * 1000; const REDIRECT_URI = `http://127.0.0.1:${AUTH_PORT}/callback`; function openUrl(url) { @@ -62,9 +78,13 @@ function readJson(file) { } } +// An API-key file is returned as saved. An OAuth file is returned as the +// per-organization store (v2); a v1 single login is read under the org its +// token names and written back as v2 on the next save. function loadCredentials() { const data = readJson(CREDENTIALS_FILE); - return data?.apiKey || data?.type === 'oauth' ? data : null; + if (data?.apiKey) return data; + return upgradeOAuthStore(data)?.store ?? null; } function writeJson(file, data) { @@ -307,66 +327,68 @@ async function exchangeToken(client, parameters, signal, previous) { throw new Error('OAuth returned an invalid token response.'); } signal.throwIfAborted(); - saveCredentials({ - ...client, - type: 'oauth', + return { accessToken: data.access_token, refreshToken, expiresAt: Date.now() + data.expires_in * 1000, - }); - return data.access_token; + }; } function authRequired() { return Object.assign(new Error('AUTH_REQUIRED'), { code: 'AUTH_REQUIRED' }); } -// 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 = null; +// Rotates one organization's 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. +const pendingRefreshes = new Map(); +const pendingReports = new Set(); -function refreshCredentials(config) { - if (pendingRefresh) return pendingRefresh; - pendingRefresh = withAuthLock(async (remaining) => { - const credentials = loadCredentials(); - if (credentials?.type !== 'oauth' || !sameConfig(credentials, config)) +function refreshCredentials(config, orgId) { + const existing = pendingRefreshes.get(orgId); + if (existing) return existing; + const pending = withAuthLock(async (remaining) => { + const store = loadCredentials(); + if (store?.type !== 'oauth' || !sameConfig(store, config)) throw authRequired(); - if ( - credentials.accessToken && - credentials.expiresAt > Date.now() + REFRESH_SKEW - ) - return credentials.accessToken; - if (!credentials.refreshToken) throw authRequired(); + const tokens = oauthTokensFor(store, orgId); + if (!tokens) throw authRequired(); + if (tokens.accessToken && tokens.expiresAt > Date.now() + REFRESH_SKEW) + return tokens.accessToken; try { - return await exchangeToken( - credentials, - { - grant_type: 'refresh_token', - refresh_token: credentials.refreshToken, - }, + const next = await exchangeToken( + store, + { grant_type: 'refresh_token', refresh_token: tokens.refreshToken }, AbortSignal.timeout(remaining), - credentials, + tokens, ); + // The server keeps a refreshed token on its original org. If it ever + // didn't, file the tokens where they belong rather than lose them. + const grantedOrg = orgIdFromAccessToken(next.accessToken) || orgId; + saveCredentials(withOAuthTokens(store, grantedOrg, next)); + if (grantedOrg !== orgId) throw authRequired(); + return next.accessToken; } catch (error) { - if (error.code === 'invalid_grant' || error.code === 'invalid_client') { - const registration = { ...credentials }; - delete registration.accessToken; - delete registration.refreshToken; - saveCredentials(registration); - if (error.code === 'invalid_client') - fs.rmSync(CLIENT_FILE, { force: true }); + if (error.code === 'invalid_grant') { + // Only this organization's login is gone; the others keep working. + saveCredentials(withoutOAuthOrg(store, orgId)); + throw authRequired(); + } + if (error.code === 'invalid_client') { + saveCredentials(withoutAnyOAuthTokens(store)); + fs.rmSync(CLIENT_FILE, { force: true }); throw authRequired(); } throw error; } }, REFRESH_REQUEST_TIMEOUT).finally(() => { - pendingRefresh = null; + pendingRefreshes.delete(orgId); }); // Callers may stop waiting before the refresh settles. - pendingRefresh.catch(() => {}); - return pendingRefresh; + pending.catch(() => {}); + pendingRefreshes.set(orgId, pending); + return pending; } // Bounds only how long the caller waits; the refresh itself keeps running. @@ -385,42 +407,95 @@ function waitFor(promise, timeoutMs) { return Promise.race([promise, expired]).finally(() => clearTimeout(timer)); } -async function getAccessToken(config, timeoutMs = REFRESH_TIMEOUT) { - const saved = loadCredentials(); - if (saved?.type !== 'oauth' || !sameConfig(saved, config)) +async function getAccessToken(config, orgId, timeoutMs = REFRESH_TIMEOUT) { + const store = loadCredentials(); + if (store?.type !== 'oauth' || !sameConfig(store, config)) throw authRequired(); - if (saved.accessToken && saved.expiresAt > Date.now() + REFRESH_SKEW) - return saved.accessToken; - return waitFor(refreshCredentials(config), timeoutMs); + const tokens = oauthTokensFor(store, orgId); + if (!tokens) throw authRequired(); + if (tokens.accessToken && tokens.expiresAt > Date.now() + REFRESH_SKEW) + return tokens.accessToken; + return waitFor(refreshCredentials(config, orgId), timeoutMs); } -// Resolves once any in-flight refresh has saved or failed, so a process that -// exits explicitly can let the rotated tokens land first. +// The API answered 401 for this access token. A token refreshed moments ago +// means the organization refused us: mark it rejected so routing pauses +// instead of using another org. An older token is just expired; force the +// next call to refresh it. +function reportUnauthorized(accessToken) { + const report = withAuthLock(async () => { + const store = loadCredentials(); + if (store?.type !== 'oauth') return; + const entry = Object.entries(store.organizations).find( + ([, tokens]) => tokens.accessToken === accessToken, + ); + if (!entry) return; + const [orgId, tokens] = entry; + const savedAt = Date.parse(tokens.savedAt || '') || 0; + if (Date.now() - savedAt < FRESH_TOKEN_MS) { + saveCredentials(withRejectedOAuthOrg(store, orgId)); + return; + } + saveCredentials({ + ...store, + organizations: { + ...store.organizations, + [orgId]: { ...tokens, expiresAt: 0 }, + }, + }); + }, REFRESH_TIMEOUT).catch(() => {}); + pendingReports.add(report); + report.finally(() => pendingReports.delete(report)); + return report; +} + +// Resolves once in-flight refreshes and 401 reports have saved or failed, so a +// process that exits explicitly can let the rotated tokens land first. async function settleAuth() { - while (pendingRefresh) { - await pendingRefresh.catch(() => {}); + while (pendingRefreshes.size || pendingReports.size) { + await Promise.allSettled([...pendingRefreshes.values(), ...pendingReports]); } } -async function clearCredentials() { +async function revoke(store, refreshToken, signal) { + await oauthRequest(issuerEndpoint(store.revocationEndpoint, store.issuer), { + method: 'POST', + signal, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: store.clientId, + token: refreshToken, + token_type_hint: 'refresh_token', + }), + }); +} + +// Logout. With `orgId`, only that organization's login is revoked and +// forgotten; without it, every organization's. +async function clearCredentials({ orgId } = {}) { return withAuthLock(async (remaining) => { - const credentials = loadCredentials(); - if (credentials?.type === 'oauth' && credentials.refreshToken) { - await oauthRequest( - issuerEndpoint(credentials.revocationEndpoint, credentials.issuer), - { - method: 'POST', - signal: AbortSignal.timeout(remaining), - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_id: credentials.clientId, - token: credentials.refreshToken, - token_type_hint: 'refresh_token', - }), - }, - ); + const store = loadCredentials(); + if (store?.type !== 'oauth') { + if (!orgId) fs.rmSync(CREDENTIALS_FILE, { force: true }); + return; } - fs.rmSync(CREDENTIALS_FILE, { force: true }); + const signal = AbortSignal.timeout(remaining); + const targets = orgId ? [orgId] : Object.keys(store.organizations); + for (const target of targets) { + const tokens = oauthTokensFor(store, target); + if (tokens) await revoke(store, tokens.refreshToken, signal); + } + if (orgId) saveCredentials(withoutOAuthOrg(store, orgId)); + else fs.rmSync(CREDENTIALS_FILE, { force: true }); + }, REFRESH_TIMEOUT); +} + +function setDefaultOrganization(orgId) { + return withAuthLock(async () => { + const store = loadCredentials(); + const updated = store?.type === 'oauth' && withDefaultOAuthOrg(store, orgId); + if (!updated) throw new Error(`Not logged in to organization ${orgId}.`); + saveCredentials(updated); }, REFRESH_TIMEOUT); } @@ -430,13 +505,14 @@ async function startAuthFlow( ) { return withAuthLock(async (remaining) => { const current = loadCredentials(); - if ( - !force && - sameConfig(current, config) && - current.accessToken && - current.expiresAt > Date.now() + REFRESH_SKEW - ) - return current.accessToken; + const fresh = + current?.type === 'oauth' && sameConfig(current, config) + ? listOAuthOrgs(current) + .map((org) => ({ orgId: org.orgId, ...oauthTokensFor(current, org.orgId) })) + .find((tokens) => tokens.expiresAt > Date.now() + REFRESH_SKEW) + : null; + if (!force && fresh) + return { accessToken: fresh.accessToken, orgId: fresh.orgId }; const controller = new AbortController(); const timer = setTimeout( () => controller.abort(new Error('AUTH_TIMEOUT')), @@ -509,7 +585,7 @@ async function startAuthFlow( }); const code = url.searchParams.get('code'); if (!code) throw new Error('Authorization code missing.'); - const token = await exchangeToken( + const tokens = await exchangeToken( client, { grant_type: 'authorization_code', @@ -519,9 +595,25 @@ async function startAuthFlow( }, controller.signal, ); + // Every login is for one organization, picked on the consent + // screen. It is added next to the others; the first becomes the + // default. + const orgId = orgIdFromAccessToken(tokens.accessToken); + if (!orgId) + throw new Error( + 'Supermemory did not say which organization this login is for.', + ); + const saved = loadCredentials(); + const base = + saved?.type === 'oauth' && sameConfig(saved, config) + ? { ...saved, ...client } + : newOAuthStore(client); + saveCredentials(withOAuthTokens(base, orgId, tokens)); res .writeHead(200, { 'Content-Type': 'text/html' }) - .end(authSuccessHtml, () => resolve(token)); + .end(authSuccessHtml, () => + resolve({ accessToken: tokens.accessToken, orgId }), + ); } catch (error) { res .writeHead(400, { 'Content-Type': 'text/html' }) @@ -564,48 +656,73 @@ module.exports = { clearCredentials, getOAuthConfig, getAccessToken, + reportUnauthorized, + setDefaultOrganization, settleAuth, startAuthFlow, openUrl, }; -// Explicit login migrates a saved API key. Logout revokes this installation's -// refresh token. Status never prints credentials. +// Explicit login adds an organization (and migrates a saved API key). Logout +// revokes every organization's refresh token, or one with `--org `. +// Status never prints credentials. if (require.main === module) { (async () => { const { getBaseUrl, getMcpUrl } = require('./settings'); - const command = process.argv[2]; + const [command, ...rest] = process.argv.slice(2); + const flag = (name) => { + const index = rest.indexOf(name); + return index >= 0 ? rest[index + 1] : undefined; + }; if (command === 'login') { - await startAuthFlow(getOAuthConfig(getBaseUrl(), getMcpUrl()), { - force: true, - timeoutMs: 5 * 60 * 1000, - }); - console.log('Supermemory connected through OAuth.'); + const { orgId } = await startAuthFlow( + getOAuthConfig(getBaseUrl(), getMcpUrl()), + { force: true, timeoutMs: 5 * 60 * 1000 }, + ); + console.log(`Supermemory connected through OAuth (organization ${orgId}).`); } else if (command === 'logout') { - await clearCredentials(); - console.log('Supermemory disconnected.'); + const orgId = flag('--org'); + await clearCredentials({ orgId }); + console.log( + orgId + ? `Supermemory disconnected from organization ${orgId}.` + : 'Supermemory disconnected.', + ); + } else if (command === 'use') { + if (!rest[0]) throw new Error('Usage: node auth.js use '); + await setDefaultOrganization(rest[0]); + console.log(`Default organization: ${rest[0]}.`); } else if (command === 'status') { const credentials = loadCredentials(); + const oauth = credentials?.type === 'oauth'; console.log( JSON.stringify( { - authentication: - credentials?.type === 'oauth' - ? 'oauth' - : credentials?.apiKey - ? 'api_key' - : 'none', + authentication: oauth + ? 'oauth' + : credentials?.apiKey + ? 'api_key' + : 'none', clientId: credentials?.clientId, - expiresAt: credentials?.expiresAt, + organizations: oauth + ? listOAuthOrgs(credentials).map((org) => ({ + id: org.orgId, + default: org.isDefault, + rejected: org.rejected, + expiresAt: credentials.organizations[org.orgId].expiresAt, + })) + : undefined, reauthenticationRequired: - credentials?.type === 'oauth' && !credentials.refreshToken, + oauth && listOAuthOrgs(credentials).length === 0, }, null, 2, ), ); } else { - throw new Error('Usage: node auth.js '); + throw new Error( + 'Usage: node auth.js ]|use |status>', + ); } })().catch((error) => { console.error(error.message); diff --git a/plugin/hooks/lib/settings.js b/plugin/hooks/lib/settings.js index c023676..fbe1a6c 100644 --- a/plugin/hooks/lib/settings.js +++ b/plugin/hooks/lib/settings.js @@ -1,7 +1,18 @@ const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { loadCredentials, getAccessToken, getOAuthConfig } = require('./auth'); +const { + loadCredentials, + getAccessToken, + getOAuthConfig, + startAuthFlow, +} = require('./auth'); +const { + appendProjectRule, + listOAuthOrgs, + planRepoBinding, + routeOrganization, +} = require('../vendor/agent-config.cjs'); const { loadProjectConfig } = require('./project-config'); const BASE_URL = 'https://api.supermemory.ai'; @@ -53,6 +64,54 @@ function loadSettings() { return settings; } +const LOGIN_HINT = `node "${path.join(__dirname, 'auth.js')}" login`; + +function authRequired() { + return Object.assign(new Error('AUTH_REQUIRED'), { code: 'AUTH_REQUIRED' }); +} + +// Which of this plugin's OAuth logins the directory uses, from the shared +// rules in ~/.supermemory/config.json and the repository's team file. +function routeDirectory(cwd, credentials = loadCredentials()) { + return routeOrganization({ + cwd: cwd || process.cwd(), + held: credentials?.type === 'oauth' ? listOAuthOrgs(credentials) : [], + loginHint: LOGIN_HINT, + }); +} + +// The directory belongs to an org this plugin can't use (not logged in to it, +// or the API rejected it). Nothing is saved or recalled, and no other org is +// used in its place. +function orgPaused(route) { + return Object.assign( + new Error(route.warnings[0] || 'Supermemory is paused in this directory.'), + { code: 'ORG_PAUSED', route }, + ); +} + +const ORG_PROMPTS_FILE = path.join(SETTINGS_DIR, 'org-login-prompts.json'); +const ORG_PROMPT_INTERVAL_MS = 12 * 60 * 60 * 1000; + +// A paused directory opens the consent screen at most every 12 hours per org, +// so a repo for an org you left doesn't open a browser every session. +function claimOrgLoginPrompt(route) { + const key = `${route.source}:${route.orgId || route.detail || ''}`; + let prompts = {}; + try { + prompts = JSON.parse(fs.readFileSync(ORG_PROMPTS_FILE, 'utf-8')) || {}; + } catch {} + if (Date.now() - (prompts[key] || 0) < ORG_PROMPT_INTERVAL_MS) return false; + try { + fs.mkdirSync(SETTINGS_DIR, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + ORG_PROMPTS_FILE, + JSON.stringify({ ...prompts, [key]: Date.now() }), + ); + } catch {} + return true; +} + async function getAuthToken(cwd, projectConfig, timeoutMs) { if (process.env.SUPERMEMORY_CC_API_KEY) return process.env.SUPERMEMORY_CC_API_KEY; @@ -62,17 +121,55 @@ async function getAuthToken(cwd, projectConfig, timeoutMs) { const credentials = loadCredentials(); if (credentials?.type === 'oauth') { + const route = routeDirectory(cwd, credentials); + if (route.paused) throw orgPaused(route); + if (!route.orgId) throw authRequired(); return getAccessToken( getOAuthConfig( getBaseUrl(cwd, projectConfig), getMcpUrl(cwd, projectConfig), ), + route.orgId, timeoutMs, ); } if (credentials?.apiKey) return credentials.apiKey; - throw Object.assign(new Error('AUTH_REQUIRED'), { code: 'AUTH_REQUIRED' }); + throw authRequired(); +} + +// Log in from this directory and return the token it should use. A login adds +// the organization picked on the consent screen. If the repository wasn't +// routed yet and would have used a different org, it is bound to the new one +// so a later default change can't move its memories. Returns the notice to +// show, if any. +async function loginForDirectory(cwd, projectConfig, timeoutMs) { + const config = getOAuthConfig( + getBaseUrl(cwd, projectConfig), + getMcpUrl(cwd, projectConfig), + ); + const before = routeDirectory(cwd); + const { orgId } = await startAuthFlow(config, { force: true, timeoutMs }); + const notices = []; + const rule = planRepoBinding(cwd, before, orgId); + if (rule && appendProjectRule(rule)) { + notices.push( + `This repository (${rule.match.repo}) now uses organization ${orgId}. Undo by removing the rule from ~/.supermemory/config.json.`, + ); + } + const route = routeDirectory(cwd); + if (route.paused || !route.orgId) { + throw orgPaused({ + ...route, + warnings: [ + `You logged in to organization ${orgId}, but this directory belongs to ${route.orgId || route.detail || 'another organization'}. Log in again and pick that organization.`, + ], + }); + } + return { + apiKey: await getAccessToken(config, route.orgId, timeoutMs), + notices, + }; } function normalizeBaseUrl(baseUrl) { @@ -209,6 +306,9 @@ module.exports = { DEFAULT_SETTINGS, loadSettings, getAuthToken, + loginForDirectory, + routeDirectory, + claimOrgLoginPrompt, getBaseUrl, getMcpUrl, getApiVersion, diff --git a/plugin/hooks/mcp-proxy.js b/plugin/hooks/mcp-proxy.js index a286875..9480934 100644 --- a/plugin/hooks/mcp-proxy.js +++ b/plugin/hooks/mcp-proxy.js @@ -1,7 +1,7 @@ #!/usr/bin/env node const readline = require('node:readline'); const { getContainerTag } = require('./lib/container-tag'); -const { settleAuth } = require('./lib/auth'); +const { reportUnauthorized, settleAuth } = require('./lib/auth'); const { getAuthToken, getMcpUrl } = require('./lib/settings'); const REQUEST_TIMEOUT_MS = 30000; @@ -84,6 +84,7 @@ async function forward(message, apiKey) { if (newSessionId) sessionId = newSessionId; if (response.status === 202) return; + if (response.status === 401) await reportUnauthorized(apiKey); if (!response.ok) { const text = await response.text().catch(() => ''); sendError( @@ -134,6 +135,11 @@ async function main() { injectRepoContainerTag(message, repoContainerTag); await forward(message, apiKey); } catch (err) { + if (err.code === 'ORG_PAUSED') { + sessionId = null; + sendError(message.id, -32001, `Supermemory is paused here: ${err.message}`); + return; + } if (err.code === 'AUTH_REQUIRED') { sessionId = null; sendError( diff --git a/plugin/hooks/session-start.js b/plugin/hooks/session-start.js index 47359cb..bffaa39 100644 --- a/plugin/hooks/session-start.js +++ b/plugin/hooks/session-start.js @@ -9,13 +9,14 @@ const { loadSettings, getAuthToken, getBaseUrl, - getMcpUrl, getApiVersion, debugLog, + loginForDirectory, + routeDirectory, + claimOrgLoginPrompt, } = require('./lib/settings'); const { BRAND, MARK, bold, gray } = require('./lib/colors'); const { readStdin, writeOutput } = require('./lib/stdin'); -const { startAuthFlow, getOAuthConfig } = require('./lib/auth'); const { getUserFriendlyError } = require('./lib/error-helpers'); const { LAST_SESSION_FILE } = require('./lib/last-session'); const { @@ -179,30 +180,64 @@ async function main() { const authStartedAt = Date.now(); let apiKey; + const routeNotices = []; try { apiKey = await getAuthToken(cwd, projectConfig); + routeNotices.push(...routeDirectory(cwd).notices); } catch (error) { - if (error.code !== 'AUTH_REQUIRED') throw error; + // Not logged in, or not logged in to the org this directory belongs to: + // open the consent screen so the user can add it. + if (error.code !== 'AUTH_REQUIRED' && error.code !== 'ORG_PAUSED') + throw error; + if (error.code === 'ORG_PAUSED' && !claimOrgLoginPrompt(error.route)) { + writeState(sessionId, 'context', { + status: 'error', + memoryItemsLoaded: 0, + }); + output( + ` +${error.message} +For an explicit login, run: node "${path.join(__dirname, 'lib/auth.js')}" login +`, + [`${MARK} ${error.message}`, captureFailureNotice()?.systemMessage], + ); + return; + } try { - apiKey = await startAuthFlow( - getOAuthConfig( - getBaseUrl(cwd, projectConfig), - getMcpUrl(cwd, projectConfig), - ), - { timeoutMs: Math.max(1, 25000 - (Date.now() - authStartedAt)) }, + const login = await loginForDirectory( + cwd, + projectConfig, + Math.max(1, 25000 - (Date.now() - authStartedAt)), ); + apiKey = login.apiKey; + routeNotices.push(...login.notices); } catch (authErr) { writeState(sessionId, 'context', { status: 'error', memoryItemsLoaded: 0, }); + const reason = + authErr.code === 'ORG_PAUSED' + ? authErr.message + : authErr.message === 'AUTH_TIMEOUT' + ? 'Authentication timed out. Start a new session to retry login.' + : 'Authentication failed. Start a new session to retry login.'; output( ` -${authErr.message === 'AUTH_TIMEOUT' ? 'Authentication timed out. Start a new session to retry login.' : 'Authentication failed. Start a new session to retry login.'} +${reason} For an explicit login, run: node "${path.join(__dirname, 'lib/auth.js')}" login Or set the SUPERMEMORY_CC_API_KEY environment variable. `, - [captureFailureNotice()?.systemMessage], + [ + // A paused directory stays paused when its login fails or times + // out; say why, not just that login failed. + authErr.code === 'ORG_PAUSED' + ? `${MARK} ${authErr.message}` + : error.code === 'ORG_PAUSED' + ? `${MARK} ${error.message}` + : null, + captureFailureNotice()?.systemMessage, + ], ); return; } @@ -275,6 +310,7 @@ Memories will be saved as you work. `)), [ captureNotice?.systemMessage, + ...routeNotices.map((notice) => `${MARK} ${notice}`), [memoryNotice, welcomeBackNotice(namespace)] .filter(Boolean) .join(gray(' ยท ')) || null, diff --git a/plugin/hooks/status.js b/plugin/hooks/status.js index ad33d86..b4168a7 100644 --- a/plugin/hooks/status.js +++ b/plugin/hooks/status.js @@ -2,7 +2,12 @@ const { getProfile } = require('./lib/api'); const { getNamespace } = require('./lib/container-tag'); const { loadProjectConfig } = require('./lib/project-config'); const { loadCredentials, settleAuth } = require('./lib/auth'); -const { getAuthToken, getBaseUrl, getApiVersion } = require('./lib/settings'); +const { + getAuthToken, + getBaseUrl, + getApiVersion, + routeDirectory, +} = require('./lib/settings'); const { getUserFriendlyError } = require('./lib/error-helpers'); async function main() { @@ -23,10 +28,17 @@ async function main() { let apiKey; try { apiKey = await getAuthToken(cwd, projectConfig, 8000); - } catch { + } catch (error) { console.log( JSON.stringify({ authenticated: false, + ...(error.code === 'ORG_PAUSED' + ? { + paused: true, + organization: error.route?.orgId ?? null, + reason: error.message, + } + : {}), namespace, apiOrigin, apiVersion, @@ -34,6 +46,8 @@ async function main() { ); return; } + const organization = + keySource === 'oauth' ? routeDirectory(cwd).orgId : undefined; try { await getProfile(baseUrl, apiKey, namespace, undefined, { apiVersion, @@ -43,6 +57,7 @@ async function main() { JSON.stringify({ authenticated: true, keySource, + organization, namespace, apiOrigin, apiVersion, @@ -55,6 +70,7 @@ async function main() { JSON.stringify({ authenticated: httpStatus === 401 ? false : null, keySource, + organization, namespace: err?.code === 'INVALID_NAMESPACE' ? null : namespace, apiOrigin, apiVersion, diff --git a/plugin/hooks/vendor/agent-config.cjs b/plugin/hooks/vendor/agent-config.cjs new file mode 100644 index 0000000..2b5c77c --- /dev/null +++ b/plugin/hooks/vendor/agent-config.cjs @@ -0,0 +1,813 @@ +// Generated from supermemoryai/mono packages/agent-config @ 760d32086. Do not edit; run npm run build:agent-config. +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// ../mono-agent-config-oauth/packages/agent-config/index.ts +var index_exports = {}; +__export(index_exports, { + AGENT_IDS: () => AGENT_IDS, + appendProjectRule: () => appendProjectRule, + clearRepoCache: () => clearRepoCache, + describePluginLogin: () => describePluginLogin, + findHeldKey: () => findHeldKey, + getRepoContext: () => getRepoContext, + getUserDir: () => getUserDir, + listHeldKeys: () => listHeldKeys, + listOAuthOrgs: () => listOAuthOrgs, + newOAuthStore: () => newOAuthStore, + normalizeGitRemote: () => normalizeGitRemote, + oauthTokensFor: () => oauthTokensFor, + orgIdFromAccessToken: () => orgIdFromAccessToken, + orgIdFromApiKey: () => orgIdFromApiKey, + planRepoBinding: () => planRepoBinding, + pluginCredentialsPath: () => pluginCredentialsPath, + readCredentialsFile: () => readCredentialsFile, + readPluginApiKey: () => readPluginApiKey, + readRoutingConfig: () => readRoutingConfig, + readTeamMarker: () => readTeamMarker, + resolveAgentConfig: () => resolveAgentConfig, + routeOrganization: () => routeOrganization, + upgradeOAuthStore: () => upgradeOAuthStore, + withDefaultOAuthOrg: () => withDefaultOAuthOrg, + withDefaultOrganization: () => withDefaultOrganization, + withOAuthTokens: () => withOAuthTokens, + withOrganization: () => withOrganization, + withRejectedKey: () => withRejectedKey, + withRejectedOAuthOrg: () => withRejectedOAuthOrg, + withoutAnyOAuthTokens: () => withoutAnyOAuthTokens, + withoutOAuthOrg: () => withoutOAuthOrg, + withoutOrganization: () => withoutOrganization +}); +module.exports = __toCommonJS(index_exports); + +// ../mono-agent-config-oauth/packages/agent-config/credentials.ts +function orgIdFromApiKey(apiKey) { + if (!apiKey.startsWith("sm_")) return void 0; + const parts = apiKey.split("_"); + return parts.length >= 3 && parts[1] ? parts[1] : void 0; +} +function listHeldKeys(credentials) { + if (!credentials) return []; + const defaultOrgId = credentials.orgId ?? orgIdFromApiKey(credentials.apiKey) ?? null; + const keys = /* @__PURE__ */ new Map(); + for (const [orgId, entry] of Object.entries( + credentials.organizations ?? {} + )) { + if (!entry?.apiKey) continue; + keys.set(orgId, { + orgId, + apiKey: entry.apiKey, + name: entry.name, + slug: entry.slug, + isDefault: orgId === defaultOrgId, + rejected: !!entry.rejectedAt + }); + } + if (credentials.apiKey && defaultOrgId) { + const existing = keys.get(defaultOrgId); + const stored = credentials.organizations?.[defaultOrgId]; + keys.set(defaultOrgId, { + orgId: defaultOrgId, + apiKey: credentials.apiKey, + name: existing?.name, + slug: existing?.slug, + isDefault: true, + rejected: !!stored?.rejectedAt && stored.apiKey === credentials.apiKey + }); + } + return [...keys.values()]; +} +function withOrganization(credentials, entry, makeDefault) { + const { orgId, ...stored } = entry; + const organizations = { ...credentials?.organizations ?? {} }; + if (credentials?.apiKey) { + const currentOrgId = credentials.orgId ?? orgIdFromApiKey(credentials.apiKey); + if (currentOrgId && !organizations[currentOrgId]) { + organizations[currentOrgId] = { + apiKey: credentials.apiKey, + method: credentials.method, + createdAt: credentials.createdAt + }; + } + } + organizations[orgId] = stored; + const isCurrentDefault = !!credentials?.apiKey && (credentials.orgId ?? orgIdFromApiKey(credentials.apiKey)) === orgId; + if (makeDefault || isCurrentDefault || !credentials?.apiKey) { + return { + apiKey: stored.apiKey, + method: stored.method, + orgId, + createdAt: stored.createdAt, + organizations + }; + } + return { ...credentials, organizations }; +} +function withoutOrganization(credentials, orgId) { + const organizations = { ...credentials.organizations ?? {} }; + delete organizations[orgId]; + const defaultOrgId = credentials.orgId ?? orgIdFromApiKey(credentials.apiKey); + if (defaultOrgId !== orgId) return { ...credentials, organizations }; + const [nextOrgId, next] = Object.entries(organizations)[0] ?? []; + if (!nextOrgId || !next) return null; + return { + apiKey: next.apiKey, + method: next.method, + orgId: nextOrgId, + createdAt: next.createdAt, + organizations + }; +} +function withDefaultOrganization(credentials, orgId) { + const target = listHeldKeys(credentials).find((key) => key.orgId === orgId); + if (!target) return null; + const stored = credentials.organizations?.[orgId]; + return withOrganization( + credentials, + { + orgId, + apiKey: stored?.apiKey ?? target.apiKey, + method: stored?.method ?? credentials.method, + createdAt: stored?.createdAt ?? credentials.createdAt, + name: stored?.name, + slug: stored?.slug + }, + true + ); +} +function withRejectedKey(credentials, apiKey, at = (/* @__PURE__ */ new Date()).toISOString()) { + const target = listHeldKeys(credentials).find((key) => key.apiKey === apiKey); + if (!target) return null; + const stored = credentials.organizations?.[target.orgId]; + return { + ...credentials, + organizations: { + ...credentials.organizations ?? {}, + [target.orgId]: { + apiKey, + method: stored?.method ?? credentials.method, + createdAt: stored?.createdAt ?? credentials.createdAt, + ...stored?.name ? { name: stored.name } : {}, + ...stored?.slug ? { slug: stored.slug } : {}, + rejectedAt: at + } + } + }; +} + +// ../mono-agent-config-oauth/packages/agent-config/files.ts +var import_node_crypto = require("node:crypto"); +var import_node_fs = require("node:fs"); +var import_node_os = require("node:os"); +var import_node_path2 = require("node:path"); + +// ../mono-agent-config-oauth/packages/agent-config/git.ts +var import_node_child_process = require("node:child_process"); +var import_node_path = require("node:path"); +var GIT_TIMEOUT_MS = 2e3; +var repoCache = /* @__PURE__ */ new Map(); +function git(cwd, args) { + try { + const out = (0, import_node_child_process.execFileSync)("git", args, { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: GIT_TIMEOUT_MS + }).trim(); + return out || null; + } catch { + return null; + } +} +function normalizeGitRemote(remoteUrl) { + const raw = remoteUrl.trim(); + if (!raw) return null; + let normalized; + if (/^[a-z][a-z\d+.-]*:\/\//i.test(raw)) { + try { + const parsed = new URL(raw); + normalized = parsed.protocol === "file:" ? `file:${decodeURIComponent(parsed.pathname)}` : `${parsed.hostname.toLowerCase()}${parsed.port ? `:${parsed.port}` : ""}/${parsed.pathname.replace(/^\/+/, "")}`; + } catch { + normalized = raw; + } + } else { + const scpStyle = raw.match(/^(?:[^@/]+@)?([^:]+):(.+)$/); + normalized = scpStyle?.[1] && scpStyle[2] ? `${scpStyle[1].toLowerCase()}/${scpStyle[2]}` : `file:${(0, import_node_path.resolve)(raw)}`; + } + return normalized.replace(/[?#].*$/, "").replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/{2,}/g, "/").toLowerCase(); +} +function getRepoContext(cwd) { + const key = (0, import_node_path.resolve)(cwd); + if (repoCache.has(key)) return repoCache.get(key) ?? null; + const toplevel = git(key, ["rev-parse", "--show-toplevel"]); + if (!toplevel) { + repoCache.set(key, null); + return null; + } + const remoteUrl = git(key, ["remote", "get-url", "origin"]); + const context = { + root: toplevel, + remote: remoteUrl ? normalizeGitRemote(remoteUrl) : null + }; + repoCache.set(key, context); + return context; +} +function clearRepoCache() { + repoCache.clear(); +} + +// ../mono-agent-config-oauth/packages/agent-config/oauth.ts +var TOKEN_FIELDS = ["accessToken", "refreshToken", "expiresAt"]; +function decodeJwtPayload(token) { + const part = token.split(".")[1]; + if (!part) return null; + try { + const json = Buffer.from( + part.replace(/-/g, "+").replace(/_/g, "/"), + "base64" + ).toString("utf-8"); + const payload = JSON.parse(json); + return payload && typeof payload === "object" ? payload : null; + } catch { + return null; + } +} +function orgIdFromAccessToken(token) { + const claim = decodeJwtPayload(token)?.organization_id; + return typeof claim === "string" && claim ? claim : void 0; +} +function upgradeOAuthStore(data) { + if (!data || typeof data !== "object") return null; + const raw = data; + if (raw.type !== "oauth") return null; + if (raw.version === 2) { + const organizations = raw.organizations && typeof raw.organizations === "object" ? raw.organizations : {}; + return { + store: { ...raw, type: "oauth", version: 2, organizations }, + migrated: false, + droppedTokens: false + }; + } + const { accessToken, refreshToken, expiresAt, savedAt, ...registration } = raw; + const store = { + ...registration, + type: "oauth", + version: 2, + organizations: {} + }; + if (typeof refreshToken !== "string" || !refreshToken) { + return { store, migrated: true, droppedTokens: false }; + } + const orgId = typeof accessToken === "string" ? orgIdFromAccessToken(accessToken) : void 0; + if (!orgId) return { store, migrated: true, droppedTokens: true }; + store.organizations[orgId] = { + accessToken: typeof accessToken === "string" ? accessToken : "", + refreshToken, + expiresAt: typeof expiresAt === "number" ? expiresAt : 0, + ...typeof savedAt === "string" ? { savedAt } : {} + }; + store.defaultOrganization = orgId; + return { store, migrated: true, droppedTokens: false }; +} +function newOAuthStore(registration) { + const clean = { ...registration }; + for (const field of TOKEN_FIELDS) delete clean[field]; + return { ...clean, type: "oauth", version: 2, organizations: {} }; +} +function listOAuthOrgs(store) { + if (!store) return []; + return Object.entries(store.organizations).filter(([, tokens]) => !!tokens?.refreshToken).map(([orgId, tokens]) => ({ + orgId, + ...tokens.name ? { name: tokens.name } : {}, + ...tokens.slug ? { slug: tokens.slug } : {}, + isDefault: orgId === store.defaultOrganization, + rejected: !!tokens.rejectedAt + })); +} +function oauthTokensFor(store, orgId) { + const tokens = store?.organizations[orgId]; + return tokens?.refreshToken ? tokens : null; +} +function withOAuthTokens(store, orgId, tokens, options = {}) { + const previous = store.organizations[orgId]; + const { rejectedAt: _cleared, ...kept } = previous ?? {}; + const entry = { + ...kept, + ...tokens, + savedAt: (/* @__PURE__ */ new Date()).toISOString() + }; + const hasDefault = !!store.defaultOrganization && !!store.organizations[store.defaultOrganization]; + return { + ...store, + organizations: { ...store.organizations, [orgId]: entry }, + defaultOrganization: options.makeDefault || !hasDefault ? orgId : store.defaultOrganization + }; +} +function withRejectedOAuthOrg(store, orgId, at = (/* @__PURE__ */ new Date()).toISOString()) { + const tokens = store.organizations[orgId]; + if (!tokens) return store; + return { + ...store, + organizations: { + ...store.organizations, + [orgId]: { ...tokens, rejectedAt: at } + } + }; +} +function withoutOAuthOrg(store, orgId) { + const organizations = { ...store.organizations }; + delete organizations[orgId]; + const next = { ...store, organizations }; + if (store.defaultOrganization === orgId) { + const [promoted] = Object.keys(organizations); + if (promoted) next.defaultOrganization = promoted; + else delete next.defaultOrganization; + } + return next; +} +function withDefaultOAuthOrg(store, orgId) { + if (!oauthTokensFor(store, orgId)) return null; + return { ...store, defaultOrganization: orgId }; +} +function withoutAnyOAuthTokens(store) { + const next = { ...store, organizations: {} }; + delete next.defaultOrganization; + return next; +} + +// ../mono-agent-config-oauth/packages/agent-config/files.ts +var AGENT_IDS = [ + "claude", + "cursor", + "opencode", + "codex", + "amp", + "muse" +]; +function getUserDir(home = (0, import_node_os.homedir)()) { + return (0, import_node_path2.join)(home, ".supermemory"); +} +function pluginCredentialsPath(agent, home = (0, import_node_os.homedir)()) { + switch (agent) { + case "claude": + return (0, import_node_path2.join)(home, ".supermemory-claude", "credentials.json"); + case "cursor": + return (0, import_node_path2.join)(home, ".supermemory-cursor", "credentials.json"); + case "opencode": + return (0, import_node_path2.join)(home, ".supermemory-opencode", "credentials.json"); + case "codex": + return (0, import_node_path2.join)(home, ".codex", "supermemory", "credentials.json"); + case "amp": + return (0, import_node_path2.join)(home, ".amp", "supermemory", "credentials.json"); + case "muse": + return (0, import_node_path2.join)(home, ".supermemory-muse", "credentials.json"); + } +} +function readPluginApiKey(agent, home) { + const stored = readJson( + pluginCredentialsPath(agent, home) + ); + return typeof stored?.apiKey === "string" && stored.apiKey ? stored.apiKey : null; +} +function readJson(path) { + try { + if (!(0, import_node_fs.existsSync)(path)) return null; + return JSON.parse((0, import_node_fs.readFileSync)(path, "utf-8")); + } catch { + return null; + } +} +function describePluginLogin(agent, home) { + const path = pluginCredentialsPath(agent, home); + const stored = readJson(path); + const login = { + agent, + path, + kind: "none", + orgs: [], + unattributed: false, + routes: false + }; + if (!stored) return login; + if (typeof stored.apiKey === "string" && stored.apiKey) { + const orgId = orgIdFromApiKey(stored.apiKey); + return { + ...login, + kind: "api-key", + orgs: orgId ? [{ orgId, isDefault: true, rejected: false }] : [], + unattributed: !orgId + }; + } + const upgraded = upgradeOAuthStore(stored); + if (!upgraded) return login; + return { + ...login, + kind: "oauth", + orgs: listOAuthOrgs(upgraded.store), + unattributed: upgraded.droppedTokens, + routes: !upgraded.migrated + }; +} +function writeJsonAtomic(path, data, mode) { + (0, import_node_fs.mkdirSync)((0, import_node_path2.dirname)(path), { recursive: true }); + const temporary = `${path}.${(0, import_node_crypto.randomUUID)()}.tmp`; + try { + (0, import_node_fs.writeFileSync)(temporary, `${JSON.stringify(data, null, 2)} +`, { + mode, + flag: "wx" + }); + (0, import_node_fs.renameSync)(temporary, path); + } finally { + (0, import_node_fs.rmSync)(temporary, { force: true }); + } +} +function appendProjectRule(rule, home) { + const path = (0, import_node_path2.join)(getUserDir(home), "config.json"); + const current = readJson(path) ?? {}; + const projects = Array.isArray(current.projects) ? current.projects : []; + const same = (other) => other?.organization === rule.organization && other?.match?.repo === rule.match.repo && other?.match?.path === rule.match.path; + if (projects.some(same)) return false; + writeJsonAtomic(path, { ...current, projects: [...projects, rule] }, 420); + return true; +} +function readCredentialsFile(home) { + const credentials = readJson( + (0, import_node_path2.join)(getUserDir(home), "credentials.json") + ); + return credentials?.apiKey || credentials?.organizations ? credentials : null; +} +function readRoutingConfig(home) { + return readJson((0, import_node_path2.join)(getUserDir(home), "config.json")) ?? {}; +} +function planRepoBinding(cwd, before, orgId) { + const remote = getRepoContext(cwd)?.remote; + if (!remote || remote.startsWith("file:")) return null; + if (!["default", "fallback", "none"].includes(before.source)) return null; + if (before.orgId === orgId) return null; + return { match: { repo: remote }, organization: orgId }; +} +function readTeamMarker(cwd) { + let dir = (0, import_node_path2.resolve)(cwd); + while (true) { + const path = (0, import_node_path2.join)(dir, ".supermemory", "config.json"); + if ((0, import_node_fs.existsSync)(path)) { + const marker = readJson(path); + return marker ? { ...marker, path } : null; + } + if ((0, import_node_fs.existsSync)((0, import_node_path2.join)(dir, ".git"))) return null; + const parent = (0, import_node_path2.dirname)(dir); + if (parent === dir) return null; + dir = parent; + } +} + +// ../mono-agent-config-oauth/packages/agent-config/resolve.ts +var import_node_os3 = require("node:os"); + +// ../mono-agent-config-oauth/packages/agent-config/route.ts +var import_node_fs2 = require("node:fs"); +var import_node_os2 = require("node:os"); +var import_node_path3 = require("node:path"); +var TIER_REPO_AND_PATH = 0; +var TIER_REPO = 1; +var TIER_PATH = 2; +function expandHome(path, home) { + if (path === "~") return home; + if (path.startsWith("~/")) return (0, import_node_path3.join)(home, path.slice(2)); + return path; +} +function realpath(path) { + try { + return import_node_fs2.realpathSync.native(path); + } catch { + return (0, import_node_path3.resolve)(path); + } +} +function isWithin(child, parent) { + if (child === parent) return true; + const prefix = parent.endsWith(import_node_path3.sep) ? parent : `${parent}${import_node_path3.sep}`; + return child.startsWith(prefix); +} +function repoPatternMatches(pattern, remote) { + const want = pattern.trim().toLowerCase().replace(/\.git$/, "").replace(/\/+$/, "").split("/"); + const have = remote.split("/"); + if (want.length !== have.length) return false; + return want.every((segment, i) => segment === "*" || segment === have[i]); +} +function rankRules(rules, cwd, repo, home) { + const ranked = []; + rules.forEach((rule, index) => { + const match = rule?.match; + if (!match || typeof rule.organization !== "string") return; + if (!match.repo && !match.path) return; + if (match.repo) { + if (!repo?.remote || !repoPatternMatches(match.repo, repo.remote)) return; + } + let pathDepth = 0; + if (match.path) { + const expanded = expandHome(match.path, home); + if (!(0, import_node_path3.isAbsolute)(expanded)) return; + const rulePath = realpath(expanded); + if (!isWithin(cwd, rulePath)) return; + pathDepth = rulePath.split(import_node_path3.sep).filter(Boolean).length; + } + const exactSegments = match.repo ? match.repo.split("/").filter((s) => s !== "*").length : 0; + const tier = match.repo ? match.path ? TIER_REPO_AND_PATH : TIER_REPO : TIER_PATH; + ranked.push({ + rule, + index, + tier, + weight: match.path ? pathDepth : exactSegments + }); + }); + return ranked.sort( + (a, b) => a.tier - b.tier || b.weight - a.weight || b.index - a.index + ); +} +function findHeldKey(reference, held, aliases = {}) { + const target = aliases[reference] ?? reference; + return held.find((key) => key.orgId === target) ?? held.find((key) => key.slug === target || key.name === target) ?? null; +} +function describeRule(rule) { + const parts = [ + rule.match.repo && `repo ${rule.match.repo}`, + rule.match.path && `path ${rule.match.path}` + ].filter(Boolean); + return `${parts.join(" + ")} -> ${rule.organization}`; +} +function describeOrg(org) { + return org.name ?? org.slug ?? org.orgId; +} +function routeOrganization(options) { + const env = options.env ?? process.env; + const home = options.home ?? (0, import_node_os2.homedir)(); + const cwd = realpath(options.cwd); + const config = options.config ?? readRoutingConfig(home); + const aliases = config.organizations ?? {}; + const held = options.held; + const hint = options.loginHint ?? "supermemory org add"; + const trace = []; + const warnings = []; + const notices = []; + let repo; + const getRepo = () => { + if (repo === void 0) repo = getRepoContext(cwd); + return repo; + }; + const done = (orgId, source, detail, paused = false) => ({ + orgId, + source, + detail, + paused, + repo: repo ?? null, + trace, + warnings, + notices + }); + const useOrg = (org, step, source, detail, stepDetail) => { + if (org.rejected) { + trace.push({ step, outcome: "rejected", detail: stepDetail }); + warnings.push( + `The stored login for ${describeOrg(org)} was rejected by the API. Memory is paused here. Run \`${hint}\` to log in again.` + ); + return done(org.orgId, source, detail, true); + } + trace.push({ step, outcome: "used", detail: stepDetail }); + return done(org.orgId, source, detail); + }; + const orgOverride = env.SUPERMEMORY_ORG; + if (orgOverride) { + const org = findHeldKey(orgOverride, held, aliases); + if (org) { + return useOrg( + org, + "env SUPERMEMORY_ORG", + "env-org", + orgOverride, + orgOverride + ); + } + trace.push({ + step: "env SUPERMEMORY_ORG", + outcome: "no-key", + detail: orgOverride + }); + warnings.push( + `SUPERMEMORY_ORG=${orgOverride} but no stored login for that organization. Run \`${hint}\`.` + ); + return done(null, "env-org", orgOverride, true); + } + const rules = Array.isArray(config.projects) ? config.projects : []; + const needsRepo = rules.some((rule) => !!rule?.match?.repo); + const ranked = rules.length ? rankRules(rules, cwd, needsRepo ? getRepo() : null, home) : []; + const ruleTiers = (tiers) => ranked.filter((entry) => tiers.includes(entry.tier)); + const tryRules = (entries) => { + const first = entries[0]; + if (!first) return null; + const { rule } = first; + const step = `rule ${describeRule(rule)}`; + const org = findHeldKey(rule.organization, held, aliases); + if (org) return useOrg(org, step, "rule", describeRule(rule)); + trace.push({ step, outcome: "no-key" }); + warnings.push( + `Rule ${describeRule(rule)} matched, but no stored login for "${rule.organization}". Memory is paused here. Run \`${hint}\`.` + ); + return done( + aliases[rule.organization] ?? null, + "rule", + describeRule(rule), + true + ); + }; + const fromRepoRules = tryRules(ruleTiers([TIER_REPO_AND_PATH, TIER_REPO])); + if (fromRepoRules) return fromRepoRules; + const marker = readTeamMarker(cwd); + if (marker?.organization) { + const org = held.find((entry) => entry.orgId === marker.organization); + const step = `team ${marker.path}`; + if (org) { + return useOrg(org, step, "team", marker.path, marker.organization); + } + trace.push({ step, outcome: "no-key", detail: marker.organization }); + const byName = findHeldKey(marker.organization, held); + if (byName) { + warnings.push( + `${marker.path} names organization "${marker.organization}" by name. Team files must use the organization id (${byName.orgId}), so it is ignored.` + ); + } else if (marker.ifMissingKey === "pause") { + warnings.push( + `This repository's memories belong to organization ${marker.organization}, and you are not logged in to it. Memory is paused here. Run \`${hint}\`.` + ); + return done(marker.organization, "team", marker.path, true); + } else { + notices.push( + `This repository belongs to organization ${marker.organization}. You are not logged in to it, so your default organization is used. If you are a member, run \`${hint}\`.` + ); + } + } + const fromPathRules = tryRules(ruleTiers([TIER_PATH])); + if (fromPathRules) return fromPathRules; + const fallback = held.find((org) => org.isDefault); + if (fallback) return useOrg(fallback, "default", "default", "default"); + trace.push({ step: "no login", outcome: "no-match" }); + return done(null, "none", null); +} + +// ../mono-agent-config-oauth/packages/agent-config/resolve.ts +function resolveAgentConfig(options) { + const env = options.env ?? process.env; + const home = options.home ?? (0, import_node_os3.homedir)(); + const credentials = options.credentials !== void 0 ? options.credentials : readCredentialsFile(home); + const trace = []; + const fixed = (apiKey, source, detail) => { + trace.push({ + step: source === "env" ? `env ${detail}` : detail, + outcome: "used" + }); + return { + apiKey, + orgId: orgIdFromApiKey(apiKey) ?? null, + source, + detail, + paused: false, + repo: null, + trace, + warnings: [], + notices: [] + }; + }; + for (const name of options.apiKeyEnvVars ?? []) { + const value = env[name]; + if (value) return fixed(value, "env", name); + } + const held = listHeldKeys(credentials); + const route = (() => { + if (env.SUPERMEMORY_ORG) return null; + for (const override of options.overrides ?? []) { + if (override.apiKey) + return fixed(override.apiKey, "override", override.source); + } + return null; + })(); + if (route) return route; + const routed = routeOrganization({ + cwd: options.cwd, + held, + env, + home, + ...options.config ? { config: options.config } : {} + }); + const base = { + repo: routed.repo, + trace: [...trace, ...routed.trace], + warnings: routed.warnings, + notices: routed.notices + }; + if (routed.orgId && !routed.paused) { + const key = held.find((entry) => entry.orgId === routed.orgId); + if (key) { + return { + ...base, + apiKey: key.apiKey, + orgId: key.orgId, + source: routed.source, + detail: routed.source === "default" ? "credentials.json" : routed.detail, + paused: false + }; + } + } + if (routed.paused || routed.source !== "none") { + return { + ...base, + apiKey: null, + orgId: routed.orgId, + source: routed.source, + detail: routed.detail, + paused: routed.paused + }; + } + if (credentials?.apiKey) { + base.trace.push({ step: "default credentials", outcome: "used" }); + return { + ...base, + apiKey: credentials.apiKey, + orgId: null, + source: "default", + detail: "credentials.json", + paused: false + }; + } + for (const fallback of options.fallbacks ?? []) { + if (fallback.apiKey) { + base.trace.push({ step: fallback.source, outcome: "used" }); + return { + ...base, + apiKey: fallback.apiKey, + orgId: orgIdFromApiKey(fallback.apiKey) ?? null, + source: "fallback", + detail: fallback.source, + paused: false + }; + } + } + return { + ...base, + apiKey: null, + orgId: null, + source: "none", + detail: null, + paused: false + }; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + AGENT_IDS, + appendProjectRule, + clearRepoCache, + describePluginLogin, + findHeldKey, + getRepoContext, + getUserDir, + listHeldKeys, + listOAuthOrgs, + newOAuthStore, + normalizeGitRemote, + oauthTokensFor, + orgIdFromAccessToken, + orgIdFromApiKey, + planRepoBinding, + pluginCredentialsPath, + readCredentialsFile, + readPluginApiKey, + readRoutingConfig, + readTeamMarker, + resolveAgentConfig, + routeOrganization, + upgradeOAuthStore, + withDefaultOAuthOrg, + withDefaultOrganization, + withOAuthTokens, + withOrganization, + withRejectedKey, + withRejectedOAuthOrg, + withoutAnyOAuthTokens, + withoutOAuthOrg, + withoutOrganization +}); diff --git a/scripts/build-agent-config.mjs b/scripts/build-agent-config.mjs new file mode 100644 index 0000000..3e4c184 --- /dev/null +++ b/scripts/build-agent-config.mjs @@ -0,0 +1,26 @@ +// Bundles the shared routing package from the Supermemory monorepo +// (packages/agent-config) into a single CommonJS file the hooks can require. +// Run with SUPERMEMORY_MONO_DIR pointing at a mono checkout. +import { execFileSync } from 'node:child_process'; +import { join, resolve } from 'node:path'; +import { build } from 'esbuild'; + +const mono = resolve(process.env.SUPERMEMORY_MONO_DIR || '../mono'); +const commit = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { + cwd: mono, + encoding: 'utf-8', +}).trim(); + +await build({ + entryPoints: [join(mono, 'packages/agent-config/index.ts')], + outfile: 'plugin/hooks/vendor/agent-config.cjs', + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + minify: false, + legalComments: 'none', + banner: { + js: `// Generated from supermemoryai/mono packages/agent-config @ ${commit}. Do not edit; run npm run build:agent-config.`, + }, +}); diff --git a/test/unit.mjs b/test/unit.mjs index 2a9fa20..7e7afba 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -3,6 +3,7 @@ import { spawn, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { existsSync, + readFileSync, mkdirSync, readdirSync, rmSync, @@ -996,3 +997,353 @@ describe('statusline rendering', () => { ); }); }); + +describe('oauth organizations', () => { + function jwt(claims) { + const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + return `${encode({ alg: 'EdDSA' })}.${encode(claims)}.sig`; + } + + function oauthHome(t, stubUrl, organizations, extra = {}) { + const home = makeTempDir(t, 'oauth-home'); + mkdirSync(join(home, '.supermemory-claude'), { recursive: true }); + writeFileSync( + join(home, '.supermemory-claude', 'credentials.json'), + JSON.stringify({ + type: 'oauth', + apiBaseUrl: stubUrl, + issuer: `${stubUrl}/api/auth`, + resource: `${stubUrl}/mcp`, + clientId: 'client_claude', + tokenEndpoint: `${stubUrl}/api/auth/oauth2/token`, + revocationEndpoint: `${stubUrl}/api/auth/oauth2/revoke`, + ...(organizations ? { version: 2, organizations } : {}), + ...extra, + }), + ); + return home; + } + + function routeAcmeTo(home, organization) { + mkdirSync(join(home, '.supermemory'), { recursive: true }); + writeFileSync( + join(home, '.supermemory', 'config.json'), + JSON.stringify({ projects: [{ match: { repo: 'github.com/acme/*' }, organization }] }), + ); + } + + function readCredentials(home) { + return JSON.parse( + readFileSync(join(home, '.supermemory-claude', 'credentials.json'), 'utf-8'), + ); + } + + const fresh = () => Date.now() + 3_600_000; + const company = jwt({ sub: 'u1', organization_id: 'org_company' }); + const personal = jwt({ sub: 'u1', organization_id: 'org_personal' }); + + function runProxy(env, lines, cwd) { + return new Promise((resolve, reject) => { + const child = spawn('node', [join(HOOKS_DIR, 'mcp-proxy.js')], { + env: { ...process.env, SUPERMEMORY_CC_API_KEY: '', ...env }, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.on('error', reject); + child.on('close', () => + resolve(stdout.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l))), + ); + for (const line of lines) child.stdin.write(`${JSON.stringify(line)}\n`); + child.stdin.end(); + }); + } + + const envFor = (home, stub) => ({ + HOME: home, + USERPROFILE: home, + SUPERMEMORY_API_URL: stub.url, + SUPERMEMORY_MCP_URL: `${stub.url}/mcp`, + SUPERMEMORY_CC_API_KEY: '', + }); + + const mcpOk = (record, res) => { + res.setHeader('Content-Type', 'application/json'); + const { id } = JSON.parse(record.body); + res.end(JSON.stringify({ jsonrpc: '2.0', id, result: { ok: true } })); + }; + + test('reads a v1 single login under its org and routes a matching repo to it', async (t) => { + const { repo } = makeRepo(t); + const stub = await startStubServer(t, (record, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ profile: { static: [], dynamic: [] } })); + }); + const home = oauthHome(t, stub.url, null, { + accessToken: company, + refreshToken: 'rt_company', + expiresAt: fresh(), + }); + routeAcmeTo(home, 'org_company'); + + const { code } = await runHook( + 'session-start.js', + { session_id: 'sess-v1', cwd: repo }, + envFor(home, stub), + ); + assert.equal(code, 0); + assert.equal(stub.requests[0].headers.authorization, `Bearer ${company}`); + }); + + test('uses each directory\'s org when one plugin holds two logins', async (t) => { + const { repo, home: plainDir } = makeRepo(t); + const stub = await startStubServer(t, mcpOk); + const home = oauthHome(t, stub.url, { + org_personal: { accessToken: personal, refreshToken: 'rt_p', expiresAt: fresh() }, + org_company: { accessToken: company, refreshToken: 'rt_c', expiresAt: fresh() }, + }, { defaultOrganization: 'org_personal' }); + routeAcmeTo(home, 'org_company'); + + await runProxy(envFor(home, stub), [{ jsonrpc: '2.0', id: 1, method: 'tools/list' }], repo); + await runProxy(envFor(home, stub), [{ jsonrpc: '2.0', id: 2, method: 'tools/list' }], plainDir); + assert.equal(stub.requests[0].headers.authorization, `Bearer ${company}`); + assert.equal(stub.requests[1].headers.authorization, `Bearer ${personal}`); + }); + + test('pauses a company repo when only personal is logged in, and sends nothing', async (t) => { + const { repo } = makeRepo(t); + const stub = await startStubServer(t, mcpOk); + const home = oauthHome(t, stub.url, { + org_personal: { accessToken: personal, refreshToken: 'rt_p', expiresAt: fresh() }, + }, { defaultOrganization: 'org_personal' }); + routeAcmeTo(home, 'org_company'); + + const messages = await runProxy( + envFor(home, stub), + [{ jsonrpc: '2.0', id: 1, method: 'tools/list' }], + repo, + ); + assert.equal(messages[0].error.code, -32001); + assert.match(messages[0].error.message, /paused/); + assert.equal(stub.requests.length, 0); + }); + + test('refreshes only the routed org and keeps the other login', async (t) => { + const { repo } = makeRepo(t); + const rotated = jwt({ sub: 'u1', organization_id: 'org_company', n: 2 }); + const stub = await startStubServer(t, (record, res) => { + res.setHeader('Content-Type', 'application/json'); + if (record.url === '/api/auth/oauth2/token') { + res.end(JSON.stringify({ + access_token: rotated, + refresh_token: 'rt_c2', + token_type: 'Bearer', + expires_in: 3600, + })); + return; + } + mcpOk(record, res); + }); + const home = oauthHome(t, stub.url, { + org_personal: { accessToken: personal, refreshToken: 'rt_p', expiresAt: fresh() }, + org_company: { accessToken: company, refreshToken: 'rt_c', expiresAt: 0 }, + }, { defaultOrganization: 'org_personal' }); + routeAcmeTo(home, 'org_company'); + + await runProxy(envFor(home, stub), [{ jsonrpc: '2.0', id: 1, method: 'tools/list' }], repo); + assert.match(stub.requests[0].body, /refresh_token=rt_c(&|$)/); + assert.equal(stub.requests[1].headers.authorization, `Bearer ${rotated}`); + const saved = readCredentials(home); + assert.equal(saved.organizations.org_company.refreshToken, 'rt_c2'); + assert.equal(saved.organizations.org_personal.refreshToken, 'rt_p'); + }); + + test('a revoked grant removes only that org', async (t) => { + const { repo } = makeRepo(t); + const stub = await startStubServer(t, (record, res) => { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'invalid_grant' })); + }); + const home = oauthHome(t, stub.url, { + org_personal: { accessToken: personal, refreshToken: 'rt_p', expiresAt: fresh() }, + org_company: { accessToken: company, refreshToken: 'rt_c', expiresAt: 0 }, + }, { defaultOrganization: 'org_personal' }); + routeAcmeTo(home, 'org_company'); + + const messages = await runProxy( + envFor(home, stub), + [{ jsonrpc: '2.0', id: 1, method: 'tools/list' }], + repo, + ); + assert.equal(messages[0].error.code, -32001); + const saved = readCredentials(home); + assert.equal(saved.organizations.org_company, undefined); + assert.equal(saved.organizations.org_personal.refreshToken, 'rt_p'); + }); + + test('a 401 on a fresh token marks that org rejected and pauses it', async (t) => { + const { repo } = makeRepo(t); + const stub = await startStubServer(t, (record, res) => { + res.statusCode = 401; + res.end('{}'); + }); + const home = oauthHome(t, stub.url, { + org_personal: { accessToken: personal, refreshToken: 'rt_p', expiresAt: fresh() }, + org_company: { + accessToken: company, + refreshToken: 'rt_c', + expiresAt: fresh(), + savedAt: new Date().toISOString(), + }, + }, { defaultOrganization: 'org_personal' }); + routeAcmeTo(home, 'org_company'); + const env = envFor(home, stub); + + await runProxy(env, [{ jsonrpc: '2.0', id: 1, method: 'tools/list' }], repo); + assert.ok(readCredentials(home).organizations.org_company.rejectedAt); + + const before = stub.requests.length; + const messages = await runProxy(env, [{ jsonrpc: '2.0', id: 2, method: 'tools/list' }], repo); + assert.match(messages[0].error.message, /rejected/); + assert.equal(stub.requests.length, before); + }); + + test('logout --org revokes one org; status lists organizations without tokens', async (t) => { + const stub = await startStubServer(t, (record, res) => res.end('{}')); + const home = oauthHome(t, stub.url, { + org_personal: { accessToken: personal, refreshToken: 'rt_p', expiresAt: fresh() }, + org_company: { accessToken: company, refreshToken: 'rt_c', expiresAt: fresh() }, + }, { defaultOrganization: 'org_personal' }); + const env = { ...process.env, ...envFor(home, stub) }; + const auth = join(HOOKS_DIR, 'lib', 'auth.js'); + + // Async: the stub server runs in this process, so a blocking spawn would starve it. + const run = (args) => + new Promise((resolve, reject) => { + const child = spawn('node', [auth, ...args], { env }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', (status) => resolve({ status, stdout, stderr })); + }); + const logout = await run(['logout', '--org', 'org_company']); + assert.equal(logout.status, 0, logout.stderr); + assert.match(stub.requests[0].body, /token=rt_c/); + assert.deepEqual(Object.keys(readCredentials(home).organizations), ['org_personal']); + + const status = await run(['status']); + const report = JSON.parse(status.stdout); + assert.deepEqual(report.organizations.map((org) => org.id), ['org_personal']); + assert.ok(!status.stdout.includes('rt_p')); + assert.ok(!status.stdout.includes(personal)); + }); +}); + +describe('oauth organizations: session start', () => { + test('a paused repo shows why, without opening a browser again within 12 hours', async (t) => { + const { repo, home } = makeRepo(t); + const stub = await startStubServer(t, (record, res) => res.end('{}')); + const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const personal = `${encode({})}.${encode({ organization_id: 'org_personal' })}.sig`; + mkdirSync(join(home, '.supermemory-claude'), { recursive: true }); + writeFileSync( + join(home, '.supermemory-claude', 'credentials.json'), + JSON.stringify({ + type: 'oauth', + version: 2, + apiBaseUrl: stub.url, + issuer: `${stub.url}/api/auth`, + resource: `${stub.url}/mcp`, + clientId: 'client_claude', + defaultOrganization: 'org_personal', + organizations: { + org_personal: { accessToken: personal, refreshToken: 'rt_p', expiresAt: Date.now() + 3_600_000 }, + }, + }), + ); + mkdirSync(join(home, '.supermemory'), { recursive: true }); + writeFileSync( + join(home, '.supermemory', 'config.json'), + JSON.stringify({ projects: [{ match: { repo: 'github.com/acme/*' }, organization: 'org_company' }] }), + ); + // A prompt for this org was already shown recently. + writeFileSync( + join(home, '.supermemory-claude', 'org-login-prompts.json'), + JSON.stringify({ 'rule:repo github.com/acme/* -> org_company': Date.now() }), + ); + + const { code, stdout } = await runHook( + 'session-start.js', + { session_id: 'sess-paused', cwd: repo }, + { + HOME: home, + USERPROFILE: home, + SUPERMEMORY_API_URL: stub.url, + SUPERMEMORY_MCP_URL: `${stub.url}/mcp`, + SUPERMEMORY_CC_API_KEY: '', + }, + ); + assert.equal(code, 0); + const output = JSON.parse(stdout); + assert.match(output.hookSpecificOutput.additionalContext, /paused/); + assert.match(plain(output.systemMessage), /org_company/); + assert.equal(stub.requests.length, 0); + }); +}); + +describe('oauth organizations: failed login while paused', () => { + test('a paused repo whose login fails still says why it is paused', async (t) => { + const { repo, home } = makeRepo(t); + // Discovery returns nothing useful, so the login fails before any browser opens. + const stub = await startStubServer(t, (record, res) => res.end('{}')); + const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const personal = `${encode({})}.${encode({ organization_id: 'org_personal' })}.sig`; + mkdirSync(join(home, '.supermemory-claude'), { recursive: true }); + writeFileSync( + join(home, '.supermemory-claude', 'credentials.json'), + JSON.stringify({ + type: 'oauth', + version: 2, + apiBaseUrl: stub.url, + issuer: `${stub.url}/api/auth`, + resource: `${stub.url}/mcp`, + clientId: 'client_claude', + defaultOrganization: 'org_personal', + organizations: { + org_personal: { accessToken: personal, refreshToken: 'rt_p', expiresAt: Date.now() + 3_600_000 }, + }, + }), + ); + mkdirSync(join(home, '.supermemory'), { recursive: true }); + writeFileSync( + join(home, '.supermemory', 'config.json'), + JSON.stringify({ projects: [{ match: { repo: 'github.com/acme/*' }, organization: 'org_company' }] }), + ); + + const { code, stdout } = await runHook( + 'session-start.js', + { session_id: 'sess-paused-fail', cwd: repo }, + { + HOME: home, + USERPROFILE: home, + SUPERMEMORY_API_URL: stub.url, + SUPERMEMORY_MCP_URL: `${stub.url}/mcp`, + SUPERMEMORY_CC_API_KEY: '', + }, + ); + assert.equal(code, 0); + const output = JSON.parse(stdout); + assert.match(plain(output.systemMessage), /org_company/); + assert.match(plain(output.systemMessage), /paused/); + }); +});