mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Connections give agents governed access to external tools. > - Agents need durable memory across tasks and execution environments. > - Mem0, Zep, Supermemory, Cognee, and Honcho provide hosted memory tools. > - This pull request adds their setup flows behind an experimental toggle. > - It also delivers assigned MCP tools through the native remote Codex runner. > - Operators can connect a provider once and use the same governed tools locally or in Daytona. ## Linked Issues or Issue Description **Problem or motivation** The Apps catalog lacks a complete set of memory providers. Remote native Codex agents also need access to assigned managed MCP tools without receiving provider credentials. **Proposed solution** Add five memory connectors behind the disabled-by-default Experimental memory connectors setting. Use the existing connection setup and permissions UI. Default their tools to Allowed. Preserve company boundaries, operator permission changes, provider scopes, and audit attribution. **Alternatives considered** Direct provider credentials in each sandbox would duplicate setup and bypass the managed gateway. The remote runner instead uses its existing protocol channel to call the gateway on the server. **Roadmap alignment** This maintainer-requested experiment supports the Memory / Knowledge and Connected Apps roadmap areas. It adds provider connections without introducing a separate memory UI. Related connector authoring documentation is tracked in #13692; no duplicate memory-provider implementation was found. ## What Changed - Add provider definitions, official branding, and the experimental setting for all five providers. - Use OAuth for Zep and Supermemory, API credentials for Mem0 and Honcho, and a bundled Cloud API bridge for Cognee with no runtime downloads or subprocesses. - Default memory tools to Allowed and classify destructive actions explicitly. - Fix personal remote credential resolution and propagate provider tool errors. - Relay assigned managed MCP tools to remote native Codex through the runner protocol. Recheck current authority for each call and rotate stale tool contracts. - Add 21 Storybook states and complete OAuth walkthrough fixtures. - Document provider research, sanitized tool inventory, and live local and Daytona proof. ## Verification - Passed workspace typecheck: `pnpm -r typecheck`. - Passed production build: `pnpm build`. - Full local `pnpm test:run`: 13,273 passed, with failures from process/readiness timeouts under parallel load. Reran all 16 affected suites with one worker: 456 passed, leaving two macOS `/var` versus `/private/var` path assertions. Both passed with `TMPDIR=/private/tmp`. No test failures remain unverified. Latest-head remote CI passes all 54 checks (two optional Storybook jobs skipped). Greptile is 5/5 with no unresolved findings. - Latest Cognee gateway regression: 71 passed, including public deployment without a runtime host and immediate recovery after a provider error. Bundled bridge tests: 25 passed. - Browser setup and real agent tasks exercised all five providers. Mem0, Cognee, Zep, and Honcho have successful store/retrieve proof. - Supermemory now has scoped read/write consent. Local storage and real Daytona write, document read, and semantic recall passed; indexing completion was verified before claiming success. - All five providers were exercised through a real Daytona sandbox and its native runner MCP relay. Provider credentials remained on the server. The final bundled Cognee bridge also passed a fresh Daytona store/recall run. All disposable sandboxes were removed and verified absent after testing. - Storybook is rebuilt and contains the experimental toggle, catalog, setup, permissions, and error states. The Zep and Supermemory access steps advance correctly. ## Risks - Provider OAuth scopes and plan limits remain independent of Paperclip tool permissions. An Allowed tool can still be rejected by the provider. - Providers can queue memory indexing; save acceptance does not prove that semantic recall is ready. - Remote tool contracts must stay synchronized with current connection authority. Regression tests cover revocation and stale contracts. - This change has no database migration. Existing connections remain usable when the experimental catalog toggle is disabled. ## Model Used OpenAI Codex, GPT-6 Astra (`gpt-6-astra`), with reasoning, repository edits, code execution, and browser automation. The exact context window size is not exposed in this session. Live acceptance agents used `gpt-5.6-sol`. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
73 lines
5.3 KiB
TypeScript
73 lines
5.3 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { classifyRisk } from "../services/tool-access.js";
|
|
import { COGNEE_STDIO_TEMPLATE, cogneeCloudUrl, callCogneeCloud } from "../services/cognee-connection.js";
|
|
|
|
describe("memory tool governance", () => {
|
|
it.each([
|
|
["mem0", "add_memory", "write"], ["mem0", "search_memories", "read"],
|
|
["mem0", "delete_entities", "destructive"], ["zep", "add_memory_to_graph", "write"],
|
|
["supermemory", "add_memory", "destructive"], ["supermemory", "select-space", "write"],
|
|
["supermemory", "get_profile", "read"], ["cognee", "remember", "write"],
|
|
["cognee", "recall", "read"], ["cognee", "forget", "destructive"],
|
|
["honcho", "create_peer", "write"], ["honcho", "future_unknown_action", "write"],
|
|
])("classifies %s %s even with a misleading read hint", (provider, name, expected) => {
|
|
expect(classifyRisk({ name, annotations: { readOnlyHint: true } }, provider)).toBe(expected);
|
|
});
|
|
it("keeps the approved Cognee bridge free of runtime package resolution", () => {
|
|
expect(COGNEE_STDIO_TEMPLATE.command).toBeNull();
|
|
expect(COGNEE_STDIO_TEMPLATE.args).toEqual([]);
|
|
expect(COGNEE_STDIO_TEMPLATE.envKeys).toEqual(["COGNEE_BASE_URL", "COGNEE_API_KEY"]);
|
|
expect(COGNEE_STDIO_TEMPLATE.tools.map(t => t.name)).toEqual(["remember", "recall", "forget"]);
|
|
});
|
|
it.each(["http://localhost", "https://evil.test", "https://tenant.aws.cognee.ai@evil.test", "https://tenant.aws.cognee.ai/other", "https://tenant.aws.cognee.ai?key=secret", "https://tenant.aws.cognee.ai:8443"])("rejects unreviewed Cognee URL %s", (url) => {
|
|
expect(() => cogneeCloudUrl(url)).toThrow();
|
|
});
|
|
it("accepts the tenant API origin", () => expect(cogneeCloudUrl("https://tenant.aws.cognee.ai").origin).toBe("https://tenant.aws.cognee.ai"));
|
|
it("writes text as a file to the tenant-only Cloud endpoint", async () => {
|
|
const request = vi.fn().mockResolvedValue({ status: "completed" });
|
|
const result = await callCogneeCloud({ baseUrl: "https://fixture.aws.cognee.ai", apiKey: "fixture-key",
|
|
tool: "remember", parameters: { data: "synthetic memory", dataset_name: "fixture" }, request,
|
|
signal: AbortSignal.timeout(1000) });
|
|
const [url, init] = request.mock.calls[0]!;
|
|
expect(url.href).toBe("https://fixture.aws.cognee.ai/api/v1/remember");
|
|
expect(init).toMatchObject({ method: "POST", redirect: "manual", headers: expect.objectContaining({ "x-api-key": "fixture-key" }) });
|
|
expect(Buffer.isBuffer(init.body)).toBe(true);
|
|
const form = await new Response(init.body, { headers: init.headers }).formData();
|
|
expect(form.get("datasetName")).toBe("fixture");
|
|
expect(await (form.get("data") as File).text()).toBe("synthetic memory");
|
|
expect(result).toMatchObject({ isError: false, structuredContent: { result: { status: "completed" } } });
|
|
});
|
|
it("keeps session writes on the Cloud session endpoint", async () => {
|
|
const request = vi.fn().mockResolvedValue({ status: "completed" });
|
|
await callCogneeCloud({ baseUrl: "https://fixture.aws.cognee.ai", apiKey: "key", tool: "remember",
|
|
parameters: { data: "text", session_id: "session", dataset_name: "fixture" }, request, signal: AbortSignal.timeout(1000) });
|
|
expect(request.mock.calls[0]![0].pathname).toBe("/api/v1/remember/entry");
|
|
expect(JSON.parse(request.mock.calls[0]![1].body)).toMatchObject({
|
|
entry: { type: "qa", answer: "text" }, session_id: "session", dataset_name: "fixture" });
|
|
});
|
|
it("preserves scoped recall arguments and does not fetch other datasets", async () => {
|
|
const request = vi.fn().mockResolvedValue(["memory"]);
|
|
await callCogneeCloud({ baseUrl: "https://fixture.aws.cognee.ai", apiKey: "key", tool: "recall",
|
|
parameters: { query: "find", datasets: "one, two", search_type: "chunks", top_k: 3 }, request, signal: AbortSignal.timeout(1000) });
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
expect(request.mock.calls[0]![0].pathname).toBe("/api/v1/recall");
|
|
expect(JSON.parse(request.mock.calls[0]![1].body)).toEqual({ query: "find", datasets: ["one", "two"], search_type: "CHUNKS", top_k: 3 });
|
|
});
|
|
it("matches the official client's unscoped dataset discovery", async () => {
|
|
const request = vi.fn().mockResolvedValueOnce([{ name: "one" }]).mockResolvedValueOnce(["memory"]);
|
|
await callCogneeCloud({ baseUrl: "https://fixture.aws.cognee.ai", apiKey: "key", tool: "recall",
|
|
parameters: { query: "find" }, request, signal: AbortSignal.timeout(1000) });
|
|
expect(request.mock.calls[0]![0].pathname).toBe("/api/v1/datasets/");
|
|
expect(JSON.parse(request.mock.calls[1]![1].body).datasets).toEqual(["one"]);
|
|
});
|
|
it("requires an explicit delete target and never falls back after a provider failure", async () => {
|
|
const request = vi.fn().mockRejectedValue(new Error("Cloud unavailable"));
|
|
const input = { baseUrl: "https://fixture.aws.cognee.ai", apiKey: "key", tool: "forget", parameters: {}, request, signal: AbortSignal.timeout(1000) };
|
|
await expect(callCogneeCloud(input)).rejects.toThrow("Specify a dataset");
|
|
expect(request).not.toHaveBeenCalled();
|
|
await expect(callCogneeCloud({ ...input, parameters: { dataset: "fixture" } })).rejects.toThrow("Cloud unavailable");
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse(request.mock.calls[0]![1].body)).toEqual({ dataset: "fixture", everything: false });
|
|
});
|
|
});
|