From 35e7efe3fcd1148dfe4a39667cb693b2274654fd Mon Sep 17 00:00:00 2001 From: rrader26 Date: Thu, 14 May 2026 22:23:23 -0400 Subject: [PATCH] feat(memory): env-aware backend + memory in default plugin set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the Memory Pack a zero-config default. The MCP server now picks the right backend based on what's in the environment, and exposes the agentmark_memory_* tools whether or not SaaS creds are present. Detection cascade in detectMemoryBackend(): - All three of THINKFLEET_BASE_URL + THINKFLEET_PROJECT_ID + THINKFLEET_API_KEY present → ActivepiecesMemoryBackend (memory flows to SaaS; available across machines + AI tools). - None present → LocalFileMemoryBackend (legacy default, on-disk, offline-safe). - Some-but-not-all present → throw MemoryBackendConfigError. Refusing to silently fall back to local matters: a typo in one variable shouldn't quietly demote a user from "memory syncs to my team" to "memory only on my disk". - Malformed API key (no sk- prefix) → throw; truncated to first 4 chars in the error so secrets don't leak into logs. Dispatcher integration: - createDispatcherState() now always builds a memory plugin and adds it to the default plugin set alongside web + pdf + desktop. - On misconfiguration, memory is disabled and the reason logged to stderr; the rest of the server stays alive. MCP clients (Claude Code, Cursor, …) surface stderr so users see the misconfiguration. - Selected backend is logged at startup via describeMemoryBackend() — credential-free description, safe for any log sink. Threat model: - API key only ever lives in process env. Callers (ThinkFleet Desktop installer in upcoming PRs) should pass it from an OS keychain, not persist it in a plain settings file. - The MCP install CLI's --env support (PR β) is the supported transport into a client's MCP config. Tests: - test/memory/detect-backend.test.ts — 16 cases covering each branch of the detection cascade, error-message sanitization, whitespace handling. - test/mcp/default-memory-plugin.test.ts — 5 cases covering dispatcher integration: tools register, partial creds disable cleanly, malformed keys don't leak to logs. 540 tests pass. Typecheck clean. Backwards-compat: callers passing an explicit `plugins` array to createMcpServer() see no change; default callers get the memory tools. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mcp/dispatcher.ts | 52 +++++++- src/mcp/index.ts | 3 + src/plugins/memory/index.ts | 111 ++++++++++++++++ test/mcp/default-memory-plugin.test.ts | 108 +++++++++++++++ test/memory/detect-backend.test.ts | 176 +++++++++++++++++++++++++ 5 files changed, 447 insertions(+), 3 deletions(-) create mode 100644 test/mcp/default-memory-plugin.test.ts create mode 100644 test/memory/detect-backend.test.ts diff --git a/src/mcp/dispatcher.ts b/src/mcp/dispatcher.ts index dca2e5e..1e8e530 100644 --- a/src/mcp/dispatcher.ts +++ b/src/mcp/dispatcher.ts @@ -17,6 +17,12 @@ import { createWebPlugin, type WebPlugin } from './plugins/web' import { createPdfPlugin, type PdfPlugin } from './plugins/pdf' import { createDesktopPlugin, type DesktopPlugin } from './plugins/desktop' import { createMetaPlugin } from './plugins/meta' +import { + createMemoryPlugin, + detectMemoryBackend, + describeMemoryBackend, + MemoryBackendConfigError, +} from '../plugins/memory' import type { BrowserSession, DesktopSession, PdfSession } from './types' import type { Page } from '../index' @@ -41,16 +47,32 @@ export interface DispatcherState { } /** - * Build the default first-party plugin set (web + pdf + desktop + meta) + * Build the default first-party plugin set (web + pdf + desktop + memory + meta) * and return a `DispatcherState` that exposes both the new dispatcher * and the legacy per-capability maps. + * + * Memory plugin notes: + * - Always included so AI tools have a stable `agentmark_memory_*` surface. + * - Backend chosen by `detectMemoryBackend()` — ActivepiecesMemoryBackend + * when THINKFLEET_* env vars are present (memory syncs to SaaS), + * LocalFileMemoryBackend otherwise (memory stays on disk). + * - Partial / malformed creds disable the memory plugin and log to + * stderr rather than crashing the whole MCP server. MCP clients + * (Claude Code, Cursor, …) surface those stderr lines so users see + * the misconfiguration. */ export function createDispatcherState(): DispatcherState { const web: WebPlugin = createWebPlugin() const pdf: PdfPlugin = createPdfPlugin() const desktop: DesktopPlugin = createDesktopPlugin() - const meta = createMetaPlugin([web, pdf, desktop]) - const plugins: AgentMarkPlugin[] = [web, pdf, desktop, meta] + + const memory = tryBuildMemoryPlugin() + + const featurePlugins: AgentMarkPlugin[] = [web, pdf, desktop] + if (memory) featurePlugins.push(memory) + + const meta = createMetaPlugin(featurePlugins) + const plugins: AgentMarkPlugin[] = [...featurePlugins, meta] const dispatcher = new Dispatcher(plugins) return { @@ -63,6 +85,30 @@ export function createDispatcherState(): DispatcherState { } } +/** + * Construct the memory plugin with an env-detected backend. Returns + * `null` when the env is misconfigured — keeps the rest of the MCP + * server alive but disables memory tools until the user fixes the + * config. + */ +function tryBuildMemoryPlugin(): AgentMarkPlugin | null { + try { + const backend = detectMemoryBackend() + // eslint-disable-next-line no-console + console.error( + `[agentmark] memory backend: ${describeMemoryBackend(backend)}`, + ) + return createMemoryPlugin({ backend }) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + const tag = err instanceof MemoryBackendConfigError ? 'config' : 'init' + // eslint-disable-next-line no-console + console.error(`[agentmark] memory plugin disabled (${tag}): ${message}`) + return null + } +} + /** * Route a tool invocation through the legacy state's dispatcher. * Equivalent to `state.dispatcher.dispatch(name, args)`. diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 6d542bf..3fc71cf 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -131,6 +131,9 @@ export type { // the live ThinkFleet agent-memory API). export { createMemoryPlugin, + detectMemoryBackend, + describeMemoryBackend, + MemoryBackendConfigError, LocalFileMemoryBackend, ActivepiecesMemoryBackend, ActivepiecesMemoryError, diff --git a/src/plugins/memory/index.ts b/src/plugins/memory/index.ts index e5d42d3..dfb9542 100644 --- a/src/plugins/memory/index.ts +++ b/src/plugins/memory/index.ts @@ -12,6 +12,7 @@ * right place. agentmark_memory_get can walk the scope hierarchy in * one call. */ +import { ActivepiecesMemoryBackend } from './activepieces-backend' import { LocalFileMemoryBackend } from './store' import { MEMORY_TOOLS } from './tool-defs' import type { MemoryBackend } from './backend' @@ -167,6 +168,116 @@ function optionalStringArray(v: unknown): string[] | undefined { return v as string[] } +/* ─── Env-aware backend detection ───────────────────────────────────── + * + * Lets the MCP server pick the right MemoryBackend automatically based + * on what's in the environment, without forcing every caller to know + * the construction recipe. + * + * Cascading rule: + * - All three of THINKFLEET_BASE_URL + THINKFLEET_PROJECT_ID + + * THINKFLEET_API_KEY present → ActivepiecesMemoryBackend (writes go + * to the SaaS hierarchical memory; available across machines + AI + * tools). + * - None present → LocalFileMemoryBackend (on-disk, offline-safe, + * same default the plugin has shipped with since v0.6). + * - Some-but-not-all present → throw. Refusing to silently fall back + * to local matters: a typo in one variable shouldn't quietly demote + * a user from "memory syncs to my team" to "memory only on my disk." + * Misconfiguration should be loud. + * + * Security note: the API key only ever lives in process env. Callers + * (e.g. ThinkFleet Desktop) should pass it from an OS keychain, not + * persist it in a plain settings file. The MCP install CLI's `--env` + * flag (β) is the supported transport into a client's MCP config. + */ + +const ENV_BASE_URL = 'THINKFLEET_BASE_URL' +const ENV_PROJECT_ID = 'THINKFLEET_PROJECT_ID' +const ENV_API_KEY = 'THINKFLEET_API_KEY' +const ENV_CHATBOT_ID = 'THINKFLEET_CHATBOT_ID' + +export class MemoryBackendConfigError extends Error { + constructor(message: string) { + super(message) + this.name = 'MemoryBackendConfigError' + } +} + +/** + * Select the right `MemoryBackend` for the current environment. + * + * @param env Defaults to `process.env`. Pass a literal object in tests. + * @returns `ActivepiecesMemoryBackend` when full THINKFLEET creds are + * present, `LocalFileMemoryBackend` when none are. + * @throws {@link MemoryBackendConfigError} when partial creds or a + * malformed API key are detected. + */ +export function detectMemoryBackend( + env: NodeJS.ProcessEnv = process.env, +): MemoryBackend { + const baseUrl = (env[ENV_BASE_URL] ?? '').trim() + const projectId = (env[ENV_PROJECT_ID] ?? '').trim() + const apiKey = (env[ENV_API_KEY] ?? '').trim() + const chatbotId = (env[ENV_CHATBOT_ID] ?? '').trim() || undefined + + const present: string[] = [] + const missing: string[] = [] + for (const [name, value] of [ + [ENV_BASE_URL, baseUrl], + [ENV_PROJECT_ID, projectId], + [ENV_API_KEY, apiKey], + ] as const) { + if (value.length > 0) present.push(name) + else missing.push(name) + } + + if (present.length === 0) { + return new LocalFileMemoryBackend() + } + + if (missing.length > 0) { + throw new MemoryBackendConfigError( + `Partial ThinkFleet credentials detected — found [${present.join(', ')}], ` + + `missing [${missing.join(', ')}]. Set all three of ${ENV_BASE_URL}, ` + + `${ENV_PROJECT_ID}, ${ENV_API_KEY} to use the SaaS memory backend, ` + + 'or unset all three to use the local file backend.', + ) + } + + if (!apiKey.startsWith('sk-')) { + throw new MemoryBackendConfigError( + `${ENV_API_KEY} must start with "sk-" (got "${apiKey.slice(0, 4)}…"). ` + + 'Generate a service-key from the ThinkFleet dashboard.', + ) + } + + return new ActivepiecesMemoryBackend({ + baseUrl, + projectId, + apiKey, + chatbotId, + }) +} + +/** + * Human-readable label for a backend instance. Safe to log — never + * includes credentials. Used by the dispatcher to surface which + * backend got picked at startup. + */ +export function describeMemoryBackend(backend: MemoryBackend): string { + if (backend instanceof ActivepiecesMemoryBackend) { + const scope = backend.chatbotId + ? `project:${backend.projectId}/chatbot:${backend.chatbotId}` + : `project:${backend.projectId}` + return `activepieces (${scope} @ ${backend.baseUrl})` + } + if (backend instanceof LocalFileMemoryBackend) { + return 'local-file' + } + return backend.constructor.name +} + export { LocalFileMemoryBackend, MemoryStore } from './store' export type { LocalFileMemoryBackendConfig, MemoryStoreConfig } from './store' export { ActivepiecesMemoryBackend, ActivepiecesMemoryError } from './activepieces-backend' diff --git a/test/mcp/default-memory-plugin.test.ts b/test/mcp/default-memory-plugin.test.ts new file mode 100644 index 0000000..561dce8 --- /dev/null +++ b/test/mcp/default-memory-plugin.test.ts @@ -0,0 +1,108 @@ +/** + * Tests that the default MCP plugin set picks up the memory plugin + * automatically — and that misconfigured env vars disable memory but + * leave the rest of the server working. + * + * The memory plugin in the default set is what makes "install ThinkFleet + * Desktop → AI tools have persistent memory" a zero-config experience. + * Regressing this means every user-facing wiring path breaks silently. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createDispatcherState, disposeAll, type DispatcherState } from '../../src/mcp/dispatcher' +import { MEMORY_TOOLS } from '../../src/plugins/memory' + +const SAAS_ENV_KEYS = [ + 'THINKFLEET_BASE_URL', + 'THINKFLEET_PROJECT_ID', + 'THINKFLEET_API_KEY', + 'THINKFLEET_CHATBOT_ID', +] + +let originalEnv: Record +let consoleSpy: ReturnType +let state: DispatcherState | null = null + +beforeEach(() => { + originalEnv = Object.fromEntries(SAAS_ENV_KEYS.map((k) => [k, process.env[k]])) + for (const k of SAAS_ENV_KEYS) delete process.env[k] + consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(async () => { + if (state) { + await disposeAll(state).catch(() => {}) + state = null + } + for (const k of SAAS_ENV_KEYS) { + if (originalEnv[k] === undefined) delete process.env[k] + else process.env[k] = originalEnv[k] + } + consoleSpy.mockRestore() +}) + +const memoryToolNames = MEMORY_TOOLS.map((t) => t.name).sort() + +describe('default plugin set — memory wiring', () => { + it('registers every memory tool when no creds are set (local-file backend)', () => { + state = createDispatcherState() + const tools = Array.from(state.dispatcher.toolNames).filter((n) => n.startsWith('agentmark_memory_')) + expect(tools.sort()).toEqual(memoryToolNames) + }) + + it('still registers memory tools when full SaaS creds are present', () => { + process.env.THINKFLEET_BASE_URL = 'https://app.thinkfleet.ai' + process.env.THINKFLEET_PROJECT_ID = 'proj_test_1234567890' + process.env.THINKFLEET_API_KEY = 'sk-test-aaaaaaaaaaaaaaaaaaaaaaaa' + + state = createDispatcherState() + const tools = Array.from(state.dispatcher.toolNames).filter((n) => n.startsWith('agentmark_memory_')) + expect(tools.sort()).toEqual(memoryToolNames) + }) + + it('logs which backend got selected on startup (no creds in the log line)', () => { + process.env.THINKFLEET_BASE_URL = 'https://app.thinkfleet.ai' + process.env.THINKFLEET_PROJECT_ID = 'proj_test_1234567890' + process.env.THINKFLEET_API_KEY = 'sk-test-aaaaaaaaaaaaaaaaaaaaaaaa' + + state = createDispatcherState() + + const logLines = consoleSpy.mock.calls.map((c) => String(c[0])) + const backendLine = logLines.find((l) => l.includes('memory backend:')) + expect(backendLine).toBeDefined() + expect(backendLine).toContain('activepieces') + expect(backendLine).not.toContain('sk-test') + }) + + it('disables memory but keeps the rest of the server alive on partial creds', () => { + process.env.THINKFLEET_BASE_URL = 'https://app.thinkfleet.ai' + // intentionally omit api key + + state = createDispatcherState() + + // Memory tools are gone. + const memTools = Array.from(state.dispatcher.toolNames).filter((n) => n.startsWith('agentmark_memory_')) + expect(memTools).toEqual([]) + + // Other capabilities still registered. + const webTools = Array.from(state.dispatcher.toolNames).filter((n) => n.startsWith('agentmark_browser_')) + expect(webTools.length).toBeGreaterThan(0) + + // Disable reason surfaced to stderr (MCP clients show it). + const logLines = consoleSpy.mock.calls.map((c) => String(c[0])) + expect(logLines.some((l) => l.includes('memory plugin disabled'))).toBe(true) + }) + + it('disables memory on malformed API key without leaking the bad key', () => { + process.env.THINKFLEET_BASE_URL = 'https://app.thinkfleet.ai' + process.env.THINKFLEET_PROJECT_ID = 'proj_test' + process.env.THINKFLEET_API_KEY = 'totally-not-an-sk-key-very-secret' + + state = createDispatcherState() + + const memTools = Array.from(state.dispatcher.toolNames).filter((n) => n.startsWith('agentmark_memory_')) + expect(memTools).toEqual([]) + + const allLogs = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(allLogs).not.toContain('totally-not-an-sk-key-very-secret') + }) +}) diff --git a/test/memory/detect-backend.test.ts b/test/memory/detect-backend.test.ts new file mode 100644 index 0000000..dda5da2 --- /dev/null +++ b/test/memory/detect-backend.test.ts @@ -0,0 +1,176 @@ +/** + * Tests for `detectMemoryBackend()` — env-aware backend selection. + * + * Threat model under test: + * - Partial creds MUST throw (silent fallback to local would demote + * a user from "memory syncs to my team" to "memory only on my disk" + * on a typo). + * - Malformed API key MUST throw (an invalid key would burn every + * memory call as a 401 round-trip). + * - No creds MUST return a local backend (the legacy default). + * - Full creds MUST return the SaaS backend. + * - Successful path MUST NOT leak credentials in thrown errors or + * `describeMemoryBackend` output. + */ +import { describe, it, expect } from 'vitest' +import { + detectMemoryBackend, + describeMemoryBackend, + LocalFileMemoryBackend, + ActivepiecesMemoryBackend, + MemoryBackendConfigError, +} from '../../src/plugins/memory' + +const FULL_ENV = { + THINKFLEET_BASE_URL: 'https://app.thinkfleet.ai', + THINKFLEET_PROJECT_ID: 'proj_test_1234567890', + THINKFLEET_API_KEY: 'sk-test-aaaaaaaaaaaaaaaaaaaaaaaa', +} as const + +describe('detectMemoryBackend — no creds', () => { + it('returns LocalFileMemoryBackend when none of the env vars are set', () => { + const backend = detectMemoryBackend({}) + expect(backend).toBeInstanceOf(LocalFileMemoryBackend) + }) + + it('treats empty strings as absent', () => { + const backend = detectMemoryBackend({ + THINKFLEET_BASE_URL: '', + THINKFLEET_PROJECT_ID: '', + THINKFLEET_API_KEY: '', + }) + expect(backend).toBeInstanceOf(LocalFileMemoryBackend) + }) + + it('treats whitespace-only strings as absent', () => { + const backend = detectMemoryBackend({ + THINKFLEET_BASE_URL: ' ', + THINKFLEET_PROJECT_ID: '\t', + THINKFLEET_API_KEY: '\n', + }) + expect(backend).toBeInstanceOf(LocalFileMemoryBackend) + }) +}) + +describe('detectMemoryBackend — full creds', () => { + it('returns ActivepiecesMemoryBackend when all three env vars are set', () => { + const backend = detectMemoryBackend(FULL_ENV) + expect(backend).toBeInstanceOf(ActivepiecesMemoryBackend) + }) + + it('trims surrounding whitespace before constructing', () => { + const backend = detectMemoryBackend({ + THINKFLEET_BASE_URL: ' https://app.thinkfleet.ai ', + THINKFLEET_PROJECT_ID: ' proj_test ', + THINKFLEET_API_KEY: ' sk-test-aaaa ', + }) as ActivepiecesMemoryBackend + expect(backend.baseUrl).toBe('https://app.thinkfleet.ai') + expect(backend.projectId).toBe('proj_test') + }) + + it('plumbs THINKFLEET_CHATBOT_ID through when present', () => { + const backend = detectMemoryBackend({ + ...FULL_ENV, + THINKFLEET_CHATBOT_ID: 'cb_abc', + }) as ActivepiecesMemoryBackend + expect(backend.chatbotId).toBe('cb_abc') + }) + + it('omits chatbotId when the env var is absent or empty', () => { + const backend = detectMemoryBackend(FULL_ENV) as ActivepiecesMemoryBackend + expect(backend.chatbotId).toBeUndefined() + }) +}) + +describe('detectMemoryBackend — partial creds (loud failures)', () => { + it('throws when API key is set without base url + project id', () => { + expect(() => detectMemoryBackend({ + THINKFLEET_API_KEY: 'sk-test', + })).toThrowError(MemoryBackendConfigError) + }) + + it('throws when only base url is set', () => { + expect(() => detectMemoryBackend({ + THINKFLEET_BASE_URL: 'https://app.thinkfleet.ai', + })).toThrowError(/Partial ThinkFleet credentials/) + }) + + it('error message names exactly which vars are missing', () => { + try { + detectMemoryBackend({ + THINKFLEET_BASE_URL: 'https://app.thinkfleet.ai', + THINKFLEET_PROJECT_ID: 'proj_test', + }) + expect.fail('should have thrown') + } + catch (err) { + expect(err).toBeInstanceOf(MemoryBackendConfigError) + const m = (err as Error).message + expect(m).toContain('THINKFLEET_API_KEY') + expect(m).toContain('missing') + expect(m).toContain('THINKFLEET_BASE_URL') + expect(m).toContain('found') + } + }) + + it('error message does NOT contain the partially-supplied API key value', () => { + try { + detectMemoryBackend({ + THINKFLEET_BASE_URL: 'https://app.thinkfleet.ai', + THINKFLEET_API_KEY: 'sk-very-secret-do-not-leak', + }) + expect.fail('should have thrown') + } + catch (err) { + const m = (err as Error).message + expect(m).not.toContain('sk-very-secret-do-not-leak') + } + }) +}) + +describe('detectMemoryBackend — malformed creds', () => { + it('throws when API key does not start with "sk-"', () => { + expect(() => detectMemoryBackend({ + ...FULL_ENV, + THINKFLEET_API_KEY: 'wrong-format-12345', + })).toThrowError(/must start with "sk-"/) + }) + + it('error truncates the bad key to first 4 chars so secrets do not leak', () => { + try { + detectMemoryBackend({ + ...FULL_ENV, + THINKFLEET_API_KEY: 'pk-abcdefghijklmnopqrstuvwxyz', + }) + expect.fail('should have thrown') + } + catch (err) { + const m = (err as Error).message + expect(m).toContain('pk-a…') + expect(m).not.toContain('pk-abcdefghijklmnopqrstuvwxyz') + } + }) +}) + +describe('describeMemoryBackend', () => { + it('returns a credential-free description for ActivepiecesMemoryBackend', () => { + const backend = detectMemoryBackend(FULL_ENV) + const label = describeMemoryBackend(backend) + expect(label).toContain('activepieces') + expect(label).toContain(FULL_ENV.THINKFLEET_PROJECT_ID) + expect(label).toContain(FULL_ENV.THINKFLEET_BASE_URL) + expect(label).not.toContain(FULL_ENV.THINKFLEET_API_KEY) + }) + + it('includes chatbot id when present', () => { + const backend = detectMemoryBackend({ + ...FULL_ENV, + THINKFLEET_CHATBOT_ID: 'cb_xyz', + }) + expect(describeMemoryBackend(backend)).toContain('cb_xyz') + }) + + it('returns "local-file" for LocalFileMemoryBackend', () => { + expect(describeMemoryBackend(detectMemoryBackend({}))).toBe('local-file') + }) +})