From 1afb050315bb69a9d9683b311dadd5d6c50a40fd Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 5 Aug 2026 07:04:24 -0700 Subject: [PATCH 1/5] Export Interchange defineTool memory tools as HTTP clients Workflow agents install @corbits/memory/tools with hub credentials and call the mounted tenant memory routes. Tools never take model-supplied identity and do not touch the in-process plane. --- bun.lock | 1 + package.json | 7 +- src/tools/add.ts | 138 ++++++++++++++++++++ src/tools/client.ts | 147 +++++++++++++++++++++ src/tools/index.ts | 18 +++ src/tools/list.ts | 67 ++++++++++ src/tools/search.ts | 136 +++++++++++++++++++ src/tools/tools.test.ts | 280 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 793 insertions(+), 1 deletion(-) create mode 100644 src/tools/add.ts create mode 100644 src/tools/client.ts create mode 100644 src/tools/index.ts create mode 100644 src/tools/list.ts create mode 100644 src/tools/search.ts create mode 100644 src/tools/tools.test.ts diff --git a/bun.lock b/bun.lock index 682997b..eeeb56d 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "company-knowledge-engine", "dependencies": { + "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", diff --git a/package.json b/package.json index 0633878..d754bed 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,11 @@ "exports": { ".": "./src/index.ts", "./migrations": "./src/migrations.ts", - "./config": "./src/mount-config.ts" + "./config": "./src/mount-config.ts", + "./tools": "./src/tools/index.ts" + }, + "interchange": { + "tools": "./src/tools/index.ts" }, "license": "LGPL-2.1-only", "type": "module", @@ -20,6 +24,7 @@ "test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-reporter=text ./src" }, "dependencies": { + "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", diff --git a/src/tools/add.ts b/src/tools/add.ts new file mode 100644 index 0000000..2728562 --- /dev/null +++ b/src/tools/add.ts @@ -0,0 +1,138 @@ +import { + createToolRunner, + defineTool, + stringTool, + type BaseEnv, +} from "@intx/agent"; + +import { + createMemoryHttpClient, + MEMORY_TOOL_ENV_KEYS, + readMemoryToolEnv, + type MemoryAddBody, + type MemoryToolEnv, +} from "./client.ts"; + +type AddEnv = BaseEnv & MemoryToolEnv; + +function asString(v: unknown, field: string): string { + if (typeof v !== "string" || v.length === 0) { + throw new Error(`${field} must be a non-empty string`); + } + return v; +} + +function asOptionalStringArray( + v: unknown, + field: string, +): string[] | undefined { + if (v === undefined) return undefined; + if (!Array.isArray(v) || !v.every((x) => typeof x === "string")) { + throw new Error(`${field} must be an array of strings`); + } + return v; +} + +function parseAddArgs(args: Record): MemoryAddBody { + const title = asString(args["title"], "title"); + const text = asString(args["text"], "text"); + const access_tags = asOptionalStringArray(args["access_tags"], "access_tags"); + + let share: MemoryAddBody["share"]; + const rawShare = args["share"]; + if (rawShare !== undefined) { + if (rawShare === null || typeof rawShare !== "object" || Array.isArray(rawShare)) { + throw new Error("share must be an object"); + } + const s = rawShare as Record; + const tenant = s["tenant"]; + if (tenant !== undefined && typeof tenant !== "boolean") { + throw new Error("share.tenant must be a boolean"); + } + const principals = asOptionalStringArray(s["principals"], "share.principals"); + const tags = asOptionalStringArray(s["tags"], "share.tags"); + share = { + ...(tenant !== undefined ? { tenant } : {}), + ...(principals !== undefined ? { principals } : {}), + ...(tags !== undefined ? { tags } : {}), + }; + } + + return { + title, + text, + ...(access_tags !== undefined ? { access_tags } : {}), + ...(share !== undefined ? { share } : {}), + }; +} + +/** + * Installable tool: POST /api/tenants/:tenantId/memory/add. + * + * Tenant and auth come from env (`memoryTenantId`, `memoryAuthToken`); + * model args never carry identity. + */ +export const memoryAdd = defineTool({ + id: "@corbits/memory/add", + requires: MEMORY_TOOL_ENV_KEYS, + factory(env) { + const client = createMemoryHttpClient(readMemoryToolEnv(env)); + const runner = createToolRunner([ + stringTool({ + definition: { + name: "memory_add", + description: + "Store a note in tenant memory. Returns { documentId }. " + + "Identity is the authenticated principal on the hub; do not " + + "pass tenant or principal ids.", + inputSchema: { + type: "object", + properties: { + title: { + type: "string", + description: "Short title for the document", + }, + text: { + type: "string", + description: "Full body text to store", + }, + access_tags: { + type: "array", + items: { type: "string" }, + description: + "Optional grant-pattern tags controlling document visibility", + }, + share: { + type: "object", + properties: { + tenant: { type: "boolean" }, + principals: { + type: "array", + items: { type: "string" }, + }, + tags: { + type: "array", + items: { type: "string" }, + }, + }, + description: + "Optional share sugar that mints access tags (tenant / principals / tags)", + }, + }, + required: ["title", "text"], + additionalProperties: false, + }, + }, + handler: async (args, signal) => { + const body = parseAddArgs(args); + const result = await client.add(body, signal); + return JSON.stringify(result); + }, + }), + ]); + return { + definitions: runner.definitions, + run: (call, signal) => runner.run(call, signal), + }; + }, +}); diff --git a/src/tools/client.ts b/src/tools/client.ts new file mode 100644 index 0000000..77a7d76 --- /dev/null +++ b/src/tools/client.ts @@ -0,0 +1,147 @@ +/** + * Thin HTTP client for mounted hub memory routes. + * + * Tools never touch the in-process plane — they only call + * `/api/tenants/:tenantId/memory/*` with credentials from install env. + */ + +export type MemoryHttpConfig = { + baseUrl: string; + tenantId: string; + authToken: string; + fetch?: typeof globalThis.fetch; +}; + +export type MemoryAddBody = { + title: string; + text: string; + access_tags?: string[]; + share?: { + tenant?: boolean; + principals?: string[]; + tags?: string[]; + }; +}; + +export type MemorySearchBody = { + query: string; + limit?: number; + kinds?: string[]; + entity_ids?: string[]; + sources?: string[]; + includeEvidence?: boolean; +}; + +export type MemoryHttpClient = { + add(body: MemoryAddBody, signal?: AbortSignal): Promise; + search(body: MemorySearchBody, signal?: AbortSignal): Promise; + list(limit?: number, signal?: AbortSignal): Promise; +}; + +function stripTrailingSlash(url: string): string { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +export function createMemoryHttpClient( + config: MemoryHttpConfig, +): MemoryHttpClient { + const base = stripTrailingSlash(config.baseUrl); + const root = `${base}/api/tenants/${encodeURIComponent(config.tenantId)}/memory`; + const doFetch = config.fetch ?? globalThis.fetch.bind(globalThis); + + async function request( + path: string, + init: { + method: string; + body?: string; + signal?: AbortSignal; + }, + ): Promise { + const headers: Record = { + Authorization: `Bearer ${config.authToken}`, + Accept: "application/json", + }; + if (init.body !== undefined) { + headers["Content-Type"] = "application/json"; + } + + const fetchInit: RequestInit = { + method: init.method, + headers, + }; + if (init.body !== undefined) { + fetchInit.body = init.body; + } + if (init.signal !== undefined) { + fetchInit.signal = init.signal; + } + + const res = await doFetch(`${root}${path}`, fetchInit); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + const detail = text.trim() || res.statusText || "request failed"; + throw new Error(`memory HTTP ${res.status}: ${detail}`); + } + + return res.json(); + } + + return { + add(body, signal) { + return request("/add", { + method: "POST", + body: JSON.stringify(body), + ...(signal !== undefined ? { signal } : {}), + }); + }, + search(body, signal) { + return request("/search", { + method: "POST", + body: JSON.stringify(body), + ...(signal !== undefined ? { signal } : {}), + }); + }, + list(limit, signal) { + const qs = + limit !== undefined + ? `?limit=${encodeURIComponent(String(limit))}` + : ""; + return request(`/list${qs}`, { + method: "GET", + ...(signal !== undefined ? { signal } : {}), + }); + }, + }; +} + +/** Env keys declared by every memory tool factory via `requires`. */ +export const MEMORY_TOOL_ENV_KEYS = [ + "memoryBaseUrl", + "memoryTenantId", + "memoryAuthToken", +] as const; + +export type MemoryToolEnvKeys = (typeof MEMORY_TOOL_ENV_KEYS)[number]; + +export type MemoryToolEnv = { + memoryBaseUrl: string; + memoryTenantId: string; + memoryAuthToken: string; +}; + +export function readMemoryToolEnv(env: MemoryToolEnv): MemoryHttpConfig { + const baseUrl = env.memoryBaseUrl; + const tenantId = env.memoryTenantId; + const authToken = env.memoryAuthToken; + if (typeof baseUrl !== "string" || baseUrl.length === 0) { + throw new Error("memoryBaseUrl must be a non-empty string"); + } + if (typeof tenantId !== "string" || tenantId.length === 0) { + throw new Error("memoryTenantId must be a non-empty string"); + } + if (typeof authToken !== "string" || authToken.length === 0) { + throw new Error("memoryAuthToken must be a non-empty string"); + } + return { baseUrl, tenantId, authToken }; +} diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..0249836 --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,18 @@ +/** + * Interchange `defineTool` factories for hub memory routes. + * + * Install on a workflow like any other open tool package. Each factory + * requires env: `memoryBaseUrl`, `memoryTenantId`, `memoryAuthToken`. + * Tools call `/api/tenants/:tenantId/memory/*` over HTTP — no plane DI. + */ + +export { memoryAdd } from "./add.ts"; +export { memorySearch } from "./search.ts"; +export { memoryList } from "./list.ts"; +export { + createMemoryHttpClient, + MEMORY_TOOL_ENV_KEYS, + type MemoryHttpClient, + type MemoryHttpConfig, + type MemoryToolEnv, +} from "./client.ts"; diff --git a/src/tools/list.ts b/src/tools/list.ts new file mode 100644 index 0000000..21f9544 --- /dev/null +++ b/src/tools/list.ts @@ -0,0 +1,67 @@ +import { + createToolRunner, + defineTool, + stringTool, + type BaseEnv, +} from "@intx/agent"; + +import { + createMemoryHttpClient, + MEMORY_TOOL_ENV_KEYS, + readMemoryToolEnv, + type MemoryToolEnv, +} from "./client.ts"; + +type ListEnv = BaseEnv & MemoryToolEnv; + +function asOptionalLimit(v: unknown): number | undefined { + if (v === undefined) return undefined; + if (typeof v !== "number" || !Number.isInteger(v) || v < 1 || v > 100) { + throw new Error("limit must be an integer from 1 to 100"); + } + return v; +} + +/** + * Installable tool: GET /api/tenants/:tenantId/memory/list. + * + * Tenant and auth come from env; model args never carry identity. + */ +export const memoryList = defineTool({ + id: "@corbits/memory/list", + requires: MEMORY_TOOL_ENV_KEYS, + factory(env) { + const client = createMemoryHttpClient(readMemoryToolEnv(env)); + const runner = createToolRunner([ + stringTool({ + definition: { + name: "memory_list", + description: + "List recent documents visible to the authenticated principal " + + "in this tenant's memory.", + inputSchema: { + type: "object", + properties: { + limit: { + type: "integer", + minimum: 1, + maximum: 100, + description: "Max events to return (1–100)", + }, + }, + additionalProperties: false, + }, + }, + handler: async (args, signal) => { + const limit = asOptionalLimit(args["limit"]); + const result = await client.list(limit, signal); + return JSON.stringify(result); + }, + }), + ]); + return { + definitions: runner.definitions, + run: (call, signal) => runner.run(call, signal), + }; + }, +}); diff --git a/src/tools/search.ts b/src/tools/search.ts new file mode 100644 index 0000000..0776162 --- /dev/null +++ b/src/tools/search.ts @@ -0,0 +1,136 @@ +import { + createToolRunner, + defineTool, + stringTool, + type BaseEnv, +} from "@intx/agent"; + +import { + createMemoryHttpClient, + MEMORY_TOOL_ENV_KEYS, + readMemoryToolEnv, + type MemorySearchBody, + type MemoryToolEnv, +} from "./client.ts"; + +type SearchEnv = BaseEnv & MemoryToolEnv; + +function asString(v: unknown, field: string): string { + if (typeof v !== "string" || v.length === 0) { + throw new Error(`${field} must be a non-empty string`); + } + return v; +} + +function asOptionalStringArray( + v: unknown, + field: string, +): string[] | undefined { + if (v === undefined) return undefined; + if (!Array.isArray(v) || !v.every((x) => typeof x === "string")) { + throw new Error(`${field} must be an array of strings`); + } + return v; +} + +function asOptionalLimit(v: unknown): number | undefined { + if (v === undefined) return undefined; + if (typeof v !== "number" || !Number.isInteger(v) || v < 1 || v > 50) { + throw new Error("limit must be an integer from 1 to 50"); + } + return v; +} + +function parseSearchArgs(args: Record): MemorySearchBody { + const query = asString(args["query"], "query"); + const limit = asOptionalLimit(args["limit"]); + const kinds = asOptionalStringArray(args["kinds"], "kinds"); + const entity_ids = asOptionalStringArray(args["entity_ids"], "entity_ids"); + const sources = asOptionalStringArray(args["sources"], "sources"); + const includeEvidence = args["includeEvidence"]; + if (includeEvidence !== undefined && typeof includeEvidence !== "boolean") { + throw new Error("includeEvidence must be a boolean"); + } + + return { + query, + ...(limit !== undefined ? { limit } : {}), + ...(kinds !== undefined ? { kinds } : {}), + ...(entity_ids !== undefined ? { entity_ids } : {}), + ...(sources !== undefined ? { sources } : {}), + ...(typeof includeEvidence === "boolean" + ? { includeEvidence } + : {}), + }; +} + +/** + * Installable tool: POST /api/tenants/:tenantId/memory/search. + * + * Tenant and auth come from env; model args never carry identity. + */ +export const memorySearch = defineTool({ + id: "@corbits/memory/search", + requires: MEMORY_TOOL_ENV_KEYS, + factory(env) { + const client = createMemoryHttpClient(readMemoryToolEnv(env)); + const runner = createToolRunner([ + stringTool({ + definition: { + name: "memory_search", + description: + "Hybrid semantic + keyword search over tenant memory. " + + "Returns ranked items (and optional evidence). Identity is " + + "the authenticated principal on the hub.", + inputSchema: { + type: "object", + properties: { + query: { + type: "string", + description: "Search query text", + }, + limit: { + type: "integer", + minimum: 1, + maximum: 50, + description: "Max hits to return (1–50)", + }, + kinds: { + type: "array", + items: { type: "string" }, + description: "Optional document kind filter", + }, + entity_ids: { + type: "array", + items: { type: "string" }, + description: "Optional entity-id filter", + }, + sources: { + type: "array", + items: { type: "string" }, + description: + 'Optional channel filter (e.g. "local" and/or live source ids)', + }, + includeEvidence: { + type: "boolean", + description: + "Include evidence strength on the response (hub default true)", + }, + }, + required: ["query"], + additionalProperties: false, + }, + }, + handler: async (args, signal) => { + const body = parseSearchArgs(args); + const result = await client.search(body, signal); + return JSON.stringify(result); + }, + }), + ]); + return { + definitions: runner.definitions, + run: (call, signal) => runner.run(call, signal), + }; + }, +}); diff --git a/src/tools/tools.test.ts b/src/tools/tools.test.ts new file mode 100644 index 0000000..63b451e --- /dev/null +++ b/src/tools/tools.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { BaseEnv } from "@intx/agent"; + +import { memoryAdd } from "./add.ts"; +import { memorySearch } from "./search.ts"; +import { memoryList } from "./list.ts"; +import { + createMemoryHttpClient, + MEMORY_TOOL_ENV_KEYS, + readMemoryToolEnv, + type MemoryToolEnv, +} from "./client.ts"; + +const BASE = "https://hub.example"; +const TENANT = "tenant-abc"; +const TOKEN = "tok-xyz"; + +/** Factories only read memory* keys; cast a minimal env for tests. */ +function toolEnv(overrides?: Partial): BaseEnv & MemoryToolEnv { + return { + memoryBaseUrl: overrides?.memoryBaseUrl ?? BASE, + memoryTenantId: overrides?.memoryTenantId ?? TENANT, + memoryAuthToken: overrides?.memoryAuthToken ?? TOKEN, + } as BaseEnv & MemoryToolEnv; +} + +type Captured = { + url: string; + method: string; + headers: Headers; + body: string | null; +}; + +function installFetch( + respond: (req: Captured) => { status: number; json: unknown }, +) { + const calls: Captured[] = []; + const fetchMock = mock( + async (input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const method = init?.method ?? "GET"; + const headers = new Headers(init?.headers); + const body = + typeof init?.body === "string" + ? init.body + : init?.body == null + ? null + : String(init.body); + const captured: Captured = { url, method, headers, body }; + calls.push(captured); + const { status, json } = respond(captured); + return new Response(JSON.stringify(json), { + status, + headers: { "Content-Type": "application/json" }, + }); + }, + ); + const previous = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + return { + calls, + restore() { + globalThis.fetch = previous; + }, + }; +} + +describe("MEMORY_TOOL_ENV_KEYS", () => { + test("lists the three credential keys", () => { + expect([...MEMORY_TOOL_ENV_KEYS]).toEqual([ + "memoryBaseUrl", + "memoryTenantId", + "memoryAuthToken", + ]); + }); +}); + +describe("readMemoryToolEnv", () => { + test("rejects empty base url", () => { + expect(() => + readMemoryToolEnv({ + memoryBaseUrl: "", + memoryTenantId: TENANT, + memoryAuthToken: TOKEN, + }), + ).toThrow(/memoryBaseUrl/); + }); +}); + +describe("createMemoryHttpClient", () => { + test("POSTs add under tenant path with Bearer auth", async () => { + const { calls, restore } = installFetch(() => ({ + status: 200, + json: { documentId: "doc-1" }, + })); + try { + const client = createMemoryHttpClient({ + baseUrl: `${BASE}/`, + tenantId: TENANT, + authToken: TOKEN, + }); + const out = await client.add({ title: "t", text: "body" }); + expect(out).toEqual({ documentId: "doc-1" }); + expect(calls).toHaveLength(1); + const c = calls[0]!; + expect(c.method).toBe("POST"); + expect(c.url).toBe(`${BASE}/api/tenants/${TENANT}/memory/add`); + expect(c.headers.get("Authorization")).toBe(`Bearer ${TOKEN}`); + expect(c.headers.get("Content-Type")).toBe("application/json"); + const parsed = JSON.parse(c.body ?? "{}") as Record; + expect(parsed).toEqual({ title: "t", text: "body" }); + expect(parsed).not.toHaveProperty("tenantId"); + expect(parsed).not.toHaveProperty("principalId"); + } finally { + restore(); + } + }); + + test("GET list with limit query", async () => { + const { calls, restore } = installFetch(() => ({ + status: 200, + json: { events: [] }, + })); + try { + const client = createMemoryHttpClient({ + baseUrl: BASE, + tenantId: TENANT, + authToken: TOKEN, + }); + await client.list(5); + expect(calls[0]!.method).toBe("GET"); + expect(calls[0]!.url).toBe( + `${BASE}/api/tenants/${TENANT}/memory/list?limit=5`, + ); + expect(calls[0]!.body).toBeNull(); + } finally { + restore(); + } + }); + + test("surfaces non-2xx as Error", async () => { + const { restore } = installFetch(() => ({ + status: 403, + json: { error: "forbidden" }, + })); + try { + const client = createMemoryHttpClient({ + baseUrl: BASE, + tenantId: TENANT, + authToken: TOKEN, + }); + await expect(client.search({ query: "x" })).rejects.toThrow( + /memory HTTP 403/, + ); + } finally { + restore(); + } + }); +}); + +describe("memoryAdd factory", () => { + test("declares id and requires", () => { + expect(memoryAdd.id).toBe("@corbits/memory/add"); + expect([...memoryAdd.requires]).toEqual([...MEMORY_TOOL_ENV_KEYS]); + }); + + test("happy path: body has no identity fields", async () => { + const { calls, restore } = installFetch(() => ({ + status: 200, + json: { documentId: "doc-9" }, + })); + try { + const bundle = memoryAdd(toolEnv()); + expect(bundle.definitions.map((d) => d.name)).toEqual(["memory_add"]); + const result = await bundle.run( + { + id: "call-1", + name: "memory_add", + arguments: { title: "note", text: "hello" }, + }, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + expect(result.content).toBe(JSON.stringify({ documentId: "doc-9" })); + const body = JSON.parse(calls[0]!.body ?? "{}") as Record; + expect(body).not.toHaveProperty("tenantId"); + expect(body).not.toHaveProperty("principalId"); + expect(body).not.toHaveProperty("tenant_id"); + expect(body).not.toHaveProperty("principal_id"); + } finally { + restore(); + } + }); +}); + +describe("memorySearch factory", () => { + test("POSTs search with query only", async () => { + const { calls, restore } = installFetch(() => ({ + status: 200, + json: { items: [], evidence: "none" }, + })); + try { + const bundle = memorySearch(toolEnv()); + const result = await bundle.run( + { + id: "call-2", + name: "memory_search", + arguments: { query: "standup notes" }, + }, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + expect(calls[0]!.url).toBe( + `${BASE}/api/tenants/${TENANT}/memory/search`, + ); + expect(JSON.parse(calls[0]!.body ?? "{}")).toEqual({ + query: "standup notes", + }); + } finally { + restore(); + } + }); +}); + +describe("memoryList factory", () => { + test("GETs list without identity in query", async () => { + const { calls, restore } = installFetch(() => ({ + status: 200, + json: { + events: [ + { + at: "2026-01-01", + title: "a", + source: "local", + tenantId: TENANT, + principalId: "p", + }, + ], + }, + })); + try { + const bundle = memoryList(toolEnv()); + const result = await bundle.run( + { + id: "call-3", + name: "memory_list", + arguments: { limit: 10 }, + }, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + expect(calls[0]!.url).toContain("/memory/list?limit=10"); + expect(calls[0]!.url).not.toContain("principal"); + } finally { + restore(); + } + }); + + test("HTTP error becomes isError tool result", async () => { + const { restore } = installFetch(() => ({ + status: 401, + json: { error: "unauthorized" }, + })); + try { + const bundle = memoryList(toolEnv()); + const result = await bundle.run( + { id: "call-4", name: "memory_list", arguments: {} }, + new AbortController().signal, + ); + expect(result.isError).toBe(true); + } finally { + restore(); + } + }); +}); From 463b44926db7dd9385a280c40e3dd055b4f73f8d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 5 Aug 2026 07:04:28 -0700 Subject: [PATCH 2/5] Document workflow defineTool install path for memory README and product docs now lead with mount plus the shipped @corbits/memory/tools factories instead of OpenAPI-MCP only. --- AGENTS.md | 2 ++ ARCHITECTURE.md | 7 ++++--- CHANGELOG.md | 7 +++++++ IMPLEMENTATION.md | 9 ++++++--- PRODUCT.md | 8 +++++--- README.md | 48 +++++++++++++++++++++++++++++++++++++---------- 6 files changed, 62 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 985934c..3c08490 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,8 @@ CI runs `typecheck` + `test` — both must pass before any push. - `src/mount-config.ts` / `src/config.ts` — mount config + engine config - `src/routes/` — Hono routes (`add`, `search`, `list`) +- `src/tools/` — Interchange `defineTool` factories (`@corbits/memory/tools`); + HTTP clients for mounted routes (env credentials; no in-process plane) - `src/services/` — capture / search / transform internals (not public verbs) - `src/ports/` — `DocumentStore` / `SourceProvider` + fakes - `src/core/` — embed/rerank clients, merge, arktype schemas diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6e1855f..de59243 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -73,9 +73,10 @@ exposes the same three verbs. Returns an in-process `Memory` (`add`, `search`, `list`, `close`) for host workers and ingestion modules that already resolved identity. -**Agent tools are not in this package.** Routes are OpenAPI-described -(`describeRoute`). The host mounts `@corbitsdev/hono-openapi-mcp` (or any -OpenAPI→tools bridge) so agents call these routes under Interchange auth. +**Agent tools live in this package** as thin HTTP clients +(`@corbits/memory/tools` / `interchange.tools`): `defineTool` factories that +`fetch` the mounted routes with install credentials. They do not import the +in-process plane. OpenAPI→MCP remains an optional host bridge. ## Provenance diff --git a/CHANGELOG.md b/CHANGELOG.md index fa02ede..19f99b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Interchange `defineTool` factories at `@corbits/memory/tools` (`memory_add`, + `memory_search`, `memory_list`) — HTTP clients for mounted hub routes with + install env `memoryBaseUrl` / `memoryTenantId` / `memoryAuthToken`. Declared + via `package.json` `interchange.tools` and `exports["./tools"]`. + ### Changed - **Breaking:** package and public surface renamed from `@corbits/knowledge-engine` diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 556318b..88b61e0 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -491,9 +491,12 @@ Each route is guarded with `grantGuard(deps, action)`, which applies the host's | `GET /api/tenants/:tenantId/memory/list` | `search` | — | `200 { events: [{ at, title, source, tenantId, principalId }] }` — durable recent documents for the caller's scope, filtered with grant-tag access (`canAccessDocument`). One event per document (active live version). | `registerMemoryRoutes` and `createMemory({ app })` register the three HTTP routes. -Agent tools are a host concern — mount `@corbitsdev/hono-openapi-mcp` (or any -OpenAPI→tools bridge) against the same app. The plane surface is only -`add` / `search` / `list` (plus `close`); inference stays on the host. +Agent tools ship in this package as Interchange `defineTool` factories +(`@corbits/memory/tools` / `interchange.tools`): thin HTTP clients that call the +mounted routes with install env (`memoryBaseUrl`, `memoryTenantId`, +`memoryAuthToken`). They do not import the plane. OpenAPI→MCP remains an optional +host bridge. The plane surface is only `add` / `search` / `list` (plus `close`); +inference stays on the host. diff --git a/PRODUCT.md b/PRODUCT.md index a31d3cc..e4e070d 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -18,6 +18,7 @@ never creates one; it mounts onto yours. | `loadMemoryConfig()` | Config from env | | `runMemoryMigrations(url)` | Apply pgvector schema | | `registerMemoryRoutes` | Low-level HTTP only (optional) | +| `@corbits/memory/tools` | Interchange `defineTool` factories (`memory_add` / `memory_search` / `memory_list`) | ### Verbs @@ -53,9 +54,10 @@ Agent / ingestion module ``` 1. **Mount** — host passes `app` + the same grant store it already uses. -2. **Tools** — host exposes the OpenAPI routes as agent tools (e.g. - `@corbitsdev/hono-openapi-mcp`). Agents call add/search/list as the - authenticated principal. +2. **Tools** — install `@corbits/memory/tools` (`defineTool` factories) on a + workflow with env credentials (`memoryBaseUrl`, `memoryTenantId`, + `memoryAuthToken`). Tools HTTP-call the mounted routes; identity is the + hub-authenticated principal. OpenAPI→MCP remains an optional host bridge. 3. **Ingestion** — host modules (webhooks, batch jobs) call the routes or the returned plane with a resolved principal. diff --git a/README.md b/README.md index 0cb4496..4bd9fc8 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ Memory for [Interchange](https://github.com/corbitsdev) hubs: **add**, **search* Mount it on the hub. Routes land under `/api/tenants/:tenantId/memory/*`, so the hub’s existing `createResolveTenant` middleware supplies principal + tenant -— same as workflows, assets, and agents. Agents and ingestion modules call those -routes (tools / OpenAPI→MCP, or in-process from a host worker). That’s the -product. +— same as workflows, assets, and agents. Workflow agents install the package’s +`defineTool` factories; ingestion modules call the same routes or the in-process +plane. That’s the product. Requires Bun 1.2+. @@ -20,7 +20,7 @@ bun add git+https://github.com/corbitsdev/corbits-memory.git ``` Peer stack you already have on an Interchange hub: `@intx/authz`, `@intx/hub-api`, -`hono`. +`hono`. Agent tools also need `@intx/agent` (declared as a direct dependency). ## Mount (≈5 lines) @@ -57,13 +57,41 @@ POST /api/tenants/:tenantId/memory/search { "query", "limit"? } GET /api/tenants/:tenantId/memory/list ?limit= ``` -## Who calls the routes +## Workflow agent tools -1. **Agent tools** — routes are OpenAPI-described (`hono-openapi`). On the host, - mount `@corbitsdev/hono-openapi-mcp` (or any OpenAPI→tools bridge) so agents - get tools that hit the memory paths under Interchange auth. -2. **Ingestion modules** — host workers that already resolved identity call the - same plane in-process (no HTTP hop): +This package exports Interchange `defineTool` factories at +`@corbits/memory/tools` (also `package.json` → `interchange.tools`). Each tool +is a thin HTTP client: install credentials in agent env, call the mounted hub +routes. No plane inject, no model-supplied identity. + +| Factory id | Tool name | HTTP | +| --- | --- | --- | +| `@corbits/memory/add` | `memory_add` | `POST …/memory/add` | +| `@corbits/memory/search` | `memory_search` | `POST …/memory/search` | +| `@corbits/memory/list` | `memory_list` | `GET …/memory/list` | + +**Env keys** (declared on each factory’s `requires`): + +| Key | Meaning | +| --- | --- | +| `memoryBaseUrl` | Hub origin, e.g. `https://hub.example` | +| `memoryTenantId` | Tenant path segment | +| `memoryAuthToken` | Bearer token accepted by the hub for that principal | + +```ts +import { memoryAdd, memorySearch, memoryList } from "@corbits/memory/tools"; + +// On a workflow / agent definition — install like any open tool package: +// tools: [memoryAdd, memorySearch, memoryList] +// and supply memoryBaseUrl / memoryTenantId / memoryAuthToken in agent env. +``` + +OpenAPI→MCP remains available as an alternative host bridge; the shipped +`defineTool`s are the primary install path for workflow agents. + +## Ingestion (in-process) + +Host workers that already resolved identity can call the plane without HTTP: ```ts await memory.add({ From aa8386fa5994f11dbcf7de19a303c12259c4aa29 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 5 Aug 2026 07:28:53 -0700 Subject: [PATCH 3/5] Harden memory tools: shared HTTP schemas, client, factory Share AddRequest/SearchRequest (and limit bounds) between routes and defineTool parsers without importing the plane. Collapse tool factories through defineMemoryHttpTool; harden the HTTP client (multi-slash base, empty/invalid JSON); strip adversarial identity args and coerce LLM string limits. Expand tools tests accordingly. --- src/http-bodies.ts | 71 ++++++++ src/limits.ts | 7 + src/memory.ts | 22 ++- src/routes/add.ts | 16 +- src/routes/search.ts | 9 +- src/tools/add.ts | 182 +++++++------------- src/tools/client.ts | 34 +++- src/tools/install.ts | 62 +++++++ src/tools/list.ts | 89 ++++------ src/tools/search.ts | 190 ++++++++------------- src/tools/tools.test.ts | 365 +++++++++++++++++++++++++--------------- 11 files changed, 578 insertions(+), 469 deletions(-) create mode 100644 src/http-bodies.ts create mode 100644 src/limits.ts create mode 100644 src/tools/install.ts diff --git a/src/http-bodies.ts b/src/http-bodies.ts new file mode 100644 index 0000000..3de7bbf --- /dev/null +++ b/src/http-bodies.ts @@ -0,0 +1,71 @@ +/** + * Shared request bodies for hub memory HTTP and defineTool factories. + * Keep route validators and tool arg parsers on the same schemas. + */ +import { type } from "arktype"; + +import { + LIST_LIMIT_MAX, + LIST_LIMIT_MIN, + SEARCH_LIMIT_MAX, + SEARCH_LIMIT_MIN, +} from "./limits.ts"; + +export const ShareBody = type({ + "tenant?": "boolean", + "principals?": "string[]", + "tags?": "string[]", +}); + +export const AddRequest = type({ + title: "string >= 1", + text: "string >= 1", + "access_tags?": "string[]", + "share?": ShareBody, +}); + +export type AddRequest = typeof AddRequest.infer; + +export const SearchRequest = type({ + query: "string >= 1", + "limit?": type(`${SEARCH_LIMIT_MIN} <= number.integer <= ${SEARCH_LIMIT_MAX}`), + "kinds?": "string[]", + "entity_ids?": "string[]", + "sources?": "string[]", + "includeEvidence?": "boolean", +}); + +export type SearchRequest = typeof SearchRequest.infer; + +export const ListArgs = type({ + "limit?": type(`${LIST_LIMIT_MIN} <= number.integer <= ${LIST_LIMIT_MAX}`), +}); + +export type ListArgs = typeof ListArgs.infer; + +/** Coerce LLM-stringified integers before arktype number.integer checks. */ +export function coerceOptionalLimitArg( + args: Record, +): Record { + const raw = args["limit"]; + if (raw === undefined || typeof raw === "number") return args; + if (typeof raw === "string" && raw.trim() !== "") { + const n = Number(raw); + if (Number.isFinite(n)) { + return { ...args, limit: n }; + } + } + return args; +} + +export function parseWithArk( + schema: (data: unknown) => T | type.errors, + data: unknown, + label: string, +): T { + const parsed = schema(data); + if (parsed instanceof type.errors) { + throw new Error(`${label}: ${parsed.summary}`); + } + return parsed; +} diff --git a/src/limits.ts b/src/limits.ts new file mode 100644 index 0000000..1a6fe23 --- /dev/null +++ b/src/limits.ts @@ -0,0 +1,7 @@ +/** Green find (search) limit bounds. */ +export const SEARCH_LIMIT_MIN = 1; +export const SEARCH_LIMIT_MAX = 50; + +/** Green recent (list) limit bounds. */ +export const LIST_LIMIT_MIN = 1; +export const LIST_LIMIT_MAX = 100; diff --git a/src/memory.ts b/src/memory.ts index 54e5e58..0d00076 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -41,6 +41,13 @@ import type { DocumentStoreSearchParams, SourceProvider, } from "./ports/types.ts"; +import { + LIST_LIMIT_MAX, + LIST_LIMIT_MIN, + SEARCH_LIMIT_MAX, + SEARCH_LIMIT_MIN, +} from "./limits.ts"; + // (drizzle select was used briefly for grant-tag load; raw sql keeps unit-test // mocks simple and matches the rest of the engine store.) @@ -53,6 +60,13 @@ export type { LiveSearchItem, SourceProvider, } from "./ports/types.ts"; +export { + SEARCH_LIMIT_MIN, + SEARCH_LIMIT_MAX, + LIST_LIMIT_MIN, + LIST_LIMIT_MAX, +} from "./limits.ts"; + export { resolveAccessTags, ownerTag, @@ -78,14 +92,6 @@ export type MemoryIdentity = { tenantId: string; }; -/** Green find limit bounds (stricter than hybridSearch's internal MAX_K). */ -export const SEARCH_LIMIT_MIN = 1; -export const SEARCH_LIMIT_MAX = 50; - -/** Green recent limit bounds (matches timeline service default/cap). */ -export const LIST_LIMIT_MIN = 1; -export const LIST_LIMIT_MAX = 100; - export type MemorySearchParams = MemoryIdentity & { query: string; /** Max items to return (1–50). Default 8. */ diff --git a/src/routes/add.ts b/src/routes/add.ts index 365c110..7e5a979 100644 --- a/src/routes/add.ts +++ b/src/routes/add.ts @@ -5,26 +5,12 @@ import { type } from "arktype"; import { formatCaughtError, log } from "../log.ts"; import { resolveAccessTags, type ShareSugar } from "../grant-tags.ts"; +import { AddRequest } from "../http-bodies.ts"; import { MemoryError } from "../memory.ts"; import type { RouteDeps } from "./deps.ts"; import { caller, grantGuard, requirePrincipal } from "./deps.ts"; -const ShareBody = type({ - "tenant?": "boolean", - "principals?": "string[]", - "tags?": "string[]", -}); - -const AddRequest = type({ - title: "string >= 1", - text: "string >= 1", - /** Explicit resource tags (grant-pattern space). */ - "access_tags?": "string[]", - /** Share sugar — mints tags only. */ - "share?": ShareBody, -}); - const AddResponse = type({ documentId: "string", }); diff --git a/src/routes/search.ts b/src/routes/search.ts index e030bfb..2dc0481 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -4,6 +4,7 @@ import { describeRoute, resolver, validator } from "hono-openapi"; import { type } from "arktype"; import { formatCaughtError, log } from "../log.ts"; +import { SearchRequest } from "../http-bodies.ts"; import { MemoryError } from "../memory.ts"; import type { RouteDeps } from "./deps.ts"; import { caller, grantGuard, requirePrincipal } from "./deps.ts"; @@ -15,14 +16,6 @@ import { caller, grantGuard, requirePrincipal } from "./deps.ts"; // An empty array on either field is equivalent to omitting it — "no filter" // — not "match nothing", and does not satisfy the requirement that an empty // `query` be paired with a non-empty structured filter. -const SearchRequest = type({ - query: "string >= 1", - "limit?": "1 <= number.integer <= 50", - "kinds?": "string[]", - "entity_ids?": "string[]", - "sources?": "string[]", - "includeEvidence?": "boolean", -}); const SearchResponse = type({ items: type({ diff --git a/src/tools/add.ts b/src/tools/add.ts index 2728562..aaa3784 100644 --- a/src/tools/add.ts +++ b/src/tools/add.ts @@ -1,69 +1,21 @@ -import { - createToolRunner, - defineTool, - stringTool, - type BaseEnv, -} from "@intx/agent"; - -import { - createMemoryHttpClient, - MEMORY_TOOL_ENV_KEYS, - readMemoryToolEnv, - type MemoryAddBody, - type MemoryToolEnv, -} from "./client.ts"; - -type AddEnv = BaseEnv & MemoryToolEnv; - -function asString(v: unknown, field: string): string { - if (typeof v !== "string" || v.length === 0) { - throw new Error(`${field} must be a non-empty string`); - } - return v; -} - -function asOptionalStringArray( - v: unknown, - field: string, -): string[] | undefined { - if (v === undefined) return undefined; - if (!Array.isArray(v) || !v.every((x) => typeof x === "string")) { - throw new Error(`${field} must be an array of strings`); - } - return v; -} +import { AddRequest, parseWithArk } from "../http-bodies.ts"; +import type { MemoryAddBody } from "./client.ts"; +import { defineMemoryHttpTool } from "./install.ts"; function parseAddArgs(args: Record): MemoryAddBody { - const title = asString(args["title"], "title"); - const text = asString(args["text"], "text"); - const access_tags = asOptionalStringArray(args["access_tags"], "access_tags"); - - let share: MemoryAddBody["share"]; - const rawShare = args["share"]; - if (rawShare !== undefined) { - if (rawShare === null || typeof rawShare !== "object" || Array.isArray(rawShare)) { - throw new Error("share must be an object"); - } - const s = rawShare as Record; - const tenant = s["tenant"]; - if (tenant !== undefined && typeof tenant !== "boolean") { - throw new Error("share.tenant must be a boolean"); - } - const principals = asOptionalStringArray(s["principals"], "share.principals"); - const tags = asOptionalStringArray(s["tags"], "share.tags"); - share = { - ...(tenant !== undefined ? { tenant } : {}), - ...(principals !== undefined ? { principals } : {}), - ...(tags !== undefined ? { tags } : {}), - }; - } - - return { - title, - text, - ...(access_tags !== undefined ? { access_tags } : {}), - ...(share !== undefined ? { share } : {}), + const parsed = parseWithArk(AddRequest, args, "memory_add"); + // Forward only schema fields so identity keys never ride the wire. + const body: MemoryAddBody = { + title: parsed.title, + text: parsed.text, }; + if (parsed.access_tags !== undefined) { + body.access_tags = parsed.access_tags; + } + if (parsed.share !== undefined) { + body.share = parsed.share; + } + return body; } /** @@ -72,67 +24,53 @@ function parseAddArgs(args: Record): MemoryAddBody { * Tenant and auth come from env (`memoryTenantId`, `memoryAuthToken`); * model args never carry identity. */ -export const memoryAdd = defineTool({ +export const memoryAdd = defineMemoryHttpTool({ id: "@corbits/memory/add", - requires: MEMORY_TOOL_ENV_KEYS, - factory(env) { - const client = createMemoryHttpClient(readMemoryToolEnv(env)); - const runner = createToolRunner([ - stringTool({ - definition: { - name: "memory_add", - description: - "Store a note in tenant memory. Returns { documentId }. " + - "Identity is the authenticated principal on the hub; do not " + - "pass tenant or principal ids.", - inputSchema: { - type: "object", - properties: { - title: { - type: "string", - description: "Short title for the document", - }, - text: { - type: "string", - description: "Full body text to store", - }, - access_tags: { - type: "array", - items: { type: "string" }, - description: - "Optional grant-pattern tags controlling document visibility", - }, - share: { - type: "object", - properties: { - tenant: { type: "boolean" }, - principals: { - type: "array", - items: { type: "string" }, - }, - tags: { - type: "array", - items: { type: "string" }, - }, - }, - description: - "Optional share sugar that mints access tags (tenant / principals / tags)", - }, - }, - required: ["title", "text"], - additionalProperties: false, + name: "memory_add", + description: + "Store a note in tenant memory. Returns { documentId }. " + + "Identity is the authenticated principal on the hub; do not " + + "pass tenant or principal ids.", + inputSchema: { + type: "object", + properties: { + title: { + type: "string", + description: "Short title for the document", + }, + text: { + type: "string", + description: "Full body text to store", + }, + access_tags: { + type: "array", + items: { type: "string" }, + description: + "Optional grant-pattern tags controlling document visibility", + }, + share: { + type: "object", + properties: { + tenant: { type: "boolean" }, + principals: { + type: "array", + items: { type: "string" }, + }, + tags: { + type: "array", + items: { type: "string" }, }, }, - handler: async (args, signal) => { - const body = parseAddArgs(args); - const result = await client.add(body, signal); - return JSON.stringify(result); - }, - }), - ]); - return { - definitions: runner.definitions, - run: (call, signal) => runner.run(call, signal), - }; + description: + "Optional share sugar that mints access tags (tenant / principals / tags)", + }, + }, + required: ["title", "text"], + additionalProperties: false, + }, + async handle(client, args, signal) { + const body = parseAddArgs(args); + const result = await client.add(body, signal); + return JSON.stringify(result); }, }); diff --git a/src/tools/client.ts b/src/tools/client.ts index 77a7d76..a70357f 100644 --- a/src/tools/client.ts +++ b/src/tools/client.ts @@ -38,14 +38,18 @@ export type MemoryHttpClient = { list(limit?: number, signal?: AbortSignal): Promise; }; -function stripTrailingSlash(url: string): string { - return url.endsWith("/") ? url.slice(0, -1) : url; +function stripTrailingSlashes(url: string): string { + let out = url; + while (out.endsWith("/")) { + out = out.slice(0, -1); + } + return out; } export function createMemoryHttpClient( config: MemoryHttpConfig, ): MemoryHttpClient { - const base = stripTrailingSlash(config.baseUrl); + const base = stripTrailingSlashes(config.baseUrl); const root = `${base}/api/tenants/${encodeURIComponent(config.tenantId)}/memory`; const doFetch = config.fetch ?? globalThis.fetch.bind(globalThis); @@ -84,7 +88,18 @@ export function createMemoryHttpClient( throw new Error(`memory HTTP ${res.status}: ${detail}`); } - return res.json(); + const text = await res.text().catch(() => ""); + if (!text.trim()) { + return {}; + } + try { + return JSON.parse(text) as unknown; + } catch (cause) { + throw new Error( + `memory HTTP ${res.status}: invalid JSON response`, + { cause }, + ); + } } return { @@ -128,6 +143,10 @@ export type MemoryToolEnv = { memoryBaseUrl: string; memoryTenantId: string; memoryAuthToken: string; + /** + * Optional host/test inject. Not part of `requires` — agents never set this. + */ + memoryFetch?: typeof globalThis.fetch; }; export function readMemoryToolEnv(env: MemoryToolEnv): MemoryHttpConfig { @@ -143,5 +162,10 @@ export function readMemoryToolEnv(env: MemoryToolEnv): MemoryHttpConfig { if (typeof authToken !== "string" || authToken.length === 0) { throw new Error("memoryAuthToken must be a non-empty string"); } - return { baseUrl, tenantId, authToken }; + return { + baseUrl, + tenantId, + authToken, + ...(env.memoryFetch !== undefined ? { fetch: env.memoryFetch } : {}), + }; } diff --git a/src/tools/install.ts b/src/tools/install.ts new file mode 100644 index 0000000..688dd62 --- /dev/null +++ b/src/tools/install.ts @@ -0,0 +1,62 @@ +import { + createToolRunner, + defineTool, + stringTool, + type BaseEnv, +} from "@intx/agent"; + +import { + createMemoryHttpClient, + MEMORY_TOOL_ENV_KEYS, + readMemoryToolEnv, + type MemoryHttpClient, + type MemoryToolEnv, +} from "./client.ts"; + +export type MemoryInstallEnv = BaseEnv & MemoryToolEnv; + +type JSONSchemaObject = { + type: "object"; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; +}; + +/** + * Shared defineTool shell for memory HTTP tools. + * Credentials from env; model args never carry identity. + */ +export function defineMemoryHttpTool(opts: { + id: string; + name: string; + description: string; + inputSchema: JSONSchemaObject; + handle: ( + client: MemoryHttpClient, + args: Record, + signal: AbortSignal | undefined, + ) => Promise; +}) { + return defineTool({ + id: opts.id, + requires: MEMORY_TOOL_ENV_KEYS, + factory(env) { + const client = createMemoryHttpClient(readMemoryToolEnv(env)); + const runner = createToolRunner([ + stringTool({ + definition: { + name: opts.name, + description: opts.description, + inputSchema: opts.inputSchema, + }, + handler: async (args, signal) => + opts.handle(client, args, signal), + }), + ]); + return { + definitions: runner.definitions, + run: (call, signal) => runner.run(call, signal), + }; + }, + }); +} diff --git a/src/tools/list.ts b/src/tools/list.ts index 21f9544..bf8c328 100644 --- a/src/tools/list.ts +++ b/src/tools/list.ts @@ -1,25 +1,18 @@ import { - createToolRunner, - defineTool, - stringTool, - type BaseEnv, -} from "@intx/agent"; + coerceOptionalLimitArg, + ListArgs, + parseWithArk, +} from "../http-bodies.ts"; +import { LIST_LIMIT_MAX, LIST_LIMIT_MIN } from "../limits.ts"; +import { defineMemoryHttpTool } from "./install.ts"; -import { - createMemoryHttpClient, - MEMORY_TOOL_ENV_KEYS, - readMemoryToolEnv, - type MemoryToolEnv, -} from "./client.ts"; - -type ListEnv = BaseEnv & MemoryToolEnv; - -function asOptionalLimit(v: unknown): number | undefined { - if (v === undefined) return undefined; - if (typeof v !== "number" || !Number.isInteger(v) || v < 1 || v > 100) { - throw new Error("limit must be an integer from 1 to 100"); - } - return v; +function parseListLimit(args: Record): number | undefined { + const parsed = parseWithArk( + ListArgs, + coerceOptionalLimitArg(args), + "memory_list", + ); + return parsed.limit; } /** @@ -27,41 +20,27 @@ function asOptionalLimit(v: unknown): number | undefined { * * Tenant and auth come from env; model args never carry identity. */ -export const memoryList = defineTool({ +export const memoryList = defineMemoryHttpTool({ id: "@corbits/memory/list", - requires: MEMORY_TOOL_ENV_KEYS, - factory(env) { - const client = createMemoryHttpClient(readMemoryToolEnv(env)); - const runner = createToolRunner([ - stringTool({ - definition: { - name: "memory_list", - description: - "List recent documents visible to the authenticated principal " + - "in this tenant's memory.", - inputSchema: { - type: "object", - properties: { - limit: { - type: "integer", - minimum: 1, - maximum: 100, - description: "Max events to return (1–100)", - }, - }, - additionalProperties: false, - }, - }, - handler: async (args, signal) => { - const limit = asOptionalLimit(args["limit"]); - const result = await client.list(limit, signal); - return JSON.stringify(result); - }, - }), - ]); - return { - definitions: runner.definitions, - run: (call, signal) => runner.run(call, signal), - }; + name: "memory_list", + description: + "List recent documents visible to the authenticated principal " + + "in this tenant's memory.", + inputSchema: { + type: "object", + properties: { + limit: { + type: "integer", + minimum: LIST_LIMIT_MIN, + maximum: LIST_LIMIT_MAX, + description: `Max events to return (${LIST_LIMIT_MIN}–${LIST_LIMIT_MAX})`, + }, + }, + additionalProperties: false, + }, + async handle(client, args, signal) { + const limit = parseListLimit(args); + const result = await client.list(limit, signal); + return JSON.stringify(result); }, }); diff --git a/src/tools/search.ts b/src/tools/search.ts index 0776162..77dd9b8 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -1,67 +1,27 @@ import { - createToolRunner, - defineTool, - stringTool, - type BaseEnv, -} from "@intx/agent"; - -import { - createMemoryHttpClient, - MEMORY_TOOL_ENV_KEYS, - readMemoryToolEnv, - type MemorySearchBody, - type MemoryToolEnv, -} from "./client.ts"; - -type SearchEnv = BaseEnv & MemoryToolEnv; - -function asString(v: unknown, field: string): string { - if (typeof v !== "string" || v.length === 0) { - throw new Error(`${field} must be a non-empty string`); - } - return v; -} - -function asOptionalStringArray( - v: unknown, - field: string, -): string[] | undefined { - if (v === undefined) return undefined; - if (!Array.isArray(v) || !v.every((x) => typeof x === "string")) { - throw new Error(`${field} must be an array of strings`); - } - return v; -} - -function asOptionalLimit(v: unknown): number | undefined { - if (v === undefined) return undefined; - if (typeof v !== "number" || !Number.isInteger(v) || v < 1 || v > 50) { - throw new Error("limit must be an integer from 1 to 50"); - } - return v; -} + coerceOptionalLimitArg, + parseWithArk, + SearchRequest, +} from "../http-bodies.ts"; +import { SEARCH_LIMIT_MAX, SEARCH_LIMIT_MIN } from "../limits.ts"; +import type { MemorySearchBody } from "./client.ts"; +import { defineMemoryHttpTool } from "./install.ts"; function parseSearchArgs(args: Record): MemorySearchBody { - const query = asString(args["query"], "query"); - const limit = asOptionalLimit(args["limit"]); - const kinds = asOptionalStringArray(args["kinds"], "kinds"); - const entity_ids = asOptionalStringArray(args["entity_ids"], "entity_ids"); - const sources = asOptionalStringArray(args["sources"], "sources"); - const includeEvidence = args["includeEvidence"]; - if (includeEvidence !== undefined && typeof includeEvidence !== "boolean") { - throw new Error("includeEvidence must be a boolean"); + const parsed = parseWithArk( + SearchRequest, + coerceOptionalLimitArg(args), + "memory_search", + ); + const body: MemorySearchBody = { query: parsed.query }; + if (parsed.limit !== undefined) body.limit = parsed.limit; + if (parsed.kinds !== undefined) body.kinds = parsed.kinds; + if (parsed.entity_ids !== undefined) body.entity_ids = parsed.entity_ids; + if (parsed.sources !== undefined) body.sources = parsed.sources; + if (parsed.includeEvidence !== undefined) { + body.includeEvidence = parsed.includeEvidence; } - - return { - query, - ...(limit !== undefined ? { limit } : {}), - ...(kinds !== undefined ? { kinds } : {}), - ...(entity_ids !== undefined ? { entity_ids } : {}), - ...(sources !== undefined ? { sources } : {}), - ...(typeof includeEvidence === "boolean" - ? { includeEvidence } - : {}), - }; + return body; } /** @@ -69,68 +29,54 @@ function parseSearchArgs(args: Record): MemorySearchBody { * * Tenant and auth come from env; model args never carry identity. */ -export const memorySearch = defineTool({ +export const memorySearch = defineMemoryHttpTool({ id: "@corbits/memory/search", - requires: MEMORY_TOOL_ENV_KEYS, - factory(env) { - const client = createMemoryHttpClient(readMemoryToolEnv(env)); - const runner = createToolRunner([ - stringTool({ - definition: { - name: "memory_search", - description: - "Hybrid semantic + keyword search over tenant memory. " + - "Returns ranked items (and optional evidence). Identity is " + - "the authenticated principal on the hub.", - inputSchema: { - type: "object", - properties: { - query: { - type: "string", - description: "Search query text", - }, - limit: { - type: "integer", - minimum: 1, - maximum: 50, - description: "Max hits to return (1–50)", - }, - kinds: { - type: "array", - items: { type: "string" }, - description: "Optional document kind filter", - }, - entity_ids: { - type: "array", - items: { type: "string" }, - description: "Optional entity-id filter", - }, - sources: { - type: "array", - items: { type: "string" }, - description: - 'Optional channel filter (e.g. "local" and/or live source ids)', - }, - includeEvidence: { - type: "boolean", - description: - "Include evidence strength on the response (hub default true)", - }, - }, - required: ["query"], - additionalProperties: false, - }, - }, - handler: async (args, signal) => { - const body = parseSearchArgs(args); - const result = await client.search(body, signal); - return JSON.stringify(result); - }, - }), - ]); - return { - definitions: runner.definitions, - run: (call, signal) => runner.run(call, signal), - }; + name: "memory_search", + description: + "Hybrid semantic + keyword search over tenant memory. " + + "Returns ranked items (and optional evidence). Identity is " + + "the authenticated principal on the hub.", + inputSchema: { + type: "object", + properties: { + query: { + type: "string", + description: "Search query text", + }, + limit: { + type: "integer", + minimum: SEARCH_LIMIT_MIN, + maximum: SEARCH_LIMIT_MAX, + description: `Max hits to return (${SEARCH_LIMIT_MIN}–${SEARCH_LIMIT_MAX})`, + }, + kinds: { + type: "array", + items: { type: "string" }, + description: "Optional document kind filter", + }, + entity_ids: { + type: "array", + items: { type: "string" }, + description: "Optional entity-id filter", + }, + sources: { + type: "array", + items: { type: "string" }, + description: + 'Optional channel filter (e.g. "local" and/or live source ids)', + }, + includeEvidence: { + type: "boolean", + description: + "Include evidence strength on the response (hub default true)", + }, + }, + required: ["query"], + additionalProperties: false, + }, + async handle(client, args, signal) { + const body = parseSearchArgs(args); + const result = await client.search(body, signal); + return JSON.stringify(result); }, }); diff --git a/src/tools/tools.test.ts b/src/tools/tools.test.ts index 63b451e..b3de8e7 100644 --- a/src/tools/tools.test.ts +++ b/src/tools/tools.test.ts @@ -21,6 +21,9 @@ function toolEnv(overrides?: Partial): BaseEnv & MemoryToolEnv { memoryBaseUrl: overrides?.memoryBaseUrl ?? BASE, memoryTenantId: overrides?.memoryTenantId ?? TENANT, memoryAuthToken: overrides?.memoryAuthToken ?? TOKEN, + ...(overrides?.memoryFetch !== undefined + ? { memoryFetch: overrides.memoryFetch } + : {}), } as BaseEnv & MemoryToolEnv; } @@ -31,8 +34,8 @@ type Captured = { body: string | null; }; -function installFetch( - respond: (req: Captured) => { status: number; json: unknown }, +function makeFetchMock( + respond: (req: Captured) => { status: number; json?: unknown; text?: string }, ) { const calls: Captured[] = []; const fetchMock = mock( @@ -53,21 +56,20 @@ function installFetch( : String(init.body); const captured: Captured = { url, method, headers, body }; calls.push(captured); - const { status, json } = respond(captured); - return new Response(JSON.stringify(json), { - status, + const r = respond(captured); + if (r.text !== undefined) { + return new Response(r.text, { + status: r.status, + headers: { "Content-Type": "text/plain" }, + }); + } + return new Response(JSON.stringify(r.json ?? {}), { + status: r.status, headers: { "Content-Type": "application/json" }, }); }, ); - const previous = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof fetch; - return { - calls, - restore() { - globalThis.fetch = previous; - }, - }; + return { calls, fetchMock: fetchMock as unknown as typeof fetch }; } describe("MEMORY_TOOL_ENV_KEYS", () => { @@ -90,76 +92,112 @@ describe("readMemoryToolEnv", () => { }), ).toThrow(/memoryBaseUrl/); }); + + test("rejects empty tenant and token", () => { + expect(() => + readMemoryToolEnv({ + memoryBaseUrl: BASE, + memoryTenantId: "", + memoryAuthToken: TOKEN, + }), + ).toThrow(/memoryTenantId/); + expect(() => + readMemoryToolEnv({ + memoryBaseUrl: BASE, + memoryTenantId: TENANT, + memoryAuthToken: "", + }), + ).toThrow(/memoryAuthToken/); + }); }); describe("createMemoryHttpClient", () => { test("POSTs add under tenant path with Bearer auth", async () => { - const { calls, restore } = installFetch(() => ({ + const { calls, fetchMock } = makeFetchMock(() => ({ status: 200, json: { documentId: "doc-1" }, })); - try { - const client = createMemoryHttpClient({ - baseUrl: `${BASE}/`, - tenantId: TENANT, - authToken: TOKEN, - }); - const out = await client.add({ title: "t", text: "body" }); - expect(out).toEqual({ documentId: "doc-1" }); - expect(calls).toHaveLength(1); - const c = calls[0]!; - expect(c.method).toBe("POST"); - expect(c.url).toBe(`${BASE}/api/tenants/${TENANT}/memory/add`); - expect(c.headers.get("Authorization")).toBe(`Bearer ${TOKEN}`); - expect(c.headers.get("Content-Type")).toBe("application/json"); - const parsed = JSON.parse(c.body ?? "{}") as Record; - expect(parsed).toEqual({ title: "t", text: "body" }); - expect(parsed).not.toHaveProperty("tenantId"); - expect(parsed).not.toHaveProperty("principalId"); - } finally { - restore(); - } + const client = createMemoryHttpClient({ + baseUrl: `${BASE}///`, + tenantId: TENANT, + authToken: TOKEN, + fetch: fetchMock, + }); + const out = await client.add({ title: "t", text: "body" }); + expect(out).toEqual({ documentId: "doc-1" }); + expect(calls).toHaveLength(1); + const c = calls[0]!; + expect(c.method).toBe("POST"); + expect(c.url).toBe(`${BASE}/api/tenants/${TENANT}/memory/add`); + expect(c.headers.get("Authorization")).toBe(`Bearer ${TOKEN}`); + expect(c.headers.get("Content-Type")).toBe("application/json"); + const parsed = JSON.parse(c.body ?? "{}") as Record; + expect(parsed).toEqual({ title: "t", text: "body" }); + expect(parsed).not.toHaveProperty("tenantId"); + expect(parsed).not.toHaveProperty("principalId"); }); test("GET list with limit query", async () => { - const { calls, restore } = installFetch(() => ({ + const { calls, fetchMock } = makeFetchMock(() => ({ status: 200, json: { events: [] }, })); - try { - const client = createMemoryHttpClient({ - baseUrl: BASE, - tenantId: TENANT, - authToken: TOKEN, - }); - await client.list(5); - expect(calls[0]!.method).toBe("GET"); - expect(calls[0]!.url).toBe( - `${BASE}/api/tenants/${TENANT}/memory/list?limit=5`, - ); - expect(calls[0]!.body).toBeNull(); - } finally { - restore(); - } + const client = createMemoryHttpClient({ + baseUrl: BASE, + tenantId: TENANT, + authToken: TOKEN, + fetch: fetchMock, + }); + await client.list(5); + expect(calls[0]!.method).toBe("GET"); + expect(calls[0]!.url).toBe( + `${BASE}/api/tenants/${TENANT}/memory/list?limit=5`, + ); + expect(calls[0]!.body).toBeNull(); }); test("surfaces non-2xx as Error", async () => { - const { restore } = installFetch(() => ({ + const { fetchMock } = makeFetchMock(() => ({ status: 403, json: { error: "forbidden" }, })); - try { - const client = createMemoryHttpClient({ - baseUrl: BASE, - tenantId: TENANT, - authToken: TOKEN, - }); - await expect(client.search({ query: "x" })).rejects.toThrow( - /memory HTTP 403/, - ); - } finally { - restore(); - } + const client = createMemoryHttpClient({ + baseUrl: BASE, + tenantId: TENANT, + authToken: TOKEN, + fetch: fetchMock, + }); + await expect(client.search({ query: "x" })).rejects.toThrow( + /memory HTTP 403/, + ); + }); + + test("rejects invalid JSON on 2xx", async () => { + const { fetchMock } = makeFetchMock(() => ({ + status: 200, + text: "not-json", + })); + const client = createMemoryHttpClient({ + baseUrl: BASE, + tenantId: TENANT, + authToken: TOKEN, + fetch: fetchMock, + }); + await expect(client.list()).rejects.toThrow(/invalid JSON/); + }); + + test("empty 2xx body becomes {}", async () => { + const { fetchMock } = makeFetchMock(() => ({ + status: 200, + text: "", + })); + const client = createMemoryHttpClient({ + baseUrl: BASE, + tenantId: TENANT, + authToken: TOKEN, + fetch: fetchMock, + }); + expect(await client.list()).toEqual({}); }); }); @@ -170,66 +208,133 @@ describe("memoryAdd factory", () => { }); test("happy path: body has no identity fields", async () => { - const { calls, restore } = installFetch(() => ({ + const { calls, fetchMock } = makeFetchMock(() => ({ status: 200, json: { documentId: "doc-9" }, })); - try { - const bundle = memoryAdd(toolEnv()); - expect(bundle.definitions.map((d) => d.name)).toEqual(["memory_add"]); - const result = await bundle.run( - { - id: "call-1", - name: "memory_add", - arguments: { title: "note", text: "hello" }, + const bundle = memoryAdd(toolEnv({ memoryFetch: fetchMock })); + expect(bundle.definitions.map((d) => d.name)).toEqual(["memory_add"]); + const result = await bundle.run( + { + id: "call-1", + name: "memory_add", + arguments: { title: "note", text: "hello" }, + }, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + expect(result.content).toBe(JSON.stringify({ documentId: "doc-9" })); + const body = JSON.parse(calls[0]!.body ?? "{}") as Record; + expect(body).not.toHaveProperty("tenantId"); + expect(body).not.toHaveProperty("principalId"); + expect(body).not.toHaveProperty("tenant_id"); + expect(body).not.toHaveProperty("principal_id"); + }); + + test("strips adversarial identity args from wire body", async () => { + const { calls, fetchMock } = makeFetchMock(() => ({ + status: 200, + json: { documentId: "doc-x" }, + })); + const bundle = memoryAdd(toolEnv({ memoryFetch: fetchMock })); + const result = await bundle.run( + { + id: "call-adv", + name: "memory_add", + arguments: { + title: "note", + text: "hello", + tenantId: "evil-tenant", + principalId: "evil-principal", + tenant_id: "evil", + principal_id: "evil", }, - new AbortController().signal, - ); - expect(result.isError).toBeFalsy(); - expect(result.content).toBe(JSON.stringify({ documentId: "doc-9" })); - const body = JSON.parse(calls[0]!.body ?? "{}") as Record; - expect(body).not.toHaveProperty("tenantId"); - expect(body).not.toHaveProperty("principalId"); - expect(body).not.toHaveProperty("tenant_id"); - expect(body).not.toHaveProperty("principal_id"); - } finally { - restore(); - } + }, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + const body = JSON.parse(calls[0]!.body ?? "{}") as Record; + expect(body).toEqual({ title: "note", text: "hello" }); + expect(calls[0]!.url).toContain(`/tenants/${TENANT}/`); + }); + + test("rejects empty title", async () => { + const { fetchMock } = makeFetchMock(() => ({ + status: 200, + json: { documentId: "x" }, + })); + const bundle = memoryAdd(toolEnv({ memoryFetch: fetchMock })); + const result = await bundle.run( + { + id: "call-bad", + name: "memory_add", + arguments: { title: "", text: "body" }, + }, + new AbortController().signal, + ); + expect(result.isError).toBe(true); }); }); describe("memorySearch factory", () => { test("POSTs search with query only", async () => { - const { calls, restore } = installFetch(() => ({ + const { calls, fetchMock } = makeFetchMock(() => ({ status: 200, json: { items: [], evidence: "none" }, })); - try { - const bundle = memorySearch(toolEnv()); - const result = await bundle.run( - { - id: "call-2", - name: "memory_search", - arguments: { query: "standup notes" }, - }, - new AbortController().signal, - ); - expect(result.isError).toBeFalsy(); - expect(calls[0]!.url).toBe( - `${BASE}/api/tenants/${TENANT}/memory/search`, - ); - expect(JSON.parse(calls[0]!.body ?? "{}")).toEqual({ - query: "standup notes", - }); - } finally { - restore(); - } + const bundle = memorySearch(toolEnv({ memoryFetch: fetchMock })); + const result = await bundle.run( + { + id: "call-2", + name: "memory_search", + arguments: { query: "standup notes" }, + }, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + expect(calls[0]!.url).toBe( + `${BASE}/api/tenants/${TENANT}/memory/search`, + ); + expect(JSON.parse(calls[0]!.body ?? "{}")).toEqual({ + query: "standup notes", + }); + }); + + test("coerces string limit and rejects out-of-range", async () => { + const { calls, fetchMock } = makeFetchMock(() => ({ + status: 200, + json: { items: [] }, + })); + const bundle = memorySearch(toolEnv({ memoryFetch: fetchMock })); + const ok = await bundle.run( + { + id: "call-lim", + name: "memory_search", + arguments: { query: "q", limit: "5" }, + }, + new AbortController().signal, + ); + expect(ok.isError).toBeFalsy(); + expect(JSON.parse(calls[0]!.body ?? "{}")).toEqual({ + query: "q", + limit: 5, + }); + + const bad = await bundle.run( + { + id: "call-lim-bad", + name: "memory_search", + arguments: { query: "q", limit: 99 }, + }, + new AbortController().signal, + ); + expect(bad.isError).toBe(true); }); }); describe("memoryList factory", () => { test("GETs list without identity in query", async () => { - const { calls, restore } = installFetch(() => ({ + const { calls, fetchMock } = makeFetchMock(() => ({ status: 200, json: { events: [ @@ -243,38 +348,30 @@ describe("memoryList factory", () => { ], }, })); - try { - const bundle = memoryList(toolEnv()); - const result = await bundle.run( - { - id: "call-3", - name: "memory_list", - arguments: { limit: 10 }, - }, - new AbortController().signal, - ); - expect(result.isError).toBeFalsy(); - expect(calls[0]!.url).toContain("/memory/list?limit=10"); - expect(calls[0]!.url).not.toContain("principal"); - } finally { - restore(); - } + const bundle = memoryList(toolEnv({ memoryFetch: fetchMock })); + const result = await bundle.run( + { + id: "call-3", + name: "memory_list", + arguments: { limit: 10 }, + }, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + expect(calls[0]!.url).toContain("/memory/list?limit=10"); + expect(calls[0]!.url).not.toContain("principal"); }); test("HTTP error becomes isError tool result", async () => { - const { restore } = installFetch(() => ({ + const { fetchMock } = makeFetchMock(() => ({ status: 401, json: { error: "unauthorized" }, })); - try { - const bundle = memoryList(toolEnv()); - const result = await bundle.run( - { id: "call-4", name: "memory_list", arguments: {} }, - new AbortController().signal, - ); - expect(result.isError).toBe(true); - } finally { - restore(); - } + const bundle = memoryList(toolEnv({ memoryFetch: fetchMock })); + const result = await bundle.run( + { id: "call-4", name: "memory_list", arguments: {} }, + new AbortController().signal, + ); + expect(result.isError).toBe(true); }); }); From acee3c4e1702f578095bbb6b6d238f29603458b7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 5 Aug 2026 07:33:50 -0700 Subject: [PATCH 4/5] Strip nested extras from memory_add share payloads Arktype keeps undeclared nested keys; rebuild share field-by-field so model junk under share never rides the wire. Add regression tests for nested share and search/list identity strip. --- src/http-bodies.ts | 5 +++ src/tools/add.ts | 13 ++++++- src/tools/tools.test.ts | 82 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/http-bodies.ts b/src/http-bodies.ts index 3de7bbf..511439d 100644 --- a/src/http-bodies.ts +++ b/src/http-bodies.ts @@ -1,6 +1,10 @@ /** * Shared request bodies for hub memory HTTP and defineTool factories. * Keep route validators and tool arg parsers on the same schemas. + * + * Note: GET list uses a string query param on the wire (`ListQuery` in + * routes/list.ts); tools use numeric `ListArgs` below. Bounds are shared + * via limits.ts — shapes intentionally differ. */ import { type } from "arktype"; @@ -37,6 +41,7 @@ export const SearchRequest = type({ export type SearchRequest = typeof SearchRequest.infer; +/** Tool-arg shape for memory_list (numeric limit). Not the HTTP query schema. */ export const ListArgs = type({ "limit?": type(`${LIST_LIMIT_MIN} <= number.integer <= ${LIST_LIMIT_MAX}`), }); diff --git a/src/tools/add.ts b/src/tools/add.ts index aaa3784..08e4373 100644 --- a/src/tools/add.ts +++ b/src/tools/add.ts @@ -13,7 +13,18 @@ function parseAddArgs(args: Record): MemoryAddBody { body.access_tags = parsed.access_tags; } if (parsed.share !== undefined) { - body.share = parsed.share; + // Rebuild share field-by-field — arktype keeps undeclared nested keys. + const share: NonNullable = {}; + if (parsed.share.tenant !== undefined) { + share.tenant = parsed.share.tenant; + } + if (parsed.share.principals !== undefined) { + share.principals = parsed.share.principals; + } + if (parsed.share.tags !== undefined) { + share.tags = parsed.share.tags; + } + body.share = share; } return body; } diff --git a/src/tools/tools.test.ts b/src/tools/tools.test.ts index b3de8e7..5578ab1 100644 --- a/src/tools/tools.test.ts +++ b/src/tools/tools.test.ts @@ -258,6 +258,38 @@ describe("memoryAdd factory", () => { expect(calls[0]!.url).toContain(`/tenants/${TENANT}/`); }); + test("rebuilds share without nested extras", async () => { + const { calls, fetchMock } = makeFetchMock(() => ({ + status: 200, + json: { documentId: "doc-share" }, + })); + const bundle = memoryAdd(toolEnv({ memoryFetch: fetchMock })); + const result = await bundle.run( + { + id: "call-share", + name: "memory_add", + arguments: { + title: "note", + text: "hello", + share: { + tenant: true, + principalId: "nested-evil", + authToken: "should-not-wire", + tags: ["team:eng"], + }, + }, + }, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + const body = JSON.parse(calls[0]!.body ?? "{}") as { + share?: Record; + }; + expect(body.share).toEqual({ tenant: true, tags: ["team:eng"] }); + expect(body.share).not.toHaveProperty("principalId"); + expect(body.share).not.toHaveProperty("authToken"); + }); + test("rejects empty title", async () => { const { fetchMock } = makeFetchMock(() => ({ status: 200, @@ -330,6 +362,31 @@ describe("memorySearch factory", () => { ); expect(bad.isError).toBe(true); }); + + test("strips adversarial identity args from search wire body", async () => { + const { calls, fetchMock } = makeFetchMock(() => ({ + status: 200, + json: { items: [] }, + })); + const bundle = memorySearch(toolEnv({ memoryFetch: fetchMock })); + const result = await bundle.run( + { + id: "call-search-adv", + name: "memory_search", + arguments: { + query: "q", + tenantId: "evil-tenant", + principalId: "evil-principal", + authToken: "nope", + }, + }, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + const body = JSON.parse(calls[0]!.body ?? "{}") as Record; + expect(body).toEqual({ query: "q" }); + expect(calls[0]!.url).toContain(`/tenants/${TENANT}/`); + }); }); describe("memoryList factory", () => { @@ -362,6 +419,31 @@ describe("memoryList factory", () => { expect(calls[0]!.url).not.toContain("principal"); }); + test("ignores adversarial identity args on list", async () => { + const { calls, fetchMock } = makeFetchMock(() => ({ + status: 200, + json: { events: [] }, + })); + const bundle = memoryList(toolEnv({ memoryFetch: fetchMock })); + const result = await bundle.run( + { + id: "call-list-adv", + name: "memory_list", + arguments: { + limit: 3, + tenantId: "evil", + principalId: "evil", + }, + }, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + expect(calls[0]!.url).toBe( + `${BASE}/api/tenants/${TENANT}/memory/list?limit=3`, + ); + expect(calls[0]!.url).not.toContain("evil"); + }); + test("HTTP error becomes isError tool result", async () => { const { fetchMock } = makeFetchMock(() => ({ status: 401, From 0a69e3b7c763bead2a8aceb6686529348d879c9c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 5 Aug 2026 07:39:34 -0700 Subject: [PATCH 5/5] Polish tools surface: shared types, list limits, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive MemoryAddBody/MemorySearchBody from arktype infer; share ListQuery and parseListLimitString with the list route; clip long HTTP error bodies; document host install checklist; fix search wire table and find→search grant-tag action in IMPLEMENTATION and AUTHZ docs. --- IMPLEMENTATION.md | 13 +++++++----- README.md | 17 ++++++++++++---- docs/AUTHZ-DOCUMENT-ACCESS.md | 10 ++++----- src/http-bodies.ts | 33 ++++++++++++++++++++++++++---- src/limits.ts | 4 ++-- src/routes/list.ts | 27 ++++--------------------- src/tools/client.ts | 38 +++++++++++++++++------------------ src/tools/tools.test.ts | 23 +++++++++++++++++++++ 8 files changed, 103 insertions(+), 62 deletions(-) diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 88b61e0..8e489d2 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -487,14 +487,17 @@ Each route is guarded with `grantGuard(deps, action)`, which applies the host's | Method + path | Grant action | Request body | Response | |---|---|---|---| | `POST /api/tenants/:tenantId/memory/add` | `add` | `{ title, text, access_tags?, share? }` | `200 { documentId }`; `400` on validation | -| `POST /api/tenants/:tenantId/memory/search` | `search` | `{ query, limit?, kinds?, entity_ids? }` (limit 1–50; `kinds`/`entity_ids` narrow every retrieval channel — lexical and dense — to a document `kind` or linked entity id before fusion; unset or `[]` = unfiltered) | `200 { items[], evidence?, degraded? }`; `400` on bad input | -| `GET /api/tenants/:tenantId/memory/list` | `search` | — | `200 { events: [{ at, title, source, tenantId, principalId }] }` — durable recent documents for the caller's scope, filtered with grant-tag access (`canAccessDocument`). One event per document (active live version). | +| `POST /api/tenants/:tenantId/memory/search` | `search` | `{ query, limit?, kinds?, entity_ids?, sources?, includeEvidence? }` (limit 1–50; `kinds`/`entity_ids`/`sources` narrow retrieval before fusion; unset or `[]` = unfiltered; `includeEvidence` adds a short evidence string when true) | `200 { items[], evidence?, degraded? }`; `400` on bad input | +| `GET /api/tenants/:tenantId/memory/list` | `search` | query `?limit=` (1–100, string on the wire) | `200 { events: [{ at, title, source, tenantId, principalId }] }` — durable recent documents for the caller's scope, filtered with grant-tag access (`canAccessDocument`). One event per document (active live version). | `registerMemoryRoutes` and `createMemory({ app })` register the three HTTP routes. Agent tools ship in this package as Interchange `defineTool` factories (`@corbits/memory/tools` / `interchange.tools`): thin HTTP clients that call the mounted routes with install env (`memoryBaseUrl`, `memoryTenantId`, -`memoryAuthToken`). They do not import the plane. OpenAPI→MCP remains an optional +`memoryAuthToken`). They do not import the plane. Host checklist: agent principal +needs `memory:add` and/or `memory:search` grants; Bearer token only (no session +cookie path); tool results are JSON strings; pass `AbortSignal` if you need hang +protection — the client has no default timeout. OpenAPI→MCP remains an optional host bridge. The plane surface is only `add` / `search` / `list` (plus `close`); inference stays on the host. @@ -521,8 +524,8 @@ Document access is Interchange authz — **not** a mini-ACL. - Write path: `resolveAccessTags` always writes `memory.owner:` and merges optional `accessTags` / share sugar (`tenant`, peer `principals`, explicit `tags`). Stored on `knowledge.document.access_tags`. -- Read path (find + recent): `canAccessDocument` — creator always allowed; - otherwise `authorize(grantStore, principal, tenant, tag, "find")` for any +- Read path (search + list): `canAccessDocument` — creator always allowed; + otherwise `authorize(grantStore, principal, tenant, tag, "search")` for any tag on the document. - SQL retrieval is **tenant-scoped only**. Document access is grant-tag post-filter in the plane (`canAccessDocument`); there is no SQL mini-ACL. diff --git a/README.md b/README.md index 4bd9fc8..4bab4c6 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Missing grant → **403**. ```http POST /api/tenants/:tenantId/memory/add { "title", "text", "access_tags"?, "share"? } -POST /api/tenants/:tenantId/memory/search { "query", "limit"? } +POST /api/tenants/:tenantId/memory/search { "query", "limit"?, "kinds"?, "entity_ids"?, "sources"?, "includeEvidence"? } GET /api/tenants/:tenantId/memory/list ?limit= ``` @@ -74,9 +74,18 @@ routes. No plane inject, no model-supplied identity. | Key | Meaning | | --- | --- | -| `memoryBaseUrl` | Hub origin, e.g. `https://hub.example` | -| `memoryTenantId` | Tenant path segment | -| `memoryAuthToken` | Bearer token accepted by the hub for that principal | +| `memoryBaseUrl` | Hub **origin** only, e.g. `https://hub.example` (no `/api/...` path) | +| `memoryTenantId` | Tenant path segment (must match the principal’s tenant on the hub) | +| `memoryAuthToken` | Bearer token the hub accepts for that agent principal | + +**Host checklist** + +1. Mount routes: `createMemory({ app, grantStore, … })` under the hub tenant tree. +2. Grant the agent principal `memory:add` and/or `memory:search` (`list` uses `search`). +3. For peer/space share visibility, also grant `search` on the relevant document tags (see `docs/AUTHZ-DOCUMENT-ACCESS.md`). +4. Install factories on the workflow and set the three env keys above. +5. Auth is **Bearer only** on the tool client — session cookies are not sent. +6. Tool results are **JSON strings** (`stringTool`); pass `AbortSignal` if you need hang protection (no default client timeout). ```ts import { memoryAdd, memorySearch, memoryList } from "@corbits/memory/tools"; diff --git a/docs/AUTHZ-DOCUMENT-ACCESS.md b/docs/AUTHZ-DOCUMENT-ACCESS.md index 83e77b5..af1a31e 100644 --- a/docs/AUTHZ-DOCUMENT-ACCESS.md +++ b/docs/AUTHZ-DOCUMENT-ACCESS.md @@ -79,9 +79,9 @@ Tag minting is **not** grant minting. For peer share to work in product: 1. When Alice adds with `share: { principals: ["bob"] }`, the document is tagged `memory.owner:alice` and `memory.owner:bob`. -2. Bob sees it only if the host has granted Bob `find` on `memory.owner:bob` +2. Bob sees it only if the host has granted Bob `search` on `memory.owner:bob` (or a pattern that matches). **Recommended host bootstrap:** every principal - receives `find` (and optionally `add` side-effects as you prefer) on + receives `search` (and optionally `add` side-effects as you prefer) on `memory.owner:` at signup, or a single pattern grant such as `memory.owner:*` only if that matches your tenancy model. 3. Space/tenant tags work the same way: host must issue grants on @@ -100,7 +100,7 @@ Deny is expressed as **absence of allow** (or an explicit deny grant in the host ### Capability (unchanged) ```ts -authorize(grantStore, principalId, tenantId, "memory", "find"|"add") +authorize(grantStore, principalId, tenantId, "memory", "search"|"add") // effect must be "allow" ``` @@ -111,7 +111,7 @@ function canSeeDocument(doc, principalId, grantStore, tenantId): if doc.createdByPrincipalId === principalId: return true // creator for tag of doc.accessTags: - r = authorize(grantStore, principalId, tenantId, tag, "find") + r = authorize(grantStore, principalId, tenantId, tag, "search") if r.effect === "allow": return true return false @@ -120,7 +120,7 @@ function canSeeDocument(doc, principalId, grantStore, tenantId): **SQL / store path:** prefer expand-then-filter: 1. `collectGrants(principalId, tenantId)` once per request. -2. Keep allow-grants whose `action` matches `find` (exact or pattern). +2. Keep allow-grants whose `action` matches `search` (exact or pattern). 3. Document is visible if creator **or** any `accessTags[i]` is matched by any allow grant resource pattern (`matchPattern(grant.resource, tag)`), and not denied by a more specific deny. This keeps evaluation inside Interchange authz semantics (specificity, conditions, deny). diff --git a/src/http-bodies.ts b/src/http-bodies.ts index 511439d..716a957 100644 --- a/src/http-bodies.ts +++ b/src/http-bodies.ts @@ -2,9 +2,8 @@ * Shared request bodies for hub memory HTTP and defineTool factories. * Keep route validators and tool arg parsers on the same schemas. * - * Note: GET list uses a string query param on the wire (`ListQuery` in - * routes/list.ts); tools use numeric `ListArgs` below. Bounds are shared - * via limits.ts — shapes intentionally differ. + * GET list uses a string query param on the wire (`ListQuery`); tools use + * numeric `ListArgs`. Bounds are shared via `limits.ts` and `parseListLimitString`. */ import { type } from "arktype"; @@ -41,13 +40,39 @@ export const SearchRequest = type({ export type SearchRequest = typeof SearchRequest.infer; -/** Tool-arg shape for memory_list (numeric limit). Not the HTTP query schema. */ +/** HTTP query schema for GET /memory/list (string limit from the URL). */ +export const ListQuery = type({ + "limit?": "string", +}); + +export type ListQuery = typeof ListQuery.infer; + +/** Tool-arg shape for memory_list (numeric limit after LLM coerce). */ export const ListArgs = type({ "limit?": type(`${LIST_LIMIT_MIN} <= number.integer <= ${LIST_LIMIT_MAX}`), }); export type ListArgs = typeof ListArgs.infer; +/** + * Parse a list `limit` query string into a bounded integer. + * Returns `undefined` for missing/empty; `null` for invalid/out-of-range. + */ +export function parseListLimitString( + raw: string | undefined, +): number | undefined | null { + if (raw === undefined || raw === "") return undefined; + const n = Number(raw); + if ( + !Number.isInteger(n) || + n < LIST_LIMIT_MIN || + n > LIST_LIMIT_MAX + ) { + return null; + } + return n; +} + /** Coerce LLM-stringified integers before arktype number.integer checks. */ export function coerceOptionalLimitArg( args: Record, diff --git a/src/limits.ts b/src/limits.ts index 1a6fe23..2f860a2 100644 --- a/src/limits.ts +++ b/src/limits.ts @@ -1,7 +1,7 @@ -/** Green find (search) limit bounds. */ +/** Search limit bounds (hybrid search + HTTP/tool args). */ export const SEARCH_LIMIT_MIN = 1; export const SEARCH_LIMIT_MAX = 50; -/** Green recent (list) limit bounds. */ +/** List/timeline limit bounds (GET list + memory_list tool). */ export const LIST_LIMIT_MIN = 1; export const LIST_LIMIT_MAX = 100; diff --git a/src/routes/list.ts b/src/routes/list.ts index 117299a..b07a6b5 100644 --- a/src/routes/list.ts +++ b/src/routes/list.ts @@ -4,6 +4,7 @@ import { describeRoute, resolver, validator } from "hono-openapi"; import { type } from "arktype"; import { formatCaughtError, log } from "../log.ts"; +import { ListQuery, parseListLimitString } from "../http-bodies.ts"; import { MemoryError, LIST_LIMIT_MAX, @@ -12,10 +13,6 @@ import { import type { RouteDeps } from "./deps.ts"; import { caller, grantGuard, requirePrincipal } from "./deps.ts"; -const ListQuery = type({ - "limit?": "string", -}); - const ListResponse = type({ events: type({ at: "string", @@ -26,19 +23,6 @@ const ListResponse = type({ }).array(), }); -function parseLimit(raw: string | undefined): number | undefined { - if (raw === undefined || raw === "") return undefined; - const n = Number(raw); - if ( - !Number.isInteger(n) || - n < LIST_LIMIT_MIN || - n > LIST_LIMIT_MAX - ) { - return undefined; - } - return n; -} - export function mountListRoute(app: Hono, deps: RouteDeps): void { app.get( "/api/tenants/:tenantId/memory/list", @@ -65,11 +49,8 @@ export function mountListRoute(app: Hono, deps: RouteDeps): void { async (c) => { const { scopeId, subjectId } = caller(c); const rawLimit = c.req.valid("query").limit; - if ( - rawLimit !== undefined && - rawLimit !== "" && - parseLimit(rawLimit) === undefined - ) { + const parsedLimit = parseListLimitString(rawLimit); + if (parsedLimit === null) { return c.json( { error: `limit must be an integer from ${LIST_LIMIT_MIN} to ${LIST_LIMIT_MAX}`, @@ -77,7 +58,7 @@ export function mountListRoute(app: Hono, deps: RouteDeps): void { 400, ); } - const limit = parseLimit(rawLimit); + const limit = parsedLimit; try { const events = await deps.memory.list({ tenantId: scopeId, diff --git a/src/tools/client.ts b/src/tools/client.ts index a70357f..37cef5e 100644 --- a/src/tools/client.ts +++ b/src/tools/client.ts @@ -3,7 +3,11 @@ * * Tools never touch the in-process plane — they only call * `/api/tenants/:tenantId/memory/*` with credentials from install env. + * + * Pass `signal` on each call (or via the tool runner) so a hung hub can be + * cancelled; this client does not invent a default timeout. */ +import type { AddRequest, SearchRequest } from "../http-bodies.ts"; export type MemoryHttpConfig = { baseUrl: string; @@ -12,25 +16,11 @@ export type MemoryHttpConfig = { fetch?: typeof globalThis.fetch; }; -export type MemoryAddBody = { - title: string; - text: string; - access_tags?: string[]; - share?: { - tenant?: boolean; - principals?: string[]; - tags?: string[]; - }; -}; +/** Wire body for POST /memory/add — same shape as shared AddRequest. */ +export type MemoryAddBody = AddRequest; -export type MemorySearchBody = { - query: string; - limit?: number; - kinds?: string[]; - entity_ids?: string[]; - sources?: string[]; - includeEvidence?: boolean; -}; +/** Wire body for POST /memory/search — same shape as shared SearchRequest. */ +export type MemorySearchBody = SearchRequest; export type MemoryHttpClient = { add(body: MemoryAddBody, signal?: AbortSignal): Promise; @@ -38,6 +28,9 @@ export type MemoryHttpClient = { list(limit?: number, signal?: AbortSignal): Promise; }; +/** Cap hub error text embedded in tool errors (avoid huge/secret-ish dumps). */ +const MAX_ERROR_DETAIL_CHARS = 512; + function stripTrailingSlashes(url: string): string { let out = url; while (out.endsWith("/")) { @@ -46,6 +39,12 @@ function stripTrailingSlashes(url: string): string { return out; } +function clipErrorDetail(text: string): string { + const t = text.trim(); + if (t.length <= MAX_ERROR_DETAIL_CHARS) return t; + return `${t.slice(0, MAX_ERROR_DETAIL_CHARS)}…`; +} + export function createMemoryHttpClient( config: MemoryHttpConfig, ): MemoryHttpClient { @@ -84,7 +83,8 @@ export function createMemoryHttpClient( if (!res.ok) { const text = await res.text().catch(() => ""); - const detail = text.trim() || res.statusText || "request failed"; + const detail = + clipErrorDetail(text) || res.statusText || "request failed"; throw new Error(`memory HTTP ${res.status}: ${detail}`); } diff --git a/src/tools/tools.test.ts b/src/tools/tools.test.ts index 5578ab1..09f7fd5 100644 --- a/src/tools/tools.test.ts +++ b/src/tools/tools.test.ts @@ -172,6 +172,29 @@ describe("createMemoryHttpClient", () => { ); }); + test("clips long error response bodies", async () => { + const long = "e".repeat(800); + const { fetchMock } = makeFetchMock(() => ({ + status: 500, + text: long, + })); + const client = createMemoryHttpClient({ + baseUrl: BASE, + tenantId: TENANT, + authToken: TOKEN, + fetch: fetchMock, + }); + try { + await client.list(); + expect.unreachable("expected throw"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + expect(msg).toMatch(/memory HTTP 500:/); + expect(msg.length).toBeLessThan(600); + expect(msg.endsWith("…")).toBe(true); + } + }); + test("rejects invalid JSON on 2xx", async () => { const { fetchMock } = makeFetchMock(() => ({ status: 200,